mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
Merge upstream main into pr/street-level-show-action
gevActions.test.mjs: keep the PR's street-level tests and main's analyst display tests.
This commit is contained in:
commit
5370b8a268
258 files changed
+20257
-734
No files matched your search
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- An application's own tool can take the pointer as Draw does, and its click
|
||||
handlers can yield while a tool holds it, as the layers' do (`pointer` from
|
||||
`createApplicationTools`) (samehkhamis, #1641).
|
||||
|
||||
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
|
||||
|
||||
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||
|
||||
+61
-8
@@ -30,6 +30,7 @@ How to read this:
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo, linked to its Planespotters page; "Planespotters.net" in Data attribution |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
@@ -103,6 +104,40 @@ How to read this:
|
||||
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
|
||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
|
||||
| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy |
|
||||
| **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana |
|
||||
| **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio |
|
||||
| **Warsaw buses and trams** (mkuran.pl) | Transit layer live vehicles, Warsaw, Poland | Warsaw Open Data Project Usage Terms (https://mkuran.pl/gtfs/). Stated: “The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.” Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API and carries the date of the feed timestamp, the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs. | Miasto Stołeczne Warszawa (api.um.warszawa.pl) |
|
||||
| **Bordeaux TBM** (bdx.mecatran.com) | Transit layer live vehicles, Bordeaux, France | Licence Ouverte / Etalab 2.0 (https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite). The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), mapped from TBM’s static GTFS; other numbered lines are buses. | Bordeaux Métropole (TBM) |
|
||||
| **Sendai City Bus** (api-public.odpt.org) | Transit layer live vehicles, Sendai, Japan | CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s. | 仙台市交通局 (Sendai City Transportation Bureau) / ODPT |
|
||||
| **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp as the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) |
|
||||
| **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku |
|
||||
| **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton |
|
||||
| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Open Government Licence – Halifax v1.0 (https://www.halifax.ca/home/open-data/open-data-license), modelled on OGL – Canada 2.0. Stated: “The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.” No logos or implied endorsement. Speeds are m/s. | Contains information licenced under the Open Government Licence – Halifax |
|
||||
| **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit |
|
||||
| **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) |
|
||||
| **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie |
|
||||
| **Kraków trams** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). Same server and basis as the bus feed. | Zarząd Transportu Publicznego w Krakowie |
|
||||
| **Vilnius public transport** (stops.lt) | Transit layer live vehicles, Vilnius, Lithuania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383). Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since. | SĮ Susisiekimo paslaugos (stops.lt) |
|
||||
| **Bucharest public transport** (gtfs.tpbi.ro) | Transit layer live vehicles, Bucharest, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104). Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi. | TPBI (Transport Public București–Ilfov) |
|
||||
| **Rome public transport** (romamobilita.it) | Transit layer live vehicles, Rome, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776). Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement. | Roma Servizi per la Mobilità |
|
||||
| **Turin GTT** (percorsieorari.gtt.to.it) | Transit layer live vehicles, Turin, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines. | Gruppo Torinese Trasporti (GTT) |
|
||||
| **Venice ACTV buses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`). | ACTV S.p.A. |
|
||||
| **Venice ACTV waterbuses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). Same catalogue and basis as the ACTV bus feed. | ACTV S.p.A. |
|
||||
| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Transit layer live vehicles, Adelaide, SA | No licence stated for the realtime feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772). Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override. | Adelaide Metro |
|
||||
| **MARTA buses** (gtfs-rt.itsmarta.com) | Transit layer live vehicles, Atlanta, GA | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s. | MARTA |
|
||||
| **Denver RTD** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). Stated: “RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data”. Read 2026-11-09: a click-through agreement that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from use with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s. | Regional Transportation District (RTD) |
|
||||
| **Cluj-Napoca CTP** (cluj-rt-feed.gtfs.ro) | Transit layer live vehicles, Cluj-Napoca, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Cluj-Napoca |
|
||||
| **Iași CTP** (iasi-rt-feed.gtfs.ro) | Transit layer live vehicles, Iași, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Iași |
|
||||
| **Rzeszów city transport** (mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | MPK Rzeszów open data (https://www.mpkrzeszow.pl/otwartedane/). Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement. | MPK Rzeszów |
|
||||
| **Málaga EMT** (gtfs.emtmalaga.es) | Transit layer live vehicles, Málaga, Spain | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213). Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement. | EMT Málaga |
|
||||
| **Carris (Lisbon city)** (gateway.carris.pt) | Transit layer live vehicles, Lisbon, Portugal | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930). Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant. | Carris |
|
||||
| **BAS.MY Johor Bahru** (api.data.gov.my) | Transit layer live vehicles, Johor Bahru, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Stated: “All GTFS Realtime feeds are licensed under CC BY 4.0.” Served keyless by Malaysia’s national open-data API, which caps GTFS Realtime at 4 requests a minute and refreshes positions every 30 seconds, so the proxy caches this feed for 30 seconds. Without the trailing slash the API redirects. Route ids append an operator code. | BAS.MY / data.gov.my |
|
||||
| **BAS.MY Kota Bharu** (api.data.gov.my) | Transit layer live vehicles, Kota Bharu, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Same API, licence and rate limit as Johor Bahru. | BAS.MY / data.gov.my |
|
||||
| **Rouen Astuce** (api.mrn.cityway.fr) | Transit layer live vehicles, Rouen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie). The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); and this is its own TCAR vehicle feed from that record; the suburban TAE and TNI networks are separate feeds and are not read. Route ids carry the network prefix (`TCAR:`); line 90 is the Métro (a street-running light rail, drawn as a tram) and 99 the Calypso boat. | Métropole Rouen Normandie (Astuce) |
|
||||
| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) |
|
||||
| **Le Havre LiA** (opendata.transports-lia.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); and this is its own GTFS-RT vehicle feed from that record; route ids carry the network prefix `8-`. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) |
|
||||
| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
@@ -166,7 +201,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
contributors, ODbL 1.0; GPSJAM for aggregation only.
|
||||
|
||||
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, whose page is an `https://www.planespotters.net` URL, and which names its photographer is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name is visible text next to it ("Photo © {photographer}"), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
|
||||
@@ -194,7 +230,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
||||
|
||||
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
@@ -229,10 +265,12 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
|
||||
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
|
||||
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
|
||||
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
|
||||
@@ -288,15 +326,20 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons
|
||||
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
|
||||
|
||||
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
|
||||
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
|
||||
Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21)
|
||||
and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`)
|
||||
is not requested: NASA stopped delivering it on 2026-11-02.
|
||||
|
||||
### UCS Satellite Database (`ucs_satellites/`)
|
||||
|
||||
The purpose of each active satellite, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
|
||||
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
|
||||
|
||||
### Imaging sensor pack (`imaging_sensors/`)
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
@@ -317,6 +360,16 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w
|
||||
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
|
||||
wiring (see below).
|
||||
|
||||
### Natural Earth populated places (`natural_earth/populated_places.json`)
|
||||
|
||||
Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned
|
||||
`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one
|
||||
row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max`
|
||||
(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half
|
||||
its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving
|
||||
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
|
||||
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
|
||||
|
||||
### Sky background (`sky/`)
|
||||
|
||||
Sources, licences, the changes CC BY asks to state, hashes and build commands
|
||||
@@ -487,4 +540,4 @@ NOAA density imagery with the raw GLM product.
|
||||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
@@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
||||
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
||||
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
|
||||
@@ -444,7 +444,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep outside text in voice and MCP tool results to one line of 180
|
||||
characters: ALPR tags, station names and other display fields (asturksever,
|
||||
#1280).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The agent and voice query tools wrap whole-degree directions into 0–359, so
|
||||
a vessel course or heading, aircraft course, transit bearing, camera heading
|
||||
or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The Nominatim disk-cache test checks the cache directory and file modes
|
||||
only where POSIX modes exist, so it no longer always fails on Windows
|
||||
(quanticsoul4772, #1547).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data
|
||||
on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing
|
||||
a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20,
|
||||
VIIRS NOAA-21 and MODIS (IMBrandao, #1552).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8×
|
||||
zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the
|
||||
owner's flag, operator and TLE identity. `C` no longer enters or leaves first
|
||||
person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's
|
||||
confirmed swath allows at the live altitude, with a to-scale swath strip, a
|
||||
SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and
|
||||
the TLE's age (Denis Groshev, @ZloyS, #1560).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Show a fire perimeter under one acre at its reported size. The incident card
|
||||
rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac"
|
||||
(Rudy Celekli, @rudycelekli, #1569).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Say why Transit and Bike Share are empty outside coverage. Over a city with
|
||||
no feed or system (London, Tokyo) their Layers rows say so and count the
|
||||
covered regions or systems, or say how low to fly, instead of `never`;
|
||||
leaving coverage clears the last-update time (German, @gekh, #1576).
|
||||
@@ -0,0 +1,3 @@
|
||||
- A vessel whose ambient card is on the map no longer gets a Detection callout
|
||||
showing through the card and covering its speed and course. A vessel whose
|
||||
card the overlay did not paint keeps its callout (German, @gekh, #1577).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Hold the orbital camera on a mapped site as the satellite passes over it,
|
||||
from a click on its marker or its Live Signals row, and list nearby sites
|
||||
and satellites in orbit's Live Signals, where a row focuses a site or rides
|
||||
a satellite (Denis Groshev, @ZloyS, #1580).
|
||||
@@ -0,0 +1,2 @@
|
||||
- French catalog review: missing narrow no-break spaces, apostrophes, one
|
||||
mistranslated message and the Starlink shell wording (abiyeenzo, #1611).
|
||||
@@ -0,0 +1,14 @@
|
||||
- Add 34 keyless feeds to the Transit layer, the first in Central and Eastern
|
||||
Europe, Italy, Spain, France, Japan and Malaysia: Szczecin, Lisbon (Carris
|
||||
Metropolitana and the city's Carris), Prague, Warsaw, Kraków, Poznań,
|
||||
Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice
|
||||
(buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre,
|
||||
Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver,
|
||||
Edmonton, Halifax and Victoria. Each feed records its licence, or that none
|
||||
is stated, in the registry and DATA_SOURCES.md. Speeds published in km/h
|
||||
(Lisbon, Montpellier, Toulon) are converted to m/s by the proxy; route ids
|
||||
read as the line on the vehicle; Prague metro, trains, trams and ferries,
|
||||
Bordeaux trams and boats, the Rouen Métro, Le Havre trams and Venice
|
||||
waterbuses take their own mode. The Warsaw and Poznań credits carry the
|
||||
data date their terms ask for, and the data.gov.my feeds are fetched at
|
||||
most twice a minute to stay inside the API's rate limit (IMBrandao).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw rows take an application's own actions: `createApplicationTools({
|
||||
drawMarkActions })` adds buttons to drawn areas' and lines' rows, each given
|
||||
the shape's `[lon, lat]` geometry, with what it answers shown under the row.
|
||||
The standalone build passes none and is unchanged (samehkhamis, #1636).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keep the orbital cockpit's sensor strip steady through a zoom step, load the
|
||||
satellite owner flags at startup from one generated 15 KB sheet, and name
|
||||
"Place names" in the loading status while the place-name packs load on first
|
||||
lookup (Sameh Khamis, @samehkhamis, #1637).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Voice on OpenAI connects again: OpenAI Realtime refused every session, so
|
||||
the mic failed with "Failed to create Realtime token", because
|
||||
`resolve_area`'s area-id pattern uses Unicode classes it no longer accepts.
|
||||
Voice leaves those patterns out, as typed chat does; area ids are still
|
||||
checked before the tool runs (avaldini, #1638).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Keep buses and trains off rooftops and out of bridge decks on the photoreal
|
||||
3D tiles: the ground floor probes the rendered mesh where each vehicle is,
|
||||
keeps each reading at the point it was taken (up to sixteen a cell) and
|
||||
answers from the reading nearest the vehicle, instead of one probe at each
|
||||
110 m cell's centre that lifted every vehicle in the cell onto whatever lay
|
||||
there. Transit probes each vehicle's latest point, and grounded flights read
|
||||
the floor at their own position (Sameh Khamis, @samehkhamis, #1642).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Street Traffic Lines build one set at a time while a city loads, instead of
|
||||
one per paint, so panning with Lines on no longer piles up line builds
|
||||
(Sameh Khamis, @samehkhamis, #1645).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Show a photo of the tracked aircraft. Tracking a civil flight asks
|
||||
Planespotters for that aircraft's photo, and the CONTEXT panel's CONTACTS view
|
||||
shows it with the photographer's credit and a link to the photo's page. A
|
||||
flight lookup (type, route or photo) that failed temporarily is now asked
|
||||
again after a minute instead of staying empty for the session, and a callsign
|
||||
change drops the previous callsign's route (Sameh Khamis, @samehkhamis, #1647).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Move the tile budget helpers `utcDayKey`, `normalizeBudget` and
|
||||
`isOverBudget` out of the TomTom module into `src/data/tileBudget.js`, so
|
||||
other keyed tile proxies can share them. `src/data/tomtomTiles.js`
|
||||
re-exports them; behaviour does not change (monwiss, #762).
|
||||
@@ -0,0 +1 @@
|
||||
- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (BreezeDelegate, #766; fixes #39).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET` on
|
||||
`/api/flights`, `/api/military` and `/api/route`, as the other query proxies
|
||||
do. They answered every verb with the same body. The refusal comes before
|
||||
rate limiting and any upstream call (daikaginza, #794).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Compress the live flight snapshot on the wire. `/api/flights` gzips its
|
||||
cached global snapshot and its regional adsb.lol fallback once per refresh
|
||||
and sends that frame to clients that offer gzip: about 860 KB becomes about
|
||||
250 KB. Other clients get the same bytes uncompressed, and both answers carry
|
||||
`Vary: Accept-Encoding` (daikaginza, #795).
|
||||
@@ -136,6 +136,9 @@ socket/watchdog on server close and re-reads configuration after restart.
|
||||
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
|
||||
records without importing Node middleware or a renderer. Browser layer/controller
|
||||
separation is outside this server extraction.
|
||||
The portable `gods-eye-view/sources/planespotters` export reads a Planespotters
|
||||
photo answer (upstream or a server's cached shape) into one host-checked,
|
||||
credited thumbnail; it has no fetch, DOM or Node dependency.
|
||||
|
||||
## Place-search and routing providers
|
||||
|
||||
|
||||
+319
-36
@@ -129,12 +129,28 @@ search keeps a successful geocode, with no search or view-based region recovery.
|
||||
|
||||
Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
|
||||
|
||||
Overpass reserves each normalized query before its disk-cache lookup. The
|
||||
initial disk lookup, upstream quota admission, fetching and memory publication
|
||||
are coalesced under that reservation. A caller that joined a reservation
|
||||
rejected by another client's quota retries admission with its own quota.
|
||||
Stale fallback runs per response after the reservation is released, and disk
|
||||
writes finish asynchronously. Settled and rejected reservations are removed
|
||||
only while they still own their map entry.
|
||||
|
||||
External ALPR tags, surveillance-tool fields, voice context text and compact
|
||||
analyst strings become one line of at most 180 characters (scene labels: 120).
|
||||
Empty ALPR values stay null, and the cap never splits a surrogate pair.
|
||||
Canonical analyst identifiers such as `id` and `routeId` stay unchanged, because
|
||||
voice follow-ups match on them and bikeshare uses the raw station name as its
|
||||
ID. The text is still untrusted: normalization does not prevent prompt
|
||||
injection.
|
||||
|
||||
Terrain height requests accept at most 64 points, matching the browser service's
|
||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
|
||||
The browser continues to split larger workloads into sequential 64-point requests.
|
||||
|
||||
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks; one set builds at a time, and of the rebuilds asked for meanwhile only the newest runs, when that build finishes; turning lines off removes them at once); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
@@ -1347,6 +1363,30 @@ and snapshot source. The standalone adapter keeps the existing USGS daily feed,
|
||||
M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or
|
||||
destroying the layer cancels pending work and ignores late results.
|
||||
|
||||
## Rendered-mesh floor readings — November 9, 2026
|
||||
|
||||
On the photoreal stack the shared floor (`cachedGroundFloor`) answers from
|
||||
rendered-mesh readings per coarse cell (0.001°, about 110 × 80 m), sampled by
|
||||
`src/services/meshFloorSampler.js` and gated against the DEM by
|
||||
`reportValidatedMeshFloorCell` in `src/services/groundFloor.js`. The probe goes
|
||||
where the caller's point is (a vehicle or contact), not to the cell's centre;
|
||||
the sampler reads a cell at up to eight spots about 12–17 m or more apart, and
|
||||
each reading that passes the gate is kept at its point (ground snaps add theirs
|
||||
the same way; a cell keeps at most sixteen). A read answers from the cell's
|
||||
reading nearest the asked point, so a viaduct and the street beside it in one
|
||||
cell keep their own heights; two readings within about 5 m of each other keep
|
||||
the lower. A spot refused only because the cell's DEM prior was cold is tried
|
||||
again later. Transit offers the sampler the latest point of each vehicle's path
|
||||
in a cell (its newest fix in its own cell), and the grounded-flight display
|
||||
clamp reads at the contact, held inside its sticky cell. Before this, one probe
|
||||
at the cell's centre latched for the session, so a centre on a roof lifted
|
||||
everything in the cell (a Haymarket bus drew 23.5 m above the street, on the
|
||||
garage roof) and a centre in the river put Red Line trains on the Longfellow
|
||||
Bridge 6–9 m under the deck. Residual: the probe is vertical, so a vehicle
|
||||
directly under a deck or a roof overhang reads the structure above it, and a
|
||||
vehicle between readings takes the nearest one, so its floor steps where the
|
||||
nearest reading changes.
|
||||
|
||||
## Vessel components and sources
|
||||
|
||||
`src/data/aisLiveVessels.js` assembles `createVesselLayer` from the
|
||||
@@ -1530,17 +1570,16 @@ With no aircraft tracked, Cockpit can ride the selected AIS vessel instead.
|
||||
Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a
|
||||
selected vessel; Contacts and every other layer may be off, before or during
|
||||
the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode
|
||||
change still in flight blocks entry until it settles. `C` enters it (when
|
||||
the bridge cannot be entered, `C` stays the CCTV shortcut), and the
|
||||
entry button reads BRIDGE with a ship icon both in Contacts (`#cockpit-entry`)
|
||||
and under the selected vessel's Intel HUD readout in the bottom-right corner
|
||||
(`#cockpit-entry-readout`, shown with the HUD on, in its Tactical and Cyber
|
||||
layouts). Turning Live AIS Vessels off ends the bridge: the layer then has no
|
||||
subject (`getCockpitSubject` returns null). The bridge picture uses AIS alone,
|
||||
so no other feed being off makes it partial.
|
||||
change still in flight blocks entry until it settles. Double-clicking the
|
||||
selected vessel enters it, and the entry button reads BRIDGE with a ship icon
|
||||
both in Contacts (`#cockpit-entry`) and under the selected vessel's Intel HUD
|
||||
readout in the bottom-right corner (`#cockpit-entry-readout`, shown with the
|
||||
HUD on, in its Tactical and Cyber layouts). Turning Live AIS Vessels off ends
|
||||
the bridge: the layer then has no subject (`getCockpitSubject` returns null).
|
||||
The bridge picture uses AIS alone, so no other feed being off makes it partial.
|
||||
Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only;
|
||||
voice `enter` on a bridge is refused before it touches tracking, with the
|
||||
reason to exit the bridge first.
|
||||
voice `enter` on a bridge or in an orbital view is refused before it touches
|
||||
tracking, with the reason to exit it first.
|
||||
The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's
|
||||
sprite, card, detection bracket and trail while it is held
|
||||
(`setCockpitSubject`), and
|
||||
@@ -1605,6 +1644,217 @@ through the layer's own focus. Tracking an aircraft or losing the vessel
|
||||
selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs`
|
||||
covers entry, readouts, refusal and exit.
|
||||
|
||||
### Orbital view
|
||||
|
||||
With a satellite tracked and no aircraft tracked, Cockpit can ride the
|
||||
satellite instead. Entry needs only the Satellites layer
|
||||
(`orbitEntryAllowed` in `src/contextModePolicy.js`); Contacts and every other
|
||||
layer may be off. A Context mode change in flight blocks entry until it
|
||||
settles. Debris (rocket bodies, fragments and dead satellites; see the
|
||||
satellite class toggles) can be tracked but never entered: no ORBIT entry is
|
||||
offered and double-click refuses it. A tracked aircraft still takes
|
||||
entry first, and a tracked satellite
|
||||
takes it before a selected vessel. Double-clicking the tracked satellite
|
||||
enters it, and the entry button reads
|
||||
ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel
|
||||
HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays
|
||||
aircraft-only; its state reports an orbital view in use as a `satellite`
|
||||
subject. `gev:cockpit-mode-changed` carries layer `satellites` and the NORAD
|
||||
id.
|
||||
|
||||
The camera sits on the satellite's own per-frame SGP4 position
|
||||
(`getCockpitPosition`, the sample the dot uses) and looks at the Earth's
|
||||
centre track-up: the ground track points to the top of the frame, so the
|
||||
picture scrolls along it (`nadirOrientation` in `src/cockpitOrbital.js`). A
|
||||
satellite with no ground track (a geostationary one) is shown north-up, and
|
||||
over a pole the previous frame's up is kept. It updates on every rendered
|
||||
frame, with no ground clamp. Entry opens in the plain optical vision style
|
||||
(the other styles stay one click away) and leases the map for flat imagery:
|
||||
with Google 3D tiles shown, which stay coarse from hundreds of kilometres up,
|
||||
the map switches to Esri imagery, and exit puts it back unless the operator
|
||||
picked another map meanwhile; another owner already holding the map (an
|
||||
imagery comparison) keeps it as it is. While orbit holds the lease, Recent
|
||||
Imagery cannot take it: turned on in orbit, the layer reports the comparison
|
||||
in use until, after exit, it is turned off and on again or its shown days
|
||||
are cleared and picked again. The Cockpit cloud pass is off in orbit
|
||||
(it fades out above 24 km); cloud cover from orbit is the Weather layer's,
|
||||
and the saved cloud preference stands for the next aircraft or bridge. The layer hides the satellite's dot, docked
|
||||
companions, its own orbit ring and its detection bracket, and draws every
|
||||
other orbit ring (the ISS ring included) at 30% of its alpha; other
|
||||
satellites' points and labels stay. Scene clicks and the layer's Escape are
|
||||
ignored by the satellites layer while it is ridden (a click on a mapped
|
||||
site focuses it; see below). The optical zoom steps 1×, 2×, 4× and 8× for
|
||||
every satellite with `+` (or `=`) and `-` (or `_`), the panel's buttons and
|
||||
the mouse wheel (one step per notch; trackpad scrolling is summed to a step):
|
||||
the 1× frame is 60° vertical in low orbit and fits the Earth's disk with
|
||||
margin from high orbits, each step divides the tangent of the half-angle, and
|
||||
Cesium's `frustum.fov` is set from it for the canvas aspect. A step animates
|
||||
over 400 ms with a focus-pull blur and a reticle lock tick, and cuts at once
|
||||
under `prefers-reduced-motion`. An imaging satellite matched to an entry of
|
||||
the bundled sensor pack (`src/data/local_data/imaging_sensors/`, by NORAD
|
||||
number or name in `src/layers/satellites/sensors.js`) whose narrowest swath
|
||||
is confirmed also gets 16×, 32×, 64× and 128×, each only while the view at
|
||||
the satellite's live altitude and the window's shape is still at least as
|
||||
wide as that swath (`orbitalMaxZoom` in `src/cockpitOrbital.js`); when the
|
||||
cap falls below the zoom shown, the view steps back to it. Unconfirmed
|
||||
swaths add no steps, but are still drawn, read out and offered to SENSOR.
|
||||
The sensor's swath is drawn to scale as a strip along the ground track (its
|
||||
width follows the view through a zoom step, not ahead of it) with
|
||||
a SENSOR SWATH readout, and a SENSOR button zooms to exactly that width when
|
||||
it lies between 1× and the cap. No strip is drawn for a geostationary view
|
||||
(no ground track; its sensors image areas, not strips) or a SAR (it images
|
||||
to one side of nadir, not under it); the readout stays. Tilted off nadir
|
||||
the strip stays (view and swath widen alike), the readout scales as the swath
|
||||
readout does (`SENSOR SWATH ≈ 191 KM`), and SENSOR, a nadir framing, is
|
||||
hidden; tilted past the Earth's limb neither the sensor nor the swath
|
||||
reads. A swath is confirmed only on the operator's, an agency's (ESA's
|
||||
eoPortal included) or an academic page for its platform. From
|
||||
16×, with flat imagery on the globe (not Google 3D tiles) and the view at
|
||||
nadir, every 2 s the view asks the base imagery layer for the tiles it will
|
||||
need 5 to 20 s ahead along the ground track (`prefetchImageryAhead` in
|
||||
`src/ui/cockpitOrbitalView.js`), so the browser has them cached when the
|
||||
view arrives. The level is the one Cesium will draw there
|
||||
(`imageryLevelForView` in `src/cockpitOrbital.js`, following Cesium's own
|
||||
choice: the terrain level whose error spans at most the globe's maximum
|
||||
screen-space error, then the imagery level whose texels are nearest that
|
||||
error, about one to two screen pixels per texel). It asks for at most 12
|
||||
tiles at a time through Cesium's request scheduler, throttled per server and
|
||||
at the lowest priority, so a request is declined while the imagery server's
|
||||
slots are taken by the tiles on screen and is the first dropped when the
|
||||
scheduler is full (a declined or dropped tile is asked for again later, one that failed to load is not); it remembers the last 512 tiles asked for and does not ask
|
||||
for those again, starts over on another base map (cancelling the old map's
|
||||
pending requests), and cancels the requests still pending on exit. Across the track it samples the centre line, a third
|
||||
out and the frame's edges. While tilted off nadir the view looks away from
|
||||
the track, and with the world clock more than a minute from now (paused or
|
||||
played back) it will not pass where the look-ahead points, so nothing is
|
||||
pre-loaded. The arrows tilt the view off nadir in 5° steps
|
||||
along the frame's axes (Up/Down tilt forward/aft along the ground track,
|
||||
Left/Right across it, each axis to ±45°; Shift takes no larger step); Home,
|
||||
`0` or NADIR return to nadir. While tilted the panel shows the total angle
|
||||
from nadir, acos(cos along · cos across), before each axis
|
||||
(`OFF-NADIR 18.0° · FWD 10° · RIGHT 15°`; N, S, E and W for a north-up view)
|
||||
and a NADIR button, and the swath readout turns approximate
|
||||
(`SWATH ≈ 26 KM`): an estimate of the ground width at the view's centre, the
|
||||
nadir footprint scaled by 1/cos² of the tilt across the frame and 1/cos of
|
||||
the tilt along it (`offNadirSwathScale`, flat Earth). At nadir it reads the
|
||||
footprint without ≈. After a zoom step lands, the focus pull holds until the
|
||||
imagery under the view has loaded (at most 2.5 s, `zoomSettleHolding`), then
|
||||
the reticle locks. The look keys and zoom keys keep the bridge's focus
|
||||
guards.
|
||||
|
||||
In orbit the briefing's Live Signals page (OBSERVED / MAPPED PINGS) lists
|
||||
what is around the satellite, as the aircraft Cockpit lists the contacts
|
||||
of its Contacts snapshot (`src/ui/cockpitOrbitalSignals.js`): five rows at
|
||||
most, rebuilt once a second with stable keys, so a row keeps its place and
|
||||
time. TRACK ACQUIRED stays first. Then:
|
||||
|
||||
- **Sites**: the Mapped Installations layer's loaded records around the
|
||||
sub-satellite point (`getNearby`, asked only as far as the reach extends
|
||||
over the ground, and never past 3,000 km, so from a high orbit only the
|
||||
sites within 3,000 km of the point below are listed) that face the
|
||||
satellite and lie within the off-nadir reach, nearest to nadir first, the focused site always among them. A row
|
||||
reads the site's name over its class and off-nadir angle; its button
|
||||
(`Keep the camera on …`) focuses the site, and on the focused site's row,
|
||||
marked as the subject is (`FOCUS · …`, `Release the camera from …`), ends
|
||||
the focus. With Mapped Installations off the layer keeps its records, so
|
||||
nothing is listed then: a SITES STANDBY row says to turn the layer on.
|
||||
- **Satellites**: the nearest shown satellites to the ridden one, debris
|
||||
left out (the satellites layer's bounded `getNearestPositions`, measured
|
||||
from the satellite itself), with their bearing from the top of the frame
|
||||
and distance. A row (`Ride …`) rides that satellite. While there is a
|
||||
satellite ridden before this one, a RETURN · *name* row goes back to it; if
|
||||
that satellite has left the catalogue it is dropped and RETURN offers the
|
||||
one before. Riding stays in orbit: the satellites layer tracks the new
|
||||
satellite (`trackById`), the Cockpit takes the camera back at once and
|
||||
marks it as the ridden subject, any ground focus ends with the view easing
|
||||
back to nadir, and the camera hops to the new satellite along the great
|
||||
circle over 1.2 s (cut under reduced motion), keeping the zoom. Until the
|
||||
camera is on the new satellite the page shows only TRACK ACQUIRED and
|
||||
RETURN (and SITES STANDBY with Mapped Installations off), then rebuilds
|
||||
at once. The new satellite's TRACK ACQUIRED carries the time it was
|
||||
ridden. The hop starts from where the camera was, which does not follow
|
||||
the old satellite on.
|
||||
|
||||
Sites take two rows while satellites fill the rest, more when there are
|
||||
fewer satellites. The Contact card stays hidden in orbit.
|
||||
|
||||
The focus (`src/ui/cockpitOrbitalFocus.js`, geometry in
|
||||
`src/cockpitOrbitalFocus.js`) aims the look each frame from the satellite's
|
||||
position to the site's Earth-fixed position in the same track-up nadir
|
||||
frame the arrows tilt, so the site stays at the centre as the satellite
|
||||
moves. The turn onto it (and between sites) eases what is left of the swing
|
||||
with the arrows' easing on a slower clock (250 ms time constant, about
|
||||
0.75 s to 95%), then holds the site exactly, and cuts under
|
||||
`prefers-reduced-motion`. A single click on a site's marker focuses it too:
|
||||
the pick is resolved through the layer's own pick ids (`findByPick`: a named
|
||||
marker's `{ installationId }` or a site entity). A click anywhere else
|
||||
(a site beyond the reach included), the
|
||||
focused site's row, NADIR, Home or `0` ends the focus and the view eases
|
||||
back to nadir on the same clock; an arrow ends it (or the ease back) and
|
||||
tilts on from where the view is. The layer reloads its thinned view as the
|
||||
camera moves (about once a second in orbit), so a site in view can leave
|
||||
its records for a moment: while the layer is on, a site seen in the last
|
||||
10 s stays a candidate (sites do not move), so the rows hold still and a
|
||||
row's site can be focused, and a focused site keeps its last position. The focus ends by
|
||||
itself, easing back, when the site leaves the reach (either tilt axis past
|
||||
±45°, or the site below the satellite's horizon), when the layer is turned
|
||||
off, when another satellite is ridden, and with the orbital view. A site
|
||||
beyond the reach cannot be focused. While focused the readout gives the
|
||||
single off-nadir angle to the site, as image metadata does
|
||||
(`OFF-NADIR 23.4°`, updated on the HUD's beat and not announced), names it
|
||||
in a polite live region (`FOCUS · Camp Alpha`), and the swath reads
|
||||
approximate as for a tilt, scaled by the tilt onto the site. Clicks still
|
||||
reach Cesium's own handler and never untrack the satellite; a click a tool
|
||||
owns (drawing) is ignored, and the draw tool still borrows the click while
|
||||
it draws. A double-click on a site keeps or starts the focus on it and
|
||||
never reaches Cesium's double-click, which would track the site and end the
|
||||
view (the Cockpit's double-click routing in `src/ui/cockpitInput.js`); any
|
||||
other double-click goes on as before. `src/ui/cockpitOrbitalFocus.test.mjs`
|
||||
covers the rows, the easing in and out, the reach release, switching sites
|
||||
and satellites, the hop, the layer turned off, click handling and the
|
||||
readout, against the layers' method shapes.
|
||||
|
||||
The HUD (`#cockpit-hud[data-subject='satellite']`, body class
|
||||
`cockpit-orbit`) mounts `CockpitOrbital` over the picture: name, class,
|
||||
altitude, orbital speed, sub-satellite point, TRACK UP (NORTH UP without a
|
||||
ground track) with a pointer that turns to keep north marked N on the frame,
|
||||
zoom and swath. Under
|
||||
the vision selector a "CURRENTLY OVER" band names what the sub-satellite point
|
||||
is over, looked up offline from the bundled Natural Earth packs
|
||||
(`src/data/placeBelow.js`: on land the first-level division as Natural Earth
|
||||
names it and the country in the reader's language through `Intl.DisplayNames`,
|
||||
"Sichuan, China", and from 8× the most prominent populated place within half
|
||||
the view's smaller side, so it is in the picture, "Sichuan, China · near
|
||||
Chengdu", from a bundled pack of Natural Earth populated places loaded on
|
||||
first use (each pack loads on the first lookup that needs it, and the
|
||||
top-centre loading status lists "Place names" until it arrives); else the
|
||||
smallest named sea, gulf, strait, channel, sound or bay;
|
||||
else the ocean; nothing in a coastline gap), at most every 2 s and only after
|
||||
20 km of ground travel, or at once when a zoom step changes how much ground
|
||||
the view shows. A division or populated-places pack that fails to load drops
|
||||
only that part (the country still shows); a lookup that fails outright hides
|
||||
the band rather than leave the last place up, and the next HUD beat looks
|
||||
again. The readout card puts
|
||||
the owner country's flag (drawn from one 15 KB sheet of every owner
|
||||
country's flag, built from `flag-icons` by `scripts/build-owner-flags.mjs`
|
||||
and loaded at startup, so a ride never waits for a flag; if the sheet will
|
||||
not load the country code shows as a badge) or a text badge for an organisation before the name, the operator
|
||||
under it, and a NORAD · COSPAR · TLE-epoch line, ending with the element
|
||||
set's age (`<1 H OLD`, then whole hours, then whole days: the gap, either
|
||||
side, between the epoch and the world-clock time the satellite is shown at), whose TLE button
|
||||
unfolds the two element lines; each row is left out when the satellites layer does not
|
||||
know it. The
|
||||
aircraft and bridge instruments, tapes, compass, radar, look controls,
|
||||
position readout, route card and Contact card are hidden; the vision-style
|
||||
switcher, briefing card (Live Signals as above, Local and Regional for the sub-satellite point; the
|
||||
regional brief refreshes at most once a minute) and the reset and EXIT ORBIT
|
||||
buttons stay, square-cornered like the bridge. Exit (`Escape`, EXIT
|
||||
ORBIT) restores the camera's own field of view exactly and returns to the
|
||||
satellite's follow camera through the layer's `refocusTrackedById`. Losing
|
||||
the subject (tracking stopped, Satellites off) or tracking another target
|
||||
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
|
||||
covers entry, camera, zoom, look, HUD and exit.
|
||||
|
||||
## Context coordination
|
||||
|
||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||
@@ -2074,6 +2324,17 @@ Mapped-installation zoom guidance appears in the layer row without counting as
|
||||
a global loading failure. Guidance statuses do not suppress independent refresh
|
||||
errors.
|
||||
|
||||
Transit and Bike Share say the same when no feed or system covers the view,
|
||||
instead of an undated `never`. Above their altitude gates the rows read
|
||||
`GTFS-RT · Fly below 2,850 km to a covered region` and
|
||||
`GBFS · Fly below 48 km to a covered city`; lower, over a city neither covers
|
||||
(London, Tokyo), `GTFS-RT · No feed here yet · N regions available` and
|
||||
`GBFS · No bike share here yet · N systems available` (covered regions,
|
||||
catalogued systems). The chip stays ON. A loading or failed Bike Share city
|
||||
shows its own text. Leaving coverage clears the last-update time. The voice
|
||||
analyst gets the same English notes; both translate them through
|
||||
`translateStatusMessage` (`src/ui/statusText.js`).
|
||||
|
||||
## Places and CCTV request bounds
|
||||
|
||||
With a Google key configured, nearby and text search reject missing, blank,
|
||||
@@ -2567,6 +2828,17 @@ in `ais-store.js`. Common response caps, request coalescing and query parsing
|
||||
have their own modules. `server/providers/local.js` composes these with the
|
||||
remaining providers and retains existing named compatibility exports.
|
||||
|
||||
The tracked flight also gets a photo. The browser asks Planespotters for it
|
||||
directly (`src/sources/live/standalone.js`, read by
|
||||
`src/data/planespottersPhoto.js`), once per aircraft, and the CONTEXT panel's
|
||||
CONTACTS view shows it under the flight's name with the photographer's credit
|
||||
and a link to the photo's page. Military aircraft and a converted TR-3B are
|
||||
never looked up. A flight lookup (type, route or photo) whose source failed
|
||||
temporarily is asked again after a 60-second cool-down while the aircraft
|
||||
stays in view or tracked; an answer, found or not, and a permanent refusal
|
||||
stay final for the session. A route belongs to the callsign it was looked up
|
||||
for, so a callsign change drops the old route. See `DATA_SOURCES.md`.
|
||||
|
||||
`gods-eye-view/server/providers/live` is a Node-only entry for the existing
|
||||
plugins and shared request helpers. Importing it starts no sockets or timers.
|
||||
The existing aircraft normalizer is separately available through the portable
|
||||
@@ -2638,6 +2910,9 @@ reporting are unchanged.
|
||||
|
||||
## FIRMS source status
|
||||
|
||||
The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and
|
||||
Aqua). It skips VIIRS Suomi NPP, which NASA stopped delivering on 2026-11-02.
|
||||
|
||||
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||
The `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
|
||||
including zero-padded hours and minutes; missing or invalid dates and times
|
||||
@@ -2924,7 +3199,7 @@ This is the current runtime/source-of-truth snapshot for the project.
|
||||
> `HELD_SNAP_CONTRADICTION_M` (5 m) ABOVE the held value drops the hold and the
|
||||
> contact is COLD again, back to the floored 2D billboard. The rule is
|
||||
> ONE-SIDED on purpose — it targets BURIAL. A cell reading BELOW a real sample
|
||||
> is the floor chain's expected under-read (one-shot latch over ~111 m,
|
||||
> is the floor chain's expected under-read (a few point readings over ~111 m,
|
||||
> neighbours lean lowest, `displayFloorHeightM` only ever raises), not evidence
|
||||
> of anything, and a two-sided cut was disproved on the track rig by a planted
|
||||
> cell 66.7 m below a real sample at the same spot. Accepted residual, stated
|
||||
@@ -3495,7 +3770,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row
|
||||
> places the single Cockpit entry before Search Nearby Sites. Cockpit removes
|
||||
> the duplicate floating map entry and topline exit; the bottom-center
|
||||
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus
|
||||
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape` own exit, with entry/exit focus
|
||||
> transfer and failure-safe shortcut routing. The exit control sits at the
|
||||
> bottom-center compass position. A Cockpit-only control strip sits
|
||||
> directly above Live Signals, anchored 12px under the REC readout it shares
|
||||
@@ -3920,10 +4195,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> style-switch persistence state. Browser/GPU performance comparison remains
|
||||
> operator-side; the final accounting records only comparable population
|
||||
> reductions and deterministic Node allocation/solve measurements. Those
|
||||
> allocation gates were calibrated on Node.js 24.14.x and run in CI on the
|
||||
> pinned Node.js 24.22.2 with the same ceilings; `package.json` permits
|
||||
> supported product runtimes on Node 24 or 26, while the allocation runner
|
||||
> separately enforces Node 24 for these two calibrated probes. Each probe
|
||||
> allocation gates run on both supported Node majors (24 and 26) with
|
||||
> unchanged byte ceilings; the existing CI matrix executes both probes
|
||||
> on its Node 24.22.2 and Node 26 entries. Each probe
|
||||
> compiles synchronously and discards explicit-GC
|
||||
> transition chunks before applying the unchanged byte ceilings. The unit
|
||||
> runner executes ordinary test files with Node's default parallelism, then
|
||||
@@ -4460,7 +4734,7 @@ its criteria cannot be silently ignored.
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×2 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
@@ -4497,6 +4771,10 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
The incident card rounds the WFIGS size to whole acres, except under one acre,
|
||||
where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not
|
||||
`0 ac`.
|
||||
|
||||
Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in
|
||||
Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA
|
||||
FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last
|
||||
@@ -4996,9 +5274,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord
|
||||
places Transit directly after Street Traffic and before Bike Share. Cameras and
|
||||
utilities retain their own groups. The layer polls
|
||||
registered feeds near the camera every 15 seconds, below a 3,000 km altitude
|
||||
gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
|
||||
gate. Forty-nine keyless regions are available: Boston, Austin, Minneapolis–St Paul,
|
||||
Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
|
||||
(municipal buses and the Mobi-Rio BRT, two feeds), and nine in
|
||||
(municipal buses and the Mobi-Rio BRT, two feeds), Szczecin, the Lisbon Metropolitan Area and the city of Lisbon, Prague and Central Bohemia, Warsaw, Kraków (buses and trams, two feeds), Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses, two feeds), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in
|
||||
Ontario — Toronto (TTC surface vehicles; the subway publishes no
|
||||
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
|
||||
Burlington, Barrie and Kingston. Mode silhouettes
|
||||
@@ -5550,7 +5828,8 @@ silently demoting every later lookup for the session.
|
||||
- **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class.
|
||||
- Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened.
|
||||
- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not.
|
||||
- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/<group>`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: each satellite's purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead.
|
||||
- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/<group>`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Debris can be tracked but is never ridden: the orbital entry gate (`orbitEntryAllowed`) refuses it, so no ORBIT entry is offered and double-click does nothing for it. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead.
|
||||
- The satellite Cockpit subject (`getCockpitSubject()`) also carries `satelliteClass`, `cosparId` (from SATCAT, else TLE line 1), `tle` (`{line1, line2, epochMs}`), `owner` (`{countryCode, label}`: ISO 3166-1 alpha-2 for a single country, `null` code for organisations such as ESA, EUMETSAT or Intelsat and for joint owners) and `operator` (UCS; SpaceX for Starlink). UCS names the operator's country and wins over the SATCAT owner code; CelesTrak's CIS (former USSR) is filed under Russia.
|
||||
- **The STARLINK chip (formerly DENSE) 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 `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` 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.
|
||||
@@ -5679,7 +5958,7 @@ silently demoting every later lookup for the session.
|
||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
|
||||
@@ -5942,12 +6221,11 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
|
||||
> **Reading `npm test` totals:** the count depends on the Node major. The two
|
||||
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
|
||||
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the
|
||||
> calibrated Node 24 runtime; on any other major the runner skips both files and
|
||||
> their 14 tests are absent from the total. A branch total quoted without its
|
||||
> Node version is therefore not reproducible. As of the fly_route cinematic
|
||||
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
||||
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
||||
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) RUN on both
|
||||
> supported Node 24 and Node 26. Unsupported majors may still skip those files
|
||||
> unless `GEV_REQUIRE_ALLOCATION_GATE=1` is set; every current supported CI
|
||||
> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25
|
||||
> runs can therefore remain lower because their 14 allocation tests were skipped.
|
||||
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
|
||||
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
||||
@@ -6191,7 +6469,9 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
||||
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
|
||||
- **The pointer, for an application's own tool.** `createApplicationTools` also returns `pointer` (`data/inputOwnership.js`): `claim(owner)` takes the pointer as Draw does and returns its lease, or `null` while another tool holds it; `release(lease)` gives it back; `isFree()` lets the application's own click handlers yield, as the layers' do. An owner without a name in `pointerOwnerNames.js` is named by its id when another tool reports who holds the pointer.
|
||||
- **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one.
|
||||
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
|
||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||
@@ -6367,6 +6647,8 @@ apply to `public/` assets or built-preview serving.
|
||||
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
||||
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
|
||||
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
|
||||
- `/api/flights`, `/api/military`, and `/api/route` answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET`, before rate limiting and any upstream call.
|
||||
- `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding.
|
||||
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail).
|
||||
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
|
||||
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
|
||||
@@ -6457,17 +6739,18 @@ apply to `public/` assets or built-preview serving.
|
||||
ending, by its own switch or one of its layers going off, restores the
|
||||
pre-Contacts layers but keeps the ridden one and the view; Live Signals then
|
||||
drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible
|
||||
Cockpit actions, the `C` shortcut and voice use the same gate; voice entry
|
||||
still opens Contacts first, as its layer-targeted entry navigates through
|
||||
Contacts. The ship bridge has its own own-layer gate (`bridgeEntryAllowed`;
|
||||
see [Ship bridge view](#ship-bridge-view)).
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
|
||||
Cockpit actions, double-click and voice use the same gate; voice entry still
|
||||
opens Contacts first, as its layer-targeted entry navigates through Contacts.
|
||||
The ship bridge and the orbital view have their own own-layer gates
|
||||
(`bridgeEntryAllowed`, `orbitEntryAllowed`; see
|
||||
[Ship bridge view](#ship-bridge-view) and [Orbital view](#orbital-view)).
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving bothLine truncated
|
||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
|
||||
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
|
||||
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
|
||||
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
|
||||
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
||||
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
||||
|
||||
Generated
+8
@@ -28,6 +28,7 @@
|
||||
"@happy-dom/global-registrator": "^20.14.6",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"flag-icons": "7.5.0",
|
||||
"happy-dom": "^20.14.6",
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
@@ -2411,6 +2412,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"@happy-dom/global-registrator": "^20.14.6",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"flag-icons": "7.5.0",
|
||||
"happy-dom": "^20.14.6",
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
@@ -98,6 +99,7 @@
|
||||
"node": "./server/providers/live.js"
|
||||
},
|
||||
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
|
||||
"./sources/planespotters": "./src/data/planespottersPhoto.js",
|
||||
"./server/providers/places": {
|
||||
"node": "./server/providers/places.js"
|
||||
},
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
* Natural Earth 10m admin-1 states and provinces (public domain).
|
||||
* src/data/local_data/us_census_counties/counties.json
|
||||
* US Census Bureau cartographic boundary counties (public domain).
|
||||
* src/data/local_data/natural_earth/populated_places.json
|
||||
* Natural Earth 10m populated places as points, for the orbital view's
|
||||
* "near <city>" (`src/data/placeBelow.js`; public domain).
|
||||
*
|
||||
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
|
||||
* byte for byte. Downloads are cached outside the repository (default: the
|
||||
* system temp directory) and are never committed.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties|places]
|
||||
*
|
||||
* The provenance READMEs beside each pack record these parameters.
|
||||
*/
|
||||
@@ -101,6 +104,9 @@ export const PARAMS = Object.freeze({
|
||||
elsewhereMinPopulation: 50_000,
|
||||
cityStateMaxKm2: 1_000,
|
||||
},
|
||||
// Every populated place, kept as a point: name, position and the two
|
||||
// prominence ranks the orbital view picks "near <city>" by.
|
||||
places: { decimals: 3 },
|
||||
});
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -688,6 +694,47 @@ async function buildAdmin1() {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPlaces() {
|
||||
const params = PARAMS.places;
|
||||
const source = await fetchPinned('places');
|
||||
const factor = 10 ** params.decimals;
|
||||
const round = (value) => Math.round(value * factor) / factor;
|
||||
const places = JSON.parse(source.bytes.toString('utf8'))
|
||||
.features.map((f) => f.properties)
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name &&
|
||||
Number.isFinite(p.latitude) &&
|
||||
Number.isFinite(p.longitude) &&
|
||||
Number.isFinite(p.scalerank),
|
||||
)
|
||||
.map((p) => [
|
||||
p.name,
|
||||
round(p.latitude),
|
||||
round(p.longitude),
|
||||
p.scalerank,
|
||||
Number.isFinite(p.rank_max) ? p.rank_max : 0,
|
||||
])
|
||||
.sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0]));
|
||||
return {
|
||||
meta: {
|
||||
title: 'Natural Earth populated places',
|
||||
source: 'ne_10m_populated_places_simple',
|
||||
url: SOURCES.places.url,
|
||||
sha256: source.digest,
|
||||
commit: NE_COMMIT,
|
||||
license:
|
||||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||||
schema:
|
||||
'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]',
|
||||
decimals: params.decimals,
|
||||
curation: params,
|
||||
script: 'scripts/build-admin-packs.mjs',
|
||||
},
|
||||
places,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCounties() {
|
||||
const params = PARAMS.counties;
|
||||
const source = await fetchPinned('counties');
|
||||
@@ -768,4 +815,13 @@ if (isMain) {
|
||||
'src/data/local_data/us_census_counties/counties.json',
|
||||
await buildCounties(),
|
||||
);
|
||||
if (!only || only === 'places') {
|
||||
const relative = 'src/data/local_data/natural_earth/populated_places.json';
|
||||
const pack = await buildPlaces();
|
||||
const text = JSON.stringify(pack) + '\n';
|
||||
await writeFile(path.join(ROOT, relative), text);
|
||||
console.log(
|
||||
`${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
|
||||
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
|
||||
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
|
||||
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
|
||||
*
|
||||
* src/data/local_data/owner_flags/owner_flags.png the sheet
|
||||
* src/ui/ownerFlagSheet.js its layout (generated)
|
||||
*
|
||||
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
|
||||
* floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions
|
||||
* reproduces both files.
|
||||
*
|
||||
* Usage: node scripts/build-owner-flags.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as prettier from 'prettier';
|
||||
import sharp from 'sharp';
|
||||
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const FLAGS_DIR = path.join(
|
||||
path.dirname(require.resolve('flag-icons/package.json')),
|
||||
'flags/4x3',
|
||||
);
|
||||
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
|
||||
|
||||
/** One flag's cell, CSS pixels × 2. */
|
||||
export const CELL = Object.freeze({ width: 40, height: 30 });
|
||||
/**
|
||||
* Transparent sheet pixels after each flag, so a browser smoothing a scaled
|
||||
* flag (a 125% display) never reaches into its neighbour.
|
||||
*/
|
||||
export const GUTTER = 2;
|
||||
export const COLUMNS = 16;
|
||||
|
||||
const SHEET = path.join(
|
||||
ROOT,
|
||||
'src/data/local_data/owner_flags/owner_flags.png',
|
||||
);
|
||||
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
|
||||
|
||||
async function flagCell(code) {
|
||||
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
|
||||
return sharp(svg, { density: 300 })
|
||||
.resize(CELL.width, CELL.height, { fit: 'fill' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const codes = [...OWNER_COUNTRY_CODES];
|
||||
const rows = Math.ceil(codes.length / COLUMNS);
|
||||
const pitchX = CELL.width + GUTTER;
|
||||
const pitchY = CELL.height + GUTTER;
|
||||
const cells = await Promise.all(codes.map(flagCell));
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: COLUMNS * pitchX,
|
||||
height: rows * pitchY,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite(
|
||||
cells.map((input, i) => ({
|
||||
input,
|
||||
left: (i % COLUMNS) * pitchX,
|
||||
top: Math.floor(i / COLUMNS) * pitchY,
|
||||
})),
|
||||
)
|
||||
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
|
||||
.toBuffer();
|
||||
await writeFile(SHEET, sheet);
|
||||
|
||||
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
|
||||
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
|
||||
export const OWNER_FLAG_SHEET = Object.freeze({
|
||||
cellWidthPx: ${CELL.width},
|
||||
cellHeightPx: ${CELL.height},
|
||||
gutterPx: ${GUTTER},
|
||||
columns: ${COLUMNS},
|
||||
rows: ${rows},
|
||||
codes: Object.freeze(${JSON.stringify(codes)}),
|
||||
});
|
||||
`;
|
||||
await writeFile(
|
||||
LAYOUT,
|
||||
await prettier.format(layout, {
|
||||
...(await prettier.resolveConfig(LAYOUT)),
|
||||
filepath: LAYOUT,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
|
||||
);
|
||||
@@ -2,9 +2,9 @@
|
||||
'''Maintainer-only UCS Satellite Database pack build (openpyxl).
|
||||
|
||||
Reads the UCS Excel release and writes the compact pack the satellites layer
|
||||
loads: one row per satellite, keyed by NORAD number, with a string-table index
|
||||
for its purpose, the one column the layer's classes read. Starlink rows are
|
||||
left out: the layer files every STARLINK object under its own class by name.
|
||||
loads: one row per satellite, keyed by NORAD number, with string-table indexes
|
||||
for the repeated text columns. Starlink rows are left out: the layer files
|
||||
every STARLINK object under its own class and operator without them.
|
||||
|
||||
Usage: python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"
|
||||
'''
|
||||
@@ -26,6 +26,8 @@ URL = 'https://www.ucsusa.org/satellite_database'
|
||||
|
||||
# Output column -> spreadsheet header.
|
||||
COLUMNS = {
|
||||
'operator': 'Operator/Owner',
|
||||
'operatorCountry': 'Country of Operator/Owner',
|
||||
'purpose': 'Purpose',
|
||||
}
|
||||
STARLINK = re.compile(r'\bstarlink\b', re.I)
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
"src/data/terrainHeightsProxy.test.mjs",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tomtomTiles.test.mjs",
|
||||
"src/data/tileBudget.js",
|
||||
"src/data/tileBudget.test.mjs",
|
||||
"src/data/firmsCsv.js",
|
||||
"src/data/firmsCsv.test.mjs",
|
||||
"src/tooling/environmentProviders.test.mjs",
|
||||
@@ -330,6 +332,10 @@
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/cockpitOrbital.css",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/layers/satellites/sensors.test.mjs",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/ui/styles/command-dock-compact.css",
|
||||
"src/app/stateChannel.js",
|
||||
"src/app/stateChannel.test.mjs",
|
||||
@@ -1071,6 +1077,7 @@
|
||||
"src/layers/traffic/roadModes.test.mjs",
|
||||
"scripts/qa-traffic-motion.mjs",
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"src/layers/traffic/lineRebuild.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
@@ -1122,6 +1129,7 @@
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"src/tooling/responseCompression.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
@@ -1359,5 +1367,10 @@
|
||||
"src/layers/wildlifeSightings/cache.integration.test.mjs",
|
||||
"src/layers/wildlifeSightings/density.test.mjs",
|
||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||
"server/providers/inaturalist/proxy.fixture.mjs"
|
||||
"server/providers/inaturalist/proxy.fixture.mjs",
|
||||
"scripts/build-owner-flags.mjs",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs"
|
||||
]
|
||||
@@ -40,6 +40,9 @@
|
||||
"src/ui/views/panels/CockpitContext.vue",
|
||||
"src/ui/views/panels/CockpitRoute.vue",
|
||||
"src/ui/views/panels/CockpitRadar.vue",
|
||||
"src/ui/views/panels/CockpitOrbital.vue",
|
||||
"src/ui/ownerFlag.js",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
@@ -202,6 +205,7 @@
|
||||
"modules": [
|
||||
"src/sources/adsbFields.js",
|
||||
"server/providers/live.js",
|
||||
"server/providers/common/compress.js",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
"server/providers/aircraft/opensky.js",
|
||||
@@ -238,6 +242,11 @@
|
||||
"modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"],
|
||||
"external": []
|
||||
},
|
||||
"planespotters-source": {
|
||||
"exports": ["./sources/planespotters"],
|
||||
"modules": ["src/data/planespottersPhoto.js"],
|
||||
"external": []
|
||||
},
|
||||
"place-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/places"],
|
||||
@@ -315,6 +324,7 @@
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
@@ -322,7 +332,7 @@
|
||||
},
|
||||
"traffic-source": {
|
||||
"exports": ["./sources/traffic"],
|
||||
"modules": ["src/data/tomtomTiles.js"],
|
||||
"modules": ["src/data/tomtomTiles.js", "src/data/tileBudget.js"],
|
||||
"external": []
|
||||
},
|
||||
"firms-csv": {
|
||||
@@ -900,6 +910,19 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -917,6 +940,8 @@
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
@@ -990,6 +1015,8 @@
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
@@ -1042,6 +1069,20 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/ownerFlag.js",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -1359,7 +1400,8 @@
|
||||
"src/sources/live/contract.js",
|
||||
"src/sources/live/index.js",
|
||||
"src/sources/live/standalone.js",
|
||||
"src/sources/live/vessels.js"
|
||||
"src/sources/live/vessels.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1426,7 +1468,8 @@
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
"src/time/julian.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1651,6 +1694,7 @@
|
||||
"src/data/satellitePass.js",
|
||||
"src/sources/tle.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/cockpitSubject.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
@@ -1681,7 +1725,9 @@
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/time/worldClock.js"
|
||||
@@ -1749,6 +1795,7 @@
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -1837,6 +1884,7 @@
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/data/trafficBounds.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/trafficFlowStyle.js",
|
||||
@@ -2067,8 +2115,10 @@
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/displayText.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
@@ -2428,6 +2478,8 @@
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/creditKeyboard.js",
|
||||
@@ -2538,6 +2590,7 @@
|
||||
"src/data/telegeographySubmarineCables.js",
|
||||
"src/data/terrainHeights.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/data/tr3bRegistry.js",
|
||||
"src/data/trackedCamera.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
@@ -2722,6 +2775,7 @@
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/cockpitSubject.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
@@ -2871,6 +2925,15 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/ownerFlag.js",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -2961,6 +3024,7 @@
|
||||
"src/voice/chatPreferences.js",
|
||||
"src/voice/panelLayout.js",
|
||||
"src/voice/voiceMiniPreview.js",
|
||||
"src/data/displayText.js",
|
||||
"src/voice/gevActions.js",
|
||||
"src/agent/worldPacket.js",
|
||||
"src/voice/speech.js",
|
||||
@@ -3381,7 +3445,10 @@
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/data/ucsSatellites.js"
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3522,6 +3589,7 @@
|
||||
"src/data/scenePick.js",
|
||||
"src/data/spriteOrder.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/data/tr3bRegistry.js",
|
||||
"src/data/trackedCamera.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
@@ -3695,6 +3763,7 @@
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/cockpitSubject.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
@@ -3804,6 +3873,7 @@
|
||||
"src/search/coordinateParser.js",
|
||||
"src/search/presetGeocoder.js",
|
||||
"src/app/localGeojsonServices.js",
|
||||
"src/data/displayText.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
@@ -4037,7 +4107,10 @@
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/data/ucsSatellites.js"
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4116,6 +4189,7 @@
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/sources/militaryTileGeometry.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
"src/sources/openFreeMapOutlines.js",
|
||||
@@ -4208,6 +4282,9 @@
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
@@ -4473,9 +4550,11 @@
|
||||
"exports": ["./layers/alpr/source"],
|
||||
"modules": [
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/data/displayText.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -4514,6 +4593,7 @@
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/sources/capability.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -4544,7 +4624,9 @@
|
||||
"modules": [
|
||||
"src/layers/satellites/source.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js"
|
||||
],
|
||||
@@ -4554,6 +4636,7 @@
|
||||
"exports": ["./layers/traffic/source"],
|
||||
"modules": [
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/footprint.js",
|
||||
@@ -5128,6 +5211,7 @@
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tileBudget.js",
|
||||
"src/sources/capability.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/layers/launches/source.js",
|
||||
@@ -5166,12 +5250,16 @@
|
||||
"src/layers/submarineCables/bundledSource.js",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/data/displayText.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/sources/eventPacks.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/data/ucsSatellites.js"
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
||||
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
const localHttpErrors = [];
|
||||
@@ -825,6 +827,7 @@ try {
|
||||
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
|
||||
// assertion when camera motion supersedes the manager's initial update.
|
||||
const readinessPage = await browser.newPage();
|
||||
await stubPlanespotters(readinessPage);
|
||||
let deferredInstallationReadiness;
|
||||
try {
|
||||
await readinessPage.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -77,6 +78,7 @@ const browser = await puppeteer.launch({
|
||||
|
||||
async function openPage({ aircraft = false } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -223,6 +224,7 @@ async function main() {
|
||||
const errors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
const { lon, lat } = CENTER;
|
||||
|
||||
@@ -21,6 +21,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback = null) => {
|
||||
@@ -651,6 +652,7 @@ async function main() {
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport(VIEWPORT);
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as track-regression.mjs)
|
||||
@@ -466,6 +467,7 @@ async function main() {
|
||||
const failedResponses = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
|
||||
+12
-12
@@ -239,11 +239,11 @@ function satisfiesEngines(version, range) {
|
||||
* re-resolve the interpreter from PATH and hand the gate a different Node than
|
||||
* the one the check believes it selected.
|
||||
* @param {string} version e.g. "v24.19.0"
|
||||
* @returns {boolean} true only for a parseable Node 24.
|
||||
* @returns {boolean} true for a parseable supported Node 24 or 26.
|
||||
*/
|
||||
function isCalibratedAllocationRuntime(version) {
|
||||
const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10));
|
||||
return maj === 24;
|
||||
return maj === 24 || maj === 26;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); };
|
||||
|
||||
// ─── A · REPO GATES ───────────────────────────────────────────────────────
|
||||
check({
|
||||
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)',
|
||||
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)',
|
||||
run: async () => {
|
||||
const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8'));
|
||||
const range = pkg.engines?.node || '(unset)';
|
||||
@@ -557,7 +557,7 @@ check({
|
||||
if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`);
|
||||
// An off-range runtime is an environment problem, not a product defect —
|
||||
// but it silently disables the allocation gate, so say so loudly. A3 then
|
||||
// runs that gate under a discovered Node 24.
|
||||
// runs that gate under a discovered calibrated Node 24 fallback.
|
||||
return env.node24
|
||||
? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV')
|
||||
: fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`);
|
||||
@@ -579,13 +579,13 @@ check({
|
||||
});
|
||||
|
||||
check({
|
||||
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)',
|
||||
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors',
|
||||
run: async () => {
|
||||
// npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still
|
||||
// exits 0 when the runtime is not Node 24. A green suite is therefore NOT
|
||||
// proof the gate ran. Force it, under Node 24 when one is discoverable.
|
||||
// exits 0 when the runtime is outside the calibrated supported majors. A green
|
||||
// suite is therefore NOT proof the gate ran. Force it under a calibrated runtime.
|
||||
//
|
||||
// ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm
|
||||
// ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm
|
||||
// re-resolves the interpreter from PATH and can land back on the system
|
||||
// Node even when THIS process is already 24: running the whole matrix under
|
||||
// `mise exec [email protected] --`, the npm shell-out still re-execed system
|
||||
@@ -597,10 +597,10 @@ check({
|
||||
const v = await sh(bin, ['--version'], { timeoutMs: 60000 });
|
||||
const version = (v.out || '').trim();
|
||||
if (!isCalibratedAllocationRuntime(version)) {
|
||||
// run-unit-tests.mjs refuses to measure calibrated budgets off Node 24,
|
||||
// run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors,
|
||||
// so a wrong binary means the gate never ran. That is this check failing
|
||||
// to establish its own claim — not evidence about the product.
|
||||
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`);
|
||||
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`);
|
||||
}
|
||||
const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], {
|
||||
timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' },
|
||||
@@ -611,9 +611,9 @@ check({
|
||||
};
|
||||
|
||||
const [maj] = process.versions.node.split('.').map(Number);
|
||||
if (maj === 24) return runGate(process.execPath, 'this runtime');
|
||||
if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime');
|
||||
if (env.node24) return runGate(env.node24.bin, env.node24.label);
|
||||
return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN');
|
||||
return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Keep browser QA off third-party aircraft photo services.
|
||||
*
|
||||
* Tracking a civil flight asks Planespotters for the aircraft's photo. QA
|
||||
* runs track synthetic hexes, so a real request only adds a third-party
|
||||
* dependency and, when the service refuses a headless browser, a console
|
||||
* error that fails the "no console errors" checks. Every photo lookup is
|
||||
* answered with no photos instead, without leaving the browser.
|
||||
*/
|
||||
|
||||
/** Origin of the aircraft photo lookup. */
|
||||
export const PLANESPOTTERS_ORIGIN = 'https://api.planespotters.net';
|
||||
|
||||
/** The answer every stubbed lookup gets: no photos for this aircraft. */
|
||||
export const PLANESPOTTERS_EMPTY_BODY = JSON.stringify({ photos: [] });
|
||||
|
||||
/** Whether a URL is a photo lookup the stub answers. */
|
||||
export function isPlanespottersRequest(url) {
|
||||
try {
|
||||
return new URL(String(url)).origin === PLANESPOTTERS_ORIGIN;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer an intercepted photo lookup from a page that already uses request
|
||||
* interception. Returns false for any other request, which the caller
|
||||
* handles as before.
|
||||
* @param {import('puppeteer').HTTPRequest} request
|
||||
* @returns {boolean} Whether the request was answered.
|
||||
*/
|
||||
export function respondToPlanespotters(request) {
|
||||
if (!isPlanespottersRequest(request.url())) return false;
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: PLANESPOTTERS_EMPTY_BODY,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub photo lookups in a page that does not intercept requests: wraps
|
||||
* `fetch` before any app code runs, so a lookup never reaches the network.
|
||||
* Composes with other `fetch` shims in either order.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function stubPlanespotters(page) {
|
||||
await page.evaluateOnNewDocument(
|
||||
(origin, body) => {
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const href =
|
||||
typeof input === 'string' ? input : input?.url || String(input);
|
||||
let target = '';
|
||||
try {
|
||||
target = new URL(href, location.href).origin;
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
if (target === origin) {
|
||||
return Promise.resolve(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
},
|
||||
PLANESPOTTERS_ORIGIN,
|
||||
PLANESPOTTERS_EMPTY_BODY,
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
|
||||
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
|
||||
@@ -186,6 +187,7 @@ async function main() {
|
||||
const httpErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
|
||||
+11
-11
@@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([
|
||||
'src/overlays/worldOverlayAllocation.test.mjs',
|
||||
]);
|
||||
|
||||
/** Whether this runtime matches the one the allocation budgets were calibrated on. */
|
||||
/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */
|
||||
export function isCalibratedAllocationRuntime(version = process.versions.node) {
|
||||
return Number.parseInt(String(version).split('.')[0], 10) === 24;
|
||||
const major = Number.parseInt(String(version).split('.')[0], 10);
|
||||
return major === 24 || major === 26;
|
||||
}
|
||||
|
||||
/** Require the runtime on which allocation budgets were calibrated. */
|
||||
export function assertNode24AllocationRuntime(version = process.versions.node) {
|
||||
/** Require a runtime on which allocation budgets are calibrated. */
|
||||
export function assertCalibratedAllocationRuntime(version = process.versions.node) {
|
||||
if (!isCalibratedAllocationRuntime(version)) {
|
||||
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`);
|
||||
throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
@@ -92,19 +93,18 @@ export function runUnitTests() {
|
||||
const parallelStatus = runTests(['--test', ...plan.parallel]);
|
||||
if (parallelStatus !== 0) return parallelStatus;
|
||||
|
||||
// The GC-bracketed budgets are calibrated on Node 24 and are meaningless on
|
||||
// other allocators. A contributor's suite must stay green on any supported
|
||||
// engine (package.json permits >=24), so uncalibrated runtimes skip the
|
||||
// The GC-bracketed budgets are calibrated on every supported Node major.
|
||||
// Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the
|
||||
// probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI /
|
||||
// release batteries) to make an uncalibrated runtime a hard failure.
|
||||
if (!isCalibratedAllocationRuntime()) {
|
||||
if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') {
|
||||
assertNode24AllocationRuntime();
|
||||
assertCalibratedAllocationRuntime();
|
||||
}
|
||||
console.warn(
|
||||
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
|
||||
+ `budgets are calibrated for Node 24, running ${process.versions.node}. `
|
||||
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
|
||||
+ `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. `
|
||||
+ 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { respondToPlanespotters } from './qa-photo-stub.mjs';
|
||||
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
@@ -287,6 +288,8 @@ async function main() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Aircraft photo lookups never reach the third party: no photos.
|
||||
if (respondToPlanespotters(request)) return;
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
|
||||
@@ -83,6 +83,15 @@ export function adsbLolProxy() {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
// After the /track hand-off, so /track keeps its own method handling.
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
Allow: 'GET',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||
serve(res, 200, _cache, 'HIT');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
|
||||
import { encodedBody, gzipBody } from '../common/compress.js';
|
||||
import {
|
||||
coalesceProxyRequest,
|
||||
readResponseJsonCapped,
|
||||
@@ -18,6 +19,11 @@ let _openskyTokenExpiry = 0;
|
||||
let _openskyTokenPromise = null;
|
||||
/** @type {string|null} Cached upstream response body (JSON text). */
|
||||
let _openskyCacheBody = null;
|
||||
/**
|
||||
* @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it.
|
||||
* Null when the body is not worth compressing.
|
||||
*/
|
||||
let _openskyCacheGzip = null;
|
||||
/** @type {number} HTTP status of the cached response. */
|
||||
let _openskyCacheStatus = 0;
|
||||
/** @type {number} Epoch-ms when the response was cached. */
|
||||
@@ -399,8 +405,12 @@ async function fetchAdsbLolPointFallback(req) {
|
||||
ADSBLOL_POINT_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
const normalized = normalizeAdsbLolPointResponse(payload);
|
||||
const body = JSON.stringify(normalized);
|
||||
const record = {
|
||||
body: JSON.stringify(normalized),
|
||||
body,
|
||||
// Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the
|
||||
// extra memory.
|
||||
gzip: await gzipBody(body),
|
||||
cachedAt: Date.now(),
|
||||
count: normalized.states.length,
|
||||
};
|
||||
@@ -429,6 +439,7 @@ async function fetchAdsbLolPointFallback(req) {
|
||||
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
|
||||
const fallback = await fetchAdsbLolPointFallback(req);
|
||||
if (!fallback) return false;
|
||||
const encoded = encodedBody(req, fallback.body, fallback.gzip);
|
||||
res.writeHead(200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: fallback.cacheStatus,
|
||||
@@ -439,8 +450,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
|
||||
'X-Flight-Source': 'adsb.lol',
|
||||
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
|
||||
'X-Flight-Count': String(fallback.count),
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(fallback.body);
|
||||
res.end(encoded.body);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -484,6 +496,15 @@ export function openSkyProxy() {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
// After the /track hand-off, so /track keeps its own method handling.
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
Allow: 'GET',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
res.setHeader('X-Feed-Source', 'OpenSky Network');
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
process.env.OPENSKY_AUTH_MODE,
|
||||
@@ -514,9 +535,13 @@ export function openSkyProxy() {
|
||||
reason: 'cached',
|
||||
};
|
||||
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
|
||||
res.writeHead(
|
||||
_openskyCacheStatus || 200,
|
||||
buildOpenSkyHeaders({
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
_openskyCacheBody,
|
||||
_openskyCacheGzip,
|
||||
);
|
||||
res.writeHead(_openskyCacheStatus || 200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: isStale ? 'STALE' : 'HIT',
|
||||
requestedMode: cachedMeta.requestedMode || requestedMode,
|
||||
usedMode: cachedMeta.usedMode || 'unknown',
|
||||
@@ -532,8 +557,9 @@ export function openSkyProxy() {
|
||||
? (_openskyCooldownUntil - now) / 1000
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
res.end(_openskyCacheBody);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
return;
|
||||
}
|
||||
// Cooling down with nothing cached (cold start into a rate limit):
|
||||
@@ -628,7 +654,11 @@ export function openSkyProxy() {
|
||||
// Only cache successful responses — error responses (401/403/429/5xx)
|
||||
// should not be served from cache on subsequent requests
|
||||
if (upstream.ok) {
|
||||
// Compress before writing either slot, so a request arriving
|
||||
// mid-refresh still reads a body with its own frame.
|
||||
const frame = await gzipBody(body);
|
||||
_openskyCacheBody = body;
|
||||
_openskyCacheGzip = frame;
|
||||
_openskyCacheStatus = upstream.status;
|
||||
_openskyCacheTime = now;
|
||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||
@@ -681,9 +711,13 @@ export function openSkyProxy() {
|
||||
// Serve the last-good body instead of the 429 when we have one —
|
||||
// the layer keeps rendering (STALE-cued) instead of dying.
|
||||
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
||||
res.writeHead(
|
||||
200,
|
||||
buildOpenSkyHeaders({
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
_openskyCacheBody,
|
||||
_openskyCacheGzip,
|
||||
);
|
||||
res.writeHead(200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: 'STALE',
|
||||
requestedMode,
|
||||
usedMode,
|
||||
@@ -691,8 +725,9 @@ export function openSkyProxy() {
|
||||
staleSeconds: (now - _openskyCacheTime) / 1000,
|
||||
retryAfterSeconds: cooldownMs / 1000,
|
||||
}),
|
||||
);
|
||||
res.end(_openskyCacheBody);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -734,16 +769,23 @@ export function openSkyProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(
|
||||
upstream.status,
|
||||
buildOpenSkyHeaders({
|
||||
// The frame belongs to this body only if this body was cached. An
|
||||
// uncached error must not borrow the previous snapshot's frame.
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
body,
|
||||
_openskyCacheBody === body ? _openskyCacheGzip : null,
|
||||
);
|
||||
res.writeHead(upstream.status, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: 'MISS',
|
||||
requestedMode,
|
||||
usedMode,
|
||||
reason,
|
||||
}),
|
||||
);
|
||||
res.end(body);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
} catch (e) {
|
||||
console.error('[OpenSky Proxy]', e.message);
|
||||
if (_openskyCacheBody) {
|
||||
@@ -754,17 +796,22 @@ export function openSkyProxy() {
|
||||
usedMode: 'unknown',
|
||||
reason: 'cached_stale',
|
||||
};
|
||||
res.writeHead(
|
||||
_openskyCacheStatus || 200,
|
||||
buildOpenSkyHeaders({
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
_openskyCacheBody,
|
||||
_openskyCacheGzip,
|
||||
);
|
||||
res.writeHead(_openskyCacheStatus || 200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: 'STALE',
|
||||
requestedMode:
|
||||
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
|
||||
usedMode: cachedMeta.usedMode || 'unknown',
|
||||
reason: cachedMeta.reason || 'cached_stale',
|
||||
}),
|
||||
);
|
||||
res.end(_openskyCacheBody);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
return;
|
||||
}
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { gzip } from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
|
||||
/**
|
||||
* Opt-in gzip for text bodies a proxy already holds in memory.
|
||||
*
|
||||
* Not a middleware: `cctv/media.js` streams bodies and answers range requests,
|
||||
* and several providers forward binary upstream types that gzip only grows.
|
||||
* `encodedBody` returns the body with its headers so the two cannot disagree.
|
||||
*/
|
||||
|
||||
/** Skip compression for bodies smaller than 1 KiB. */
|
||||
const MIN_COMPRESSED_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* RFC 9110 section 12.4.2 `qvalue`. Matching the whole token keeps `q=1oops`
|
||||
* and `q=2` unreadable instead of rounding them into acceptance.
|
||||
*/
|
||||
const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
|
||||
|
||||
/**
|
||||
* Whether `Accept-Encoding` offers gzip. Anything unreadable (no header, a bad
|
||||
* q value, `x-gzip`) reads as no: every client can decode identity.
|
||||
*
|
||||
* @param {import('node:http').IncomingMessage} request
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function acceptsGzip(request) {
|
||||
const header = request?.headers?.['accept-encoding'];
|
||||
if (typeof header !== 'string') return false;
|
||||
return header.split(',').some((part) => {
|
||||
const [token, ...parameters] = part.split(';');
|
||||
if (token.trim().toLowerCase() !== 'gzip') return false;
|
||||
const quality = parameters
|
||||
.map((parameter) => parameter.trim().toLowerCase())
|
||||
.find((parameter) => parameter.startsWith('q='));
|
||||
if (quality === undefined) return true;
|
||||
// `q=0` names gzip and refuses it.
|
||||
const value = quality.slice(2);
|
||||
return QVALUE.test(value) && Number(value) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gzip a body once for a caller that caches it. Async, so the ~860 KB OpenSky
|
||||
* snapshot does not block other requests while it compresses.
|
||||
*
|
||||
* Returns null, meaning "serve identity", when the body is under 1 KiB or gzip
|
||||
* does not shrink it.
|
||||
*
|
||||
* @param {string|Buffer} body - the exact bytes the identity response sends.
|
||||
* @returns {Promise<Buffer|null>}
|
||||
*/
|
||||
export async function gzipBody(body) {
|
||||
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
||||
if (raw.byteLength < MIN_COMPRESSED_BYTES) return null;
|
||||
const compressed = await gzipAsync(raw);
|
||||
return compressed.byteLength < raw.byteLength ? compressed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the body and the headers that describe it. `Vary` is on both branches
|
||||
* so a shared cache never serves one encoding to a client that asked for the
|
||||
* other.
|
||||
*
|
||||
* @param {import('node:http').IncomingMessage} request
|
||||
* @param {string|Buffer} identity - the uncompressed body.
|
||||
* @param {Buffer|null} compressed - `gzipBody(identity)`, or null.
|
||||
* @returns {{body: string|Buffer, headers: Record<string,string>}}
|
||||
*/
|
||||
export function encodedBody(request, identity, compressed) {
|
||||
if (compressed && acceptsGzip(request)) {
|
||||
return {
|
||||
body: compressed,
|
||||
headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' },
|
||||
};
|
||||
}
|
||||
return { body: identity, headers: { Vary: 'Accept-Encoding' } };
|
||||
}
|
||||
@@ -131,9 +131,9 @@ export async function fetchFirms(
|
||||
* NASA FIRMS live active-fire proxy with a memory + disk cache.
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
|
||||
*
|
||||
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
|
||||
* satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua,
|
||||
* ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy)
|
||||
* Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no
|
||||
* cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, ~1 km, numeric
|
||||
* 0-100 confidence), fetched sequentially (quota courtesy)
|
||||
* with `days=2` (`days=1` means "current UTC day", nearly empty just after
|
||||
* 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS
|
||||
* quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
|
||||
@@ -141,6 +141,8 @@ export async function fetchFirms(
|
||||
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
|
||||
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
|
||||
*
|
||||
* VIIRS_SNPP_NRT is left out: NASA stopped delivering Suomi NPP on 2026-11-02.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires}
|
||||
* GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions}
|
||||
@@ -153,12 +155,7 @@ export async function fetchFirms(
|
||||
export function firmsProxy() {
|
||||
const TTL_MS = 30 * 60_000;
|
||||
const STATUS_TTL_MS = 5 * 60_000;
|
||||
const SOURCES = [
|
||||
'VIIRS_NOAA20_NRT',
|
||||
'VIIRS_NOAA21_NRT',
|
||||
'VIIRS_SNPP_NRT',
|
||||
'MODIS_NRT',
|
||||
];
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT'];
|
||||
/**
|
||||
* Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about
|
||||
* 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a
|
||||
|
||||
@@ -69,7 +69,7 @@ function validMessages(messages) {
|
||||
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
|
||||
* as `resolve_area`'s area ids do. Every argument is still checked against
|
||||
* the action's own contract before the tool runs, so the model loses a hint,
|
||||
* not a rule. Realtime voice accepts these patterns and keeps them.
|
||||
* not a rule. Realtime voice refuses them too now (#1618) and uses this as well.
|
||||
*/
|
||||
export function chatSchema(schema) {
|
||||
if (Array.isArray(schema)) return schema.map(chatSchema);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './constants.js';
|
||||
import { realtimeInstructions } from './instructions.js';
|
||||
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
|
||||
import { chatSchema } from './chat.js';
|
||||
import { resolveOverpassUpstreams } from '../overpass/constants.js';
|
||||
import {
|
||||
preferCodexOAuth,
|
||||
@@ -128,6 +129,19 @@ function createRealtimeOAuthLoginHandler({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools one Realtime session is offered, as OpenAI accepts them: it now
|
||||
* refuses a whole session whose tool schema uses `\p{…}` patterns (#1618), as
|
||||
* Chat Completions did (#1616), so voice drops the same pattern hints chat does.
|
||||
*/
|
||||
function realtimeSessionTools(tools, { overpass = false } = {}) {
|
||||
return offeredTools(tools, { overpass }).map((tool) =>
|
||||
tool.parameters
|
||||
? { ...tool, parameters: chatSchema(tool.parameters) }
|
||||
: tool,
|
||||
);
|
||||
}
|
||||
|
||||
function createRealtimeTokenHandler({
|
||||
annotationGuidance,
|
||||
endpoint = 'https://api.openai.com/v1/realtime/client_secrets',
|
||||
@@ -303,7 +317,7 @@ function createRealtimeTokenHandler({
|
||||
output: { voice },
|
||||
},
|
||||
instructions: realtimeInstructions(annotationGuidance, { overpass }),
|
||||
tools: offeredTools(tools, { overpass }),
|
||||
tools: realtimeSessionTools(tools, { overpass }),
|
||||
tool_choice: 'auto',
|
||||
},
|
||||
};
|
||||
@@ -370,4 +384,5 @@ export {
|
||||
createRealtimeOAuthLoginHandler,
|
||||
createRealtimeOAuthStatusHandler,
|
||||
createRealtimeTokenHandler,
|
||||
realtimeSessionTools,
|
||||
};
|
||||
@@ -227,6 +227,11 @@ export function installRouteMiddleware(
|
||||
// only shape that asks the upstream for them. A response WITHOUT steps is
|
||||
// byte for byte what this endpoint has always returned.
|
||||
middlewares.use('/api/route', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
const fail = (msg) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: msg }));
|
||||
|
||||
@@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) {
|
||||
: shape;
|
||||
}
|
||||
|
||||
/** The kind of shape a row action is offered for: `area` or `line`. */
|
||||
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
|
||||
|
||||
const lonLat = (point) =>
|
||||
Array.isArray(point)
|
||||
? [Number(point[0]), Number(point[1])]
|
||||
: [Number(point?.lon), Number(point?.lat)];
|
||||
const finitePairs = (points) =>
|
||||
(Array.isArray(points) ? points : [])
|
||||
.map(lonLat)
|
||||
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
|
||||
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
|
||||
const wrapLon = (lon) =>
|
||||
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
/** Whether consecutive positions step across the antimeridian once wrapped. */
|
||||
const crossesAntimeridian = (points) =>
|
||||
points.some(
|
||||
(point, index) =>
|
||||
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
|
||||
);
|
||||
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
|
||||
const inRange = (points) => {
|
||||
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
|
||||
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
|
||||
return crossesAntimeridian(wrapped) ? null : wrapped;
|
||||
};
|
||||
const closed = (ring) => {
|
||||
if (ring.length < 3) return ring;
|
||||
const [first, last] = [ring[0], ring[ring.length - 1]];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
};
|
||||
|
||||
/**
|
||||
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
|
||||
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
|
||||
* handle has several parts), a line a `LineString`. A pin, a shape too small
|
||||
* to be one, or one that crosses the antimeridian has none, so it is offered
|
||||
* no action rather than a shape that wraps the wrong way round the world.
|
||||
*/
|
||||
export function drawMarkGeometry(mark) {
|
||||
if (mark?.type === 'route') {
|
||||
const path = inRange(finitePairs(mark.path));
|
||||
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
|
||||
}
|
||||
if (mark?.type !== 'area') return null;
|
||||
const ringOf = (points) => {
|
||||
const ring = inRange(closed(finitePairs(points)));
|
||||
return ring?.length >= 4 ? ring : null;
|
||||
};
|
||||
if (Array.isArray(mark.polygons) && mark.polygons.length) {
|
||||
const polygons = [];
|
||||
for (const rings of mark.polygons) {
|
||||
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
|
||||
// A part that cannot be given whole is no part: never a hole dropped.
|
||||
if (!parts.length || parts.some((ring) => !ring)) return null;
|
||||
polygons.push(parts);
|
||||
}
|
||||
return polygons.length > 1
|
||||
? { type: 'MultiPolygon', coordinates: polygons }
|
||||
: { type: 'Polygon', coordinates: polygons[0] };
|
||||
}
|
||||
const ring = ringOf(mark.ring);
|
||||
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions an application adds to Draw's rows, checked once: each has an
|
||||
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
|
||||
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
|
||||
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
|
||||
* as the row shows it). Malformed or repeated ids are dropped.
|
||||
*/
|
||||
export function drawMarkActions(actions = []) {
|
||||
const kept = new Map();
|
||||
for (const action of Array.isArray(actions) ? actions : []) {
|
||||
const wellFormed =
|
||||
typeof action?.id === 'string' &&
|
||||
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
|
||||
typeof action.icon === 'string' &&
|
||||
typeof action.label === 'function' &&
|
||||
typeof action.run === 'function' &&
|
||||
Array.isArray(action.shapes);
|
||||
if (!wellFormed || kept.has(action.id)) continue;
|
||||
kept.set(
|
||||
action.id,
|
||||
Object.freeze({
|
||||
id: action.id,
|
||||
icon: action.icon,
|
||||
label: action.label,
|
||||
shapes: Object.freeze(
|
||||
action.shapes.filter((shape) =>
|
||||
Object.values(ACTION_SHAPES).includes(shape),
|
||||
),
|
||||
),
|
||||
accepts:
|
||||
typeof action.accepts === 'function' ? action.accepts : () => true,
|
||||
run: action.run,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze([...kept.values()]);
|
||||
}
|
||||
|
||||
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
|
||||
export function drawMarkActionsFor(mark, actions = []) {
|
||||
const shape = ACTION_SHAPES[mark?.type];
|
||||
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
|
||||
const geometry = drawMarkGeometry(mark);
|
||||
if (!geometry) return { shape, geometry, actions: [] };
|
||||
return {
|
||||
shape,
|
||||
geometry,
|
||||
actions: actions.filter((action) => {
|
||||
if (!action.shapes.includes(shape)) return false;
|
||||
try {
|
||||
return action.accepts(geometry, shape) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** An action's words for a row; an application's label that throws shows its id. */
|
||||
function actionLabel(action, title) {
|
||||
try {
|
||||
const text = action.label(title);
|
||||
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
|
||||
} catch {
|
||||
return action.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for the hand-drawn marks on the board, in board order. Text is
|
||||
* worded here, in the current language; the view only lays it out.
|
||||
* @param {object[]} marks The engine's `list()`.
|
||||
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
|
||||
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
|
||||
*/
|
||||
export function presentDrawMarks(marks = []) {
|
||||
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
const offered = drawMarkActionsFor(mark, actions).actions;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
busy: notes.get(mark.id)?.busy === true,
|
||||
note: offered.length ? notes.get(mark.id)?.text || '' : '',
|
||||
actions: offered.map((action) => ({
|
||||
id: action.id,
|
||||
icon: action.icon,
|
||||
label: actionLabel(action, title),
|
||||
})),
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
|
||||
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkActions,
|
||||
drawMarkActionsFor,
|
||||
drawMarkFallbackName,
|
||||
drawMarkGeometry,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
@@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () =>
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
|
||||
test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => {
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
}),
|
||||
{
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
}).coordinates[0].at(-1),
|
||||
[0, 0],
|
||||
'an open ring is closed',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
{ lon: 1, lat: 2, height: 9 },
|
||||
{ lon: 3, lat: 4, height: 0 },
|
||||
],
|
||||
}),
|
||||
{
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }),
|
||||
null,
|
||||
);
|
||||
const square = (x) => [
|
||||
[x, 0],
|
||||
[x + 1, 0],
|
||||
[x + 1, 1],
|
||||
[x, 0],
|
||||
];
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: square(0),
|
||||
polygons: [[square(0)], [square(5)]],
|
||||
}),
|
||||
{ type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] },
|
||||
'an area handle with several parts keeps them all',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
polygons: [
|
||||
[
|
||||
square(0),
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.3, 0.2],
|
||||
],
|
||||
],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
'a hole that cannot be given whole is never dropped silently',
|
||||
);
|
||||
});
|
||||
|
||||
test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => {
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[370, 1],
|
||||
[371, 1],
|
||||
],
|
||||
}).coordinates,
|
||||
[
|
||||
[10, 1],
|
||||
[11, 1],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[180, 0],
|
||||
[179, 0],
|
||||
[179, 1],
|
||||
],
|
||||
}).coordinates[0][0],
|
||||
[180, 0],
|
||||
'the antimeridian itself stays put',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[179.9, 0],
|
||||
[-179.9, 0],
|
||||
[-179.9, 1],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[179.5, 0],
|
||||
[180.5, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[0, 91],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('row actions are checked once: malformed ones and repeated ids are dropped', () => {
|
||||
const run = () => {};
|
||||
const label = () => 'x';
|
||||
const actions = drawMarkActions([
|
||||
{ id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] },
|
||||
{ id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] },
|
||||
{
|
||||
id: 'tag',
|
||||
icon: 'sell',
|
||||
label,
|
||||
run,
|
||||
shapes: ['area', 'line', 'pin'],
|
||||
},
|
||||
{ id: 'tag', icon: 'other', label, run, shapes: ['area'] },
|
||||
null,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })),
|
||||
[{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }],
|
||||
);
|
||||
assert.deepEqual(drawMarkActions('nope'), []);
|
||||
const area = {
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
};
|
||||
const offer = drawMarkActionsFor(area, actions);
|
||||
assert.equal(offer.shape, 'area');
|
||||
assert.deepEqual(
|
||||
offer.actions.map((a) => a.id),
|
||||
['tag'],
|
||||
);
|
||||
const refusing = drawMarkActions([
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'a',
|
||||
label,
|
||||
run,
|
||||
shapes: ['area'],
|
||||
accepts: () => {
|
||||
throw new Error('no');
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
drawMarkActionsFor(area, refusing).actions,
|
||||
[],
|
||||
'an accepts that throws offers nothing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0]
|
||||
.actions,
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -50,9 +50,28 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkActions,
|
||||
drawMarkActionsFor,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
||||
/** The longest note a row action leaves under its row, in characters. */
|
||||
const NOTE_MAX = 200;
|
||||
/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */
|
||||
function noteText(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= NOTE_MAX) return trimmed;
|
||||
const cut = trimmed.slice(0, NOTE_MAX - 1);
|
||||
const space = cut.lastIndexOf(' ');
|
||||
return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** The id this tool claims the pointer under. */
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
@@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS;
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* `markActions` are the application's own buttons on an area's or a line's
|
||||
* row (drawMarks.js `drawMarkActions`); a press runs the action with the
|
||||
* shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`).
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({
|
||||
@@ -76,7 +98,12 @@ export function initDrawTool({
|
||||
navigation = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
markActions = [],
|
||||
}) {
|
||||
const rowActions = drawMarkActions(markActions);
|
||||
// What a row action said last on each drawn shape: `{ run, busy, text }`.
|
||||
const notes = new Map();
|
||||
let noteRun = 0;
|
||||
// A page without the Draw controls (the Display controls view omitted) has
|
||||
// no draw tool.
|
||||
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
|
||||
@@ -115,14 +142,19 @@ export function initDrawTool({
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () =>
|
||||
const present = () => {
|
||||
const marks = annotations.list?.() || [];
|
||||
// A note goes with its shape.
|
||||
for (const id of notes.keys())
|
||||
if (!marks.some((mark) => mark.id === id)) notes.delete(id);
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
marks: presentDrawMarks(marks, rowActions, notes),
|
||||
});
|
||||
};
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
@@ -450,6 +482,56 @@ export function initDrawTool({
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application's row action `actionId` on drawn shape `id`, if the
|
||||
* shape is still on the board and still offered it. Returns whether it ran.
|
||||
* What the action returns, or resolves to, is the row's note (a measurement,
|
||||
* "saved"): the row is busy until it settles, a newer press or the shape's
|
||||
* removal makes a late answer moot, and a failure clears the note, so an
|
||||
* action words its own refusals and resolves with them.
|
||||
*/
|
||||
function runMarkAction(id, actionId) {
|
||||
const mark = (annotations.list?.() || []).find(
|
||||
(entry) => entry.id === id && entry.manual === true,
|
||||
);
|
||||
const offer = drawMarkActionsFor(mark, rowActions);
|
||||
const action = offer.actions.find((entry) => entry.id === actionId);
|
||||
if (!action || notes.get(mark.id)?.busy) return false;
|
||||
const run = ++noteRun;
|
||||
const settle = (text) => {
|
||||
if (destroyed || notes.get(mark.id)?.run !== run) return;
|
||||
notes.set(mark.id, {
|
||||
run,
|
||||
busy: false,
|
||||
text: noteText(text),
|
||||
});
|
||||
present();
|
||||
};
|
||||
const fail = (error) => {
|
||||
console.warn(`[draw] ${actionId} failed`, error);
|
||||
settle('');
|
||||
};
|
||||
notes.set(mark.id, { run, busy: true, text: '' });
|
||||
let answer;
|
||||
try {
|
||||
answer = action.run({
|
||||
id: mark.id,
|
||||
shape: offer.shape,
|
||||
name: mark.name || '',
|
||||
title: mark.name || drawMarkFallbackName(mark),
|
||||
geometry: offer.geometry,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
return false;
|
||||
}
|
||||
if (typeof answer?.then === 'function') {
|
||||
present();
|
||||
Promise.resolve(answer).then(settle, fail);
|
||||
} else settle(answer);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
@@ -478,6 +560,7 @@ export function initDrawTool({
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
runDrawMarkAction: runMarkAction,
|
||||
});
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
@@ -500,13 +583,15 @@ export function initDrawTool({
|
||||
setActive,
|
||||
setShape: chooseShape,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
marks: () =>
|
||||
presentDrawMarks(annotations.list?.() || [], rowActions, notes),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
runMarkAction,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
|
||||
@@ -223,7 +223,7 @@ function fakeAnnotations() {
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness({ navigation = null } = {}) {
|
||||
function harness({ navigation = null, markActions = [] } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) {
|
||||
viewer,
|
||||
annotations,
|
||||
navigation,
|
||||
markActions,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
@@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
|
||||
}
|
||||
});
|
||||
|
||||
test('an application\'s row actions show on the shapes they are for and run with the shape', async () => {
|
||||
const ran = [];
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{
|
||||
id: 'tag',
|
||||
icon: 'sell',
|
||||
label: (name) => `Tag ${name}`,
|
||||
shapes: ['area', 'line'],
|
||||
run: (shape) => ran.push(['tag', shape]),
|
||||
},
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'straighten',
|
||||
label: (name) => `Measure ${name}`,
|
||||
shapes: ['area'],
|
||||
accepts: (geometry) => geometry.type === 'Polygon',
|
||||
run: (shape) => ran.push(['measure', shape]),
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] },
|
||||
{ id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] },
|
||||
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
|
||||
[
|
||||
['r1', [
|
||||
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
|
||||
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
|
||||
]],
|
||||
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
|
||||
['p1', []],
|
||||
],
|
||||
);
|
||||
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
|
||||
assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action');
|
||||
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
|
||||
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||
assert.deepEqual(ran, [
|
||||
['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }],
|
||||
['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }],
|
||||
]);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an action that throws is reported and leaves the row alone', async () => {
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
|
||||
],
|
||||
});
|
||||
const warn = console.warn;
|
||||
const warned = [];
|
||||
console.warn = (...args) => warned.push(args[0]);
|
||||
try {
|
||||
h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
|
||||
h.annotations.changed();
|
||||
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
|
||||
assert.deepEqual(warned, ['[draw] boom failed']);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
|
||||
const pending = [];
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'straighten',
|
||||
label: (name) => `Measure ${name}`,
|
||||
shapes: ['area'],
|
||||
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
|
||||
},
|
||||
{ id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' },
|
||||
{ id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` },
|
||||
],
|
||||
});
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const board = h.annotations;
|
||||
const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
|
||||
board.marks.push(area('r1'), area('r2'));
|
||||
board.changed();
|
||||
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
|
||||
h.act('runDrawMarkAction', 'r1', 'long');
|
||||
assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word');
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [true, '']);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press');
|
||||
pending[0].resolve('412 km², 38 km of edge');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
|
||||
h.act('runDrawMarkAction', 'r1', 'measure');
|
||||
pending[1].reject(new Error('refused'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
|
||||
h.act('runDrawMarkAction', 'r2', 'measure');
|
||||
board.marks.splice(1, 1);
|
||||
board.changed();
|
||||
pending[2].resolve('late');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(row('r2'), undefined);
|
||||
board.marks.push(area('r2'));
|
||||
board.changed();
|
||||
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
|
||||
+22
-1
@@ -1,6 +1,11 @@
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import {
|
||||
claimPointer,
|
||||
isPointerFree,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||
import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
@@ -35,6 +40,7 @@ export function createApplicationTools({
|
||||
startChrome,
|
||||
onSceneDirector,
|
||||
sceneDataPacks,
|
||||
drawMarkActions = [],
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
@@ -80,12 +86,15 @@ export function createApplicationTools({
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button. A row's fly-to
|
||||
// goes through the shell's navigation policy, as Import's does.
|
||||
// `drawMarkActions` are an application's own buttons on a drawn area's or
|
||||
// line's row (annotations/drawMarks.js); the standalone shell adds none.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
markActions: drawMarkActions,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
@@ -242,5 +251,17 @@ export function createApplicationTools({
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return { sceneDirector, annotations, voiceCommands };
|
||||
// An application's own tool takes the pointer as Draw does, and its own
|
||||
// click handlers yield while a tool holds it, as the layers' do
|
||||
// (data/inputOwnership.js).
|
||||
return {
|
||||
sceneDirector,
|
||||
annotations,
|
||||
voiceCommands,
|
||||
pointer: {
|
||||
claim: claimPointer,
|
||||
release: releasePointer,
|
||||
isFree: isPointerFree,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function ordered(source, needles, label) {
|
||||
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
|
||||
const enter = cockpitEnter.toString();
|
||||
ordered(enter, [
|
||||
'if (!info || (!vessel && !entity?.position)) return false;',
|
||||
'if (!info || (!vessel && !satellite && !entity?.position)) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
|
||||
@@ -170,6 +170,18 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
||||
return value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cloud pass runs for a Cockpit subject's layer. Not in orbit:
|
||||
* the pass fades out above 24 km (weatherAltitudeFactors), so a satellite
|
||||
* would pay for it and see nothing. The saved preference is left as it is
|
||||
* for the next aircraft or bridge; cloud cover from orbit is the Weather
|
||||
* layer's.
|
||||
* @param {string|null|undefined} layerId
|
||||
*/
|
||||
export function cockpitCloudsShowFor(layerId) {
|
||||
return layerId !== 'satellites';
|
||||
}
|
||||
|
||||
/**
|
||||
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
|
||||
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||
@@ -207,10 +219,12 @@ class CockpitCloudEffectsController {
|
||||
this.enabled = this.readEnabledPreference();
|
||||
|
||||
this.onResize = () => this.resize();
|
||||
this.orbit = false;
|
||||
this.onCockpitMode = (event) => {
|
||||
const active =
|
||||
event?.detail?.active ??
|
||||
document.body.classList.contains('cockpit-mode');
|
||||
this.orbit = active && !cockpitCloudsShowFor(event?.detail?.layerId);
|
||||
if (active && this.enabled) this.start();
|
||||
else this.stop();
|
||||
};
|
||||
@@ -344,7 +358,8 @@ class CockpitCloudEffectsController {
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
this.frame !== null ||
|
||||
this.suspended
|
||||
this.suspended ||
|
||||
this.orbit
|
||||
)
|
||||
return;
|
||||
if (!this.gl) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
cockpitCloudRenderSize,
|
||||
cockpitCloudsShowFor,
|
||||
cockpitWeatherRefreshDue,
|
||||
cockpitWeatherEnabledFromStoredValue,
|
||||
} from './cockpitCloudEffects.js';
|
||||
@@ -23,6 +24,13 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i
|
||||
assert.equal(cockpitWeatherEnabledFromStoredValue('1'), true);
|
||||
});
|
||||
|
||||
test('the cloud pass stays off in orbit and runs for aircraft and bridges', () => {
|
||||
assert.equal(cockpitCloudsShowFor('satellites'), false);
|
||||
for (const layerId of ['flights', 'military', 'ais-live-vessels', null]) {
|
||||
assert.equal(cockpitCloudsShowFor(layerId), true, String(layerId));
|
||||
}
|
||||
});
|
||||
|
||||
test('cockpit weather refreshes after time or meaningful movement', () => {
|
||||
const anchor = { latitude: 30, longitude: -97 };
|
||||
assert.equal(cockpitWeatherRefreshDue({
|
||||
|
||||
@@ -181,3 +181,64 @@ export function lookReadoutParts(look, restPitchDeg) {
|
||||
angleDeg: Math.abs(pitch),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The orbital view's off-nadir limit on each axis. The orbital look reuses
|
||||
* the bridge's `{yawDeg, pitchDeg}` shape over a fixed nadir frame: yaw tilts
|
||||
* the view east (positive) or west, pitch tilts it north (positive) or
|
||||
* south. Single 5° steps only; the bridge keeps its own limits.
|
||||
*/
|
||||
export const ORBITAL_LOOK_LIMIT_DEG = 45;
|
||||
|
||||
/** Clamp one orbital look axis to ±ORBITAL_LOOK_LIMIT_DEG; NaN rests. */
|
||||
function clampOrbitalAxis(deg) {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
const value = Math.min(
|
||||
ORBITAL_LOOK_LIMIT_DEG,
|
||||
Math.max(-ORBITAL_LOOK_LIMIT_DEG, deg),
|
||||
);
|
||||
return value === 0 ? 0 : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp an orbital look to the off-nadir limit on both axes.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look East and north tilt.
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function clampOrbitalLook(look) {
|
||||
return {
|
||||
yawDeg: clampOrbitalAxis(look?.yawDeg),
|
||||
pitchDeg: clampOrbitalAxis(look?.pitchDeg),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one arrow step to an orbital look: the bridge's stepping on the
|
||||
* same 5° grid, then the off-nadir clamp.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} target Current look target.
|
||||
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From
|
||||
* {@link lookKeyStep}.
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function stepOrbitalLook(target, step) {
|
||||
return clampOrbitalLook(stepLook(clampOrbitalLook(target), step));
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of an off-nadir readout, for the Cockpit to word: the north or
|
||||
* south tilt and the east or west tilt, each null at rest, in whole degrees.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look Orbital look target.
|
||||
* @returns {{northSouth: 'N'|'S'|null, northSouthDeg: number,
|
||||
* eastWest: 'E'|'W'|null, eastWestDeg: number}}
|
||||
*/
|
||||
export function orbitalLookReadoutParts(look) {
|
||||
const { yawDeg, pitchDeg } = clampOrbitalLook(look);
|
||||
const north = Math.round(pitchDeg);
|
||||
const east = Math.round(yawDeg);
|
||||
return {
|
||||
northSouth: north > 0 ? 'N' : north < 0 ? 'S' : null,
|
||||
northSouthDeg: Math.abs(north),
|
||||
eastWest: east > 0 ? 'E' : east < 0 ? 'W' : null,
|
||||
eastWestDeg: Math.abs(east),
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
LOOK_COARSE_STEP_DEG,
|
||||
LOOK_STEP_DEG,
|
||||
ORBITAL_LOOK_LIMIT_DEG,
|
||||
clampLookPitch,
|
||||
clampOrbitalLook,
|
||||
easeLook,
|
||||
lookIsOffset,
|
||||
lookKeyStep,
|
||||
lookReadoutParts,
|
||||
orbitalLookReadoutParts,
|
||||
stepLook,
|
||||
stepOrbitalLook,
|
||||
wrapLookYaw,
|
||||
} from './cockpitLook.js';
|
||||
|
||||
@@ -135,3 +139,57 @@ test('the readout names the side, the bearing and the elevation', () => {
|
||||
'astern',
|
||||
);
|
||||
});
|
||||
|
||||
test('an orbital look steps 5° per arrow and stops at 45° on each axis', () => {
|
||||
let look = { yawDeg: 0, pitchDeg: 0 };
|
||||
for (let index = 0; index < 3; index += 1)
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowUp'));
|
||||
assert.deepEqual(look, { yawDeg: 0, pitchDeg: 15 });
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowRight'));
|
||||
assert.deepEqual(look, { yawDeg: 5, pitchDeg: 15 });
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowDown'));
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowLeft'));
|
||||
}
|
||||
assert.deepEqual(look, {
|
||||
yawDeg: -ORBITAL_LOOK_LIMIT_DEG,
|
||||
pitchDeg: -ORBITAL_LOOK_LIMIT_DEG,
|
||||
});
|
||||
assert.deepEqual(clampOrbitalLook({ yawDeg: 90, pitchDeg: NaN }), {
|
||||
yawDeg: 45,
|
||||
pitchDeg: 0,
|
||||
});
|
||||
assert.deepEqual(clampOrbitalLook({ yawDeg: 180, pitchDeg: -60 }), {
|
||||
yawDeg: 45,
|
||||
pitchDeg: -45,
|
||||
});
|
||||
});
|
||||
|
||||
test('the orbital readout names north or south and east or west', () => {
|
||||
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: 15, pitchDeg: 10 }), {
|
||||
northSouth: 'N',
|
||||
northSouthDeg: 10,
|
||||
eastWest: 'E',
|
||||
eastWestDeg: 15,
|
||||
});
|
||||
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: -5, pitchDeg: 0 }), {
|
||||
northSouth: null,
|
||||
northSouthDeg: 0,
|
||||
eastWest: 'W',
|
||||
eastWestDeg: 5,
|
||||
});
|
||||
assert.equal(
|
||||
orbitalLookReadoutParts({ yawDeg: 0, pitchDeg: -45 }).northSouth,
|
||||
'S',
|
||||
);
|
||||
});
|
||||
|
||||
test('the bridge look keeps its own limits beside the orbital clamp', () => {
|
||||
const look = stepLook({ yawDeg: 45, pitchDeg: 45 }, lookKeyStep('ArrowUp'));
|
||||
assert.deepEqual(look, { yawDeg: 45, pitchDeg: 50 });
|
||||
assert.equal(
|
||||
stepLook({ yawDeg: 45, pitchDeg: 0 }, lookKeyStep('ArrowRight', true))
|
||||
.yawDeg,
|
||||
90,
|
||||
);
|
||||
});
|
||||
@@ -79,7 +79,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
|
||||
assert.doesNotMatch(html, /id="cockpit-exit"/);
|
||||
assert.match(
|
||||
html,
|
||||
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
|
||||
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
|
||||
);
|
||||
const topCenterActions = html.match(
|
||||
/<nav id="top-center-actions"[\s\S]*?<\/nav>/,
|
||||
@@ -163,7 +163,8 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
|
||||
// Orbit alone opens optical (src/ui/cockpitOrbitalView.test.mjs).
|
||||
/this\.setVisionMode\(\s*satellite \? 'optical' : this\.getInitialVisionMode\(\)\s*,?\s*\)/,
|
||||
'Cockpit must enter on the mode matching the active map preset',
|
||||
);
|
||||
assert.match(
|
||||
@@ -232,14 +233,12 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
);
|
||||
});
|
||||
|
||||
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
|
||||
test('open utilities own the first Escape and Cockpit leaves C alone', () => {
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
||||
assert.match(
|
||||
keydown,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
);
|
||||
// C is the app-wide CCTV shortcut; Cockpit never claims it.
|
||||
assert.doesNotMatch(keydown, /=== 'c'/);
|
||||
});
|
||||
|
||||
test('the Contact panel never hides itself out from under its own NEXT button', () => {
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
/**
|
||||
* Camera policy for the orbital (satellite) view: the eye rides the tracked
|
||||
* satellite and looks straight down at the Earth's centre. Satellites publish
|
||||
* no attitude, so the picture is turned to the ground track (track-up; see
|
||||
* nadirOrientation's `headingDeg`), or north-up for a satellite with none
|
||||
* (geostationary). This module holds the zoom steps,
|
||||
* the field of view, the nadir orientation, the ground footprint and the zoom
|
||||
* transition curves. No DOM, Cesium or i18n dependencies: vectors are plain
|
||||
* `{x, y, z}` objects in Earth-fixed (ECEF) metres, so the policy is
|
||||
* unit-testable and the Cockpit camera copies them into its own scratch.
|
||||
*/
|
||||
|
||||
/** Mean Earth radius used for footprint and framing geometry, metres. */
|
||||
export const ORBITAL_EARTH_RADIUS_M = 6371008.8;
|
||||
|
||||
/**
|
||||
* The whole zoom ladder. Steps past ORBITAL_BASE_MAX_ZOOM belong only to
|
||||
* imaging satellites with a known sensor, each up to the step its sensor's
|
||||
* swath allows (`orbitalMaxZoom`).
|
||||
*/
|
||||
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8, 16, 32, 64, 128]);
|
||||
|
||||
/** The tightest step for a satellite without sensor data. */
|
||||
export const ORBITAL_BASE_MAX_ZOOM = 8;
|
||||
|
||||
/** The zoom an orbital view opens at. */
|
||||
export const ORBITAL_DEFAULT_ZOOM = 1;
|
||||
|
||||
/**
|
||||
* Vertical field of view at 1x for low orbits. From the ISS (~420 km) a 60°
|
||||
* frame spans roughly 490 km of ground top to bottom: a regional picture
|
||||
* with the curvature just out of frame, which 8x narrows to ~60 km.
|
||||
*/
|
||||
export const ORBITAL_BASE_FOV_LEO_RAD = (60 * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* How much wider than the Earth's disk a high orbit's 1x frame is. A fixed
|
||||
* 60° frame from GEO (~35,786 km) would show a 17° Earth floating in black;
|
||||
* above about 8,000 km the base frame instead fits the whole disk with this
|
||||
* margin, so 1x is "the whole Earth" and the steps zoom into it.
|
||||
*/
|
||||
export const ORBITAL_DISK_MARGIN = 1.15;
|
||||
|
||||
/** Duration of one zoom step's field-of-view change, ms. */
|
||||
export const ORBITAL_ZOOM_TRANSITION_MS = 400;
|
||||
|
||||
/** How long the reticle shows its lock tick after a step settles, ms. */
|
||||
export const ORBITAL_LOCK_TICK_MS = 220;
|
||||
|
||||
/** Peak focus-pull blur at the middle of a zoom step, CSS pixels. */
|
||||
export const ORBITAL_MAX_BLUR_PX = 6;
|
||||
|
||||
/**
|
||||
* Longest a zoom step holds its focus pull for the new field of view's
|
||||
* imagery, ms. The camera moves at orbital speed, so the tiles may never
|
||||
* all arrive; the hold gives them a start, then the reticle locks anyway.
|
||||
*/
|
||||
export const ORBITAL_SETTLE_MAX_MS = 2500;
|
||||
|
||||
/** The soft blur held while a step waits for its imagery, CSS pixels. */
|
||||
export const ORBITAL_SETTLE_BLUR_PX = 2.5;
|
||||
|
||||
/** Relative slack so a zoom computed in floating point still meets its step. */
|
||||
const ZOOM_EPSILON = 1e-6;
|
||||
|
||||
/**
|
||||
* The next tighter ladder step, up to `maxZoom`; at the cap the zoom stays.
|
||||
* A zoom between steps (the SENSOR step) moves to the step above it.
|
||||
* @param {number} zoom Current zoom.
|
||||
* @param {number} [maxZoom] The satellite's tightest step.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function nextOrbitalZoom(zoom, maxZoom = ORBITAL_BASE_MAX_ZOOM) {
|
||||
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
||||
const next = ORBITAL_ZOOM_LADDER.find(
|
||||
(step) => step > current * (1 + ZOOM_EPSILON) && step <= maxZoom,
|
||||
);
|
||||
return next ?? current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next wider ladder step; 1x stays where it is. A zoom between steps
|
||||
* moves to the step below it.
|
||||
* @param {number} zoom Current zoom.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function previousOrbitalZoom(zoom) {
|
||||
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
||||
const wider = ORBITAL_ZOOM_LADDER.filter(
|
||||
(step) => step < current * (1 - ZOOM_EPSILON),
|
||||
);
|
||||
return wider.length ? wider[wider.length - 1] : ORBITAL_DEFAULT_ZOOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground width the view spans at a zoom: what the SWATH readout shows.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @param {number} zoom Magnification.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number|null} Kilometres, or null for an unusable altitude.
|
||||
*/
|
||||
export function viewWidthKm(altitudeM, zoom, aspect) {
|
||||
return footprintWidthKm(
|
||||
altitudeM,
|
||||
fovForZoom(orbitalBaseFovRad(altitudeM), zoom),
|
||||
aspect,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A satellite's tightest zoom step at its live altitude: the tightest ladder
|
||||
* step whose view is still at least as wide as its sensor's narrowest
|
||||
* standard swath, so the view never shows finer ground than the sensor
|
||||
* images. Never below ORBITAL_BASE_MAX_ZOOM; no sensor swath, no extra steps.
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm Narrowest standard swath.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number} One of ORBITAL_ZOOM_LADDER.
|
||||
*/
|
||||
export function orbitalMaxZoom(altitudeM, sensorSwathKm, aspect) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0)
|
||||
return ORBITAL_BASE_MAX_ZOOM;
|
||||
let max = ORBITAL_BASE_MAX_ZOOM;
|
||||
for (const step of ORBITAL_ZOOM_LADDER) {
|
||||
if (step <= ORBITAL_BASE_MAX_ZOOM) continue;
|
||||
const width = viewWidthKm(altitudeM, step, aspect);
|
||||
if (!Number.isFinite(width)) break;
|
||||
if (width * (1 + ZOOM_EPSILON) < sensorSwathKm) break;
|
||||
max = step;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* The zoom at which the view is exactly as wide as the sensor's swath, for
|
||||
* the SENSOR step; null when that lies outside 1x..`maxZoom` (a swath wider
|
||||
* than the 1x view, or narrower than the tightest allowed step).
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @param {number} maxZoom The satellite's tightest step.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function sensorSwathZoom(altitudeM, sensorSwathKm, aspect, maxZoom) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
const widest = viewWidthKm(altitudeM, 1, aspect);
|
||||
const tightest = viewWidthKm(altitudeM, maxZoom, aspect);
|
||||
if (!Number.isFinite(widest) || !Number.isFinite(tightest)) return null;
|
||||
if (sensorSwathKm > widest * (1 + ZOOM_EPSILON)) return null;
|
||||
if (sensorSwathKm < tightest * (1 - ZOOM_EPSILON)) return null;
|
||||
// The width falls monotonically with zoom: bisect in log space.
|
||||
let low = 0;
|
||||
let high = Math.log2(maxZoom);
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
const mid = (low + high) / 2;
|
||||
if (viewWidthKm(altitudeM, 2 ** mid, aspect) > sensorSwathKm) low = mid;
|
||||
else high = mid;
|
||||
}
|
||||
return Math.round(2 ** ((low + high) / 2) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the view's width a sensor swath covers, for the strip drawn
|
||||
* to scale in the reticle: 0..1, 1 when the swath fills the view or more.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number|null|undefined} viewKm The view's current ground width.
|
||||
* @returns {number|null} Null without both widths.
|
||||
*/
|
||||
export function sensorStripFraction(sensorSwathKm, viewKm) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
if (!Number.isFinite(viewKm) || viewKm <= 0) return null;
|
||||
return Math.min(1, sensorSwathKm / viewKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular radius of the Earth's disk seen from an altitude, radians.
|
||||
* @param {number} altitudeM Height above the mean sphere, metres.
|
||||
* @returns {number} Half-angle from nadir to the limb (π/2 at the surface).
|
||||
*/
|
||||
export function earthAngularRadiusRad(altitudeM) {
|
||||
const h = Math.max(0, Number.isFinite(altitudeM) ? altitudeM : 0);
|
||||
return Math.asin(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical field of view at 1x for an altitude, radians.
|
||||
*
|
||||
* Low orbits get the fixed 60° regional frame. Higher orbits, where 60° would
|
||||
* leave the Earth small in a black frame, get the Earth's disk plus
|
||||
* ORBITAL_DISK_MARGIN. The two meet near 8,100 km, so the base never jumps
|
||||
* as an eccentric orbit climbs: GPS (~20,200 km) opens at ~32°, GEO at ~20°.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @returns {number} Vertical field of view, radians.
|
||||
*/
|
||||
export function orbitalBaseFovRad(altitudeM) {
|
||||
const diskFov = 2 * earthAngularRadiusRad(altitudeM) * ORBITAL_DISK_MARGIN;
|
||||
return Math.min(ORBITAL_BASE_FOV_LEO_RAD, diskFov);
|
||||
}
|
||||
|
||||
/**
|
||||
* Field of view at an optical zoom: magnification divides the tangent of
|
||||
* the half-angle, as a lens's focal length does.
|
||||
* @param {number} baseFovRad Field of view at 1x, radians.
|
||||
* @param {number} zoom Magnification (1 leaves the base unchanged).
|
||||
* @returns {number} Field of view, radians.
|
||||
*/
|
||||
export function fovForZoom(baseFovRad, zoom) {
|
||||
const magnification = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
||||
return 2 * Math.atan(Math.tan(baseFovRad / 2) / magnification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cesium's `PerspectiveFrustum.fov` is horizontal on a landscape canvas and
|
||||
* vertical on a portrait one; convert a vertical field of view to it.
|
||||
* @param {number} verticalFovRad Vertical field of view, radians.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number} The value to assign to `frustum.fov`, radians.
|
||||
*/
|
||||
export function cesiumFrustumFovRad(verticalFovRad, aspect) {
|
||||
if (!(aspect > 1)) return verticalFovRad;
|
||||
return 2 * Math.atan(Math.tan(verticalFovRad / 2) * aspect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground width covered across the frame, along a great circle through the
|
||||
* sub-satellite point, km. A ray past the limb ends at the horizon, so a
|
||||
* frame wider than the disk reports the visible cap's width.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @param {number} fovRad Vertical field of view, radians.
|
||||
* @param {number} [aspect=1] Frame width / height; the width is measured
|
||||
* across the wider horizontal extent.
|
||||
* @returns {number|null} Width in kilometres, or null without a usable input.
|
||||
*/
|
||||
export function footprintWidthKm(altitudeM, fovRad, aspect = 1) {
|
||||
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return null;
|
||||
if (!Number.isFinite(fovRad) || fovRad <= 0) return null;
|
||||
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||
const halfAngle = Math.atan(Math.tan(fovRad / 2) * ratio);
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const sinGround = ((R + altitudeM) / R) * Math.sin(halfAngle);
|
||||
// Central angle from nadir to where the ray meets the sphere, or the limb.
|
||||
const central =
|
||||
sinGround >= 1
|
||||
? Math.acos(R / (R + altitudeM))
|
||||
: Math.asin(sinGround) - halfAngle;
|
||||
return (2 * R * central) / 1000;
|
||||
}
|
||||
|
||||
function length(v) {
|
||||
return Math.hypot(v.x, v.y, v.z);
|
||||
}
|
||||
|
||||
function scale(v, s) {
|
||||
return { x: v.x * s, y: v.y * s, z: v.z * s };
|
||||
}
|
||||
|
||||
function dot(a, b) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function cross(a, b) {
|
||||
return {
|
||||
x: a.y * b.z - a.z * b.y,
|
||||
y: a.z * b.x - a.x * b.z,
|
||||
z: a.x * b.y - a.y * b.x,
|
||||
};
|
||||
}
|
||||
|
||||
/** `v` with its component along unit `d` removed, or null when degenerate. */
|
||||
function perpendicularUnit(v, d) {
|
||||
const along = dot(v, d);
|
||||
const p = {
|
||||
x: v.x - along * d.x,
|
||||
y: v.y - along * d.y,
|
||||
z: v.z - along * d.z,
|
||||
};
|
||||
const n = length(p);
|
||||
return n > 1e-6 ? scale(p, 1 / n) : null;
|
||||
}
|
||||
|
||||
const NORTH_AXIS = Object.freeze({ x: 0, y: 0, z: 1 });
|
||||
|
||||
/**
|
||||
* Camera orientation looking from an Earth-fixed position at the Earth's
|
||||
* centre, with north at the top of the frame.
|
||||
*
|
||||
* `up` is the Earth's rotation axis projected perpendicular to the view, which
|
||||
* is local north at the sub-satellite point. Directly over a pole north is
|
||||
* undefined; the previous frame's `up` is kept when given (so a polar pass
|
||||
* does not spin the picture), otherwise the limit along the prime meridian
|
||||
* is used: over the north pole the top of the frame faces 180°E, over the
|
||||
* south pole 0°E.
|
||||
*
|
||||
* With `headingDeg` the frame turns so that bearing (degrees clockwise from
|
||||
* north) is at the top: the ground track, for a view that scrolls along it.
|
||||
* @param {{x: number, y: number, z: number}} position ECEF position, metres.
|
||||
* @param {{previousUp?: {x: number, y: number, z: number}|null,
|
||||
* headingDeg?: number|null}} [options] `previousUp` is a previous frame's
|
||||
* `north`, not its turned `up`.
|
||||
* @returns {{direction: {x: number, y: number, z: number},
|
||||
* up: {x: number, y: number, z: number},
|
||||
* right: {x: number, y: number, z: number},
|
||||
* north: {x: number, y: number, z: number}}|null} Unit vectors (right =
|
||||
* direction × up, as Cesium's camera keeps them), or null at the centre.
|
||||
*/
|
||||
export function nadirOrientation(
|
||||
position,
|
||||
{ previousUp = null, headingDeg = null } = {},
|
||||
) {
|
||||
if (!position) return null;
|
||||
const r = length(position);
|
||||
if (!Number.isFinite(r) || r < 1) return null;
|
||||
const direction = scale(position, -1 / r);
|
||||
let up = perpendicularUnit(NORTH_AXIS, direction);
|
||||
if (!up && previousUp && Number.isFinite(length(previousUp)))
|
||||
up = perpendicularUnit(previousUp, direction);
|
||||
if (!up) {
|
||||
// Looking down -z (north pole): north at longitude 0 points along -x.
|
||||
up = perpendicularUnit(
|
||||
{ x: direction.z < 0 ? -1 : 1, y: 0, z: 0 },
|
||||
direction,
|
||||
);
|
||||
}
|
||||
const north = up;
|
||||
if (Number.isFinite(headingDeg) && headingDeg % 360 !== 0) {
|
||||
// Looking down, right = direction × north is east: the bearing turns
|
||||
// north toward it.
|
||||
const east = cross(direction, north);
|
||||
const θ = (headingDeg * Math.PI) / 180;
|
||||
up = {
|
||||
x: north.x * Math.cos(θ) + east.x * Math.sin(θ),
|
||||
y: north.y * Math.cos(θ) + east.y * Math.sin(θ),
|
||||
z: north.z * Math.cos(θ) + east.z * Math.sin(θ),
|
||||
};
|
||||
}
|
||||
return { direction, up, right: cross(direction, up), north };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease-in-out progress of a zoom step (cubic), clamped to 0..1.
|
||||
* @param {number} elapsedMs Time since the step began.
|
||||
* @param {{durationMs?: number, reducedMotion?: boolean}} [options]
|
||||
* Reduced motion cuts straight to the end.
|
||||
* @returns {number} Eased progress, 0..1.
|
||||
*/
|
||||
export function zoomTransitionProgress(
|
||||
elapsedMs,
|
||||
{ durationMs = ORBITAL_ZOOM_TRANSITION_MS, reducedMotion = false } = {},
|
||||
) {
|
||||
if (reducedMotion || !(durationMs > 0)) return 1;
|
||||
const linear = Math.min(1, Math.max(0, (elapsedMs || 0) / durationMs));
|
||||
return linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus-pull blur for a zoom step: zero at both ends, peaking mid-step.
|
||||
* @param {number} progress Step progress, 0..1 (linear or eased).
|
||||
* @param {{maxBlurPx?: number, reducedMotion?: boolean}} [options]
|
||||
* Reduced motion never blurs.
|
||||
* @returns {number} Blur radius, CSS pixels.
|
||||
*/
|
||||
export function zoomTransitionBlurPx(
|
||||
progress,
|
||||
{ maxBlurPx = ORBITAL_MAX_BLUR_PX, reducedMotion = false } = {},
|
||||
) {
|
||||
if (reducedMotion || !Number.isFinite(progress)) return 0;
|
||||
const p = Math.min(1, Math.max(0, progress));
|
||||
return Math.max(0, maxBlurPx) * Math.sin(Math.PI * p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Field of view part-way through a zoom step. Interpolates the tangent of
|
||||
* the half-angle geometrically, so each moment of the step magnifies by the
|
||||
* same ratio and 1x→2x feels like 4x→8x.
|
||||
* @param {number} fromFovRad Field of view the step starts at.
|
||||
* @param {number} toFovRad Field of view the step ends at.
|
||||
* @param {number} progress Eased progress, 0..1.
|
||||
* @returns {number} Field of view, radians.
|
||||
*/
|
||||
export function interpolateZoomFov(fromFovRad, toFovRad, progress) {
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
|
||||
const a = Math.log(Math.tan(fromFovRad / 2));
|
||||
const b = Math.log(Math.tan(toFovRad / 2));
|
||||
return 2 * Math.atan(Math.exp(a + (b - a) * p));
|
||||
}
|
||||
|
||||
/**
|
||||
* The HUD's transition state at a moment of a zoom step, in the store shape
|
||||
* `{ active, blurPx, lockTick }`: blur while the field of view moves, then a
|
||||
* brief reticle lock tick once it settles. Reduced motion skips the blur and
|
||||
* the tick (the cut is instant).
|
||||
* @param {number} elapsedMs Time since the step began.
|
||||
* @param {{durationMs?: number, lockTickMs?: number, maxBlurPx?: number,
|
||||
* reducedMotion?: boolean}} [options]
|
||||
* @returns {{active: boolean, progress: number, blurPx: number,
|
||||
* lockTick: boolean}}
|
||||
*/
|
||||
export function zoomTransitionState(
|
||||
elapsedMs,
|
||||
{
|
||||
durationMs = ORBITAL_ZOOM_TRANSITION_MS,
|
||||
lockTickMs = ORBITAL_LOCK_TICK_MS,
|
||||
maxBlurPx = ORBITAL_MAX_BLUR_PX,
|
||||
reducedMotion = false,
|
||||
} = {},
|
||||
) {
|
||||
const progress = zoomTransitionProgress(elapsedMs, {
|
||||
durationMs,
|
||||
reducedMotion,
|
||||
});
|
||||
if (reducedMotion)
|
||||
return { active: false, progress: 1, blurPx: 0, lockTick: false };
|
||||
const elapsed = Math.max(0, elapsedMs || 0);
|
||||
const linear = durationMs > 0 ? Math.min(1, elapsed / durationMs) : 1;
|
||||
return {
|
||||
active: elapsed < durationMs,
|
||||
progress,
|
||||
blurPx: zoomTransitionBlurPx(linear, { maxBlurPx }),
|
||||
lockTick: elapsed >= durationMs && elapsed < durationMs + lockTickMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a zoom step whose field of view has landed still holds its focus
|
||||
* pull: until the imagery has loaded, at most `maxHoldMs`.
|
||||
* @param {number} sinceLandedMs Time since the field of view stopped moving.
|
||||
* @param {boolean} tilesLoaded Whether the scene's imagery has loaded.
|
||||
* @param {{maxHoldMs?: number}} [options]
|
||||
*/
|
||||
export function zoomSettleHolding(
|
||||
sinceLandedMs,
|
||||
tilesLoaded,
|
||||
{ maxHoldMs = ORBITAL_SETTLE_MAX_MS } = {},
|
||||
) {
|
||||
return !tilesLoaded && Math.max(0, sinceLandedMs || 0) < maxHoldMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total angle between a tilted view and nadir, degrees:
|
||||
* acos(cos north · cos east), as `offNadirOrientation` tilts.
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function offNadirAngleDeg({ northDeg = 0, eastDeg = 0 } = {}) {
|
||||
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
|
||||
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
|
||||
const cosine = Math.min(1, Math.max(-1, Math.cos(n) * Math.cos(e)));
|
||||
return (Math.acos(cosine) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the view's centre, tilted off nadir, still meets the Earth: its
|
||||
* angle from nadir (`offNadirAngleDeg`) is within the Earth's angular radius
|
||||
* from the satellite, asin(R / (R + altitude)).
|
||||
* @param {number} altitudeM
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function offNadirCentreMeetsEarth(altitudeM, offset = {}) {
|
||||
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return false;
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const limbDeg = (Math.asin(R / (R + altitudeM)) * 180) / Math.PI;
|
||||
return offNadirAngleDeg(offset) < limbDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roughly how much wider the ground across the frame is at the centre of a
|
||||
* tilted view than at nadir, on a flat Earth: tilting across the frame (east,
|
||||
* the frame's right) stretches its width by 1/cos², tilting along it (north,
|
||||
* the frame's top) by 1/cos. 1 at nadir; an approximation, so the HUD marks
|
||||
* the width it scales with ≈.
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
|
||||
* @returns {number} At least 1.
|
||||
*/
|
||||
export function offNadirSwathScale({ northDeg = 0, eastDeg = 0 } = {}) {
|
||||
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
|
||||
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
|
||||
const along = Math.abs(Math.cos(n));
|
||||
const across = Math.cos(e) ** 2;
|
||||
if (!(along > 1e-6) || !(across > 1e-6)) return 1;
|
||||
return Math.max(1, 1 / (along * across));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tilt a nadir orientation off nadir: first toward north (a rotation about
|
||||
* the nadir frame's right axis), then toward east (about the tilted up
|
||||
* axis). The base frame is always the nadir one, so a tilt never drifts with
|
||||
* the orbit; the angle from nadir is acos(cos north · cos east).
|
||||
* @param {{direction: object, up: object, right: object}} nadir From
|
||||
* {@link nadirOrientation}.
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees;
|
||||
* negative is south or west.
|
||||
* @returns {{direction: object, up: object, right: object}} Unit vectors,
|
||||
* right = direction × up.
|
||||
*/
|
||||
export function offNadirOrientation(nadir, { northDeg = 0, eastDeg = 0 } = {}) {
|
||||
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
|
||||
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
|
||||
if (n === 0 && e === 0) return nadir;
|
||||
const { direction: d, up: u, right: r } = nadir;
|
||||
const combine = (a, ka, b, kb) => ({
|
||||
x: a.x * ka + b.x * kb,
|
||||
y: a.y * ka + b.y * kb,
|
||||
z: a.z * ka + b.z * kb,
|
||||
});
|
||||
// Toward north: the view swings onto the up axis, up swings off it.
|
||||
const d1 = combine(d, Math.cos(n), u, Math.sin(n));
|
||||
const u1 = combine(u, Math.cos(n), d, -Math.sin(n));
|
||||
// Toward east: the view swings onto the right axis.
|
||||
const direction = combine(d1, Math.cos(e), r, Math.sin(e));
|
||||
return { direction, up: u1, right: cross(direction, u1) };
|
||||
}
|
||||
|
||||
/** Scroll distance, in pixels, that makes one zoom step. A mouse notch is ~100. */
|
||||
export const ORBITAL_WHEEL_PIXELS_PER_STEP = 100;
|
||||
|
||||
/**
|
||||
* After a step, the rest of that gesture is absorbed for this long, so one
|
||||
* physical notch (which some browsers deliver as several events) is one step.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_COOLDOWN_MS = 200;
|
||||
|
||||
/**
|
||||
* A pause this long ends a scroll gesture: a partial sum left from an earlier
|
||||
* gesture never adds to a later one.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_GESTURE_GAP_MS = 300;
|
||||
|
||||
/** `WheelEvent.deltaMode` line and page units, in pixels. */
|
||||
const WHEEL_LINE_PX = 16;
|
||||
const WHEEL_PAGE_PX = 800;
|
||||
|
||||
/**
|
||||
* Turn wheel events into zoom steps. Trackpads send many small pixel deltas,
|
||||
* so they are summed until they reach a step; a change of direction, or a
|
||||
* pause of ORBITAL_WHEEL_GESTURE_GAP_MS, starts over. Wheeling away from the
|
||||
* viewer (negative deltaY) zooms in.
|
||||
* @param {{sum: number, lastStepMs: number, lastEventMs?: number}|null}
|
||||
* accumulator Previous state.
|
||||
* @param {{deltaY: number, deltaMode?: number}} wheel The event's deltas.
|
||||
* @param {number} nowMs
|
||||
* @returns {{accumulator: {sum: number, lastStepMs: number,
|
||||
* lastEventMs: number}, step: -1|0|1}} The new state and the step to take
|
||||
* (1 = zoom in).
|
||||
*/
|
||||
export function wheelZoomStep(accumulator, wheel, nowMs) {
|
||||
const held = accumulator || { sum: 0, lastStepMs: -Infinity };
|
||||
const previous =
|
||||
nowMs - (held.lastEventMs ?? -Infinity) > ORBITAL_WHEEL_GESTURE_GAP_MS
|
||||
? { ...held, sum: 0 }
|
||||
: held;
|
||||
const unit =
|
||||
wheel?.deltaMode === 1
|
||||
? WHEEL_LINE_PX
|
||||
: wheel?.deltaMode === 2
|
||||
? WHEEL_PAGE_PX
|
||||
: 1;
|
||||
const delta = (Number.isFinite(wheel?.deltaY) ? wheel.deltaY : 0) * unit;
|
||||
if (!delta) return { accumulator: held, step: 0 };
|
||||
if (nowMs - previous.lastStepMs < ORBITAL_WHEEL_COOLDOWN_MS)
|
||||
return {
|
||||
accumulator: { ...previous, sum: 0, lastEventMs: nowMs },
|
||||
step: 0,
|
||||
};
|
||||
const sum =
|
||||
Math.sign(previous.sum) === -Math.sign(delta)
|
||||
? delta
|
||||
: previous.sum + delta;
|
||||
if (Math.abs(sum) < ORBITAL_WHEEL_PIXELS_PER_STEP)
|
||||
return { accumulator: { ...previous, sum, lastEventMs: nowMs }, step: 0 };
|
||||
return {
|
||||
accumulator: { sum: 0, lastStepMs: nowMs, lastEventMs: nowMs },
|
||||
step: sum < 0 ? 1 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** From this zoom the view pre-loads imagery ahead along the ground track. */
|
||||
export const ORBITAL_PREFETCH_MIN_ZOOM = 16;
|
||||
|
||||
/** How often the pre-load looks ahead, ms. */
|
||||
export const ORBITAL_PREFETCH_INTERVAL_MS = 2000;
|
||||
|
||||
/** How far ahead it looks, seconds of ground-track travel, nearest first. */
|
||||
export const ORBITAL_PREFETCH_LEAD_S = Object.freeze([5, 10, 15, 20]);
|
||||
|
||||
/**
|
||||
* Where across the track it looks, as fractions of the view's width: the
|
||||
* centre line, a third out and the frame's edges each side, so a row of
|
||||
* tiles a fifth of the view wide or more spans the frame.
|
||||
*/
|
||||
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([
|
||||
0,
|
||||
-1 / 3,
|
||||
1 / 3,
|
||||
-1 / 2,
|
||||
1 / 2,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The points ahead, regrouped by their place across the track: every lead's
|
||||
* centre point first, then each lead's next pair out, so the tile cap trims
|
||||
* the frame's edges before it trims the far leads.
|
||||
* @param {Array<{lat: number, lon: number}>} points From `groundTrackAhead`,
|
||||
* lead by lead.
|
||||
* @param {number} [across] Points per lead.
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function centreLinesFirst(
|
||||
points,
|
||||
across = ORBITAL_PREFETCH_ACROSS.length,
|
||||
) {
|
||||
const out = [];
|
||||
for (let k = 0; k < across; k += 1)
|
||||
for (let i = k; i < points.length; i += across) out.push(points[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** At most this many tile requests per look-ahead. */
|
||||
export const ORBITAL_PREFETCH_MAX_TILES = 12;
|
||||
|
||||
const toRadians = (deg) => (deg * Math.PI) / 180;
|
||||
const toDegrees = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* The point `distanceM` from a point along an initial bearing, on a sphere.
|
||||
* @returns {{lat: number, lon: number}} Degrees, longitude in -180..180.
|
||||
*/
|
||||
export function destinationPoint(latDeg, lonDeg, bearingDeg, distanceM) {
|
||||
const delta = distanceM / ORBITAL_EARTH_RADIUS_M;
|
||||
const lat1 = toRadians(latDeg);
|
||||
const theta = toRadians(bearingDeg);
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(delta) +
|
||||
Math.cos(lat1) * Math.sin(delta) * Math.cos(theta),
|
||||
);
|
||||
const lon2 =
|
||||
toRadians(lonDeg) +
|
||||
Math.atan2(
|
||||
Math.sin(theta) * Math.sin(delta) * Math.cos(lat1),
|
||||
Math.cos(delta) - Math.sin(lat1) * Math.sin(lat2),
|
||||
);
|
||||
const lon = ((((toDegrees(lon2) + 180) % 360) + 360) % 360) - 180;
|
||||
return { lat: toDegrees(lat2), lon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the view will pass over: for each lead time, the sub-satellite
|
||||
* point that far along the ground track (orbital speed scaled down to the
|
||||
* ground), and points across the track at `across` fractions of the view's
|
||||
* width. Nearest lead first. Empty without a track, speed or width.
|
||||
* @param {{latitude: number, longitude: number, groundTrackDeg: number,
|
||||
* velocityMps: number, altitudeM: number}} info Satellite subject.
|
||||
* @param {number} viewKm The view's ground width, km.
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function groundTrackAhead(
|
||||
info,
|
||||
viewKm,
|
||||
{ leadS = ORBITAL_PREFETCH_LEAD_S, across = ORBITAL_PREFETCH_ACROSS } = {},
|
||||
) {
|
||||
const { latitude, longitude, groundTrackDeg, velocityMps, altitudeM } =
|
||||
info || {};
|
||||
if (
|
||||
![latitude, longitude, groundTrackDeg, velocityMps, altitudeM].every(
|
||||
Number.isFinite,
|
||||
) ||
|
||||
velocityMps <= 0 ||
|
||||
altitudeM <= 0 ||
|
||||
!(viewKm > 0)
|
||||
)
|
||||
return [];
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const groundMps = (velocityMps * R) / (R + altitudeM);
|
||||
const points = [];
|
||||
for (const seconds of leadS) {
|
||||
const centre = destinationPoint(
|
||||
latitude,
|
||||
longitude,
|
||||
groundTrackDeg,
|
||||
groundMps * seconds,
|
||||
);
|
||||
for (const fraction of across) {
|
||||
points.push(
|
||||
fraction === 0
|
||||
? centre
|
||||
: destinationPoint(
|
||||
centre.lat,
|
||||
centre.lon,
|
||||
groundTrackDeg + (fraction > 0 ? 90 : -90),
|
||||
Math.abs(fraction) * viewKm * 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** WGS84's equatorial radius: Cesium sizes terrain and imagery tiles on it. */
|
||||
const WGS84_MAXIMUM_RADIUS_M = 6378137;
|
||||
|
||||
/**
|
||||
* Cesium's geometric error for a heightmap terrain tile at `level`, metres:
|
||||
* its level-zero estimate (TerrainProvider's
|
||||
* getEstimatedLevelZeroGeometricErrorForAHeightmap, heightmap quality 0.25)
|
||||
* for the ellipsoid terrain's 64-sample tiles on two root tiles, halved per
|
||||
* level.
|
||||
* @param {number} level
|
||||
* @returns {number}
|
||||
*/
|
||||
export function ellipsoidTerrainLevelErrorM(level) {
|
||||
return (WGS84_MAXIMUM_RADIUS_M * 2 * Math.PI * 0.25) / (64 * 2) / 2 ** level;
|
||||
}
|
||||
|
||||
/** The deepest terrain level considered. */
|
||||
const MAX_TERRAIN_LEVEL = 30;
|
||||
|
||||
/**
|
||||
* The imagery level Cesium draws under the view, and so the one to pre-load.
|
||||
* Cesium refines the terrain tile under the view while its geometric error
|
||||
* spans more than `maximumScreenSpaceError` pixels (QuadtreePrimitive's
|
||||
* screenSpaceError: error over the ground a pixel covers), then drapes the
|
||||
* imagery level whose texel spacing is nearest that error (ImageryLayer's
|
||||
* getLevelWithMaximumTexelSpacing, rounded): about one to two screen pixels
|
||||
* per texel, not one. Fog, which below 800 km can only coarsen the choice,
|
||||
* is left out. Clamped to the provider's levels; null without a usable input.
|
||||
* @param {{getNumberOfXTilesAtLevel?: (level: number) => number,
|
||||
* rectangle?: {width: number}}} tilingScheme The imagery tiling scheme.
|
||||
* @param {{metresPerPixel: number, latitudeDeg: number,
|
||||
* geographic?: boolean, tileWidthPx?: number, minimumLevel?: number,
|
||||
* maximumLevel?: number, maximumScreenSpaceError?: number,
|
||||
* terrainLevelErrorM?: (level: number) => number}} view `metresPerPixel`
|
||||
* is the ground one CSS pixel covers at the view's centre; `geographic`
|
||||
* marks a scheme in the geographic projection, whose texels do not narrow
|
||||
* with latitude as Web Mercator's do; `terrainLevelErrorM` is the terrain
|
||||
* provider's getLevelMaximumGeometricError.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function imageryLevelForView(
|
||||
tilingScheme,
|
||||
{
|
||||
metresPerPixel,
|
||||
latitudeDeg,
|
||||
geographic = false,
|
||||
tileWidthPx = 256,
|
||||
minimumLevel = 0,
|
||||
maximumLevel = 30,
|
||||
maximumScreenSpaceError = 2,
|
||||
terrainLevelErrorM = ellipsoidTerrainLevelErrorM,
|
||||
},
|
||||
) {
|
||||
if (!(metresPerPixel > 0) || !Number.isFinite(latitudeDeg)) return null;
|
||||
const sse = maximumScreenSpaceError > 0 ? maximumScreenSpaceError : 2;
|
||||
let terrainLevel = 0;
|
||||
while (
|
||||
terrainLevel < MAX_TERRAIN_LEVEL &&
|
||||
!(terrainLevelErrorM(terrainLevel) / metresPerPixel <= sse)
|
||||
)
|
||||
terrainLevel += 1;
|
||||
const errorM = terrainLevelErrorM(terrainLevel);
|
||||
if (!(errorM > 0)) return null;
|
||||
const rootTiles = tilingScheme?.getNumberOfXTilesAtLevel?.(0) || 1;
|
||||
const widthRad = tilingScheme?.rectangle?.width || 2 * Math.PI;
|
||||
const latitudeFactor = geographic
|
||||
? 1
|
||||
: Math.cos(toRadians(Math.min(89, Math.abs(latitudeDeg))));
|
||||
const levelZeroTexelM =
|
||||
(WGS84_MAXIMUM_RADIUS_M * widthRad * latitudeFactor) /
|
||||
((tileWidthPx || 256) * rootTiles);
|
||||
const level = Math.max(0, Math.round(Math.log2(levelZeroTexelM / errorM)));
|
||||
if (!Number.isFinite(level)) return null;
|
||||
const top = Number.isFinite(maximumLevel) ? maximumLevel : 30;
|
||||
const bottom = Number.isFinite(minimumLevel) ? minimumLevel : 0;
|
||||
return Math.min(top, Math.max(bottom, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles at `level` holding `points`, in order, without repeats or tiles
|
||||
* `skip` reports as already asked for, at most `maxTiles`.
|
||||
* @param {{positionToTileXY: Function}} tilingScheme Cesium tiling scheme.
|
||||
* @param {Array<{lat: number, lon: number}>} points Degrees.
|
||||
* @param {number} level
|
||||
* @param {{maxTiles?: number, skip?: (key: string) => boolean}} [options]
|
||||
* @returns {Array<{x: number, y: number, level: number, key: string}>}
|
||||
*/
|
||||
export function tilesForPoints(
|
||||
tilingScheme,
|
||||
points,
|
||||
level,
|
||||
{ maxTiles = ORBITAL_PREFETCH_MAX_TILES, skip = () => false } = {},
|
||||
) {
|
||||
const tiles = [];
|
||||
if (typeof tilingScheme?.positionToTileXY !== 'function') return tiles;
|
||||
if (!Number.isInteger(level) || level < 0) return tiles;
|
||||
const seen = new Set();
|
||||
for (const point of points) {
|
||||
if (tiles.length >= maxTiles) break;
|
||||
const xy = tilingScheme.positionToTileXY(
|
||||
{ longitude: toRadians(point.lon), latitude: toRadians(point.lat) },
|
||||
level,
|
||||
);
|
||||
if (!xy) continue;
|
||||
const key = `${level}/${xy.x}/${xy.y}`;
|
||||
if (seen.has(key) || skip(key)) continue;
|
||||
seen.add(key);
|
||||
tiles.push({ x: xy.x, y: xy.y, level, key });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ORBITAL_ZOOM_LADDER,
|
||||
ORBITAL_BASE_FOV_LEO_RAD,
|
||||
ORBITAL_EARTH_RADIUS_M,
|
||||
ORBITAL_SETTLE_MAX_MS,
|
||||
ORBITAL_WHEEL_COOLDOWN_MS,
|
||||
cesiumFrustumFovRad,
|
||||
earthAngularRadiusRad,
|
||||
footprintWidthKm,
|
||||
fovForZoom,
|
||||
interpolateZoomFov,
|
||||
nadirOrientation,
|
||||
nextOrbitalZoom,
|
||||
offNadirAngleDeg,
|
||||
offNadirCentreMeetsEarth,
|
||||
centreLinesFirst,
|
||||
offNadirSwathScale,
|
||||
orbitalMaxZoom,
|
||||
sensorStripFraction,
|
||||
sensorSwathZoom,
|
||||
viewWidthKm,
|
||||
wheelZoomStep,
|
||||
ORBITAL_WHEEL_GESTURE_GAP_MS,
|
||||
offNadirOrientation,
|
||||
orbitalBaseFovRad,
|
||||
previousOrbitalZoom,
|
||||
zoomSettleHolding,
|
||||
zoomTransitionBlurPx,
|
||||
zoomTransitionProgress,
|
||||
zoomTransitionState,
|
||||
ORBITAL_PREFETCH_ACROSS,
|
||||
ORBITAL_PREFETCH_LEAD_S,
|
||||
ORBITAL_PREFETCH_MAX_TILES,
|
||||
destinationPoint,
|
||||
groundTrackAhead,
|
||||
imageryLevelForView,
|
||||
tilesForPoints,
|
||||
} from './cockpitOrbital.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
const norm = (v) => Math.hypot(v.x, v.y, v.z);
|
||||
|
||||
/** ECEF position on the mean sphere at a latitude, longitude and altitude. */
|
||||
function ecef(latDeg, lonDeg, altitudeM) {
|
||||
const r = ORBITAL_EARTH_RADIUS_M + altitudeM;
|
||||
const lat = latDeg * RAD;
|
||||
const lon = lonDeg * RAD;
|
||||
return {
|
||||
x: r * Math.cos(lat) * Math.cos(lon),
|
||||
y: r * Math.cos(lat) * Math.sin(lon),
|
||||
z: r * Math.sin(lat),
|
||||
};
|
||||
}
|
||||
|
||||
/** Local north unit vector at a latitude and longitude. */
|
||||
function localNorth(latDeg, lonDeg) {
|
||||
const lat = latDeg * RAD;
|
||||
const lon = lonDeg * RAD;
|
||||
return {
|
||||
x: -Math.sin(lat) * Math.cos(lon),
|
||||
y: -Math.sin(lat) * Math.sin(lon),
|
||||
z: Math.cos(lat),
|
||||
};
|
||||
}
|
||||
|
||||
function assertOrthonormal(orientation) {
|
||||
const { direction, up, right } = orientation;
|
||||
for (const v of [direction, up, right])
|
||||
assert.ok(Math.abs(norm(v) - 1) < 1e-9);
|
||||
assert.ok(Math.abs(dot(direction, up)) < 1e-9);
|
||||
assert.ok(Math.abs(dot(direction, right)) < 1e-9);
|
||||
assert.ok(Math.abs(dot(up, right)) < 1e-9);
|
||||
}
|
||||
|
||||
test('the nadir view points at the Earth centre with north up', () => {
|
||||
for (const [lat, lon, alt] of [
|
||||
[0, 0, 420e3],
|
||||
[51.6, -97.3, 420e3],
|
||||
[-33.9, 151.2, 20.2e6],
|
||||
[0.05, 75, 35.786e6],
|
||||
]) {
|
||||
const position = ecef(lat, lon, alt);
|
||||
const orientation = nadirOrientation(position);
|
||||
const toCentre = {
|
||||
x: -position.x / norm(position),
|
||||
y: -position.y / norm(position),
|
||||
z: -position.z / norm(position),
|
||||
};
|
||||
assert.ok(dot(orientation.direction, toCentre) > 1 - 1e-12);
|
||||
assert.ok(dot(orientation.up, localNorth(lat, lon)) > 1 - 1e-9);
|
||||
assertOrthonormal(orientation);
|
||||
// North up puts east on the right of a picture looking down.
|
||||
const east = { x: -Math.sin(lon * RAD), y: Math.cos(lon * RAD), z: 0 };
|
||||
assert.ok(dot(orientation.right, east) > 1 - 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('over a pole the orientation stays valid and keeps the previous up', () => {
|
||||
for (const z of [1, -1]) {
|
||||
const position = { x: 0, y: 0, z: z * (ORBITAL_EARTH_RADIUS_M + 800e3) };
|
||||
const fallback = nadirOrientation(position);
|
||||
assertOrthonormal(fallback);
|
||||
assert.equal(fallback.direction.z, -z);
|
||||
// The prime-meridian limit of north.
|
||||
assert.ok(Math.abs(fallback.up.x - -z) < 1e-9);
|
||||
const kept = nadirOrientation(position, {
|
||||
previousUp: { x: 0, y: 1, z: 0 },
|
||||
});
|
||||
assertOrthonormal(kept);
|
||||
assert.ok(kept.up.y > 1 - 1e-9);
|
||||
}
|
||||
// Just off the pole, north still points over it.
|
||||
const nearPole = nadirOrientation(ecef(89.9999, 30, 500e3));
|
||||
assertOrthonormal(nearPole);
|
||||
assert.ok(dot(nearPole.up, localNorth(89.9999, 30)) > 1 - 1e-6);
|
||||
});
|
||||
|
||||
test('a degenerate position has no orientation', () => {
|
||||
assert.equal(nadirOrientation(null), null);
|
||||
assert.equal(nadirOrientation({ x: 0, y: 0, z: 0 }), null);
|
||||
assert.equal(nadirOrientation({ x: NaN, y: 0, z: 0 }), null);
|
||||
});
|
||||
|
||||
test('each zoom step divides the tangent of the half-angle', () => {
|
||||
const base = ORBITAL_BASE_FOV_LEO_RAD;
|
||||
for (const zoom of ORBITAL_ZOOM_LADDER) {
|
||||
const fov = fovForZoom(base, zoom);
|
||||
assert.ok(Math.abs(Math.tan(fov / 2) - Math.tan(base / 2) / zoom) < 1e-12);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Math.tan(fovForZoom(base, 4) / 2) / Math.tan(fovForZoom(base, 2) / 2) -
|
||||
0.5,
|
||||
) < 1e-12,
|
||||
);
|
||||
assert.equal(fovForZoom(base, 1), base);
|
||||
assert.equal(fovForZoom(base, 0), base);
|
||||
});
|
||||
|
||||
test('zoom steps clamp at both ends without wrapping', () => {
|
||||
assert.equal(nextOrbitalZoom(1), 2);
|
||||
assert.equal(nextOrbitalZoom(4), 8);
|
||||
assert.equal(nextOrbitalZoom(8), 8);
|
||||
assert.equal(previousOrbitalZoom(8), 4);
|
||||
assert.equal(previousOrbitalZoom(2), 1);
|
||||
assert.equal(previousOrbitalZoom(1), 1);
|
||||
assert.equal(nextOrbitalZoom(undefined), 2);
|
||||
});
|
||||
|
||||
test('the base frame is regional in low orbit and fits the disk from GEO', () => {
|
||||
assert.equal(orbitalBaseFovRad(420e3), ORBITAL_BASE_FOV_LEO_RAD);
|
||||
const geo = orbitalBaseFovRad(35.786e6);
|
||||
const disk = 2 * earthAngularRadiusRad(35.786e6);
|
||||
assert.ok(geo > disk && geo < ORBITAL_BASE_FOV_LEO_RAD);
|
||||
// Continuous across the crossover: no jump as an orbit climbs.
|
||||
let previous = orbitalBaseFovRad(1e6);
|
||||
for (let alt = 1e6; alt <= 40e6; alt += 50e3) {
|
||||
const fov = orbitalBaseFovRad(alt);
|
||||
assert.ok(fov <= previous + 1e-12);
|
||||
assert.ok(previous - fov < 0.01);
|
||||
previous = fov;
|
||||
}
|
||||
});
|
||||
|
||||
test('the footprint shrinks with zoom and stops at the limb', () => {
|
||||
const alt = 420e3;
|
||||
const widths = [1, 2, 4, 8].map((zoom) =>
|
||||
footprintWidthKm(alt, fovForZoom(orbitalBaseFovRad(alt), zoom)),
|
||||
);
|
||||
for (let i = 1; i < widths.length; i++) assert.ok(widths[i] < widths[i - 1]);
|
||||
assert.ok(widths[0] > 400 && widths[0] < 600, `1x ${widths[0]} km`);
|
||||
assert.ok(widths[3] > 40 && widths[3] < 80, `8x ${widths[3]} km`);
|
||||
// A wider frame covers more ground across.
|
||||
assert.ok(
|
||||
footprintWidthKm(alt, ORBITAL_BASE_FOV_LEO_RAD, 16 / 9) > widths[0],
|
||||
);
|
||||
// From GEO a frame wider than the disk spans the visible cap only.
|
||||
const cap =
|
||||
(2 *
|
||||
ORBITAL_EARTH_RADIUS_M *
|
||||
Math.acos(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + 35.786e6))) /
|
||||
1000;
|
||||
assert.ok(Math.abs(footprintWidthKm(35.786e6, 40 * RAD) - cap) < 1e-6);
|
||||
assert.equal(footprintWidthKm(0, 1), null);
|
||||
assert.equal(footprintWidthKm(420e3, NaN), null);
|
||||
});
|
||||
|
||||
test('Cesium frustum fov widens to the horizontal on a landscape canvas', () => {
|
||||
const vertical = 30 * RAD;
|
||||
assert.equal(cesiumFrustumFovRad(vertical, 0.75), vertical);
|
||||
const horizontal = cesiumFrustumFovRad(vertical, 2);
|
||||
assert.ok(
|
||||
Math.abs(Math.tan(horizontal / 2) - 2 * Math.tan(vertical / 2)) < 1e-12,
|
||||
);
|
||||
});
|
||||
|
||||
test('zoom transition curves stay bounded and settle', () => {
|
||||
assert.equal(zoomTransitionProgress(0), 0);
|
||||
assert.equal(zoomTransitionProgress(400), 1);
|
||||
assert.equal(zoomTransitionProgress(9999), 1);
|
||||
assert.equal(zoomTransitionProgress(-50), 0);
|
||||
assert.equal(zoomTransitionProgress(0, { reducedMotion: true }), 1);
|
||||
let previous = 0;
|
||||
for (let ms = 0; ms <= 500; ms += 10) {
|
||||
const p = zoomTransitionProgress(ms);
|
||||
assert.ok(p >= previous && p >= 0 && p <= 1);
|
||||
previous = p;
|
||||
const blur = zoomTransitionBlurPx(ms / 400);
|
||||
assert.ok(blur >= 0 && blur <= 6);
|
||||
}
|
||||
assert.equal(zoomTransitionBlurPx(0), 0);
|
||||
assert.ok(zoomTransitionBlurPx(1) < 1e-9);
|
||||
assert.ok(Math.abs(zoomTransitionBlurPx(0.5) - 6) < 1e-9);
|
||||
assert.equal(zoomTransitionBlurPx(0.5, { reducedMotion: true }), 0);
|
||||
|
||||
const from = fovForZoom(1, 1);
|
||||
const to = fovForZoom(1, 2);
|
||||
assert.equal(interpolateZoomFov(from, to, 0).toFixed(12), from.toFixed(12));
|
||||
assert.equal(interpolateZoomFov(from, to, 1).toFixed(12), to.toFixed(12));
|
||||
const mid = interpolateZoomFov(from, to, 0.5);
|
||||
assert.ok(mid < from && mid > to);
|
||||
});
|
||||
|
||||
test('the transition state blurs, then ticks the lock, then rests', () => {
|
||||
const moving = zoomTransitionState(200);
|
||||
assert.equal(moving.active, true);
|
||||
assert.ok(moving.blurPx > 5);
|
||||
assert.equal(moving.lockTick, false);
|
||||
const settled = zoomTransitionState(450);
|
||||
assert.equal(settled.active, false);
|
||||
assert.equal(settled.lockTick, true);
|
||||
assert.equal(settled.progress, 1);
|
||||
assert.ok(settled.blurPx < 1e-9);
|
||||
const rest = zoomTransitionState(2000);
|
||||
assert.equal(rest.lockTick, false);
|
||||
assert.deepEqual(zoomTransitionState(100, { reducedMotion: true }), {
|
||||
active: false,
|
||||
progress: 1,
|
||||
blurPx: 0,
|
||||
lockTick: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('an off-nadir tilt turns the view by its angle from the nadir frame', () => {
|
||||
const nadir = nadirOrientation(ecef(30, -97, 420_000));
|
||||
assert.equal(offNadirOrientation(nadir), nadir, 'no tilt is nadir itself');
|
||||
const angle = (v) => Math.acos(Math.min(1, dot(v, nadir.direction))) / RAD;
|
||||
const north = offNadirOrientation(nadir, { northDeg: 10 });
|
||||
assert.ok(Math.abs(angle(north.direction) - 10) < 1e-9);
|
||||
assert.ok(dot(north.direction, nadir.up) > 0, 'toward north');
|
||||
assert.ok(Math.abs(dot(north.direction, nadir.right)) < 1e-12);
|
||||
const west = offNadirOrientation(nadir, { eastDeg: -20 });
|
||||
assert.ok(Math.abs(angle(west.direction) - 20) < 1e-9);
|
||||
assert.ok(dot(west.direction, nadir.right) < 0, 'toward west');
|
||||
const both = offNadirOrientation(nadir, { northDeg: 45, eastDeg: 45 });
|
||||
const expected = Math.acos(Math.cos(45 * RAD) ** 2) / RAD;
|
||||
assert.ok(Math.abs(angle(both.direction) - expected) < 1e-9);
|
||||
for (const view of [north, west, both]) {
|
||||
assert.ok(Math.abs(norm(view.direction) - 1) < 1e-12);
|
||||
assert.ok(Math.abs(norm(view.up) - 1) < 1e-12);
|
||||
assert.ok(Math.abs(dot(view.direction, view.up)) < 1e-12);
|
||||
}
|
||||
});
|
||||
|
||||
test('a landed zoom step holds for its imagery, but never past the cap', () => {
|
||||
assert.equal(zoomSettleHolding(0, false), true, 'still loading');
|
||||
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS - 1, false), true);
|
||||
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS, false), false, 'cap');
|
||||
assert.equal(zoomSettleHolding(0, true), false, 'loaded: lock at once');
|
||||
assert.equal(zoomSettleHolding(Number.NaN, false), true);
|
||||
});
|
||||
|
||||
const WIDE = 16 / 9;
|
||||
|
||||
test("the zoom ladder climbs to a satellite's own cap and steps off a SENSOR zoom", () => {
|
||||
assert.equal(nextOrbitalZoom(8), 8, 'no sensor: 8x is the top');
|
||||
assert.equal(nextOrbitalZoom(8, 64), 16);
|
||||
assert.equal(nextOrbitalZoom(32, 64), 64);
|
||||
assert.equal(nextOrbitalZoom(64, 64), 64, 'the cap holds');
|
||||
assert.equal(nextOrbitalZoom(5.7, 64), 8, 'between steps: the step above');
|
||||
assert.equal(previousOrbitalZoom(5.7), 4, 'between steps: the step below');
|
||||
assert.equal(previousOrbitalZoom(128), 64);
|
||||
assert.equal(previousOrbitalZoom(1), 1);
|
||||
});
|
||||
|
||||
test('a sensor caps the zoom at its swath for the live altitude', () => {
|
||||
// WorldView-3 at 617 km, 13.1 km swath: 64x spans ~20 km, 128x ~10 km.
|
||||
assert.ok(viewWidthKm(617e3, 64, WIDE) >= 13.1);
|
||||
assert.ok(viewWidthKm(617e3, 128, WIDE) < 13.1);
|
||||
assert.equal(orbitalMaxZoom(617e3, 13.1, WIDE), 64);
|
||||
// ICEYE spotlight, 5 km at 570 km.
|
||||
assert.equal(orbitalMaxZoom(570e3, 5, WIDE), 128);
|
||||
// PlanetScope SuperDove, 32.5 km: 32x at 525 km but 16x at 470 km.
|
||||
assert.equal(orbitalMaxZoom(525e3, 32.5, WIDE), 32);
|
||||
assert.equal(orbitalMaxZoom(470e3, 32.5, WIDE), 16);
|
||||
// Landsat's 185 km swath fits inside 8x: no extra steps, never fewer.
|
||||
assert.equal(orbitalMaxZoom(705e3, 185, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(705e3, 2330, WIDE), 8);
|
||||
// No sensor, or an unusable swath.
|
||||
assert.equal(orbitalMaxZoom(617e3, null, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(617e3, 0, WIDE), 8);
|
||||
// A swath exactly as wide as the 16x view allows 16x.
|
||||
assert.equal(orbitalMaxZoom(617e3, viewWidthKm(617e3, 16, WIDE), WIDE), 16);
|
||||
});
|
||||
|
||||
test('the SENSOR step frames the swath when it lies in range', () => {
|
||||
const landsat = sensorSwathZoom(705e3, 185, WIDE, 8);
|
||||
assert.ok(landsat > 4 && landsat < 8, String(landsat));
|
||||
assert.ok(Math.abs(viewWidthKm(705e3, landsat, WIDE) - 185) < 1);
|
||||
const worldview = sensorSwathZoom(617e3, 13.1, WIDE, 64);
|
||||
assert.equal(worldview, null, 'narrower than the 64x cap allows');
|
||||
assert.equal(sensorSwathZoom(705e3, 2330, WIDE, 8), null, 'wider than 1x');
|
||||
assert.equal(sensorSwathZoom(705e3, null, WIDE, 8), null);
|
||||
});
|
||||
|
||||
test('the sensor strip spans its share of the view', () => {
|
||||
assert.equal(sensorStripFraction(13, 130), 0.1);
|
||||
assert.equal(sensorStripFraction(500, 131), 1, 'clipped to the view');
|
||||
assert.equal(sensorStripFraction(null, 131), null);
|
||||
assert.equal(sensorStripFraction(13.1, null), null);
|
||||
});
|
||||
|
||||
test('the wheel steps once per notch and sums a trackpad swipe', () => {
|
||||
// A mouse notch away from the viewer zooms in.
|
||||
let r = wheelZoomStep(null, { deltaY: -100, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 1);
|
||||
// The rest of that notch, inside the cooldown, is absorbed.
|
||||
r = wheelZoomStep(r.accumulator, { deltaY: -100, deltaMode: 0 }, 1050);
|
||||
assert.equal(r.step, 0);
|
||||
// A notch toward the viewer after the cooldown zooms out.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: 120, deltaMode: 0 },
|
||||
1050 + ORBITAL_WHEEL_COOLDOWN_MS + 1,
|
||||
);
|
||||
assert.equal(r.step, -1);
|
||||
// A trackpad's small deltas add up before they step.
|
||||
let pad = null;
|
||||
const steps = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const out = wheelZoomStep(
|
||||
pad,
|
||||
{ deltaY: -30, deltaMode: 0 },
|
||||
5000 + i * 16,
|
||||
);
|
||||
pad = out.accumulator;
|
||||
steps.push(out.step);
|
||||
}
|
||||
assert.deepEqual(steps, [0, 0, 0, 1]);
|
||||
// Turning back starts the sum over.
|
||||
const turned = wheelZoomStep(
|
||||
{ sum: -90, lastStepMs: -Infinity },
|
||||
{ deltaY: 30, deltaMode: 0 },
|
||||
9000,
|
||||
);
|
||||
assert.equal(turned.step, 0);
|
||||
assert.equal(turned.accumulator.sum, 30);
|
||||
// Line-mode wheels (Firefox): three lines make a notch.
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -3, deltaMode: 1 }, 0).step, 0);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -7, deltaMode: 1 }, 0).step, 1);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: 0 }, 0).step, 0);
|
||||
});
|
||||
|
||||
test('a heading turns the nadir frame, keeping north to fall back on', () => {
|
||||
// Over the equator at 0°E: north is +z, east is +y.
|
||||
const position = { x: ORBITAL_EARTH_RADIUS_M + 500_000, y: 0, z: 0 };
|
||||
const northUp = nadirOrientation(position);
|
||||
const east = nadirOrientation(position, { headingDeg: 90 });
|
||||
const close = (a, b) =>
|
||||
Math.abs(a.x - b.x) < 1e-9 &&
|
||||
Math.abs(a.y - b.y) < 1e-9 &&
|
||||
Math.abs(a.z - b.z) < 1e-9;
|
||||
assert.ok(close(northUp.up, { x: 0, y: 0, z: 1 }));
|
||||
assert.ok(close(east.up, { x: 0, y: 1, z: 0 }), 'east at the top');
|
||||
assert.ok(close(east.north, northUp.up), 'north kept for the next frame');
|
||||
assert.ok(
|
||||
close(nadirOrientation(position, { headingDeg: 0 }).up, northUp.up),
|
||||
);
|
||||
});
|
||||
|
||||
test('a partial scroll does not carry over into a later gesture', () => {
|
||||
// 90 px: short of a step.
|
||||
let r = wheelZoomStep(null, { deltaY: -90, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 0);
|
||||
// Still the same gesture: 10 px more completes the step.
|
||||
const same = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1100,
|
||||
);
|
||||
assert.equal(same.step, 1);
|
||||
// Minutes later a 10 px nudge is a new gesture, not the rest of the old.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1000 + ORBITAL_WHEEL_GESTURE_GAP_MS + 60_000,
|
||||
);
|
||||
assert.equal(r.step, 0);
|
||||
});
|
||||
|
||||
test('the total off-nadir angle combines both tilts', () => {
|
||||
assert.equal(offNadirAngleDeg(), 0);
|
||||
assert.ok(Math.abs(offNadirAngleDeg({ northDeg: 10 }) - 10) < 1e-9);
|
||||
// FWD 15° and RIGHT 10° read OFF-NADIR 18.0°.
|
||||
const total = offNadirAngleDeg({ northDeg: 15, eastDeg: 10 });
|
||||
assert.equal(total.toFixed(1), '18.0');
|
||||
assert.equal(offNadirAngleDeg({ northDeg: -15, eastDeg: -10 }), total);
|
||||
});
|
||||
|
||||
test("a tilted view's width grows faster across the frame than along it", () => {
|
||||
assert.equal(offNadirSwathScale(), 1, 'nadir');
|
||||
const cos = (deg) => Math.cos((deg * Math.PI) / 180);
|
||||
assert.ok(
|
||||
Math.abs(offNadirSwathScale({ northDeg: 30 }) - 1 / cos(30)) < 1e-9,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(offNadirSwathScale({ eastDeg: 30 }) - 1 / cos(30) ** 2) < 1e-9,
|
||||
);
|
||||
let last = 1;
|
||||
for (const deg of [5, 15, 25, 35, 45]) {
|
||||
const scale = offNadirSwathScale({ northDeg: deg, eastDeg: deg });
|
||||
assert.ok(scale > last, `${deg}°`);
|
||||
last = scale;
|
||||
}
|
||||
assert.equal(offNadirSwathScale({ northDeg: Number.NaN }), 1);
|
||||
});
|
||||
|
||||
// ── imagery pre-load ahead of the ground track ─────────────────────────
|
||||
|
||||
const PASS = Object.freeze({
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
groundTrackDeg: 45,
|
||||
velocityMps: 7_600,
|
||||
altitudeM: 500_000,
|
||||
});
|
||||
|
||||
test('destinationPoint walks a great circle along a bearing', () => {
|
||||
const east = destinationPoint(0, 179.9, 90, 111_195 * 0.2);
|
||||
assert.ok(Math.abs(east.lat) < 1e-9);
|
||||
assert.ok(Math.abs(east.lon - -179.9) < 1e-3, 'wraps the antimeridian');
|
||||
const north = destinationPoint(10, 20, 0, 50_000);
|
||||
assert.ok(Math.abs(greatCircleKm({ lat: 10, lon: 20 }, north) - 50) < 0.1);
|
||||
assert.ok(north.lat > 10 && Math.abs(north.lon - 20) < 1e-9);
|
||||
});
|
||||
|
||||
test('the points ahead run along the ground track at ground speed', () => {
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const across = ORBITAL_PREFETCH_ACROSS.length;
|
||||
assert.equal(points.length, ORBITAL_PREFETCH_LEAD_S.length * across);
|
||||
// Ground speed is the orbital speed scaled to the surface: ~7.0 km/s.
|
||||
const groundKmPerS = (7.6 * 6371) / (6371 + 500);
|
||||
const start = { lat: PASS.latitude, lon: PASS.longitude };
|
||||
ORBITAL_PREFETCH_LEAD_S.forEach((seconds, i) => {
|
||||
const centre = points[i * across];
|
||||
const km = greatCircleKm(start, centre);
|
||||
assert.ok(Math.abs(km - groundKmPerS * seconds) < 0.5, `${seconds} s`);
|
||||
// North-east of the start, on a 45° track.
|
||||
assert.ok(centre.lat > PASS.latitude && centre.lon > PASS.longitude);
|
||||
// The side points sit their fraction of the view off the centre line.
|
||||
points.slice(i * across + 1, (i + 1) * across).forEach((side, k) => {
|
||||
const offKm = 20 * Math.abs(ORBITAL_PREFETCH_ACROSS[k + 1]);
|
||||
assert.ok(Math.abs(greatCircleKm(centre, side) - offKm) < 0.05);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(groundTrackAhead({ ...PASS, groundTrackDeg: null }, 20), []);
|
||||
assert.deepEqual(groundTrackAhead(PASS, null), []);
|
||||
});
|
||||
|
||||
test('the tile level is the one Cesium draws for the view', () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
// Worked case: 10 m per CSS pixel at the equator. Cesium refines the
|
||||
// ellipsoid terrain until a tile's error is at most 2 px, 20 m: level 12,
|
||||
// 78,271.5 m / 2^12 = 19.11 m. The Web Mercator texel nearest that is
|
||||
// level 13's (156,543 m / 2^13 = 19.11 m), about 1.9 px each, where a
|
||||
// one-pixel texel would have asked for level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 0 }),
|
||||
13,
|
||||
);
|
||||
// 5 m/px: terrain level 13 (9.55 m), imagery level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 5, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// 7 m/px still takes terrain level 13 (9.55 m <= 14 m): level 14, so a
|
||||
// texel spans 1.4 px.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 7, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// At 60° a Mercator texel covers half the ground: one level coarser.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 60 }),
|
||||
12,
|
||||
);
|
||||
// A geographic scheme starts from two root tiles and keeps its texels at
|
||||
// every latitude.
|
||||
const geographic = new Cesium.GeographicTilingScheme();
|
||||
for (const latitudeDeg of [0, 60])
|
||||
assert.equal(
|
||||
imageryLevelForView(geographic, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg,
|
||||
geographic: true,
|
||||
}),
|
||||
12,
|
||||
`${latitudeDeg}°`,
|
||||
);
|
||||
// A terrain provider's own errors (Cesium World Terrain: 65-sample tiles)
|
||||
// and a looser screen-space error are honoured.
|
||||
const worldTerrain = (level) =>
|
||||
(6378137 * 2 * Math.PI * 0.25) / (65 * 2) / 2 ** level;
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
terrainLevelErrorM: worldTerrain,
|
||||
}),
|
||||
13,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
maximumScreenSpaceError: 4,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
// Clamped to the provider's levels.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 5,
|
||||
latitudeDeg: 0,
|
||||
maximumLevel: 12,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 0, latitudeDeg: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the tiles ahead are the ones the points fall in, bounded and new', () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const tiles = tilesForPoints(mercator, points, 13);
|
||||
assert.ok(tiles.length > 0 && tiles.length <= ORBITAL_PREFETCH_MAX_TILES);
|
||||
assert.equal(new Set(tiles.map((tile) => tile.key)).size, tiles.length);
|
||||
// Each tile holds its point: the first is the centre line 5 s ahead.
|
||||
const first = mercator.tileXYToRectangle(tiles[0].x, tiles[0].y, 13);
|
||||
assert.ok(
|
||||
Cesium.Rectangle.contains(
|
||||
first,
|
||||
Cesium.Cartographic.fromDegrees(points[0].lon, points[0].lat),
|
||||
),
|
||||
);
|
||||
// Ahead along a north-east track: rows north (smaller y), columns east.
|
||||
const here = mercator.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(PASS.longitude, PASS.latitude),
|
||||
13,
|
||||
);
|
||||
assert.ok(tiles.every((tile) => tile.x >= here.x && tile.y <= here.y));
|
||||
// Bounded, and a tile already asked for is skipped.
|
||||
assert.equal(tilesForPoints(mercator, points, 15, { maxTiles: 3 }).length, 3);
|
||||
const asked = new Set(
|
||||
tilesForPoints(mercator, points, 13, { maxTiles: Infinity }).map(
|
||||
(tile) => tile.key,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
tilesForPoints(mercator, points, 13, { skip: (key) => asked.has(key) }),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(tilesForPoints({}, points, 13), []);
|
||||
});
|
||||
|
||||
test('a view tilted past the limb no longer meets the Earth', () => {
|
||||
// GEO: the Earth spans about 8.7 degrees either side of nadir.
|
||||
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 5 }), true);
|
||||
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 15 }), false);
|
||||
// LEO: a 30 degree tilt still lands on the ground.
|
||||
assert.equal(
|
||||
offNadirCentreMeetsEarth(420_000, { northDeg: 20, eastDeg: 20 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(offNadirCentreMeetsEarth(420_000, { eastDeg: 80 }), false);
|
||||
assert.equal(offNadirCentreMeetsEarth(NaN, {}), false);
|
||||
});
|
||||
|
||||
test('the centre line of every lead comes before the frame edges', () => {
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const across = ORBITAL_PREFETCH_ACROSS.length;
|
||||
const ordered = centreLinesFirst(points);
|
||||
assert.equal(ordered.length, points.length);
|
||||
const leads = ORBITAL_PREFETCH_LEAD_S.length;
|
||||
assert.deepEqual(
|
||||
ordered.slice(0, leads),
|
||||
points.filter((_, i) => i % across === 0),
|
||||
);
|
||||
// The 12-tile cap at a level of small tiles still reaches the far lead.
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
const tiles = tilesForPoints(mercator, ordered, 17);
|
||||
const far = mercator.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(
|
||||
ordered[leads - 1].lon,
|
||||
ordered[leads - 1].lat,
|
||||
),
|
||||
17,
|
||||
);
|
||||
assert.ok(tiles.some((tile) => tile.x === far.x && tile.y === far.y));
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Ground focus for the orbital view: the look that keeps a point on the
|
||||
* ground at the centre of the picture as the satellite moves, whether that
|
||||
* look is within the off-nadir reach, and which mapped points the view can
|
||||
* show or be turned to. The look is the orbital view's own
|
||||
* `{yawDeg, pitchDeg}` over its nadir frame (see `offNadirOrientation` in
|
||||
* `src/cockpitOrbital.js`), so a focused view is a tilt like any other.
|
||||
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
|
||||
* in Earth-fixed (ECEF) metres.
|
||||
*/
|
||||
import {
|
||||
LOOK_EASE_TAU_MS,
|
||||
ORBITAL_LOOK_LIMIT_DEG,
|
||||
easeLook,
|
||||
} from './cockpitLook.js';
|
||||
|
||||
/**
|
||||
* How long a focus takes to swing the view onto its target, or back to
|
||||
* nadir when it ends: the look's own easing curve, on a slower clock. Three
|
||||
* of them (about 0.75 s) close 95% of the turn.
|
||||
*/
|
||||
export const ORBITAL_FOCUS_EASE_TAU_MS = 250;
|
||||
|
||||
/** How often the orbital Live Signals rows are rebuilt, ms. */
|
||||
export const ORBITAL_REACH_REFRESH_MS = 1000;
|
||||
|
||||
/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */
|
||||
const WGS84_A2 = 6378137 ** 2;
|
||||
const WGS84_B2 = 6356752.314245179 ** 2;
|
||||
|
||||
/** Slack on the reach limit so a target held exactly at 45° stays. */
|
||||
const REACH_EPSILON_DEG = 1e-6;
|
||||
|
||||
const toDegrees = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
function dot(a, b) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function finiteVector(v) {
|
||||
return (
|
||||
Boolean(v) &&
|
||||
Number.isFinite(v.x) &&
|
||||
Number.isFinite(v.y) &&
|
||||
Number.isFinite(v.z)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The look that centres `target` in the picture, in the nadir frame's tilt
|
||||
* (north first, then east; see `offNadirOrientation`): east is the angle off
|
||||
* the frame's top-bottom plane, north the angle along it. Also the single
|
||||
* angle between nadir and the line of sight, the image's off-nadir angle,
|
||||
* and whether the target faces the satellite (above its horizon).
|
||||
* @param {{direction: object, up: object, right: object}} nadir From
|
||||
* `nadirOrientation`.
|
||||
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
|
||||
* @param {{x: number, y: number, z: number}} target ECEF, metres.
|
||||
* @returns {{yawDeg: number, pitchDeg: number, offNadirDeg: number,
|
||||
* visible: boolean}|null} Null without usable positions.
|
||||
*/
|
||||
export function focusLook(nadir, satellite, target) {
|
||||
if (!nadir || !finiteVector(satellite) || !finiteVector(target)) return null;
|
||||
const line = {
|
||||
x: target.x - satellite.x,
|
||||
y: target.y - satellite.y,
|
||||
z: target.z - satellite.z,
|
||||
};
|
||||
const range = Math.hypot(line.x, line.y, line.z);
|
||||
if (!(range > 1)) return null;
|
||||
const v = { x: line.x / range, y: line.y / range, z: line.z / range };
|
||||
const along = dot(v, nadir.direction);
|
||||
const up = dot(v, nadir.up);
|
||||
const right = Math.min(1, Math.max(-1, dot(v, nadir.right)));
|
||||
// The target's own zenith, the WGS84 surface normal there: the satellite
|
||||
// is above its horizon when the line from the target to it points up.
|
||||
const normal = {
|
||||
x: target.x / WGS84_A2,
|
||||
y: target.y / WGS84_A2,
|
||||
z: target.z / WGS84_B2,
|
||||
};
|
||||
const visible =
|
||||
Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0;
|
||||
const yawDeg = toDegrees(Math.asin(right));
|
||||
const pitchDeg = toDegrees(Math.atan2(up, along));
|
||||
return {
|
||||
yawDeg: yawDeg === 0 ? 0 : yawDeg,
|
||||
pitchDeg: pitchDeg === 0 ? 0 : pitchDeg,
|
||||
offNadirDeg: toDegrees(Math.acos(Math.min(1, Math.max(-1, along)))),
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a focus look is within the off-nadir reach: the target faces the
|
||||
* satellite and each axis is within the arrows' ±45° clamp.
|
||||
* @param {{yawDeg: number, pitchDeg: number, visible?: boolean}|null} look
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function focusInReach(look) {
|
||||
if (!look || look.visible === false) return false;
|
||||
const limit = ORBITAL_LOOK_LIMIT_DEG + REACH_EPSILON_DEG;
|
||||
return (
|
||||
Number.isFinite(look.yawDeg) &&
|
||||
Number.isFinite(look.pitchDeg) &&
|
||||
Math.abs(look.yawDeg) <= limit &&
|
||||
Math.abs(look.pitchDeg) <= limit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease what is left of the turn onto a focus target toward nothing: the
|
||||
* look's easing (`easeLook`) on the focus's slower clock. The view is the
|
||||
* live target's look plus this remainder, so once it lands the view holds
|
||||
* the target exactly, without the lag of chasing it. Reduced motion cuts.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} remainder View minus target.
|
||||
* @param {number} dtMs Milliseconds since the previous frame.
|
||||
* @param {{immediate?: boolean}} [options]
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function easeFocusRemainder(
|
||||
remainder,
|
||||
dtMs,
|
||||
{ immediate = false } = {},
|
||||
) {
|
||||
return easeLook(remainder, { yawDeg: 0, pitchDeg: 0 }, focusEaseDtMs(dtMs), {
|
||||
immediate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame's time on the focus's clock, for `easeLook`: its fixed time
|
||||
* constant scaled to ORBITAL_FOCUS_EASE_TAU_MS.
|
||||
* @param {number} dtMs
|
||||
* @returns {number}
|
||||
*/
|
||||
export function focusEaseDtMs(dtMs) {
|
||||
return Number.isFinite(dtMs) && dtMs > 0
|
||||
? (dtMs * LOOK_EASE_TAU_MS) / ORBITAL_FOCUS_EASE_TAU_MS
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped points the view can be turned to: those facing the satellite
|
||||
* and within the off-nadir reach, nearest to nadir first.
|
||||
* @param {Iterable<{key: string, position: {x: number, y: number,
|
||||
* z: number}}>} candidates
|
||||
* @param {{nadir: object, satellite: object}} frame
|
||||
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
|
||||
*/
|
||||
export function pointsInReach(candidates, { nadir, satellite }) {
|
||||
const entries = [];
|
||||
if (!nadir || !finiteVector(satellite)) return entries;
|
||||
for (const candidate of candidates) {
|
||||
const look = focusLook(nadir, satellite, candidate?.position);
|
||||
if (!focusInReach(look)) continue;
|
||||
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
|
||||
}
|
||||
return entries.sort(
|
||||
(a, b) =>
|
||||
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// src/cockpitOrbitalFocus.test.mjs — the geometry of a ground focus in the
|
||||
// orbital view: the look that centres a target, its off-nadir angle, the
|
||||
// reach, the easing onto and off a target, and the reach list.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ORBITAL_FOCUS_EASE_TAU_MS,
|
||||
easeFocusRemainder,
|
||||
focusEaseDtMs,
|
||||
focusInReach,
|
||||
focusLook,
|
||||
pointsInReach,
|
||||
} from './cockpitOrbitalFocus.js';
|
||||
import {
|
||||
ORBITAL_EARTH_RADIUS_M,
|
||||
nadirOrientation,
|
||||
offNadirAngleDeg,
|
||||
offNadirOrientation,
|
||||
} from './cockpitOrbital.js';
|
||||
import { LOOK_EASE_TAU_MS } from './cockpitLook.js';
|
||||
|
||||
const near = (actual, expected, label, tolerance = 1e-9) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: ${actual} not within ${tolerance} of ${expected}`,
|
||||
);
|
||||
|
||||
const unit = (v) => {
|
||||
const n = Math.hypot(v.x, v.y, v.z);
|
||||
return { x: v.x / n, y: v.y / n, z: v.z / n };
|
||||
};
|
||||
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
|
||||
const angleBetweenDeg = (a, b) => {
|
||||
const u = unit(a);
|
||||
const v = unit(b);
|
||||
const cosine = Math.min(1, Math.max(-1, u.x * v.x + u.y * v.y + u.z * v.z));
|
||||
return (Math.acos(cosine) * 180) / Math.PI;
|
||||
};
|
||||
|
||||
/** A point on a sphere of radius `radiusM`, ECEF. */
|
||||
const onSphere = (latDeg, lonDeg, radiusM) => {
|
||||
const lat = (latDeg * Math.PI) / 180;
|
||||
const lon = (lonDeg * Math.PI) / 180;
|
||||
return {
|
||||
x: radiusM * Math.cos(lat) * Math.cos(lon),
|
||||
y: radiusM * Math.cos(lat) * Math.sin(lon),
|
||||
z: radiusM * Math.sin(lat),
|
||||
};
|
||||
};
|
||||
|
||||
/** A point on the WGS84 ellipsoid, as the globe draws it. */
|
||||
const geodeticToEcef = (latDeg, lonDeg, heightM = 0) =>
|
||||
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, heightM);
|
||||
|
||||
const ISS_ALT_M = 420_000;
|
||||
const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) =>
|
||||
geodeticToEcef(latDeg, lonDeg, altitudeM);
|
||||
|
||||
test('the focus look turns the nadir frame onto the target exactly', () => {
|
||||
const satellite = satelliteAt(30, -97);
|
||||
for (const heading of [null, 0, 51.6, 200]) {
|
||||
const nadir = nadirOrientation(satellite, { headingDeg: heading });
|
||||
for (const [lat, lon] of [
|
||||
[31.5, -96],
|
||||
[28.2, -99.4],
|
||||
[30, -97],
|
||||
[32.9, -97.1],
|
||||
]) {
|
||||
const target = geodeticToEcef(lat, lon, 300);
|
||||
const look = focusLook(nadir, satellite, target);
|
||||
const view = offNadirOrientation(nadir, {
|
||||
northDeg: look.pitchDeg,
|
||||
eastDeg: look.yawDeg,
|
||||
});
|
||||
near(
|
||||
angleBetweenDeg(view.direction, sub(target, satellite)),
|
||||
0,
|
||||
`centred at ${lat},${lon} heading ${heading}`,
|
||||
1e-5,
|
||||
);
|
||||
// The single angle the readout gives is the tilt's total angle.
|
||||
near(
|
||||
look.offNadirDeg,
|
||||
offNadirAngleDeg({ northDeg: look.pitchDeg, eastDeg: look.yawDeg }),
|
||||
'one off-nadir angle',
|
||||
1e-9,
|
||||
);
|
||||
assert.equal(look.visible, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the off-nadir angle is the angle between nadir and the line of sight', () => {
|
||||
// On a sphere: a target `central` radians from the sub-satellite point is
|
||||
// seen atan(R sin γ / (R + h − R cos γ)) off nadir.
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const satellite = onSphere(10, 20, R + ISS_ALT_M);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
for (const centralDeg of [0, 0.5, 2, 5, 12]) {
|
||||
const target = onSphere(10 + centralDeg, 20, R);
|
||||
const γ = (centralDeg * Math.PI) / 180;
|
||||
const expected =
|
||||
(Math.atan2(R * Math.sin(γ), R + ISS_ALT_M - R * Math.cos(γ)) * 180) /
|
||||
Math.PI;
|
||||
const look = focusLook(nadir, satellite, target);
|
||||
near(look.offNadirDeg, expected, `${centralDeg}° away`, 1e-9);
|
||||
}
|
||||
// Straight below reads zero, a real angle and not a missing one.
|
||||
const below = focusLook(nadir, satellite, onSphere(10, 20, R));
|
||||
near(below.offNadirDeg, 0, 'straight below', 1e-9);
|
||||
near(below.yawDeg, 0, 'no east', 1e-9);
|
||||
near(below.pitchDeg, 0, 'no north', 1e-9);
|
||||
assert.equal(focusInReach(below), true);
|
||||
});
|
||||
|
||||
test('the reach is the arrows’ ±45° on each axis, facing the satellite', () => {
|
||||
assert.equal(focusInReach({ yawDeg: 45, pitchDeg: -45, visible: true }), true);
|
||||
assert.equal(focusInReach({ yawDeg: 45.01, pitchDeg: 0, visible: true }), false);
|
||||
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: -45.01, visible: true }), false);
|
||||
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: 0, visible: false }), false);
|
||||
assert.equal(focusInReach(null), false);
|
||||
assert.equal(focusInReach({ yawDeg: Number.NaN, pitchDeg: 0 }), false);
|
||||
|
||||
// From geostationary height the far side of the Earth lies within a few
|
||||
// degrees of nadir, but it faces away.
|
||||
const satellite = satelliteAt(0, 0, 35_786_000);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
const farSide = geodeticToEcef(0, 100, 0);
|
||||
const look = focusLook(nadir, satellite, farSide);
|
||||
assert.ok(Math.abs(look.yawDeg) < 45 && Math.abs(look.pitchDeg) < 45);
|
||||
assert.equal(look.visible, false);
|
||||
assert.equal(focusInReach(look), false);
|
||||
// A point near the sub-satellite point faces it.
|
||||
assert.equal(focusInReach(focusLook(nadir, satellite, geodeticToEcef(5, 5))), true);
|
||||
});
|
||||
|
||||
test('a target slides out of reach as the satellite passes it', () => {
|
||||
const target = geodeticToEcef(0, 10, 0);
|
||||
const inReach = [];
|
||||
for (let lon = 10; lon >= -5; lon -= 1) {
|
||||
const satellite = satelliteAt(0, lon);
|
||||
inReach.push(focusInReach(focusLook(nadirOrientation(satellite), satellite, target)));
|
||||
}
|
||||
assert.equal(inReach[0], true, 'overhead');
|
||||
assert.equal(inReach.at(-1), false, '15° of longitude behind');
|
||||
// Once out, it stays out: the reach ends once, not on and off.
|
||||
const firstOut = inReach.indexOf(false);
|
||||
assert.ok(firstOut > 0);
|
||||
assert.ok(inReach.slice(firstOut).every((value) => value === false));
|
||||
});
|
||||
|
||||
test('the turn onto a target eases on the slower focus clock and lands', () => {
|
||||
assert.equal(
|
||||
focusEaseDtMs(ORBITAL_FOCUS_EASE_TAU_MS),
|
||||
LOOK_EASE_TAU_MS,
|
||||
'one focus time constant is one look time constant',
|
||||
);
|
||||
assert.equal(focusEaseDtMs(Number.NaN), 0);
|
||||
assert.equal(focusEaseDtMs(-5), 0);
|
||||
let remainder = { yawDeg: -30, pitchDeg: 20 };
|
||||
let previous = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
|
||||
let frames = 0;
|
||||
while ((remainder.yawDeg || remainder.pitchDeg) && frames < 500) {
|
||||
const before = remainder;
|
||||
remainder = easeFocusRemainder(remainder, 16);
|
||||
const size = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
|
||||
assert.ok(size < previous, 'always closer');
|
||||
// No axis moves more than a tenth of the way in a frame, but for the
|
||||
// last few hundredths of a degree it lands on: a turn, not a cut.
|
||||
for (const axis of ['yawDeg', 'pitchDeg']) {
|
||||
const step = Math.abs(before[axis] - remainder[axis]);
|
||||
assert.ok(
|
||||
step <= Math.max(Math.abs(before[axis]) * 0.1, 0.06),
|
||||
`frame ${frames} ${axis}`,
|
||||
);
|
||||
}
|
||||
previous = size;
|
||||
frames += 1;
|
||||
}
|
||||
assert.deepEqual(remainder, { yawDeg: 0, pitchDeg: 0 }, 'lands exactly');
|
||||
// About three time constants close 95%; landing takes a little longer.
|
||||
assert.ok(frames * 16 > 2 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
|
||||
assert.ok(frames * 16 < 12 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
|
||||
assert.deepEqual(
|
||||
easeFocusRemainder({ yawDeg: 10, pitchDeg: -4 }, 16, { immediate: true }),
|
||||
{ yawDeg: 0, pitchDeg: 0 },
|
||||
'reduced motion cuts',
|
||||
);
|
||||
});
|
||||
|
||||
test('the reach list keeps points in reach or in frame, nearest first, bounded', () => {
|
||||
const satellite = satelliteAt(0, 0);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
const candidates = [];
|
||||
// A row of points eastward, 0.1° apart: those up to ~3.7° are within the
|
||||
// 45° tilt, the rest beyond it.
|
||||
for (let i = 0; i < 80; i += 1)
|
||||
candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) });
|
||||
candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) });
|
||||
const entries = pointsInReach(candidates, { nadir, satellite });
|
||||
assert.ok(entries.every((entry) => entry.key !== 'far-side'));
|
||||
const angles = entries.map((entry) => entry.offNadirDeg);
|
||||
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
|
||||
assert.equal(entries[0].key, 'east-0');
|
||||
assert.equal(entries[0].offNadirDeg, 0);
|
||||
// Only those within the 45° tilt are listed.
|
||||
assert.ok(entries.length > 0 && entries.length < 80);
|
||||
for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45);
|
||||
const beyond = focusLook(nadir, satellite, candidates[entries.length].position);
|
||||
assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach');
|
||||
assert.deepEqual(pointsInReach([], { nadir, satellite }), []);
|
||||
});
|
||||
|
||||
test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => {
|
||||
const target = geodeticToEcef(45, 0);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target);
|
||||
const satelliteAtElevation = (elevationDeg) => {
|
||||
const e = Cesium.Math.toRadians(elevationDeg);
|
||||
const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7);
|
||||
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
|
||||
};
|
||||
const below = satelliteAtElevation(-0.1);
|
||||
assert.equal(focusLook(nadirOrientation(below), below, target).visible, false);
|
||||
const above = satelliteAtElevation(0.1);
|
||||
assert.equal(focusLook(nadirOrientation(above), above, target).visible, true);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The camera's hop from the satellite the orbital view rides to another.
|
||||
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
|
||||
* in Earth-fixed (ECEF) metres.
|
||||
*/
|
||||
|
||||
/** The most nearby satellites asked for the orbital rows, nearest first. */
|
||||
export const ORBITAL_SATELLITES_MAX = 8;
|
||||
|
||||
/** How long the camera takes to hop to another satellite, ms. */
|
||||
export const ORBITAL_HOP_MS = 1200;
|
||||
|
||||
function finiteVector(v) {
|
||||
return (
|
||||
Boolean(v) &&
|
||||
Number.isFinite(v.x) &&
|
||||
Number.isFinite(v.y) &&
|
||||
Number.isFinite(v.z)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera is part-way through a hop: along the great circle
|
||||
* between the two positions (never through the Earth), its distance from the
|
||||
* centre interpolated linearly. The circle is the one through both ends, so
|
||||
* the path moves smoothly as the destination does, however far round it
|
||||
* lies; only exactly opposite ends, which no single circle joins, turn about
|
||||
* a fixed axis square to them.
|
||||
* @param {{x: number, y: number, z: number}} from
|
||||
* @param {{x: number, y: number, z: number}} to
|
||||
* @param {number} progress Eased, 0..1.
|
||||
* @returns {{x: number, y: number, z: number}|null}
|
||||
*/
|
||||
export function hopPosition(from, to, progress) {
|
||||
if (!finiteVector(from) || !finiteVector(to)) return null;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
|
||||
const ra = Math.hypot(from.x, from.y, from.z);
|
||||
const rb = Math.hypot(to.x, to.y, to.z);
|
||||
if (!(ra > 0) || !(rb > 0)) return null;
|
||||
const a = { x: from.x / ra, y: from.y / ra, z: from.z / ra };
|
||||
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
|
||||
const cosine = a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
// The direction the circle leaves `a` in: `b` less its part along `a`.
|
||||
let c = {
|
||||
x: b.x - cosine * a.x,
|
||||
y: b.y - cosine * a.y,
|
||||
z: b.z - cosine * a.z,
|
||||
};
|
||||
let rc = Math.hypot(c.x, c.y, c.z);
|
||||
const r = ra + (rb - ra) * p;
|
||||
const omega = Math.atan2(rc, cosine);
|
||||
if (!(rc > 1e-12)) {
|
||||
if (cosine > 0) return { x: a.x * r, y: a.y * r, z: a.z * r };
|
||||
// Opposite ends: set off square to `a`, northward (or, near a pole,
|
||||
// toward the prime meridian).
|
||||
const pole =
|
||||
Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
|
||||
const along = pole.x * a.x + pole.y * a.y + pole.z * a.z;
|
||||
c = {
|
||||
x: pole.x - along * a.x,
|
||||
y: pole.y - along * a.y,
|
||||
z: pole.z - along * a.z,
|
||||
};
|
||||
rc = Math.hypot(c.x, c.y, c.z);
|
||||
}
|
||||
const angle = p * omega;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle) / rc;
|
||||
return {
|
||||
x: (a.x * cos + c.x * sin) * r,
|
||||
y: (a.y * cos + c.y * sin) * r,
|
||||
z: (a.z * cos + c.z * sin) * r,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// src/cockpitOrbitalSatellites.test.mjs — the camera's hop from one ridden
|
||||
// satellite to another.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { hopPosition } from './cockpitOrbitalSatellites.js';
|
||||
|
||||
const at = (lat, lon, alt) => Cesium.Cartesian3.fromDegrees(lon, lat, alt);
|
||||
|
||||
test('the hop follows the great circle and lands on the satellite', () => {
|
||||
const from = at(0, 0, 400_000);
|
||||
const to = at(0, 90, 800_000);
|
||||
const start = hopPosition(from, to, 0);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(start[axis] - from[axis]) < 1e-6, axis);
|
||||
const end = hopPosition(from, to, 1);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-6, axis);
|
||||
// Half-way it is above the Earth, between the two heights, not on the
|
||||
// chord through it.
|
||||
const mid = hopPosition(from, to, 0.5);
|
||||
const radius = Math.hypot(mid.x, mid.y, mid.z);
|
||||
const ra = Cesium.Cartesian3.magnitude(from);
|
||||
const rb = Cesium.Cartesian3.magnitude(to);
|
||||
assert.ok(Math.abs(radius - (ra + rb) / 2) < 1e-6);
|
||||
assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9);
|
||||
assert.equal(hopPosition(null, to, 0.5), null);
|
||||
});
|
||||
|
||||
test('a hop to the opposite side goes round the Earth, not through it', () => {
|
||||
const from = at(0, 0, 400_000);
|
||||
const to = at(0, 180, 400_000);
|
||||
const radius = Cesium.Cartesian3.magnitude(from);
|
||||
for (const p of [0.25, 0.5, 0.75]) {
|
||||
const point = hopPosition(from, to, p);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3,
|
||||
`p=${p}`,
|
||||
);
|
||||
}
|
||||
const end = hopPosition(from, to, 1);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis);
|
||||
});
|
||||
|
||||
test('a hop to nearly the opposite side moves smoothly as the destination does', () => {
|
||||
const r = 6_778_137;
|
||||
const from = { x: r, y: 0, z: 0 };
|
||||
const near = (delta) => ({
|
||||
x: -r * Math.cos(delta),
|
||||
y: r * Math.sin(delta),
|
||||
z: 0,
|
||||
});
|
||||
const mid = (delta) => hopPosition(from, near(delta), 0.5);
|
||||
const [a, b] = [mid(0.999e-6), mid(1.001e-6)];
|
||||
assert.ok(
|
||||
Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < 1,
|
||||
'no jump between neighbours',
|
||||
);
|
||||
for (const delta of [1e-3, 1e-6, 1e-9]) {
|
||||
const to = near(delta);
|
||||
const end = hopPosition(from, to, 1);
|
||||
assert.ok(
|
||||
Math.hypot(end.x - to.x, end.y - to.y, end.z - to.z) < 1e-3,
|
||||
`lands at ${delta}`,
|
||||
);
|
||||
const half = mid(delta);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(half.x, half.y, half.z) - r) < 1e-3,
|
||||
`round at ${delta}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -457,6 +457,30 @@ export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) {
|
||||
return Boolean(vesselsEnabled) && !contextModeChanging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tracked satellite's orbital view can be entered. Like the
|
||||
* bridge it needs only its own layer, Satellites, with a satellite tracked.
|
||||
* A Context mode change in flight still owns the camera, so entry waits it
|
||||
* out. A subject the satellites layer marks as not enterable (debris:
|
||||
* rocket bodies, fragments, dead satellites; `satelliteClassEnterable` in
|
||||
* src/layers/satellites/classes.js) can be tracked but is never ridden: it is
|
||||
* no mission and has no view to offer.
|
||||
* @param {{satellitesEnabled: boolean, contextModeChanging: boolean,
|
||||
* subjectEnterable?: boolean}} state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function orbitEntryAllowed({
|
||||
satellitesEnabled,
|
||||
contextModeChanging,
|
||||
subjectEnterable = true,
|
||||
}) {
|
||||
return (
|
||||
Boolean(satellitesEnabled) &&
|
||||
!contextModeChanging &&
|
||||
subjectEnterable !== false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context-mode id → the word the voice tools accept for it.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bridgeEntryAllowed,
|
||||
orbitEntryAllowed,
|
||||
cockpitEntryAllowed,
|
||||
contextAllowedLayerIds,
|
||||
contextLayerEnableBlockReason,
|
||||
@@ -440,6 +441,19 @@ test('bridge entry needs only Live AIS Vessels', () => {
|
||||
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: true }), false);
|
||||
});
|
||||
|
||||
test('orbital entry needs only Satellites', () => {
|
||||
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: false }), true);
|
||||
assert.equal(orbitEntryAllowed({ satellitesEnabled: false, contextModeChanging: false }), false);
|
||||
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: true }), false);
|
||||
});
|
||||
|
||||
test('debris can be tracked but never ridden in orbit', () => {
|
||||
const on = { satellitesEnabled: true, contextModeChanging: false };
|
||||
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: false }), false);
|
||||
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: true }), true);
|
||||
assert.equal(orbitEntryAllowed(on), true, 'no subject read yet');
|
||||
});
|
||||
|
||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||
const idleOwner = { _contextModeChanging: false };
|
||||
await runWithContextModeChanging(idleOwner, async () => {
|
||||
|
||||
@@ -1802,3 +1802,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
|
||||
test('detection objects name the vessel card their callout gives way to', () => {
|
||||
const vessel = (mmsi, name) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
const keyed = vessel('200', 'GOLDEN BEAR');
|
||||
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
|
||||
_setVesselStateForTest({
|
||||
viewer: {},
|
||||
records: [keyed, unkeyed],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
const objects = Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
|
||||
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
|
||||
assert.equal(
|
||||
objects['NO MMSI'].cardEntryId,
|
||||
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
|
||||
'the same identity the vessel card is published under',
|
||||
);
|
||||
} finally {
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
@@ -1518,3 +1518,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: {
|
||||
'surveillance:type': 'ALPR',
|
||||
operator: ' Şehir\n\u0000 PD ',
|
||||
manufacturer: '😀'.repeat(200),
|
||||
'camera:type': null,
|
||||
'surveillance:zone': '\t traffic zone ',
|
||||
},
|
||||
});
|
||||
assert.equal(record.operator, 'Şehir PD');
|
||||
assert.equal(record.manufacturer.length, 180);
|
||||
assert.equal(record.manufacturer, '😀'.repeat(90));
|
||||
assert.equal(record.cameraType, null);
|
||||
assert.equal(record.zone, 'traffic zone');
|
||||
});
|
||||
+38
-5
@@ -134,6 +134,13 @@ export const DATA_CREDITS = [
|
||||
'copied, published, or incorporated into other databases without the ' +
|
||||
'explicit permission of David J Taylor, Edinburgh',
|
||||
},
|
||||
{
|
||||
key: 'planespotters',
|
||||
html:
|
||||
'Aircraft photos: ' +
|
||||
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
|
||||
'· each photo is © its photographer, credited beside it',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
@@ -150,10 +157,16 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'ucs-satellite-database',
|
||||
html:
|
||||
'Satellite purposes: ' +
|
||||
'Satellite operators and purposes: ' +
|
||||
'<a href="https://www.ucsusa.org/satellite_database" target="_blank" rel="noopener">UCS Satellite Database</a> ' +
|
||||
'(UCS-Satellite-Database 5-1-2023), Union of Concerned Scientists',
|
||||
},
|
||||
{
|
||||
key: 'imaging-sensors',
|
||||
html:
|
||||
'Imaging sensor swaths: operator and agency specifications, ' +
|
||||
'cited per platform',
|
||||
},
|
||||
{
|
||||
key: 'launch-library-2',
|
||||
html:
|
||||
@@ -667,20 +680,40 @@ export const OSM_PLACES_CREDIT = {
|
||||
/**
|
||||
* Per-feed transit credit, registered the first time that feed's vehicles
|
||||
* render (see `src/data/transitFeeds.js` for the license of each).
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
|
||||
*
|
||||
* Two optional registry fields serve licences that ask for more than a name:
|
||||
* `sourceUrl` links the attribution to the publisher (Warsaw asks for a link
|
||||
* to its API), and `creditDated` adds the UTC date of the data, from the feed
|
||||
* header or else the fetch time (Warsaw and Poznań ask for the download or
|
||||
* update date). A dated credit is keyed by its date, so a new day registers a
|
||||
* new line instead of keeping yesterday's.
|
||||
* @param {{ id: string, attribution: string, license: string, licenseUrl: string,
|
||||
* sourceUrl?: string, creditDated?: boolean }} feed
|
||||
* @param {{ feedTimestamp?: number|null, fetchedAt?: number|null }} [snapshot]
|
||||
* `feedTimestamp` in epoch seconds, `fetchedAt` in epoch ms.
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
export function transitFeedCredit(feed, snapshot = null) {
|
||||
const escape = (text) =>
|
||||
String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
const source = feed.sourceUrl
|
||||
? `<a href="${escape(feed.sourceUrl)}" target="_blank" rel="noopener">${escape(feed.attribution)}</a>`
|
||||
: escape(feed.attribution);
|
||||
const dataMs = Number.isFinite(snapshot?.feedTimestamp)
|
||||
? snapshot.feedTimestamp * 1000
|
||||
: snapshot?.fetchedAt;
|
||||
const date =
|
||||
feed.creditDated === true && Number.isFinite(dataMs) && dataMs > 0
|
||||
? new Date(dataMs).toISOString().slice(0, 10)
|
||||
: null;
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
key: date ? `transit-${feed.id}-${date}` : `transit-${feed.id}`,
|
||||
html:
|
||||
`Transit (${escape(feed.attribution)}): ` +
|
||||
`Transit (${source}${date ? `, data of ${date}` : ''}): ` +
|
||||
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
import {
|
||||
DATA_CREDITS,
|
||||
NYC_DOT_DISCLAIMER,
|
||||
transitFeedCredit,
|
||||
} from './dataCredits.js';
|
||||
import { getTransitFeed } from './transitFeeds.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -38,6 +43,13 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('Planespotters is credited with a plain followable link', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'planespotters');
|
||||
assert.ok(credit, 'the tracked aircraft photos come from Planespotters');
|
||||
assert.match(credit.html, /href="https:\/\/www\.planespotters\.net"/);
|
||||
assert.doesNotMatch(credit.html, /nofollow/);
|
||||
});
|
||||
|
||||
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
|
||||
// UCS database manual, section 3: references name the version (the Excel
|
||||
// file's name) and link www.ucsusa.org/satellite_database.
|
||||
@@ -52,6 +64,15 @@ test('the UCS Satellite Database is acknowledged with its version and webpage',
|
||||
);
|
||||
});
|
||||
|
||||
test('the imaging sensor swaths credit operator and agency specifications', () => {
|
||||
// Each sensor-pack entry cites its operator's or agency's own pages;
|
||||
// no aggregator database is credited.
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'imaging-sensors');
|
||||
assert.ok(credit, 'the orbital zoom caps come from the sensor pack');
|
||||
assert.match(credit.html, /operator and agency specifications/);
|
||||
assert.doesNotMatch(credit.html, /OSCAR|wmo\.int/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
@@ -181,3 +202,24 @@ test('the PR #1506 camera packs are credited with their sources and terms', () =
|
||||
/href="https:\/\/www\.river\.go\.jp\//,
|
||||
);
|
||||
});
|
||||
|
||||
test('Warsaw and Poznań credits carry the data date their licences ask for', () => {
|
||||
const warsaw = getTransitFeed('ztm-warszawa');
|
||||
// 2026-11-09T06:00:00Z as a GTFS-RT header timestamp (seconds).
|
||||
const credit = transitFeedCredit(warsaw, { feedTimestamp: 1_794_204_000 });
|
||||
assert.match(credit.html, /, data of 2026-11-09\)/);
|
||||
assert.match(credit.html, /href="http:\/\/api\.um\.warszawa\.pl"/);
|
||||
assert.equal(credit.key, 'transit-ztm-warszawa-2026-11-09');
|
||||
// Without a feed timestamp the fetch time is the download date.
|
||||
const poznan = transitFeedCredit(getTransitFeed('ztm-poznan'), {
|
||||
feedTimestamp: null,
|
||||
fetchedAt: Date.UTC(2026, 10, 10, 12),
|
||||
});
|
||||
assert.match(poznan.html, /data of 2026-11-10/);
|
||||
// Undated feeds keep one credit line for the session.
|
||||
const mbta = transitFeedCredit(getTransitFeed('mbta'), {
|
||||
feedTimestamp: 1_794_204_000,
|
||||
});
|
||||
assert.equal(mbta.key, 'transit-mbta');
|
||||
assert.doesNotMatch(mbta.html, /data of/);
|
||||
});
|
||||
+24
-3
@@ -21,7 +21,10 @@ import {
|
||||
getKeyholeFadeTuning,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
getQueuedOverlayEntryAlpha,
|
||||
registerWorldOverlayPaintLane,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
DETECTION_STYLE,
|
||||
DETECTION_THEME_MAP,
|
||||
@@ -107,6 +110,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
|
||||
const SPARSE_ZONE_FRACTION = 0.5;
|
||||
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
|
||||
const LABEL_SOLVE_INTERVAL_MS = 125;
|
||||
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
|
||||
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
|
||||
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
||||
const BRACKET_ALPHA_STEPS = 4;
|
||||
/** Finer bands for Global Context echo brackets, whose fade is animated. */
|
||||
@@ -576,7 +581,8 @@ export function getDetectionDiagnostics() {
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
.filter((row) => row.paintAlpha > 0.001)
|
||||
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1077,6 +1083,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
cardSourceId: '',
|
||||
cardEntryId: '',
|
||||
paintAlpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -1103,6 +1112,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
row.font = FONT;
|
||||
row.microFont = MICRO_FONT;
|
||||
row.alpha = alpha;
|
||||
row.cardSourceId = candidate.cardSourceId;
|
||||
row.cardEntryId = candidate.cardEntryId;
|
||||
_calloutCount++;
|
||||
}
|
||||
|
||||
@@ -1134,7 +1145,15 @@ function _paintCalloutLane(frame) {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
const row = _calloutPool[i];
|
||||
paintDetectionCallout(ctx, row, row.alpha);
|
||||
// The callout would show through its contact's own card: fade it by the
|
||||
// opacity that card paints with this frame.
|
||||
const cardAlpha =
|
||||
row.cardEntryId === ''
|
||||
? 0
|
||||
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
|
||||
row.paintAlpha =
|
||||
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
|
||||
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -1211,6 +1230,8 @@ function _materializeCandidate(
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
|
||||
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,16 @@ import {
|
||||
} from './detection.js';
|
||||
import {
|
||||
destroyWorldOverlay,
|
||||
getOverlayPaintRect,
|
||||
initWorldOverlay,
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import {
|
||||
getKeyholeFadeTuning,
|
||||
getKeyholeGeometry,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
clearAwarenessEchoField,
|
||||
@@ -579,6 +586,178 @@ test('every style paints its OWN plate token through the production callout path
|
||||
}
|
||||
});
|
||||
|
||||
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
|
||||
function shipWithCard(mmsi, position) {
|
||||
return {
|
||||
position,
|
||||
sourceId: mmsi,
|
||||
id: `SHIP-${mmsi}`,
|
||||
metric: '6 kn',
|
||||
type: 'SEA',
|
||||
cardSourceId: 'ais-live-vessels',
|
||||
cardEntryId: `vessel:${mmsi}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The production ambient vessel card (not protected: it goes through the arbiter). */
|
||||
function ambientVesselCard(mmsi, position) {
|
||||
return applyVesselOverlayPolicy({
|
||||
id: `vessel:${mmsi}`,
|
||||
position,
|
||||
title: `CARD-${mmsi}`,
|
||||
details: ['CARGO · 6KT · 80°'],
|
||||
gapPx: 10,
|
||||
});
|
||||
}
|
||||
|
||||
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
|
||||
// Default keyhole tuning: 7% feather, 1% outside opacity.
|
||||
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
|
||||
const width = 1600;
|
||||
const height = 900;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
const env = installEnvironment({ width, height, dpr: 1 });
|
||||
// Just outside the keyhole's left rim: the card sits above or below the
|
||||
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
|
||||
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
|
||||
const ship = shipWithCard('200', position);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
|
||||
assert.ok(rect, 'the host paints the card (at the outside opacity)');
|
||||
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
|
||||
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
|
||||
|
||||
const callouts = getDetectionDiagnostics().calloutRects;
|
||||
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
|
||||
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
|
||||
const ship = shipWithCard('200', at(0.1, 0.1));
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
const frame = (ms) => {
|
||||
env.ctx.calls.length = 0;
|
||||
env.advance(ms);
|
||||
env.postRender.raise();
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
for (let i = 0; i < 6; i++) frame(250);
|
||||
frame(16);
|
||||
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
|
||||
|
||||
// The vessel layer republishes its screen-grid cohort after a camera move:
|
||||
// this vessel's card is replaced by another one, the vessel stays detectable.
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
|
||||
frame(16);
|
||||
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
|
||||
assert.ok(painted('CARD-200'), 'and its text still paints');
|
||||
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
|
||||
|
||||
for (let i = 0; i < 4; i++) frame(250);
|
||||
assert.ok(!painted('CARD-200'), 'the card has faded out');
|
||||
assert.ok(painted('SHIP-200'), 'and the callout is back');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a vessel whose ambient card loses the collision keeps its callout', () => {
|
||||
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
|
||||
// Four vessels a few pixels apart: their cards collide (a card goes only
|
||||
// above or below its anchor), so the arbiter paints only some of them.
|
||||
const ships = [0, 1, 2, 3].map((i) =>
|
||||
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
|
||||
setOverlayEntries(
|
||||
'ais-live-vessels',
|
||||
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
|
||||
);
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
|
||||
assert.ok(
|
||||
winners.length >= 1 && winners.length < ships.length,
|
||||
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
|
||||
);
|
||||
for (const ship of ships) {
|
||||
const carded = winners.includes(ship);
|
||||
assert.equal(
|
||||
painted(ship.id),
|
||||
!carded,
|
||||
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
getDetectionDiagnostics().calloutRects.length,
|
||||
ships.length - winners.length,
|
||||
'only the callouts that painted are reported',
|
||||
);
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout fades out while its card fades in instead of vanishing at once', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
const alphas = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
|
||||
}
|
||||
assert.ok(
|
||||
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
|
||||
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
|
||||
);
|
||||
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
|
||||
// Satellites sit over the high-albedo lit Earth disc far more often than
|
||||
// aircraft do, so the space tier carries the heavier backing. Losing the
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */
|
||||
export function normalizeDisplayText(value, maxLength = 180) {
|
||||
if (value == null || typeof value === 'object') return '';
|
||||
return String(value)
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
.replace(/[\uD800-\uDBFF]$/, '')
|
||||
.trim();
|
||||
}
|
||||
@@ -120,9 +120,10 @@ test('fire detection key survives a refetch that renumbers the index', () => {
|
||||
});
|
||||
|
||||
test('two satellites over the same pixel at the same time stay distinct', () => {
|
||||
// The proxy merges SNPP + NOAA-20 + NOAA-21 without dedup and their orbits
|
||||
// overlap, so position and time alone are NOT unique. Collapsing them let a
|
||||
// restored selection land on the other satellite's record.
|
||||
// The proxy merges NOAA-20 + NOAA-21 without dedup and their orbits overlap,
|
||||
// so position and time alone are NOT unique. Collapsing them let a restored
|
||||
// selection land on the other satellite's record. Archived Suomi NPP (`N`)
|
||||
// rows must stay distinct too.
|
||||
const base = makeFire({ satellite: 'N20' });
|
||||
const twin = makeFire({ satellite: 'N21' });
|
||||
const suomi = makeFire({ satellite: 'N' });
|
||||
|
||||
@@ -21,7 +21,10 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
|
||||
const end = config.indexOf('\n }', start);
|
||||
assert.notEqual(end, -1, 'FIRMS refresh function closes');
|
||||
const refreshSource = config.slice(start, end + 4);
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
|
||||
// Read the list from production so these tests follow it.
|
||||
const listed = config.match(/const SOURCES = \[([^\]]*)\];/);
|
||||
assert.ok(listed, 'FIRMS source list exists');
|
||||
const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]);
|
||||
const NOW = Date.UTC(2026, 8, 11, 12);
|
||||
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
|
||||
@@ -54,7 +57,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
|
||||
assert.equal(result.fires[199_999], large.at(-1));
|
||||
assert.equal(result.fires.at(-1), last);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: [200_000, 1, 0, 0][index], ok: true,
|
||||
source, count: [200_000, 1, 0][index], ok: true,
|
||||
})));
|
||||
});
|
||||
|
||||
@@ -63,7 +66,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
|
||||
if (source === SOURCES[1]) throw new Error('upstream unavailable');
|
||||
return [recent];
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 3);
|
||||
assert.equal(result.fires.length, SOURCES.length - 1);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 1 ? 0 : 1, ok: index !== 1,
|
||||
})));
|
||||
@@ -77,7 +80,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
|
||||
// Fault injection for aggregation; ordinary parsed CSV returns an array.
|
||||
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 3);
|
||||
assert.equal(result.fires.length, SOURCES.length - 1);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 0 ? 0 : 1, ok: index !== 0,
|
||||
})));
|
||||
@@ -107,7 +110,7 @@ function wrappedAddressSelectionFailure() {
|
||||
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_NOAA20_NRT/world/2';
|
||||
|
||||
test('FIRMS failures log the socket cause, never the keyed URL', () => {
|
||||
const described = describeFetchError(addressSelectionFailure());
|
||||
@@ -307,3 +310,9 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () =>
|
||||
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||
});
|
||||
|
||||
test('FIRMS requests only the satellites NASA still delivers', () => {
|
||||
// NASA stopped delivering Suomi NPP on 2026-11-02; asking for it only spends
|
||||
// a MAP_KEY transaction.
|
||||
assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT'));
|
||||
});
|
||||
@@ -40,5 +40,7 @@ export const _driveFleetModelHandoffForTest =
|
||||
flightsLayer.testing._driveFleetModelHandoffForTest;
|
||||
export const _ensureFleetModelForTest =
|
||||
flightsLayer.testing._ensureFleetModelForTest;
|
||||
export const _resetEnrichmentForTest =
|
||||
flightsLayer.testing._resetEnrichmentForTest;
|
||||
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
|
||||
export default flightsLayer;
|
||||
+293
-2
@@ -1,7 +1,7 @@
|
||||
// src/data/flights.test.mjs
|
||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||
// Pure function — no viewer/DOM needed; imported directly.
|
||||
import { test } from 'node:test';
|
||||
import { after, afterEach, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
@@ -14,21 +14,53 @@ import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
_floorGroundedDisplayPositionForTest,
|
||||
_clearDisplayFloorStateForTest,
|
||||
_resetEnrichmentForTest,
|
||||
mapAnalystRecord,
|
||||
} from './flights.js';
|
||||
import { setMilitaryLayerActive } from './militaryRegistry.js';
|
||||
import {
|
||||
isMilitaryLayerActive,
|
||||
registerMilitaryIcaos,
|
||||
setMilitaryLayerActive,
|
||||
} from './militaryRegistry.js';
|
||||
import {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
reportMeshFloorCell,
|
||||
reportValidatedMeshFloorCell,
|
||||
setMeshFloorPreferred,
|
||||
_clearMeshFloorCellsForTest,
|
||||
} from './groundFloor.js';
|
||||
import { resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
import {
|
||||
_setTrackedOverlayHostForTest,
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
|
||||
// Unit tests never reach the network. A test that tracks a plane queues its
|
||||
// enrichment (type, route, photo), so every test ends on an empty queue, and a
|
||||
// fetch of any absolute URL fails the test it happened in. A test that needs
|
||||
// answers installs its own fetch and restores this one.
|
||||
const nodeFetch = globalThis.fetch;
|
||||
const networkRequests = [];
|
||||
before(() => {
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url?.url ?? url);
|
||||
if (/^https?:\/\//i.test(href)) networkRequests.push(href);
|
||||
throw new TypeError(`Failed to parse URL from ${href}`);
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
_resetEnrichmentForTest();
|
||||
assert.deepEqual(
|
||||
networkRequests.splice(0),
|
||||
[],
|
||||
'no test reaches the network',
|
||||
);
|
||||
});
|
||||
after(() => {
|
||||
globalThis.fetch = nodeFetch;
|
||||
});
|
||||
|
||||
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
|
||||
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
||||
assert.equal(
|
||||
@@ -402,6 +434,232 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
|
||||
}
|
||||
});
|
||||
|
||||
test('a tracked plane whose photo lookup failed asks again on the first poll after the cool-down', async (t) => {
|
||||
const icao24 = 'b2c3d4';
|
||||
const realNow = Date.now.bind(Date);
|
||||
let offsetMs = 0;
|
||||
t.mock.method(Date, 'now', () => realNow() + offsetMs);
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: [] } };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24,
|
||||
entity,
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: false,
|
||||
},
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer,
|
||||
meta: {
|
||||
callsign: 'UAL9',
|
||||
altitude: 9_000,
|
||||
renderAltitudeM: 9_050,
|
||||
velocity: 180,
|
||||
true_track: 80,
|
||||
klass: 'airliner',
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.2,
|
||||
rawLon: -97.7,
|
||||
},
|
||||
});
|
||||
let callsign = 'UAL9 ';
|
||||
const photoRequests = [];
|
||||
const routeRequests = [];
|
||||
const guardFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
url = String(url);
|
||||
if (url.startsWith('https://api.planespotters.net/')) {
|
||||
photoRequests.push(url);
|
||||
return { ok: false, status: 503, json: async () => ({}) };
|
||||
}
|
||||
if (url.startsWith('/api/adsbdb/route/UAL')) routeRequests.push(url);
|
||||
if (!url.startsWith('/api/flights'))
|
||||
return { ok: true, status: 200, json: async () => ({ found: false }) };
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => ({
|
||||
time: nowSec,
|
||||
states: [
|
||||
[
|
||||
icao24,
|
||||
callsign,
|
||||
'United States',
|
||||
nowSec,
|
||||
nowSec,
|
||||
-97.6,
|
||||
30.3,
|
||||
10_668,
|
||||
false,
|
||||
250,
|
||||
95,
|
||||
5,
|
||||
null,
|
||||
10_700,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
5,
|
||||
],
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
// Lookups drip out one per 200 ms. Wait for what must arrive (with a
|
||||
// generous deadline, so a loaded machine cannot fail it), then a little
|
||||
// longer so anything that must NOT arrive would have.
|
||||
const until = async (ready) => {
|
||||
const deadline = realNow() + 5_000;
|
||||
while (!ready() && realNow() < deadline)
|
||||
await new Promise((done) => setTimeout(done, 20));
|
||||
await new Promise((done) => setTimeout(done, 450));
|
||||
};
|
||||
try {
|
||||
await flightsLayer.update(viewer);
|
||||
await until(() => photoRequests.length >= 1 && routeRequests.length >= 1);
|
||||
assert.equal(photoRequests.length, 1, 'the first poll asks for the photo');
|
||||
assert.deepEqual(routeRequests, ['/api/adsbdb/route/UAL9']);
|
||||
|
||||
// Well inside the 60 s cool-down, and well past it below, so real time
|
||||
// spent between polls cannot move either side across the line.
|
||||
offsetMs += 30_000;
|
||||
callsign = 'UAL10 ';
|
||||
await flightsLayer.update(viewer);
|
||||
await until(() => routeRequests.length >= 2);
|
||||
assert.equal(photoRequests.length, 1, 'not within the cool-down');
|
||||
assert.deepEqual(
|
||||
routeRequests,
|
||||
['/api/adsbdb/route/UAL9', '/api/adsbdb/route/UAL10'],
|
||||
'the route follows the callsign the poll just recorded',
|
||||
);
|
||||
|
||||
offsetMs += 60_000;
|
||||
await flightsLayer.update(viewer);
|
||||
await until(() => photoRequests.length >= 2);
|
||||
assert.deepEqual(
|
||||
photoRequests,
|
||||
[
|
||||
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
|
||||
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
|
||||
],
|
||||
'asked again on the first poll after the cool-down',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = guardFetch;
|
||||
// The moved clock dated this layer's dispatch and cool-downs into the
|
||||
// future; reset before the real clock comes back.
|
||||
_resetEnrichmentForTest();
|
||||
t.mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('a known-military aircraft tracked in the flights layer is never looked up and shows no photo', async () => {
|
||||
const icao24 = 'ae5e01';
|
||||
// The registry has no way to forget one hex (only a whole-registry reset,
|
||||
// which would also drop its source), so the test uses a hex no other test
|
||||
// reads and restores the layer-active flag it changes.
|
||||
const militaryWasActive = isMilitaryLayerActive();
|
||||
registerMilitaryIcaos([icao24]);
|
||||
setMilitaryLayerActive(false);
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24,
|
||||
entity: { gevLabelModel: { title: 'OLD', details: [] } },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: false,
|
||||
},
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer,
|
||||
meta: {
|
||||
callsign: 'RCH123',
|
||||
altitude: 9_000,
|
||||
renderAltitudeM: 9_050,
|
||||
velocity: 180,
|
||||
true_track: 80,
|
||||
klass: 'airliner',
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.2,
|
||||
rawLon: -97.7,
|
||||
// Even a photo already on the record is never shown.
|
||||
photo: {
|
||||
src: 'https://t.plnspttrs.net/1/ae5e01_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
photographer: 'Jane Doe',
|
||||
link: 'https://www.planespotters.net/photo/1/x',
|
||||
},
|
||||
},
|
||||
});
|
||||
const photoRequests = [];
|
||||
const routeRequests = [];
|
||||
const guardFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
url = String(url);
|
||||
if (url.startsWith('https://api.planespotters.net/')) {
|
||||
photoRequests.push(url);
|
||||
return { ok: false, status: 503, json: async () => ({}) };
|
||||
}
|
||||
if (url.startsWith('/api/adsbdb/route/')) routeRequests.push(url);
|
||||
if (!url.startsWith('/api/flights'))
|
||||
return { ok: true, status: 200, json: async () => ({ found: false }) };
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => ({
|
||||
time: nowSec,
|
||||
states: [
|
||||
[
|
||||
icao24,
|
||||
'RCH123 ',
|
||||
'United States',
|
||||
nowSec,
|
||||
nowSec,
|
||||
-97.6,
|
||||
30.3,
|
||||
10_668,
|
||||
false,
|
||||
250,
|
||||
95,
|
||||
5,
|
||||
null,
|
||||
10_700,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
5,
|
||||
],
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
try {
|
||||
await flightsLayer.update(viewer);
|
||||
// A photo lookup would go out ahead of the route; wait for the route.
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!routeRequests.length && Date.now() < deadline)
|
||||
await new Promise((done) => setTimeout(done, 20));
|
||||
assert.deepEqual(routeRequests, ['/api/adsbdb/route/RCH123']);
|
||||
assert.deepEqual(photoRequests, [], 'no photo lookup for a military hex');
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.icao24, icao24);
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.photo, null);
|
||||
assert.equal(flightsLayer.getTrackedSubject()?.photo, null);
|
||||
} finally {
|
||||
globalThis.fetch = guardFetch;
|
||||
setMilitaryLayerActive(militaryWasActive);
|
||||
}
|
||||
});
|
||||
|
||||
test('real civil track path creates no native label and publishes every cached host line', async () => {
|
||||
const icao24 = 'civ001';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668);
|
||||
@@ -1181,6 +1439,39 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6);
|
||||
});
|
||||
|
||||
test('display floor: reads the reading nearest the contact, not the cell centre', async () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ results: [{ ellipsoid: 120 }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
try {
|
||||
await resolveEllipsoidalGround([{ lat: 30.317, lon: -97.717 }]);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
// A hangar roof over the cell's centre; the apron where the contact is.
|
||||
assert.equal(reportValidatedMeshFloorCell(30.317, -97.717, 142), true);
|
||||
assert.equal(reportValidatedMeshFloorCell(30.3173, -97.7173, 121), true);
|
||||
const pos = Cesium.Cartesian3.fromDegrees(-97.7173, 30.3173, 100);
|
||||
const out = _floorGroundedDisplayPositionForTest(
|
||||
{ onGround: true },
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
const c = _floorCarto(out);
|
||||
assert.ok(
|
||||
Math.abs(c.height - (121 + GROUND_FLOOR_LIFT_M)) < 0.05,
|
||||
`height ${c.height} should be the apron's 121 + lift`,
|
||||
);
|
||||
_clearMeshFloorCellsForTest();
|
||||
_clearDisplayFloorStateForTest();
|
||||
});
|
||||
|
||||
test('display floor: an AIRBORNE contact over the same cell is never lifted', () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
_clearMeshFloorCellsForTest();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
|
||||
*/
|
||||
test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => {
|
||||
if (!isCalibratedAllocationRuntime()) {
|
||||
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
|
||||
return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
|
||||
}
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const {
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
meshFloorPriorReady,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @file The bundled imaging-sensor pack: swath and resolution for the main
|
||||
* Earth-observation platforms, matched to satellites by NORAD number or name.
|
||||
*
|
||||
* Data: `local_data/imaging_sensors/imaging_sensors.json`; sources, licences
|
||||
* and curation in that folder's README. Loaded once, on demand, by the
|
||||
* satellites layer; a failed load is retried after a cooldown rather than
|
||||
* remembered for the session.
|
||||
*
|
||||
* @module data/imagingSensors
|
||||
*/
|
||||
|
||||
import { indexImagingSensors } from '../layers/satellites/sensors.js';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const PACK_URL = new URL(
|
||||
'./local_data/imaging_sensors/imaging_sensors.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
/** The pack, loaded and indexed once. */
|
||||
export const loadImagingSensors = createRetryableLoader(async () =>
|
||||
indexImagingSensors(await loadBundledJson(PACK_URL)),
|
||||
);
|
||||
+17
-1
@@ -2385,11 +2385,27 @@ export class LayerLifecycle {
|
||||
/**
|
||||
* Ask presentation to repaint layer rows now, for data that lands outside
|
||||
* the manager tick (Transit's proximity polls, Directions' route steps).
|
||||
* @param {string} [layerId] - The layer whose row changed.
|
||||
*/
|
||||
refreshLayerStats() {
|
||||
refreshLayerStats(layerId) {
|
||||
this._dropRecoveredRefreshError(layerId);
|
||||
this._publishActivity({ type: 'status' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a tick failure the layer's stats no longer report (the camera left
|
||||
* that feed) instead of showing it until the next tick.
|
||||
*/
|
||||
_dropRecoveredRefreshError(layerId) {
|
||||
const entry = layerId ? this.layers.get(layerId) : null;
|
||||
if (!entry?.managerRefreshError || entry.refreshing) return;
|
||||
const failure = refreshFailureFromStats(
|
||||
this._moduleStats(entry),
|
||||
entry.module.name || layerId,
|
||||
);
|
||||
if (!failure) entry.managerRefreshError = null;
|
||||
}
|
||||
|
||||
_publishActivity(change) {
|
||||
for (const callback of this._activityListeners) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Imaging sensor pack
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms and
|
||||
constellations, matched to satellites by NORAD catalog number or by their
|
||||
CelesTrak name. The orbital view draws a satellite's sensor swath to scale and
|
||||
lets it zoom past 8x only as far as that swath (`src/layers/satellites/sensors.js`,
|
||||
`src/data/imagingSensors.js`, `orbitalMaxZoom` in `src/cockpitOrbital.js`).
|
||||
|
||||
| File | Content | Entries |
|
||||
|------|---------|---------|
|
||||
| `imaging_sensors.json` | Platform, match rule, operator, instruments, altitude, imaging modes (swath km, best ground resolution m), narrowest standard swath, sources, unverified fields | 46 |
|
||||
|
||||
**Compiled:** 2026-11-04, by hand from the pages cited per entry (`sources`).
|
||||
Each match pattern was checked against CelesTrak's active list that day.
|
||||
Altitude is the nominal value from a cited source; where none was found it is
|
||||
the median altitude of the matching satellites from CelesTrak GP data, noted in
|
||||
`unverified`. The app zooms on the live altitude, not this one.
|
||||
|
||||
**Narrowest standard swath** is the narrowest continuous standard imaging swath
|
||||
or scene width (cross-track); for a geostationary imager, the narrower side of
|
||||
its smallest standard scan area. Some listed modes are left out of it:
|
||||
|
||||
- Sampling modes: SAR wave vignettes, and Himawari's Landmark Area scans
|
||||
(1000 × 500 km), so Himawari's narrowest is the 1000 km Target Area.
|
||||
- Generations the operator has retired: PlanetScope's Dove Classic and Dove-R
|
||||
(about 25 km scenes, imagery until April 2022 per Planet) stay in `modes`,
|
||||
while its narrowest is the SuperDove's 32.5 km. The `^FLOCK ` name rule
|
||||
would also match those older Doves.
|
||||
|
||||
**Unverified values.** A value not confirmed on an opened page is named in the
|
||||
entry's `unverified` list. `swathConfirmed` is false when the narrowest swath is
|
||||
missing or unverified; such a platform keeps the base 1–8x zoom steps:
|
||||
|
||||
- Landsat 9 (swath inferred from Landsat 8), ResourceSat-2 (LISS-3 swath),
|
||||
SuperView Neo-1 (12 km from search snippets only), BlackSky and Meteosat
|
||||
MTG FCI (no swath found).
|
||||
|
||||
## Sources and licences
|
||||
|
||||
The values are individual specification facts, each cited from the
|
||||
operator's or agency's official pages listed per entry in `sources`. No text or
|
||||
images are copied. The main sources:
|
||||
|
||||
- **Operator and agency pages and spec sheets** (NASA, USGS, NOAA/NESDIS,
|
||||
ESA SentiWiki and Earth Online, EUMETSAT, JAXA, ISRO/NRSC, INPE, CSA, DLR,
|
||||
JMA, ASI, KARI, China's Ministry of Natural Resources (LASAC), Planet,
|
||||
Vantor (formerly Maxar) datasheet pages, L3Harris, China Siwei, BlackSky,
|
||||
Airbus and KOMPSAT-5 via the Disasters Charter, ICEYE, Capella, Umbra,
|
||||
Satellogic, Chang Guang).
|
||||
- **Academic papers and proceedings** (Sensors papers on Gaofen-3, KARI's
|
||||
ACRS 2012 paper on KOMPSAT-3, CAST's IAC-16 abstract on Gaofen-2).
|
||||
- **eoPortal** (ESA's mission directory, an institutional source): a swath
|
||||
read there counts as confirmed. It is cited beside the operator's and
|
||||
agency's pages, and alone for values they do not give (the wider modes of
|
||||
Gaofen-3 and COSMO-SkyMed Second Generation, and a few other entries).
|
||||
Gaofen-1 takes eoPortal's 69 km PMS and 830 km WFV swaths by maintainer
|
||||
choice; a 2019 Chinese lecture and a Sensors paper give 60 and 800 km.
|
||||
- **CEOS MIM Database** (https://database.eohandbook.com): CC BY 4.0 since
|
||||
7 September 2026, with the credit "CEOS MIM Database,
|
||||
https://database.eohandbook.com". No entry relies on it yet; credit it if
|
||||
values are taken from it later.
|
||||
|
||||
Listed in DATA_SOURCES.md and in the app's Data attribution popover.
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"generated":"2026-11-04","entries":[{"id":"landsat-8-9","label":"Landsat 8 / Landsat 9","match":{"norad":[39084,49260],"nameRegex":"^LANDSAT [89]$"},"operator":"NASA / USGS","sensor":"OLI / OLI-2 / TIRS / TIRS-2","type":"multispectral / thermal","altitudeKm":705,"modes":[{"name":"OLI / OLI-2 · Standard scene","swathKm":185,"gsdM":15},{"name":"TIRS / TIRS-2 · Standard scene","swathKm":185,"gsdM":100}],"narrowestSwathKm":185,"swathConfirmed":false,"sources":["https://www.usgs.gov/landsat-missions/landsat-8","https://www.usgs.gov/landsat-missions/landsat-9"],"unverified":["Landsat 9 swath (inferred from Landsat 8)"]},{"id":"sentinel-2","label":"Sentinel-2A/B/C","match":{"norad":[40697,42063,60989],"nameRegex":"^SENTINEL-2[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"MSI","type":"multispectral","altitudeKm":786,"modes":[{"name":"MSI · Nominal","swathKm":290,"gsdM":10}],"narrowestSwathKm":290,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s2-mission"],"unverified":[]},{"id":"sentinel-1","label":"Sentinel-1A/C/D","match":{"norad":[39634,62261,66315],"nameRegex":"^SENTINEL-1[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"C-SAR","type":"SAR","altitudeKm":693,"modes":[{"name":"C-SAR · Stripmap (SM)","swathKm":80,"gsdM":5},{"name":"C-SAR · Interferometric Wide (IW)","swathKm":250,"gsdM":5},{"name":"C-SAR · Extra Wide (EW)","swathKm":400,"gsdM":20},{"name":"C-SAR · Wave (WV)","swathKm":null,"gsdM":5}],"narrowestSwathKm":80,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s1-mission"],"unverified":[]},{"id":"sentinel-3","label":"Sentinel-3A/B/C","match":{"norad":[41335,43437,100690],"nameRegex":"^SENTINEL-3[A-D]$"},"operator":"ESA / EUMETSAT (Copernicus)","sensor":"OLCI / SLSTR","type":"multispectral / thermal","altitudeKm":814.5,"modes":[{"name":"OLCI · Full resolution","swathKm":1270,"gsdM":300},{"name":"SLSTR · Nadir view","swathKm":1400,"gsdM":500},{"name":"SLSTR · Oblique view","swathKm":740,"gsdM":500}],"narrowestSwathKm":740,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s3-mission"],"unverified":[]},{"id":"terra-aqua-modis","label":"Terra / Aqua","match":{"norad":[25994,27424],"nameRegex":"^(TERRA|AQUA)$"},"operator":"NASA","sensor":"MODIS","type":"multispectral","altitudeKm":705,"modes":[{"name":"MODIS · Cross-track scan","swathKm":2330,"gsdM":250}],"narrowestSwathKm":2330,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/modis/"],"unverified":["Aqua altitude (assumed same as Terra)"]},{"id":"viirs","label":"Suomi NPP / NOAA-20 / NOAA-21","match":{"norad":[37849,43013,54234],"nameRegex":"^(SUOMI NPP|NOAA 2[01] \\(JPSS-[12]\\))$"},"operator":"NASA / NOAA (JPSS)","sensor":"VIIRS","type":"multispectral","altitudeKm":830,"modes":[{"name":"VIIRS · Cross-track scan","swathKm":3000,"gsdM":375}],"narrowestSwathKm":3000,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/viirs/","https://www.earthdata.nasa.gov/data/catalog/laads-vj102img-2.1"],"unverified":[]},{"id":"goes-abi","label":"GOES-16/17/18/19 (GOES-R series)","match":{"norad":[41866,43226,51850,60133],"nameRegex":"^GOES 1[6-9]$"},"operator":"NOAA / NASA","sensor":"ABI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"ABI · Full disk","swathKm":null,"gsdM":500},{"name":"ABI · CONUS","swathKm":3000,"gsdM":500},{"name":"ABI · Mesoscale sector","swathKm":1000,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.nesdis.noaa.gov/advanced-baseline-imager-scan-mode-information"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"himawari-ahi","label":"Himawari-8 / Himawari-9","match":{"norad":[40267,41836],"nameRegex":"^HIMAWARI-[89]$"},"operator":"JMA","sensor":"AHI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"AHI · Full Disk","swathKm":11000,"gsdM":500},{"name":"AHI · Japan Area","swathKm":2400,"gsdM":500},{"name":"AHI · Target Area","swathKm":1000,"gsdM":500},{"name":"AHI · Landmark Area","swathKm":500,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.data.jma.go.jp/mscweb/en/himawari89/space_segment/spsg_ahi.html"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"mtg-fci","label":"Meteosat Third Generation imager (Meteosat-12 / MTG-I2)","match":{"norad":[54743,100530],"nameRegex":"^(METEOSAT-12|MTG-I\\d)"},"operator":"EUMETSAT","sensor":"FCI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"FCI · Full disc","swathKm":null,"gsdM":1000},{"name":"FCI · Rapid scan (Europe, 1/4 disc)","swathKm":null,"gsdM":500}],"narrowestSwathKm":null,"swathConfirmed":false,"sources":["https://www.esa.int/Applications/Observing_the_Earth/Meteorological_missions/Meteosat/About_Meteosat_Third_Generation","https://www-cdn.eumetsat.int/files/2020-04/pdf_mtg_goesr_ws_1.pdf"],"unverified":["narrowestSwathKm","altitudeKm (standard GEO value)"]},{"id":"gaofen-1","label":"GaoLine truncated
|
||||
@@ -10,6 +10,7 @@ range geometry with no network dependency.
|
||||
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
|
||||
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
|
||||
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
|
||||
| `populated_places.json` | `ne_10m_populated_places_simple` (cities and towns, as points) | 7,342 |
|
||||
|
||||
**Source:** Natural Earth 10m physical vectors, via the canonical
|
||||
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
|
||||
@@ -103,3 +104,23 @@ Includes England, Scotland, Wales and Northern Ireland from
|
||||
`ne_10m_admin_0_map_units.geojson` at the same commit (SHA-256
|
||||
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
|
||||
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
|
||||
|
||||
## Populated places (`populated_places.json`)
|
||||
|
||||
The orbital view's "near <city>" (`src/data/placeBelow.js`): from 8x it
|
||||
names the most prominent place within half the view's width.
|
||||
|
||||
**Source:** `geojson/ne_10m_populated_places_simple.geojson` at the pinned
|
||||
commit `ca96624a56bd078437bca8184e78163e5039ad19` (SHA-256
|
||||
`fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f`, the file
|
||||
the admin-1 build already reads), fetched 2026-11-04. Public domain, as above.
|
||||
|
||||
**Regenerate:** `node scripts/build-admin-packs.mjs --only places`.
|
||||
|
||||
**Schema:** `meta` (source, URL, hash, license, curation) and `places[]`, each
|
||||
`[name, latitude, longitude, scalerank, rank_max]`: `scalerank` 0 (most
|
||||
prominent) to 10, `rank_max` the population class (0 to 14, higher is
|
||||
larger). Sorted by `scalerank`, then `rank_max` descending, then name.
|
||||
|
||||
**Curation:** every place kept; all other attributes dropped; coordinates
|
||||
rounded to 3 decimals (~110 m). Result: 229,461 bytes (98,444 with gzip -9).
|
||||
@@ -0,0 +1 @@
|
||||
{"meta":{"title":"Natural Earth populated places","source":"ne_10m_populated_places_simple","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_populated_places_simple.geojson","sha256":"fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f","commit":"ca96624a56bd078437bca8184e78163e5039ad19","license":"Public domain (https://www.naturalearthdata.com/about/terms-of-use/)","schema":"places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]","decimals":3,"curation":{"decimals":3},"script":"scripts/build-admin-packs.mjs"},"places":[["Beijing",39.931,116.386,0,14],["Cairo",30.052,31.248,0,14],["Istanbul",41.107,29.008,0,14],["Kolkata",22.497,88.323,0,14],["Los Angeles",33.992,-118.182,0,14],["Mexico City",19.444,-99.133,0,14],["Moscow",55.754,37.614,0,14],["Mumbai",19.019,72.855,0,14],["New York",40.752,-73.982,0,14],["Rio de Janeiro",-22.923,-43.227,0,14],["São Paulo",-23.557,-46.627,0,14],["Shanghai",31.218,121.435,0,14],["Tokyo",35.687,139.749,0,14],["Bogota",4.598,-74.085,0,13],["Hong Kong",22.307,114.183,0,13],["Jakarta",-6.172,106.827,0,13],["Lagos",6.445,3.39,0,13],["London",51.502,-0.119,0,13],["Paris",48.869,2.331,0,13],["Santiago",-33.448,-70.669,0,13],["Singapore",1.295,103.854,0,13],["Cape Town",-33.918,18.433,0,12],["Nairobi",-1.281,36.815,0,12],["Riyadh",24.643,46.771,0,12],["Rome",41.898,12.481,0,12],["Sydney",-33.918,151.183,0,12],["Washington, D.C.",38.901,-77.011,0,12],["Buenos Aires",-34.601,-58.399,1,14],["Manila",14.606,120.98,1,14],["Ōsaka",34.752,135.458,1,14],["Baghdad",33.341,44.392,1,13],["Bangkok",13.752,100.515,1,13],["Bengaluru",12.972,77.558,1,13],["Chicago",41.832,-87.752,1,13],["Kinshasa",-4.328,15.313,1,13],["Lima",-12.046,-77.052,1,13],["Madrid",40.402,-3.685,1,13],["Miami",25.79,-80.226,1,13],["Seoul",37.568,126.998,1,13],["Taipei",25.036,121.568,1,13],["Tehran",35.674,51.422,1,13],["Toronto",43.702,-79.422,1,13],["Addis Ababa",9.035,38.698,1,12],["Amsterdam",52.352,4.915,1,12],["Athens",37.985,23.731,1,12],["Atlanta",33.832,-84.402,1,12],["Auckland",-36.848,174.763,1,12],["Berlin",52.524,13.4,1,12],["Caracas",10.503,-66.919,1,12],["Casablanca",33.602,-7.618,1,12],["Chengdu",30.672,104.068,1,12],["Dakar",14.718,-17.475,1,12],["Denver",39.741,-104.986,1,12],["Dubai",25.232,55.278,1,12],["Geneva",46.21,6.14,1,12],["Houston",29.822,-95.342,1,12],["Johannesburg",-26.168,28.028,1,12],["Kabul",34.519,69.181,1,12],["Kyiv",50.435,30.515,1,12],["Melbourne",-37.818,144.973,1,12],["Monterrey",25.672,-100.332,1,12],["San Francisco",37.769,-122.417,1,12],["Stockholm",59.353,18.095,1,12],["Tashkent",41.314,69.293,1,12],["Ürümqi",43.807,87.573,1,12],["Vancouver",49.275,-123.124,1,12],["Vienna",48.202,16.365,1,12],["New Delhi",28.6,77.2,1,10],["Delhi",28.672,77.228,2,14],["Dhaka",23.725,90.407,2,14],["Karachi",24.872,66.988,2,14],["Ahmedabad",23.032,72.578,2,13],["Chennai",13.092,80.278,2,13],["Chongqing",29.567,106.593,2,13],["Guangzhou",23.147,113.323,2,13],["Ho Chi Minh City",10.782,106.693,2,13],["Hyderabad",17.402,78.478,2,13],["Lahore",31.562,74.348,2,13],["Luanda",-8.836,13.232,2,13],["Philadelphia",40.002,-75.172,2,13],["Shenzhen",22.554,114.12,2,13],["Tianjin",39.132,117.198,2,13],["Wuhan",30.582,114.268,2,13],["Abidjan",5.322,-4.042,2,12],["Accra",5.552,-0.219,2,12],["Aleppo",36.232,37.168,2,12],["Alexandria",31.202,29.948,2,12],["Algiers",36.765,3.049,2,12],["Ankara",39.929,32.862,2,12],["Bandung",-6.948,107.568,2,12],["Baotou",40.654,109.82,2,12],["Barcelona",41.385,2.181,2,12],["Belém",-1.448,-48.482,2,12],["Boston",42.332,-71.072,2,12],["Brasília",-15.781,-47.918,2,12],["Brussels",50.835,4.331,2,12],["Bucharest",44.435,26.098,2,12],["Budapest",47.502,19.081,2,12],["Cali",3.402,-76.502,2,12],["Changchun",43.867,125.338,2,12],["Changsha",28.202,112.968,2,12],["Chattogram",22.332,91.798,2,12],["Curitiba",-25.418,-49.322,2,12],["Dallas",32.822,-96.842,2,12],["Damascus",33.502,36.298,2,12],["Dar es Salaam",-6.798,39.266,2,12],["Detroit",42.332,-83.082,2,12],["Dongguan",23.051,113.743,2,12],["Dublin",53.335,-6.251,2,12],["Durban",-29.863,30.978,2,12],["Edmonton",53.552,-113.502,2,12],["Fortaleza",-3.748,-38.582,2,12],["Frankfurt",50.1,8.675,2,12],["Fuzhou",26.082,119.298,2,12],["Goiânia",-16.718,-49.302,2,12],["Guadalajara",20.672,-103.332,2,12],["Guayaquil",-2.218,-79.922,2,12],["Guiyang",26.582,106.718,2,12],["Hamburg",53.552,9.998,2,12],["Hangzhou",30.252,120.168,2,12],["Hanoi",21.035,105.848,2,12],["Harbin",45.752,126.648,2,12],["Havana",23.134,-82.366,2,12],["Helsinki",60.178,24.932,2,12],["Hiroshima",34.39,132.441,2,12],["Jaipur",26.923,75.808,2,12],["Jeddah",21.519,39.217,2,12],["Jinan",36.677,116.993,2,12],["Kano",12.002,8.518,2,12],["Kanpur",26.462,80.318,2,12],["Khartoum",15.59,32.532,2,12],["København",55.681,12.562,2,12],["Kuala Lumpur",3.169,101.698,2,12],["Kunming",25.072,102.678,2,12],["Kuwait City",29.372,4Line truncated
|
||||
@@ -0,0 +1,18 @@
|
||||
# Owner flags
|
||||
|
||||
`owner_flags.png` is one sheet of every owner country a satellite can be
|
||||
marked with (`OWNER_COUNTRY_CODES` in `src/layers/satellites/classes.js`),
|
||||
drawn at 40 × 30 px (twice the orbital readout's 20 × 15) in sorted code
|
||||
order, 16 to a row, each followed by 2 transparent pixels so a scaled flag
|
||||
never smooths into its neighbour. `src/ui/ownerFlag.js` loads it once at startup and draws
|
||||
each flag from its cell; the layout is `src/ui/ownerFlagSheet.js`.
|
||||
|
||||
**Source:** the 4:3 SVGs of [flag-icons](https://github.com/lipis/flag-icons)
|
||||
7.5.0 (a devDependency, used only to build the sheet).
|
||||
|
||||
**License:** flag-icons is MIT licensed (© Panayiotis Lipiridis). Country
|
||||
flags themselves are not copyrightable designs in most jurisdictions.
|
||||
|
||||
**Regenerate:** `node scripts/build-owner-flags.mjs` (after adding an owner
|
||||
country, or updating flag-icons). With the same flag-icons and sharp versions
|
||||
a rerun reproduces both files byte for byte.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,8 +1,9 @@
|
||||
# UCS Satellite Database pack
|
||||
|
||||
Offline purpose records for active satellites, keyed by NORAD catalog number.
|
||||
The satellites layer joins them to CelesTrak's positions to file each
|
||||
satellite under a class (stations, imaging, telecom, probes)
|
||||
Offline operator, owner-country and purpose records for active satellites,
|
||||
keyed by NORAD catalog number. The satellites layer joins them to CelesTrak's
|
||||
positions to file each satellite under a class (stations, imaging, telecom,
|
||||
probes) and to name its operator and owner country
|
||||
(`src/data/ucsSatellites.js`, `src/layers/satellites/classes.js`).
|
||||
|
||||
| File | Source dataset | Records |
|
||||
@@ -23,7 +24,7 @@ the version of the Database that was used, which is indicated by the name of
|
||||
the Excel file, and a link to or URL for the webpage
|
||||
www.ucsusa.org/satellite_database."
|
||||
|
||||
**Acknowledgement:** "Satellite purposes: UCS Satellite Database
|
||||
**Acknowledgement:** "Satellite operators and purposes: UCS Satellite Database
|
||||
(UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database".
|
||||
It is also listed in the app's Data attribution popover and in
|
||||
DATA_SOURCES.md.
|
||||
@@ -33,13 +34,17 @@ DATA_SOURCES.md.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- Kept column: Purpose, the one the classes read. Everything else
|
||||
(operators, owners, orbits, masses, contractors, launch data, sources) is
|
||||
left out.
|
||||
- Kept columns: Operator/Owner, Country of Operator/Owner and Purpose, the
|
||||
ones the layer reads. Everything else (detailed purpose, users, orbits,
|
||||
masses, contractors, launch data, sources) is left out.
|
||||
- Repeated text is stored once in `strings`; each record is
|
||||
`[noradId, purpose]` with a string-table index, `0` meaning empty.
|
||||
- The 3,996 Starlink rows are dropped: every Starlink object is classed by
|
||||
name, so they would only add size.
|
||||
`[noradId, operator, operatorCountry, purpose]` with string-table indexes,
|
||||
`0` meaning empty.
|
||||
- The 3,996 Starlink rows are dropped: every Starlink object is classed and
|
||||
credited to SpaceX by name, so they would only add size.
|
||||
- Rows without a readable, positive NORAD number are dropped and counted apart
|
||||
from duplicate NORAD numbers (the first row of a duplicate is kept); both
|
||||
counts are 0 in this release.
|
||||
- Owner countries stay as UCS writes them ("USA", "USA/Japan", "ESA",
|
||||
"Multinational"); `src/layers/satellites/classes.js` normalises them, together with
|
||||
CelesTrak SATCAT owner codes, in one table.
|
||||
@@ -1 +1 @@
|
||||
{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","purpose"],"strings":{"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"]},"records":[[7530,3],[19548,3],[20253,3],[20580,5],[20776,3],[21639,3],[22049,5],[22314,3],[22491,1],[22787,3],[22988,3],[23223,1],[23467,3],[23553,3],[23613,3],[23628,3],[23855,1],[23893,3],[24674,3],[24753,1],[24812,3],[24876,11],[24916,3],[24920,1],[24936,3],[25019,3],[25034,1],[25071,3],[25112,3],[25113,3],[25114,3],[25115,3],[25116,3],[25117,3],[25118,3],[25119,3],[25153,3],[25159,3],[25258,3],[25336,1],[25338,1],[25414,3],[25415,3],[25416,3],[25418,3],[25419,3],[25420,3],[25462,3],[25475,3],[25477,3],[25481,3],[25504,1],[25544,5],[25639,3],[25652,3],[25682,1],[25757,4],[25867,5],[25924,3],[25933,11],[25967,3],[25980,3],[25981,3],[25982,3],[25984,3],[25985,3],[25989,5],[25991,1],[25994,1],[26038,3],[26052,3],[26107,3],[26356,1],[26360,11],[26369,3],[26388,3],[26390,3],[26407,11],[26410,5],[26411,5],[26451,3],[26463,5],[26464,5],[26469,3],[26473,1],[26483,3],[26554,3],[26575,3],[26580,3],[26590,3],[26605,11],[26608,3],[26624,3],[26626,3],[26638,3],[26690,11],[26694,3],[26702,1],[26715,3],[26719,3],[26766,3],[26824,3],[26853,3],[26880,1],[26900,3],[26958,9],[26959,12],[26998,5],[27004,2],[27056,3],[27057,3],[27168,3],[27378,3],[27380,3],[27389,3],[27399,3],[27400,3],[27414,3],[27424,1],[27426,3],[27434,1],[27438,3],[27445,3],[27460,3],[27464,3],[27465,3],[27509,1],[27513,3],[27528,3],[27540,5],[27566,3],[27603,3],[27606,3],[27607,3],[27612,3],[27640,1],[27651,5],[27663,11],[27691,3],[27704,11],[27711,3],[27715,3],[27811,3],[27825,3],[27831,3],[27843,5],[27848,2],[27854,3],[27858,5],[27868,3],[27869,3],[27875,3],[27937,1],[27939,2],[27954,3],[28051,1],[28054,1],[28058,3],[28089,3],[28117,3],[28158,1],[28184,3],[28187,3],[28190,11],[28220,1],[28252,3],[28358,3],[28366,7],[28369,2],[28370,2],[28371,1],[28372,7],[28376,1],[28378,3],[28384,3],[28413,5],[28414,5],[28419,3],[28420,3],[28446,3],[28470,1],[28474,11],[28485,5],[28492,1],[28493,3],[28526,3],[28537,1],[28541,1],[28542,3],[28626,3],[28628,3],[28646,1],[28649,1],[28654,1],[28659,3],[28702,3],[28707,3],[28737,5],[28786,3],[28790,3],[28810,2],[28868,3],[28874,11],[28884,3],[28885,3],[28888,1],[28890,1],[28899,3],[28903,3],[28908,3],[28912,1],[28924,3],[28935,3],[28945,3],[28946,3],[29045,3],[29047,1],[29048,1],[29050,1],[29051,1],[29052,1],[29055,3],[29079,1],[29107,1],[29108,4],[29155,1],[29162,3],[29236,3],[29249,1],[29252,2],[29268,12],[29270,3],[29272,3],[29273,3],[29349,3],[29479,5],[29486,11],[29494,3],[29495,3],[29505,5],[29506,5],[29520,3],[29522,1],[29526,3],[29601,11],[29643,3],[29644,3],[29658,1],[29670,11],[29671,11],[29709,12],[29710,1],[30580,5],[30776,2],[30794,3],[30797,5],[30798,5],[31102,3],[31118,1],[31119,3],[31121,3],[31124,3],[31125,3],[31127,3],[31304,1],[31306,3],[31307,3],[31571,3],[31573,3],[31574,3],[31576,3],[31598,1],[31601,1],[31698,1],[31701,1],[31708,1],[31789,2],[31792,1],[31797,1],[31800,3],[31862,3],[32018,3],[32019,3],[32060,1],[32252,3],[32253,3],[32258,3],[32260,11],[32263,3],[32264,3],[32265,3],[32275,11],[32276,11],[32283,1],[32293,3],[32294,3],[32299,3],[32376,1],[32378,3],[32382,1],[32384,11],[32388,3],[32393,11],[32395,11],[32404,3],[32476,1],[32478,3],[32487,3],[32706,1],[32711,11],[32729,3],[32750,1],[32763,3],[32767,3],[32768,3],[32779,3],[32783,1],[32789,2],[32790,2],[32791,2],[32794,3],[32951,3],[32954,3],[32955,3],[32956,3],[33051,3],[33053,5],[33055,3],[33056,3],[33153,3],[33154,3],[33207,3],[33244,1],[33274,3],[33275,3],[33278,3],[33320,1],[33321,1],[33331,1],[33373,3],[33376,3],[33396,1],[33401,5],[33408,5],[33409,5],[33412,1],[33433,1],[33434,1],[33436,3],[33446,1],[33453,3],[33459,3],[33460,3],[33490,1],[33492,1],[33496,2],[33499,2],[33591,1],[33595,3],[33749,3],[33750,3],[33751,2],[337Line truncated
|
||||
{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"operator":"Operator/Owner","operatorCountry":"Country of Operator/Owner","purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","operator","operatorCountry","purpose"],"strings":{"operator":["","Hera Systems","AAC Clyde Space","Aalto University","University of Aalborg","Asia Broadcast Satellite Ltd.","Asher Space Research Institute at Technion/Israeli Aircraft Industries","Austrian Space Forum (OEWF)/Spire","National Reconnaissance Office (NRO)","US Air Force","Department of Homeland Security","European Space Agency (ESA)","Aerospace Corporation","EUTELSAT S.A.","Aurora Insight","Center for Atmospheric Sciences, Hampton University/NASA","Norwegian Coastal Admnistration","AISTech","Al Yah Satellite Communications Co. (YAHSAT)","Al-Farabi Kazakh National University","Algerian Space Agency (ASAL)","Astro Live Experiences","University of Brasilia","US Air Force Institute of Technology","Algerian Space Agency (ASAL)/UK Space Agency","Hispamar (subsidiary of Hispasat - Spain)","National Institute for Space Research (INPE)","SES S.A.","Moscow State University","Space-Communication Ltd.","AMSAT-NA","Agency for Defense Development","ANDESITE - Boston University","Centre National d'Etudes Spatiales (CNES)","AngoSat","Telesat Canada Ltd. (BCE, Inc.)","Aprize Satellite, Argentina","APT Satellite Holdings Ltd.","Arab Satellite Communications Org. (ASCO)","University of South Florida, Institute of Applied Engineering (IAE).","Norwegian Defence Research Establishment","Aoyama Gakuin University in Tokyo","Naval Physical and Oceanographic Laboratory","Planetary Resources","ArSat","Aerospacelab","Istanbul Technical University","Asia Satellite Telecommunications Co. Ltd.","1Worldspace","Japan Space Systems","Chiba Institute of Technology","Astranis","Astrocast","Telemetry Tracking and Command Network (ISTRAC) Centre","DirecTV, Inc.","PointView Tech","Centre National d'Etudes Spatiales (CNES)/Agenzia Spaziale Italiana (ASI)","Microspace Rapid Pvt Ltd.","Azercosmos","Bangladesh Telecommunication Regulatory Commission (BTRC)","Tianyi Research Institute","Ministry of Defense","Space Development Agency","Central European Institute of Technology","Technical University Berlin","Chinese Ministry of National Defense","21st Century Aerospace Technology (21AT)","Beijing Landview Mapping Information Technology Co. Ltd (BLMIT)","Changsha Tianyi space science and Technology Research Institute Co., Ltd. (Spacety)","Belintersat","Institute of Space Sensor Technology and Planetary Exploration","Norwegian Defence Research Establishment (FFI)/","German Aerospace Center (DLR)","Salish Kootenai College","National Academy of Sciences","BlackSky Global","NanoAvionics","AST SpaceMobile","Embratel Star One","SpaceQuest, Ltd.","Bank Rakyat Indonesia (BRI)","University of Toronto, Institute for Aerospace Studies","BRITE Consortium","UnseenLabs","AstroForge","Broadcasting Satellite System Corp.","Broadcasting Satellite System Corp./Sky Perfect JSAT Corporation","Defence Science and Technology Group/University of New South Wales","China Academy of Space Technology (CAST)","Satellogic S.A.","Bulsatcom","Capital Technology University","Centre National d'Etudes Spatiales (CNES)/NASA","University of Louisiana at Lafayette","Capella Space","Indian Space Research Organization (ISRO)","Korea Aerospace Research Institute (KARI)","Canadian Space Agency","China National Space Administration (CNSA)/National Institute for Space Research (Brazil)","Surrey Satellite Technology Ltd.","Earth-i/Surrey Satellite Technology Ltd.","Canon Electronics","Air Force Research Laboratory","Fleet Space Technologies","Beijing Future Navigation Technology Co. Ltd.","NASA Goddard Space Flight Center","Ministry of Armed Forces","Defense Innovation Unit/Cesium Astro","China National Space Administration (CNSA)/National Centre for Space Studies (CNES)","TELNET Holding","National Aeronautics and Space Administration (NASA) Goddard Space Flight Center","Spacety Aerospace Company","Shanghai Engineering Center for Microsatellites","Spacety Co. Ltd.","Nanjing University","Epic Aerospace","University of Florida","Chinese Academy of Sciences","GeoOptics Inc.","GeoOptics, Inc.","Ciel Satellite Group","University of California-Berkeley/Imperial College-London","Laboratory for Atmospheric and Space Physics, University of Colorado, Boulder","Utah State University","Unknown US agency","National Aeronautics and Space Administration (NASA)/Colorado State University","Department of Defense/Missile Defense Agency","FH Aachen","Armed Forces","Plan-S","US Air Force/ US Navy/NASA","Astro Digital","TaLine truncated
|
||||
@@ -15,16 +15,6 @@ export class DataLayerManager extends LayerLifecycle {
|
||||
this._presentation.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the toggle panel now. For layers whose data arrives outside their
|
||||
* manager tick (camera-driven loads such as Transit's proximity polls), so a
|
||||
* row shows its count when the data lands instead of at the next interval.
|
||||
* One DOM pass; skipped while the document is hidden.
|
||||
*/
|
||||
refreshLayerStats() {
|
||||
this._refreshTogglePanel();
|
||||
}
|
||||
|
||||
_buildMetaText(layer) {
|
||||
return this._presentation.panel._buildMetaText(layer);
|
||||
}
|
||||
|
||||
@@ -2677,6 +2677,31 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
|
||||
assert.deepEqual(changes, ['status']);
|
||||
});
|
||||
|
||||
test('refreshLayerStats(layerId) drops a tick failure the layer no longer reports', async () => {
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const lifecycle = new LayerLifecycle({});
|
||||
let stats = { count: 0, error: 'Feed unavailable' };
|
||||
lifecycle.register({
|
||||
id: 'feed',
|
||||
name: 'Feed',
|
||||
getStats: () => stats,
|
||||
async update() {},
|
||||
});
|
||||
const entry = lifecycle.layers.get('feed');
|
||||
entry.enabled = true;
|
||||
entry.initialized = true;
|
||||
entry.managerRefreshError = 'Feed unavailable';
|
||||
|
||||
lifecycle.refreshLayerStats('feed');
|
||||
assert.equal(entry.managerRefreshError, 'Feed unavailable');
|
||||
|
||||
stats = { count: 0 };
|
||||
lifecycle.refreshLayerStats();
|
||||
assert.equal(entry.managerRefreshError, 'Feed unavailable');
|
||||
lifecycle.refreshLayerStats('feed');
|
||||
assert.equal(entry.managerRefreshError, null);
|
||||
});
|
||||
|
||||
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js';
|
||||
import { resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
import {
|
||||
cachedGroundFloor, cachedMeshFloor, setMeshFloorPreferred,
|
||||
_clearMeshFloorCellsForTest,
|
||||
_clearMeshFloorCellsForTest, reportValidatedMeshFloorCell,
|
||||
} from './groundFloor.js';
|
||||
|
||||
/** Distinct site per test — terrainHeights' cache is module-global and warm
|
||||
@@ -137,3 +137,180 @@ test('sampler: a cell just across the antimeridian is still near the viewer', ()
|
||||
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
|
||||
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
|
||||
});
|
||||
|
||||
test('sampler: the probe goes where the point is, not to the cell centre', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
const asked = [];
|
||||
const scene = fakeScene((carto) => {
|
||||
asked.push(Cesium.Math.toDegrees(carto.longitude));
|
||||
return 122.0;
|
||||
});
|
||||
const point = { lat: cell.lat + 0.0003, lon: cell.lon + 0.0004 };
|
||||
sampleMeshFloorCells(scene, [point], { viewerLat: cell.lat, viewerLon: cell.lon });
|
||||
assert.equal(asked.length, 1);
|
||||
assert.ok(Math.abs(asked[0] - point.lon) < 1e-9, `probed at ${asked[0]}, not ${point.lon}`);
|
||||
});
|
||||
|
||||
test('sampler: a viaduct and the street beside it in one cell keep their own heights', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A viaduct deck 9 m up along one line of the cell, the street elsewhere.
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
let calls = 0;
|
||||
const scene = fakeScene((carto) => {
|
||||
calls += 1;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
const street = { lat: cell.lat - 0.0003, lon: cell.lon };
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
sampleMeshFloorCells(scene, [street], opts);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon + 0.0001), 129.0, 'the train stays on the deck');
|
||||
assert.equal(cachedMeshFloor(street.lat, street.lon - 0.0001), 121.0, 'the bus stays on the street');
|
||||
assert.equal(cachedGroundFloor(street.lat, street.lon), 121.0);
|
||||
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon + 0.00001 }], opts);
|
||||
assert.equal(calls, 2, 'a point next to one already probed is not probed again');
|
||||
});
|
||||
|
||||
test('sampler: a roof over the cell centre does not lift a bus on the street', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A roof 22 m up covering the cell's centre; the street 30 m west of it.
|
||||
const scene = fakeScene((carto) =>
|
||||
Math.abs(Cesium.Math.toDegrees(carto.longitude) - cell.lon) < 0.0001 ? 142.0 : 121.0);
|
||||
const bus = { lat: cell.lat, lon: cell.lon - 0.0003 };
|
||||
sampleMeshFloorCells(scene, [bus], { viewerLat: cell.lat, viewerLon: cell.lon });
|
||||
assert.equal(cachedGroundFloor(bus.lat, bus.lon), 121.0);
|
||||
});
|
||||
|
||||
test('sampler: a second reading at the same spot keeps the lower one', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon, 131.0), true);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat + 0.00002, cell.lon, 122.0), true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0);
|
||||
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon + 0.00002, 140.0), true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0, 'a higher one never raises it');
|
||||
});
|
||||
|
||||
test('sampler: a cell with a reading is probed a few times at most', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => {
|
||||
calls += 1;
|
||||
return 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 20; i++)
|
||||
sampleMeshFloorCells(
|
||||
scene,
|
||||
[{ lat: cell.lat - 0.0004 + (i % 5) * 0.0002, lon: cell.lon - 0.00045 + Math.floor(i / 5) * 0.0003 }],
|
||||
opts,
|
||||
);
|
||||
assert.equal(calls, 8);
|
||||
});
|
||||
|
||||
test('sampler: a cell with no accepted reading is probed again on every call', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => {
|
||||
calls += 1;
|
||||
return 138.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 3; i++) sampleMeshFloorCells(scene, [cell], opts);
|
||||
assert.equal(calls, 3, 'no DEM prior yet, so nothing is kept and the cell is retried');
|
||||
await seedDem(cell, 120.0);
|
||||
sampleMeshFloorCells(scene, [cell], opts);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 138.0, 'and lands once the prior is warm');
|
||||
});
|
||||
|
||||
test('sampler: a spot whose reading is refused does not use up the cell', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
// A tower 260 m up where the first vehicle waits; the street elsewhere and
|
||||
// a deck 9 m up along one line of the cell.
|
||||
const towerLon = cell.lon + 0.0004;
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
const scene = fakeScene((carto) => {
|
||||
if (Math.abs(Cesium.Math.toDegrees(carto.longitude) - towerLon) < 1e-7) return 380.0;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
for (let i = 0; i < 10; i++) sampleMeshFloorCells(scene, [{ lat: cell.lat, lon: towerLon }], opts);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'the tower is refused every time');
|
||||
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon - 0.0003 }], opts);
|
||||
assert.equal(cachedMeshFloor(deckLat, cell.lon - 0.0003), 129.0, 'the deck is still probed');
|
||||
});
|
||||
|
||||
test('ground floor: a cell keeps at most sixteen readings', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
await seedDem(cell, 120.0);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const lat = cell.lat - 0.00045 + (i % 8) * 0.0001;
|
||||
const lon = cell.lon - 0.00045 + Math.floor(i / 8) * 0.0002;
|
||||
reportValidatedMeshFloorCell(lat, lon, 121.0 + i);
|
||||
}
|
||||
// Readings 0..15 are kept; reading 39 (at the far corner) is not, so that
|
||||
// spot answers from the nearest kept one.
|
||||
assert.equal(cachedMeshFloor(cell.lat - 0.00045, cell.lon - 0.00045), 121.0);
|
||||
assert.equal(cachedMeshFloor(cell.lat + 0.00025, cell.lon + 0.00035), 121.0 + 15);
|
||||
});
|
||||
|
||||
test('sampler: a spot refused while the DEM was cold is probed again once it is warm', async () => {
|
||||
reset();
|
||||
const cell = nextSite();
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
let calls = 0;
|
||||
const scene = fakeScene((carto) => {
|
||||
calls += 1;
|
||||
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
|
||||
});
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), null, 'no prior, nothing kept');
|
||||
await seedDem(cell, 120.0);
|
||||
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
|
||||
});
|
||||
|
||||
test('sampler: a spot refused while the DEM prior was evicted is probed again', async () => {
|
||||
const { createGroundFloor } = await import('../services/groundFloor.js');
|
||||
const { createMeshFloorSampler } = await import('../services/meshFloorSampler.js');
|
||||
let prior = 120.0;
|
||||
const terrain = {
|
||||
cachedRealEllipsoidalGround: () => prior,
|
||||
resolveEllipsoidalGround: async () => {},
|
||||
};
|
||||
const groundFloor = createGroundFloor({ terrain });
|
||||
const sampler = createMeshFloorSampler({ groundFloor });
|
||||
const cell = { lat: 41.5, lon: -97.66 };
|
||||
const deckLat = cell.lat + 0.0003;
|
||||
const scene = fakeScene((carto) =>
|
||||
Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0);
|
||||
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
|
||||
const onDeck = { lat: deckLat, lon: cell.lon };
|
||||
sampler.sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
|
||||
prior = null; // the cell's DEM entry is evicted; the street reading stays
|
||||
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 121.0);
|
||||
prior = 120.0;
|
||||
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
|
||||
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
|
||||
});
|
||||
@@ -425,7 +425,7 @@ test('a dependency the user adopts stays on when Contacts turns off', async () =
|
||||
assert.deepEqual(
|
||||
released,
|
||||
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
|
||||
'the adopted layer is the user\'s now; the rest are still released',
|
||||
"the adopted layer is the user's now; the rest are still released",
|
||||
);
|
||||
} finally {
|
||||
runtime.restore();
|
||||
@@ -1118,6 +1118,92 @@ test('production military tracked-subject label falls back to registration befor
|
||||
}
|
||||
});
|
||||
|
||||
test('the tracked airliner’s photo reaches the Contacts panel when it lands after selection', async () => {
|
||||
const id = 'a1f2c3';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
||||
const sourceViewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
const runtime = installAwarenessRuntime();
|
||||
const restores = [];
|
||||
const meta = {
|
||||
callsign: 'AAL100',
|
||||
altitude: 10_668,
|
||||
renderAltitudeM: 10_700,
|
||||
velocity: 250,
|
||||
true_track: 95,
|
||||
klass: 'airliner',
|
||||
onGround: false,
|
||||
wasAirborne: true,
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.21,
|
||||
rawLon: -97.71,
|
||||
photo: null,
|
||||
};
|
||||
|
||||
try {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, color: Cesium.Color.WHITE, show: true },
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer: sourceViewer,
|
||||
meta,
|
||||
});
|
||||
runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` };
|
||||
replaceMethod(
|
||||
militaryFlightsLayer,
|
||||
'getTrackedSubject',
|
||||
() => null,
|
||||
restores,
|
||||
);
|
||||
replaceMethod(flightsLayer, 'trackById', () => true, restores);
|
||||
replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores);
|
||||
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
await nextTurn();
|
||||
assert.equal(runtime.panel().view?.kind, 'results');
|
||||
assert.equal(runtime.panel().view.photo, null, 'no photo before it lands');
|
||||
|
||||
// An unchanged refresh stays quiet.
|
||||
const settledPublishes = runtime.panel().publishes;
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.equal(runtime.panel().publishes, settledPublishes);
|
||||
|
||||
// The photo lands while the plane holds still: render-worthy on its own.
|
||||
meta.photo = {
|
||||
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
photographer: 'Jane Doe',
|
||||
link: 'https://www.planespotters.net/photo/1/n100aa',
|
||||
};
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.deepEqual(runtime.panel().view.photo, {
|
||||
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
|
||||
width: 280,
|
||||
height: 186,
|
||||
link: 'https://www.planespotters.net/photo/1/n100aa',
|
||||
credit: 'Photo © Jane Doe',
|
||||
});
|
||||
|
||||
// And it repaints once, not on every refresh.
|
||||
const shownPublishes = runtime.panel().publishes;
|
||||
await militaryAwarenessLayer.update();
|
||||
assert.equal(runtime.panel().publishes, shownPublishes);
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer: sourceViewer,
|
||||
meta: {},
|
||||
tracked: false,
|
||||
});
|
||||
runtime.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a cached Context subject re-reads its label when enrichment lands after selection', async () => {
|
||||
// The subject snapshot is captured once at selection. Selecting a
|
||||
// callsign-less aircraft BEFORE adsbdb enrichment answers used to freeze the
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* What a ground point is over, offline: on land the country (Natural Earth
|
||||
* admin-0) and its first-level division (admin-1: "Sichuan, China"), plus,
|
||||
* when asked for a radius, the most prominent populated place within it
|
||||
* ("near Chengdu"); else the smallest named sea, gulf, strait, channel,
|
||||
* sound or bay, else the ocean (`marine.json` carries the seven oceans).
|
||||
* Bays narrower than a satellite frame cares about — fjords, lagoons,
|
||||
* inlets, reefs and rivers — are skipped, so a pass reads "Bay of Bengal",
|
||||
* not a harbour.
|
||||
*
|
||||
* PURE data module: no Cesium, node-testable. Each pack loads lazily on the
|
||||
* first lookup that needs it (divisions on the first land hit, places on the
|
||||
* first radius) and is indexed once, and the loading status names them while
|
||||
* one is on its way (`placePacksStatus`); a country's division rings are decoded
|
||||
* the first time a point falls inside it. A failed load is retried later
|
||||
* (see `createRetryableLoader`). No network.
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
import { containsAnchor, decodeRing } from './adminBoundaries.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
/** Marine feature classes named as the place below; oceans come last. */
|
||||
const MARINE_KINDS = new Set([
|
||||
'sea',
|
||||
'gulf',
|
||||
'strait',
|
||||
'channel',
|
||||
'sound',
|
||||
'bay',
|
||||
]);
|
||||
|
||||
const PACKS = {
|
||||
countries: new URL(
|
||||
'./local_data/natural_earth/countries.json',
|
||||
import.meta.url,
|
||||
),
|
||||
marine: new URL('./local_data/natural_earth/marine.json', import.meta.url),
|
||||
divisions: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
places: new URL(
|
||||
'./local_data/natural_earth/populated_places.json',
|
||||
import.meta.url,
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* The packs on their way now, and those that failed in the current load (or
|
||||
* the last one): a load starts with no failures, so a retry that lands is
|
||||
* not blamed for a pack that failed before. (A load that does not need the
|
||||
* failed pack clears it too; the next lookup that needs it tries it again.)
|
||||
*/
|
||||
const loadingPacks = new Set();
|
||||
const failedPacks = new Set();
|
||||
// One lookup loads its packs one after another (countries and seas, then
|
||||
// divisions, then places): the status holds them as one load until the
|
||||
// lookup ends, rather than ending and starting a batch between packs.
|
||||
let lookupsInFlight = 0;
|
||||
let lookupLoaded = false;
|
||||
const loadingListeners = new Set();
|
||||
|
||||
function notifyLoading() {
|
||||
for (const listener of [...loadingListeners]) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* A status listener never breaks a lookup. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the status shows a load on its way. */
|
||||
function loadingNow() {
|
||||
return loadingPacks.size > 0 || (lookupLoaded && lookupsInFlight > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pack load that the loading status follows: on its way while it runs,
|
||||
* and failed, if it fails, until the next load starts.
|
||||
* @template T
|
||||
* @param {string} name
|
||||
* @param {() => Promise<T>} load
|
||||
* @returns {() => Promise<T>}
|
||||
*/
|
||||
export function reportedPlacePackLoad(name, load) {
|
||||
return async () => {
|
||||
// A fresh load forgets earlier failures: it ends on what it loads.
|
||||
if (!loadingNow()) failedPacks.clear();
|
||||
if (lookupsInFlight > 0) lookupLoaded = true;
|
||||
loadingPacks.add(name);
|
||||
notifyLoading();
|
||||
try {
|
||||
return await load();
|
||||
} catch (error) {
|
||||
failedPacks.add(name);
|
||||
throw error;
|
||||
} finally {
|
||||
loadingPacks.delete(name);
|
||||
notifyLoading();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The place packs' state for the loading status. The packs load lazily, on
|
||||
* the first lookup that needs them, so the status says so meanwhile, and
|
||||
* says when one would not load.
|
||||
* @returns {{loading: boolean, failed: boolean}}
|
||||
*/
|
||||
export function placePacksStatus() {
|
||||
return {
|
||||
loading: loadingNow(),
|
||||
failed: failedPacks.size > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hear each start and end of a place pack load.
|
||||
* @param {() => void} listener
|
||||
* @returns {() => void} Stops listening.
|
||||
*/
|
||||
export function onPlacePacksLoadingChange(listener) {
|
||||
loadingListeners.add(listener);
|
||||
return () => loadingListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Test seam: count pack loads (lazy-loading contract). */
|
||||
export const placePackLoads = { divisions: 0, places: 0 };
|
||||
|
||||
/** The zoom from which the view names the populated place it shows. */
|
||||
export const NEAR_PLACE_MIN_ZOOM = 8;
|
||||
|
||||
/**
|
||||
* How far from the sub-satellite point a populated place may lie and still
|
||||
* be named: half the view's smaller ground dimension from
|
||||
* NEAR_PLACE_MIN_ZOOM on, so the place is in the picture whichever way it
|
||||
* lies; null below it or without a size.
|
||||
* @param {number} zoom Optical zoom.
|
||||
* @param {number} widthKm The view's ground width, km.
|
||||
* @param {number} [heightKm] The view's ground height, km; the width when
|
||||
* not given.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function nearPlaceRadiusKm(zoom, widthKm, heightKm = widthKm) {
|
||||
if (!(zoom >= NEAR_PLACE_MIN_ZOOM)) return null;
|
||||
const smallerKm = Math.min(widthKm, heightKm);
|
||||
return Number.isFinite(smallerKm) && smallerKm > 0 ? smallerKm / 2 : null;
|
||||
}
|
||||
|
||||
/** Shortest wait between two lookups, ms. */
|
||||
export const PLACE_LOOKUP_MIN_MS = 2000;
|
||||
/** Ground travel that makes a fresh lookup worth it, km. */
|
||||
export const PLACE_LOOKUP_MIN_MOVE_KM = 20;
|
||||
|
||||
function boxOf(parts) {
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
for (const part of parts)
|
||||
for (const ring of part)
|
||||
for (const [lon, lat] of ring) {
|
||||
if (lon < west) west = lon;
|
||||
if (lon > east) east = lon;
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
return [west, south, east, north];
|
||||
}
|
||||
|
||||
function inBox([west, south, east, north], lat, lon) {
|
||||
return lat >= south && lat <= north && lon >= west && lon <= east;
|
||||
}
|
||||
|
||||
/** Planar ring area in degrees², shrunk by cos(latitude): enough to rank. */
|
||||
function ringRank(ring) {
|
||||
let sum = 0;
|
||||
let latSum = 0;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
sum += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
|
||||
latSum += ring[i][1];
|
||||
}
|
||||
const meanLat = ring.length ? latSum / ring.length : 0;
|
||||
return (Math.abs(sum) / 2) * Math.cos((meanLat * Math.PI) / 180);
|
||||
}
|
||||
|
||||
async function readPlaces() {
|
||||
const [countries, marine] = await Promise.all([
|
||||
loadBundledJson(PACKS.countries),
|
||||
loadBundledJson(PACKS.marine),
|
||||
]);
|
||||
const decimals = countries.meta?.decimals ?? 3;
|
||||
const land = [];
|
||||
for (const feature of countries.features || []) {
|
||||
const parts = (feature.polygons || []).map((part) =>
|
||||
part.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
if (!parts.length) continue;
|
||||
land.push({
|
||||
name: feature.name,
|
||||
// Natural Earth writes -99 for units without an ISO code.
|
||||
countryCode: /^[A-Z]{2}$/.test(feature.iso2 || '') ? feature.iso2 : null,
|
||||
parts,
|
||||
box: boxOf(parts),
|
||||
});
|
||||
}
|
||||
const water = [];
|
||||
for (const feature of marine.features || []) {
|
||||
const kind = feature.featurecla;
|
||||
if (kind !== 'ocean' && !MARINE_KINDS.has(kind)) continue;
|
||||
// Marine rings are stored outer-only and plain [lon, lat].
|
||||
const parts = (feature.polygons || []).map((ring) => [ring]);
|
||||
if (!parts.length) continue;
|
||||
water.push({
|
||||
name: feature.name,
|
||||
kind: kind === 'ocean' ? 'ocean' : 'sea',
|
||||
parts,
|
||||
box: boxOf(parts),
|
||||
rank: parts.reduce((sum, [ring]) => sum + ringRank(ring), 0),
|
||||
});
|
||||
}
|
||||
return { land, water };
|
||||
}
|
||||
const loadPlaces = createRetryableLoader(
|
||||
reportedPlacePackLoad('places', readPlaces),
|
||||
);
|
||||
|
||||
/** Divisions grouped by country code; rings decoded per country on demand. */
|
||||
async function readDivisions() {
|
||||
placePackLoads.divisions += 1;
|
||||
const pack = await loadBundledJson(PACKS.divisions);
|
||||
const decimals = pack.meta?.decimals ?? 3;
|
||||
const byCountry = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
if (!feature.name || !/^[A-Z]{2}$/.test(feature.iso2 || '')) continue;
|
||||
const list = byCountry.get(feature.iso2) || [];
|
||||
list.push({ feature, decimals: feature.d ?? decimals });
|
||||
byCountry.set(feature.iso2, list);
|
||||
}
|
||||
return { byCountry, decoded: new Map() };
|
||||
}
|
||||
const loadDivisions = createRetryableLoader(
|
||||
reportedPlacePackLoad('divisions', readDivisions),
|
||||
);
|
||||
|
||||
/** The first-level division holding a point inside country `code`, or null. */
|
||||
async function divisionAt(code, lat, lon) {
|
||||
if (!code) return null;
|
||||
const { byCountry, decoded } = await loadDivisions();
|
||||
let units = decoded.get(code);
|
||||
if (!units) {
|
||||
units = (byCountry.get(code) || []).map(({ feature, decimals }) => {
|
||||
const parts = (feature.polygons || []).map((part) =>
|
||||
part.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
return { name: feature.name, parts, box: boxOf(parts) };
|
||||
});
|
||||
decoded.set(code, units);
|
||||
}
|
||||
for (const unit of units)
|
||||
if (inBox(unit.box, lat, lon) && containsAnchor(unit.parts, lat, lon))
|
||||
return unit.name;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readPopulatedPlaces() {
|
||||
placePackLoads.places += 1;
|
||||
const pack = await loadBundledJson(PACKS.places);
|
||||
return (pack.places || []).filter(
|
||||
(row) =>
|
||||
Array.isArray(row) &&
|
||||
row[0] &&
|
||||
Number.isFinite(row[1]) &&
|
||||
Number.isFinite(row[2]),
|
||||
);
|
||||
}
|
||||
const loadPopulatedPlaces = createRetryableLoader(
|
||||
reportedPlacePackLoad('populated-places', readPopulatedPlaces),
|
||||
);
|
||||
|
||||
/**
|
||||
* The most prominent populated place within `radiusKm` of a point (lowest
|
||||
* Natural Earth scalerank, then the larger population class, then the
|
||||
* nearer), or null.
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function nearPlace(lat, lon, radiusKm) {
|
||||
if (!(radiusKm > 0) || !Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
return null;
|
||||
const places = await loadPopulatedPlaces();
|
||||
const latSpan = radiusKm / 111;
|
||||
const cosLat = Math.cos((lat * Math.PI) / 180);
|
||||
const lonSpan = cosLat > 0.01 ? latSpan / cosLat : 360;
|
||||
let best = null;
|
||||
for (const [name, pLat, pLon, scalerank, rankMax] of places) {
|
||||
if (Math.abs(pLat - lat) > latSpan) continue;
|
||||
const dLon = Math.abs(((((pLon - lon + 180) % 360) + 360) % 360) - 180);
|
||||
if (dLon > lonSpan) continue;
|
||||
const km = greatCircleKm({ lat, lon }, { lat: pLat, lon: pLon });
|
||||
if (km > radiusKm) continue;
|
||||
if (
|
||||
!best ||
|
||||
scalerank < best.scalerank ||
|
||||
(scalerank === best.scalerank &&
|
||||
(rankMax > best.rankMax || (rankMax === best.rankMax && km < best.km)))
|
||||
)
|
||||
best = { name, scalerank, rankMax, km };
|
||||
}
|
||||
return best ? best.name : null;
|
||||
}
|
||||
|
||||
/** The division and near-place lookups `placeBelow` adds on land. */
|
||||
const DETAIL_LOOKUPS = Object.freeze({ division: divisionAt, near: nearPlace });
|
||||
|
||||
/** A detail lookup's answer, or null when it (its pack) fails. */
|
||||
async function orNull(lookup) {
|
||||
try {
|
||||
return (await lookup()) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The place under a ground point.
|
||||
* @param {number} latitude Degrees.
|
||||
* @param {number} longitude Degrees, any wrap.
|
||||
* @param {{nearKm?: number|null, detail?: {division: Function,
|
||||
* near: Function}}} [options] On land, also name the most prominent
|
||||
* populated place within `nearKm` (`nearPlaceRadiusKm`). `detail` replaces
|
||||
* the division and near-place lookups (a test seam).
|
||||
* @returns {Promise<{kind: 'country'|'sea'|'ocean', name: string,
|
||||
* countryCode: string|null, division?: string|null,
|
||||
* near?: string|null}|null>} A country carries its first-level `division`
|
||||
* and the `near` place (each null when none, or when its pack failed to
|
||||
* load: the country still stands); a sea or ocean neither. Null when no
|
||||
* feature holds the point (a coastline gap left by the packs'
|
||||
* simplification) or it is invalid. Rejects only when the country and
|
||||
* marine packs fail.
|
||||
*/
|
||||
export async function placeBelow(latitude, longitude, options) {
|
||||
lookupsInFlight += 1;
|
||||
try {
|
||||
return await findPlaceBelow(latitude, longitude, options);
|
||||
} finally {
|
||||
lookupsInFlight -= 1;
|
||||
if (lookupsInFlight === 0 && lookupLoaded) {
|
||||
lookupLoaded = false;
|
||||
notifyLoading();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findPlaceBelow(
|
||||
latitude,
|
||||
longitude,
|
||||
{ nearKm = null, detail = DETAIL_LOOKUPS } = {},
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(latitude) ||
|
||||
!Number.isFinite(longitude) ||
|
||||
Math.abs(latitude) > 90
|
||||
)
|
||||
return null;
|
||||
const lon = ((((longitude + 180) % 360) + 360) % 360) - 180;
|
||||
const { land, water } = await loadPlaces();
|
||||
for (const entry of land) {
|
||||
if (
|
||||
inBox(entry.box, latitude, lon) &&
|
||||
containsAnchor(entry.parts, latitude, lon)
|
||||
)
|
||||
return {
|
||||
kind: 'country',
|
||||
name: entry.name,
|
||||
countryCode: entry.countryCode,
|
||||
division: await orNull(() =>
|
||||
detail.division(entry.countryCode, latitude, lon),
|
||||
),
|
||||
near: await orNull(() => detail.near(latitude, lon, nearKm)),
|
||||
};
|
||||
}
|
||||
let best = null;
|
||||
for (const entry of water) {
|
||||
if (best && entry.rank >= best.rank) continue;
|
||||
if (
|
||||
inBox(entry.box, latitude, lon) &&
|
||||
containsAnchor(entry.parts, latitude, lon)
|
||||
)
|
||||
best = entry;
|
||||
}
|
||||
return best ? { kind: best.kind, name: best.name, countryCode: null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two radii ask for the same detail: both off, or within 20% (a
|
||||
* zoom step at least halves the view; altitude drift moves it far less).
|
||||
*/
|
||||
function sameRadius(a, b) {
|
||||
if (a === null || b === null) return a === b;
|
||||
return Math.max(a, b) / Math.min(a, b) < 1.2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the place below a moving ground point current without a lookup per
|
||||
* frame: a new lookup once at least `minIntervalMs` has passed AND the point
|
||||
* has moved `minMoveKm` (a low orbit refreshes about every 2.6 s, a
|
||||
* geostationary one never after the first). A new `nearKm` radius (a zoom
|
||||
* step) looks up again at once. One lookup runs at a time; a result that
|
||||
* arrives after `reset()` is dropped. A failed lookup reports no place (the
|
||||
* old one would name ground the view has left) and lets the next update
|
||||
* look up again at once.
|
||||
* @param {{onPlace: (place: object|null) => void,
|
||||
* lookup?: (lat: number, lon: number,
|
||||
* options: {nearKm: number|null}) => Promise<object|null>,
|
||||
* minIntervalMs?: number, minMoveKm?: number}} options
|
||||
*/
|
||||
export function createPlaceTracker({
|
||||
onPlace,
|
||||
lookup = placeBelow,
|
||||
minIntervalMs = PLACE_LOOKUP_MIN_MS,
|
||||
minMoveKm = PLACE_LOOKUP_MIN_MOVE_KM,
|
||||
}) {
|
||||
let generation = 0;
|
||||
let inFlight = false;
|
||||
let last = null;
|
||||
return {
|
||||
/**
|
||||
* Offer the current ground point and the radius to name a place in.
|
||||
* @returns {boolean} True when a lookup started.
|
||||
*/
|
||||
update(lat, lon, nowMs, nearKm = null) {
|
||||
if (inFlight || !Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
return false;
|
||||
const radius = Number.isFinite(nearKm) && nearKm > 0 ? nearKm : null;
|
||||
if (
|
||||
last &&
|
||||
sameRadius(last.nearKm, radius) &&
|
||||
(nowMs - last.ms < minIntervalMs ||
|
||||
greatCircleKm(last, { lat, lon }) < minMoveKm)
|
||||
)
|
||||
return false;
|
||||
last = { lat, lon, ms: nowMs, nearKm: radius };
|
||||
inFlight = true;
|
||||
const token = generation;
|
||||
Promise.resolve()
|
||||
.then(() => lookup(lat, lon, { nearKm: radius }))
|
||||
.then(
|
||||
(place) => {
|
||||
if (token !== generation) return;
|
||||
inFlight = false;
|
||||
onPlace(place ?? null);
|
||||
},
|
||||
() => {
|
||||
if (token !== generation) return;
|
||||
inFlight = false;
|
||||
last = null;
|
||||
onPlace(null);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
},
|
||||
/** Forget the point and drop any lookup still in flight. */
|
||||
reset() {
|
||||
generation += 1;
|
||||
inFlight = false;
|
||||
last = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// src/data/placeBelow.test.mjs — what the ground track is over, offline:
|
||||
// division and country on land (with the populated place in view from 8x),
|
||||
// the smallest named sea, else the ocean; and the tracker that keeps it
|
||||
// current without a lookup per frame.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
NEAR_PLACE_MIN_ZOOM,
|
||||
PLACE_LOOKUP_MIN_MOVE_KM,
|
||||
PLACE_LOOKUP_MIN_MS,
|
||||
createPlaceTracker,
|
||||
nearPlaceRadiusKm,
|
||||
placeBelow,
|
||||
placePackLoads,
|
||||
} from './placeBelow.js';
|
||||
|
||||
const PLACES_PACK = new URL(
|
||||
'./local_data/natural_earth/populated_places.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
// Runs first: the counters are module-wide.
|
||||
test('the division and place packs load only when a lookup needs them', async () => {
|
||||
assert.deepEqual(placePackLoads, { divisions: 0, places: 0 });
|
||||
await placeBelow(42, 51, { nearKm: 50 });
|
||||
assert.deepEqual(placePackLoads, { divisions: 0, places: 0 }, 'at sea');
|
||||
await placeBelow(30.9, 104.2);
|
||||
assert.deepEqual(placePackLoads, { divisions: 1, places: 0 }, 'at 1x');
|
||||
await placeBelow(30.9, 104.2, { nearKm: 60 });
|
||||
await placeBelow(31.2, 104.5, { nearKm: 30 });
|
||||
assert.deepEqual(placePackLoads, { divisions: 1, places: 1 }, 'kept');
|
||||
});
|
||||
|
||||
test('the populated-place pack is compact rows of name, position and rank', () => {
|
||||
const bytes = readFileSync(PLACES_PACK);
|
||||
assert.ok(bytes.length < 300_000, `${bytes.length} bytes`);
|
||||
const pack = JSON.parse(bytes);
|
||||
assert.match(pack.meta.url, /nvkelso\/natural-earth-vector/);
|
||||
assert.match(pack.meta.license, /^Public domain/);
|
||||
assert.ok(pack.places.length > 7000);
|
||||
for (const [name, lat, lon, scalerank, rankMax] of pack.places) {
|
||||
assert.equal(typeof name, 'string');
|
||||
assert.ok(Math.abs(lat) <= 90 && Math.abs(lon) <= 180, name);
|
||||
assert.ok(Number.isInteger(scalerank) && Number.isInteger(rankMax), name);
|
||||
}
|
||||
});
|
||||
|
||||
test('over land the place is the division and country, with its ISO code', async () => {
|
||||
assert.deepEqual(await placeBelow(48.85, 2.35), {
|
||||
kind: 'country',
|
||||
name: 'France',
|
||||
countryCode: 'FR',
|
||||
division: 'Paris',
|
||||
near: null,
|
||||
});
|
||||
// Across the antimeridian, and with a wrapped longitude.
|
||||
assert.equal((await placeBelow(66, -175)).countryCode, 'RU');
|
||||
assert.equal((await placeBelow(-17.8, 178)).countryCode, 'FJ');
|
||||
assert.equal((await placeBelow(48.85, 362.35)).countryCode, 'FR');
|
||||
});
|
||||
|
||||
test('at 1x a point in Sichuan reads Sichuan, China, with no city', async () => {
|
||||
assert.deepEqual(await placeBelow(30.9, 104.2), {
|
||||
kind: 'country',
|
||||
name: 'China',
|
||||
countryCode: 'CN',
|
||||
division: 'Sichuan',
|
||||
near: null,
|
||||
});
|
||||
assert.equal(nearPlaceRadiusKm(1, 1400), null);
|
||||
assert.equal(nearPlaceRadiusKm(NEAR_PLACE_MIN_ZOOM / 2, 300), null);
|
||||
});
|
||||
|
||||
test('from 8x the most prominent populated place in view is named', async () => {
|
||||
assert.equal(NEAR_PLACE_MIN_ZOOM, 8);
|
||||
// An 8x view ~120 km wide: Chengdu lies ~30 km off, inside half of it.
|
||||
const nearKm = nearPlaceRadiusKm(8, 120);
|
||||
assert.equal(nearKm, 60);
|
||||
const place = await placeBelow(30.9, 104.2, { nearKm });
|
||||
assert.equal(place.division, 'Sichuan');
|
||||
assert.equal(place.near, 'Chengdu');
|
||||
// Deyang lies nearer this point; Chengdu still outranks it.
|
||||
assert.equal(
|
||||
(await placeBelow(31.1, 104.35, { nearKm: 60 })).near,
|
||||
'Chengdu',
|
||||
);
|
||||
});
|
||||
|
||||
test('no city is named when none lies within half the view', async () => {
|
||||
// ~100 km west of Chengdu, in the mountains.
|
||||
const place = await placeBelow(30.67, 103.0, { nearKm: 30 });
|
||||
assert.equal(place.division, 'Sichuan');
|
||||
assert.equal(place.near, null);
|
||||
assert.equal(nearPlaceRadiusKm(8, Number.NaN), null);
|
||||
});
|
||||
|
||||
test('at sea the smallest named sea wins over its ocean', async () => {
|
||||
assert.deepEqual(await placeBelow(42, 51), {
|
||||
kind: 'sea',
|
||||
name: 'Caspian Sea',
|
||||
countryCode: null,
|
||||
});
|
||||
// A radius changes nothing at sea.
|
||||
assert.deepEqual(await placeBelow(42, 51, { nearKm: 60 }), {
|
||||
kind: 'sea',
|
||||
name: 'Caspian Sea',
|
||||
countryCode: null,
|
||||
});
|
||||
assert.equal((await placeBelow(15, 88)).name, 'Bay of Bengal');
|
||||
assert.equal((await placeBelow(35, 18)).name, 'Mediterranean Sea');
|
||||
});
|
||||
|
||||
test('open water far from any sea is the ocean', async () => {
|
||||
assert.deepEqual(await placeBelow(0, -150), {
|
||||
kind: 'ocean',
|
||||
name: 'South Pacific Ocean',
|
||||
countryCode: null,
|
||||
});
|
||||
assert.equal((await placeBelow(40, 179.9)).name, 'North Pacific Ocean');
|
||||
assert.equal((await placeBelow(40, -179.9)).name, 'North Pacific Ocean');
|
||||
assert.equal((await placeBelow(40, -40)).name, 'North Atlantic Ocean');
|
||||
});
|
||||
|
||||
test('an invalid point has no place', async () => {
|
||||
assert.equal(await placeBelow(Number.NaN, 0), null);
|
||||
assert.equal(await placeBelow(91, 0), null);
|
||||
});
|
||||
|
||||
/** A lookup whose answers the test releases by hand. */
|
||||
function manualLookup() {
|
||||
const calls = [];
|
||||
const lookup = (lat, lon, options) =>
|
||||
new Promise((resolve) => calls.push({ lat, lon, options, resolve }));
|
||||
return { calls, lookup };
|
||||
}
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('the tracker looks up again only after both time and distance', async () => {
|
||||
const { calls, lookup } = manualLookup();
|
||||
const places = [];
|
||||
const tracker = createPlaceTracker({
|
||||
lookup,
|
||||
onPlace: (place) => places.push(place),
|
||||
});
|
||||
assert.equal(tracker.update(0, 0, 1000), true, 'the first point');
|
||||
await flush();
|
||||
assert.equal(tracker.update(5, 5, 1100), false, 'one lookup at a time');
|
||||
calls[0].resolve({ kind: 'ocean', name: 'A' });
|
||||
await flush();
|
||||
assert.deepEqual(places, [{ kind: 'ocean', name: 'A' }]);
|
||||
// Far enough, but too soon.
|
||||
assert.equal(tracker.update(5, 5, 1000 + PLACE_LOOKUP_MIN_MS - 1), false);
|
||||
// Long enough, but barely moved (a geostationary satellite).
|
||||
assert.equal(tracker.update(0.01, 0, 1000 + PLACE_LOOKUP_MIN_MS * 5), false);
|
||||
// Both: a low orbit ~2.6 s on.
|
||||
const movedDeg = (PLACE_LOOKUP_MIN_MOVE_KM + 1) / 111.2;
|
||||
assert.equal(
|
||||
tracker.update(movedDeg, 0, 1000 + PLACE_LOOKUP_MIN_MS * 5),
|
||||
true,
|
||||
);
|
||||
await flush();
|
||||
calls[1].resolve(null);
|
||||
await flush();
|
||||
assert.deepEqual(places.at(-1), null, 'nothing named there is reported');
|
||||
});
|
||||
|
||||
test('a new radius (a zoom step) looks up again at once', async () => {
|
||||
const { calls, lookup } = manualLookup();
|
||||
const tracker = createPlaceTracker({ lookup, onPlace: () => {} });
|
||||
assert.equal(tracker.update(0, 0, 1000), true);
|
||||
await flush();
|
||||
assert.deepEqual(calls[0].lat, 0);
|
||||
calls[0].resolve(null);
|
||||
await flush();
|
||||
// Same point, a moment later: only the zoom changed.
|
||||
assert.equal(tracker.update(0, 0, 1100, 60), true, 'into 8x');
|
||||
await flush();
|
||||
calls[1].resolve(null);
|
||||
await flush();
|
||||
// Altitude drift barely moves the radius: the gate holds.
|
||||
assert.equal(tracker.update(0, 0, 1200, 62), false);
|
||||
assert.equal(tracker.update(0, 0, 1300, 30), true, 'to 16x');
|
||||
await flush();
|
||||
calls[2].resolve(null);
|
||||
await flush();
|
||||
assert.equal(tracker.update(0, 0, 1400, null), true, 'back below 8x');
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.options),
|
||||
[{ nearKm: null }, { nearKm: 60 }, { nearKm: 30 }, { nearKm: null }],
|
||||
);
|
||||
});
|
||||
|
||||
test('a lookup that lands after reset is dropped', async () => {
|
||||
const { calls, lookup } = manualLookup();
|
||||
const places = [];
|
||||
const tracker = createPlaceTracker({
|
||||
lookup,
|
||||
onPlace: (place) => places.push(place),
|
||||
});
|
||||
tracker.update(0, 0, 0);
|
||||
await flush();
|
||||
tracker.reset();
|
||||
// A new session looks up at once.
|
||||
assert.equal(tracker.update(10, 10, 1), true);
|
||||
await flush();
|
||||
calls[0].resolve({ name: 'stale' });
|
||||
calls[1].resolve({ name: 'fresh' });
|
||||
await flush();
|
||||
assert.deepEqual(places, [{ name: 'fresh' }]);
|
||||
});
|
||||
|
||||
test('a failed lookup frees the tracker for the next point', async () => {
|
||||
let fail = true;
|
||||
const places = [];
|
||||
const tracker = createPlaceTracker({
|
||||
lookup: async () => {
|
||||
if (fail) throw new Error('pack failed');
|
||||
return { name: 'ok' };
|
||||
},
|
||||
onPlace: (place) => places.push(place),
|
||||
minIntervalMs: 0,
|
||||
minMoveKm: 0,
|
||||
});
|
||||
tracker.update(0, 0, 0);
|
||||
await flush();
|
||||
assert.deepEqual(places, [null]);
|
||||
fail = false;
|
||||
assert.equal(tracker.update(0, 0, 1), true);
|
||||
await flush();
|
||||
assert.deepEqual(places, [null, { name: 'ok' }]);
|
||||
});
|
||||
|
||||
test('the near-place radius is half the smaller side of the view', () => {
|
||||
assert.equal(nearPlaceRadiusKm(8, 120, 60), 30, 'landscape');
|
||||
assert.equal(nearPlaceRadiusKm(8, 60, 120), 30, 'portrait');
|
||||
assert.equal(nearPlaceRadiusKm(8, 120), 60, 'square without a height');
|
||||
assert.equal(nearPlaceRadiusKm(8, 120, Number.NaN), null);
|
||||
});
|
||||
|
||||
test('a failed division or place pack still names the country', async () => {
|
||||
const fail = async () => {
|
||||
throw new Error('pack failed');
|
||||
};
|
||||
const place = await placeBelow(30.9, 104.2, {
|
||||
nearKm: 60,
|
||||
detail: { division: fail, near: fail },
|
||||
});
|
||||
assert.equal(place.kind, 'country');
|
||||
assert.equal(place.countryCode, 'CN');
|
||||
assert.equal(place.division, null);
|
||||
assert.equal(place.near, null);
|
||||
});
|
||||
|
||||
test('a failed lookup hides the old place and looks again on the next update', async () => {
|
||||
let fail = false;
|
||||
const places = [];
|
||||
const tracker = createPlaceTracker({
|
||||
lookup: async () => {
|
||||
if (fail) throw new Error('pack failed');
|
||||
return { kind: 'sea', name: 'Bay of Biscay' };
|
||||
},
|
||||
onPlace: (place) => places.push(place),
|
||||
});
|
||||
assert.equal(tracker.update(45, -5, 0), true);
|
||||
await flush();
|
||||
assert.deepEqual(places, [{ kind: 'sea', name: 'Bay of Biscay' }]);
|
||||
// Over France the lookup fails: the bay must not stay on the band.
|
||||
fail = true;
|
||||
const movedDeg = (PLACE_LOOKUP_MIN_MOVE_KM + 1) / 111.2;
|
||||
assert.equal(tracker.update(45 + movedDeg, 0, PLACE_LOOKUP_MIN_MS), true);
|
||||
await flush();
|
||||
assert.equal(places.at(-1), null, 'the band hides');
|
||||
// The next tick tries again at once, even without moving (a GEO view).
|
||||
fail = false;
|
||||
assert.equal(tracker.update(45 + movedDeg, 0, PLACE_LOOKUP_MIN_MS + 1), true);
|
||||
await flush();
|
||||
assert.deepEqual(places.at(-1), { kind: 'sea', name: 'Bay of Biscay' });
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// src/data/placePacksLoading.test.mjs — the loading status hears the place
|
||||
// packs while they are on their way.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
test('a place pack load is reported from its start to its end', async () => {
|
||||
// A module of its own, so no other test has loaded the packs yet.
|
||||
const place = await import('./placeBelow.js?loading-status');
|
||||
const heard = [];
|
||||
const stop = place.onPlacePacksLoadingChange(() =>
|
||||
heard.push(place.placePacksStatus().loading),
|
||||
);
|
||||
assert.equal(place.placePacksStatus().loading, false);
|
||||
const lookup = place.nearPlace(48.86, 2.35, 50);
|
||||
assert.equal(place.placePacksStatus().loading, true, 'on its way');
|
||||
assert.equal(await lookup, 'Paris');
|
||||
assert.equal(place.placePacksStatus().loading, false, 'loaded');
|
||||
assert.deepEqual(heard, [true, false]);
|
||||
// Loaded once: a later lookup is not reported again.
|
||||
await place.nearPlace(48.86, 2.35, 50);
|
||||
assert.deepEqual(heard, [true, false]);
|
||||
stop();
|
||||
});
|
||||
|
||||
test('a pack that would not load stays failed until it loads', async () => {
|
||||
const place = await import('./placeBelow.js?failed-status');
|
||||
let fail = true;
|
||||
const load = place.reportedPlacePackLoad('test', async () => {
|
||||
if (fail) throw new Error('offline');
|
||||
return 1;
|
||||
});
|
||||
await assert.rejects(load(), /offline/);
|
||||
assert.deepEqual(place.placePacksStatus(), { loading: false, failed: true });
|
||||
fail = false;
|
||||
const retry = load();
|
||||
assert.deepEqual(
|
||||
place.placePacksStatus(),
|
||||
{ loading: true, failed: false },
|
||||
'a retry is not a failure until it fails',
|
||||
);
|
||||
assert.equal(await retry, 1);
|
||||
assert.deepEqual(place.placePacksStatus(), { loading: false, failed: false });
|
||||
});
|
||||
|
||||
test('one lookup that loads several packs reads as one load', async () => {
|
||||
const place = await import('./placeBelow.js?one-lookup');
|
||||
const heard = [];
|
||||
const stop = place.onPlacePacksLoadingChange(() => {
|
||||
const { loading } = place.placePacksStatus();
|
||||
if (heard.at(-1) !== loading) heard.push(loading);
|
||||
});
|
||||
// On land with a radius: countries and seas, then divisions, then places.
|
||||
const found = await place.placeBelow(48.86, 2.35, { nearKm: 50 });
|
||||
assert.equal(found.near, 'Paris');
|
||||
assert.deepEqual(heard, [true, false], 'no gap between the packs');
|
||||
stop();
|
||||
});
|
||||
|
||||
/** Two stand-in packs (not real pack names) that fail while named in `fail`. */
|
||||
function standInPacks(place, fail) {
|
||||
const pack = (name) =>
|
||||
place.reportedPlacePackLoad(name, async () => {
|
||||
if (fail.has(name)) throw new Error('offline');
|
||||
return name;
|
||||
});
|
||||
return { first: pack('stand-in-first'), second: pack('stand-in-second') };
|
||||
}
|
||||
|
||||
/** The statuses one lookup that loads both stand-ins reports, start to end. */
|
||||
async function lookupStatuses(place, { first, second }) {
|
||||
const statuses = [];
|
||||
const stop = place.onPlacePacksLoadingChange(() =>
|
||||
statuses.push(place.placePacksStatus()),
|
||||
);
|
||||
await place.placeBelow(48.86, 2.35, {
|
||||
detail: { division: async () => first(), near: async () => second() },
|
||||
});
|
||||
stop();
|
||||
return statuses;
|
||||
}
|
||||
|
||||
test('a load is not blamed for packs that failed in an earlier one', async () => {
|
||||
const place = await import('./placeBelow.js?two-failed');
|
||||
const fail = new Set(['stand-in-first', 'stand-in-second']);
|
||||
const packs = standInPacks(place, fail);
|
||||
await assert.rejects(packs.first());
|
||||
await assert.rejects(packs.second());
|
||||
assert.equal(place.placePacksStatus().failed, true);
|
||||
fail.clear();
|
||||
const statuses = await lookupStatuses(place, packs);
|
||||
assert.deepEqual(statuses[0], { loading: true, failed: false });
|
||||
assert.ok(
|
||||
statuses.every((status) => status.failed === false),
|
||||
'the earlier failures do not colour this load',
|
||||
);
|
||||
assert.deepEqual(place.placePacksStatus(), {
|
||||
loading: false,
|
||||
failed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a pack that fails in a load keeps it failed when the next one lands', async () => {
|
||||
const place = await import('./placeBelow.js?mixed');
|
||||
const packs = standInPacks(place, new Set(['stand-in-first']));
|
||||
const statuses = await lookupStatuses(place, packs);
|
||||
assert.deepEqual(statuses.at(-1), { loading: false, failed: true });
|
||||
assert.deepEqual(place.placePacksStatus(), {
|
||||
loading: false,
|
||||
failed: true,
|
||||
});
|
||||
});
|
||||
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user