mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 21:32:23 +00:00
Merge branch 'main' into feat/transit-icon-heading
This commit is contained in:
191 files changed
+16103
-740
No files matched your search
@@ -0,0 +1,40 @@
|
||||
name: Announce merged PRs in Discord
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
if: github.repository == 'bilawalsidhu/gods-eye-view' && github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
# This uses the trusted base workflow and never checks out or runs PR code.
|
||||
- name: Post to Discord contributions
|
||||
shell: bash
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CONTRIBUTIONS_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [[ ! "$DISCORD_WEBHOOK_URL" =~ ^https://discord\.com/api/webhooks/[0-9]+/[A-Za-z0-9_-]+$ ]]; then
|
||||
echo '::error::Set DISCORD_CONTRIBUTIONS_WEBHOOK_URL to the Discord channel webhook URL.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
payload=$(jq -ce '
|
||||
.pull_request | {
|
||||
allowed_mentions: {parse: []},
|
||||
embeds: [{
|
||||
title: ("Merged #" + (.number | tostring) + ": " + (.title | .[0:115])),
|
||||
url: .html_url,
|
||||
footer: {text: ("By " + .user.login + " · into " + (.base.ref | .[0:100]))}
|
||||
}]
|
||||
}
|
||||
' "$GITHUB_EVENT_PATH")
|
||||
|
||||
curl --silent --show-error --fail --output /dev/null \
|
||||
--connect-timeout 10 --max-time 20 --retry 3 --retry-max-time 50 \
|
||||
--header 'Content-Type: application/json' --data-binary "$payload" \
|
||||
--url "$DISCORD_WEBHOOK_URL?wait=true"
|
||||
@@ -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
|
||||
|
||||
+43
-6
@@ -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.
|
||||
@@ -290,11 +326,12 @@ 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/`)
|
||||
|
||||
@@ -503,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.
|
||||
@@ -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,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 @@
|
||||
- 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,5 @@
|
||||
- Add Hebrew (he), the second right-to-left language: the title, rails, dock
|
||||
and corner row mirror, numbers keep Western digits, and plurals use the
|
||||
one/two/other Hebrew forms. Noto Sans Hebrew joins the font stacks and the
|
||||
style, plural and glossary notes arrive in docs/translations/he.md
|
||||
(Hebrew, @meirm, #1594).
|
||||
@@ -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,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 @@
|
||||
- A FIRMS snapshot that ends at a set time (`at`, epoch ms, from a source that
|
||||
serves one) is no longer labelled live: the Active Fires row reads
|
||||
`NASA FIRMS · 24 h to 10-21 15:00 UTC`, and `get_active_fires` says "in the
|
||||
24 hours to" that time and reports it as `window_end` (samehkhamis, #1650).
|
||||
@@ -0,0 +1,7 @@
|
||||
- A tracked flight's trail splits at feed gaps, impossible jumps and reports
|
||||
without altitude instead of joining a day of history into one line, keeps
|
||||
every history point, and follows the path the aircraft was drawn along so it
|
||||
no longer cuts across turns. A tracked aircraft that stops reporting shows
|
||||
STALE, keeps flying on its last heading for five minutes, then stops dimmed
|
||||
as SIGNAL LOST and is kept ten minutes after its last report; aircraft on or
|
||||
near the ground hold instead (Sameh Khamis, @samehkhamis, #1654).
|
||||
@@ -0,0 +1,4 @@
|
||||
- A long flight keeps its origin and destination when weather or airways take
|
||||
it far off the great circle: the route check now allows 200 km or 15% of the
|
||||
route's length, whichever is larger (623 km for JFK–SFO), while a short hop
|
||||
still allows 200 km (Sameh Khamis, @samehkhamis, #1655).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
+184
-58
@@ -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
|
||||
@@ -751,7 +767,7 @@ Consult the linked official advisory for safety decisions.
|
||||
|
||||
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
|
||||
|
||||
Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance.
|
||||
Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h, or for FIRMS the 24 hours to a snapshot's `at`), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance.
|
||||
|
||||
Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
|
||||
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
|
||||
@@ -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
|
||||
@@ -1404,6 +1444,52 @@ use the supplied asset resolver, including the preload path. Enrichment receives
|
||||
an abort signal and cannot update a later lifecycle after destruction. Existing
|
||||
camera, terrain floor, trail, selection and measured model-size policies remain.
|
||||
|
||||
A tracked civil or military aircraft's trail (`src/data/trailContinuity.js`) lifts
|
||||
the pen, drawing nothing, between two points more than 48 minutes or over 1,500 kt
|
||||
apart (a parked aircraft within 0.1 nm keeps one line for up to three hours) and at a
|
||||
waypoint with no altitude; a ground waypoint is drawn on the ground. The head joins the
|
||||
aircraft only within 150 km. Each trail keeps up to 2,000 points, a full day of history
|
||||
at one fix per minute plus the live session; past that the oldest recorded points go
|
||||
first, and the oldest reports only once none are left. The renderer
|
||||
(`src/data/trailRenderer.js`) freezes every chunk of 128 points except the newest into
|
||||
static geometry, so a long trail costs about as much per frame as a short one;
|
||||
`scripts/qa-trail-freeze.mjs` checks that a freeze never shows a blank frame.
|
||||
While an aircraft is tracked, its trail between reports is the path its icon was
|
||||
drawn along: the displayed position is recorded about once a second or every 150 m
|
||||
(`recordTrailPoint`). Recorded points are thinned by shape: one stays only where
|
||||
dropping it would move the line more than 30 m across the track or in height from any
|
||||
position it stood for, or leave a straight piece longer than 10 km, so straight flight
|
||||
adds a vertex every 10 km at most and a turn keeps its curve. A recorded point that
|
||||
would turn the line back sharper than 120° over a short step is dropped. Reports stay
|
||||
in the trail as anchors, in time order. Recorded points never join two reports that are
|
||||
not one flight under the 48-minute and 1,500 kt rules; the points between them go and
|
||||
nothing is recorded across such a pair. A report for a time the display has already
|
||||
passed replaces what was drawn after it, and nothing is recorded until the icon has
|
||||
eased onto it, so the line reads arc, report, corrected path. History from before tracking started, and the backfill, run report to report.
|
||||
The trail never runs past the icon or through its 3D model, and the head runs from the
|
||||
newest kept point outside the model to the icon (to the model's tail when the model is
|
||||
the visual). A contact with no new report coasts along its reported track for 5 minutes
|
||||
past its last real report, at no more than 1,500 kt and never below known ground, then stops;
|
||||
one that
|
||||
went quiet on the ground, or within 150 m of a known ground floor, holds at its last
|
||||
report, records nothing and draws no head (with the ground unknown, a low barometric
|
||||
altitude alone does not hold it). The hold-or-coast choice is made once per report (`staleHoldsForFix`
|
||||
in `src/data/motionModel.js`), and the forward fix built when a repeated position carries
|
||||
new speed or course follows the same rules and is never a trail report. A live report
|
||||
with no altitude breaks the line and leaves no point at an invented height, and so does
|
||||
such a report seeded from position history when tracking starts.
|
||||
|
||||
A contact that misses an update is stale from the first miss: it is drawn with the
|
||||
45%-alpha stale fade (`STALE_ALPHA`), the tracked billboard and 3D model included, and the
|
||||
tracked label reads STALE. For the tracked aircraft a poll that re-sends a fix no newer
|
||||
than the last one is a miss too. Once its coast has ended, or it holds at its last report
|
||||
after a miss, it stands still, dimmed, and the label reads SIGNAL LOST · <time since its
|
||||
last report>, in seconds under a minute and minutes after (`_trackedStaleCue` in each
|
||||
flight layer's `tracking.js`). The tracked aircraft is kept, so the follow camera keeps its
|
||||
target, until 10 minutes after its newest fix (`TRACKED_RETENTION_MS`): 5 minutes coasting,
|
||||
then 5 stopped as SIGNAL LOST. Untracked aircraft keep their missed-poll limits. A report
|
||||
that arrives later eases the aircraft onto it.
|
||||
|
||||
The tracked-aircraft descriptor both flight layers hand to the cockpit carries
|
||||
`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as
|
||||
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
|
||||
@@ -1722,7 +1808,7 @@ time. TRACK ACQUIRED stays first. Then:
|
||||
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
|
||||
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
|
||||
@@ -1934,6 +2020,7 @@ video; camera poses use the existing low-confidence heading prior.
|
||||
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. The app first reads the daily MOTC 2.0 CCTV.xml table with a keyless HTTPS request, a 15-second deadline, a 6 MiB response cap, and the shared 15-minute cache. If that catalog is unreachable and no last-good source list exists, the loader uses the Highway Bureau’s public provincial-road CCTV XML feed at https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml. The fallback is capped by the same Taiwan source limit and hourly retry backoff; its MJPEG feeds are restricted to the eight cctv-ss01 through cctv-ss08.thb.gov.tw HTTPS servers. The frame proxy returns one bounded JPEG from each stream, not an unbounded video response. The Highway Bureau open-data page states XML downloads are free and asks users to attribute the source; the app credits Highway Bureau, MOTC. These are provincial-road cameras, not a replacement for the national freeway cameras. CCTV_TAIWAN_ENABLED=0 disables both feeds; CCTV_TAIWAN_MAX_SOURCES sets the shared cap (default 600, bounded to 1–2000), and CCTV_TAIWAN_URL overrides the Freeway Bureau table URL.
|
||||
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs.
|
||||
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
@@ -2284,6 +2371,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,
|
||||
@@ -2777,6 +2875,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
|
||||
@@ -2848,6 +2957,16 @@ 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.
|
||||
|
||||
`/api/firms` answers the trailing 24 hours to now, and the Active Fires row
|
||||
reads `NASA FIRMS · LIVE · updated …`. A source may instead answer the 24 hours
|
||||
to a set time, naming its end as `at` (epoch ms): the row then reads
|
||||
`NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and
|
||||
`get_active_fires` says "in the 24 hours to" that time and reports it as
|
||||
`window_end`. The dev proxy never sets `at`.
|
||||
|
||||
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
|
||||
@@ -3134,7 +3253,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
|
||||
@@ -4130,10 +4249,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
|
||||
@@ -4186,8 +4304,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> advances dead reckoning from the host frame.
|
||||
> - **Scheduled route plausibility:** Aircraft route labels and selected-route
|
||||
> context reject the distant backwards, forwards and antipodal extensions of
|
||||
> an airport pair's great circle. The existing 130 km endpoint admission and
|
||||
> 200 km cross-track tolerance remain; a projected fix outside the finite
|
||||
> an airport pair's great circle. The existing 130 km endpoint admission
|
||||
> remains; the cross-track tolerance is 200 km or 15% of the route's length,
|
||||
> whichever is larger (JFK–SFO allows 623 km, as airways and winds take a
|
||||
> coast-to-coast flight several hundred kilometres off the great circle); a
|
||||
> projected fix outside the finite
|
||||
> origin-to-destination minor arc also has a generous 200 km extension at
|
||||
> either end. Missing coordinates and coincident or antipodal endpoint pairs
|
||||
> retain their loose, unjudgeable behavior; vertical-trend checks are unchanged.
|
||||
@@ -4576,7 +4697,9 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
||||
|
||||
### Interface localization (October 2026)
|
||||
|
||||
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
|
||||
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **he**,
|
||||
**it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and
|
||||
**zh-Hant**. The code lives in
|
||||
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
|
||||
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
||||
|
||||
@@ -4590,19 +4713,21 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, *
|
||||
`navigator.languages` → English. Any `pt` tag (`pt-BR`, `pt-PT`) selects `pt`. Chinese tags default to `zh-CN`; Traditional Chinese tags
|
||||
select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop
|
||||
it, so a recipient keeps their own language. The globe picker in the
|
||||
bottom-right corner (bottom-left in Arabic) (`src/ui/localePicker.js`) stores the choice, drops
|
||||
bottom-right corner (bottom-left in Arabic and Hebrew)
|
||||
(`src/ui/localePicker.js`) stores the choice, drops
|
||||
`?lang=` (keeps it only when storage is blocked) and switches in place:
|
||||
views re-render, code that published translated text words it again through
|
||||
`onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup
|
||||
follow. Nothing is lost; a toast already on screen keeps its words.
|
||||
- **Right to left.** Arabic (`ar`) is in `RTL_LOCALES`, so `<html dir="rtl">`:
|
||||
the title, style indicator, both panel stacks and their icon rails swap
|
||||
sides through logical properties (`inset-inline-*`, `margin-inline-*`), the
|
||||
dock and corner row reverse their order, and `src/ui/styles/rtl.css` holds
|
||||
the rest: no letter-spacing on Arabic text, Noto Sans Arabic in the font
|
||||
stacks, the intel HUD kept as a left-to-right instrument overlay, FM seek
|
||||
and the radio dial kept left to right. Intl formatters get `ar-u-nu-latn`,
|
||||
so numbers and dates keep Western digits.
|
||||
- **Right to left.** Arabic (`ar`) and Hebrew (`he`) are in `RTL_LOCALES`, so
|
||||
`<html dir="rtl">`: the title, style indicator, both panel stacks and their
|
||||
icon rails swap sides through logical properties (`inset-inline-*`,
|
||||
`margin-inline-*`), the dock and corner row reverse their order, and
|
||||
`src/ui/styles/rtl.css` holds the rest: no letter-spacing on Arabic and
|
||||
Hebrew text, Noto Sans Arabic and Noto Sans Hebrew in the font stacks, the
|
||||
intel HUD kept as a left-to-right instrument overlay, FM seek and the radio
|
||||
dial kept left to right. Intl formatters get `ar-u-nu-latn`, so numbers and
|
||||
dates keep Western digits.
|
||||
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
|
||||
extracts every message and fails on missing, empty, unused,
|
||||
placeholder-mismatched, still-English (plural forms included) or
|
||||
@@ -4651,35 +4776,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| 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 ×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) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||
@@ -5118,9 +5243,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
|
||||
@@ -5668,7 +5793,7 @@ silently demoting every later lookup for the session.
|
||||
|
||||
### Motion & Symbology Correctness (June 10, 2026)
|
||||
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal, except the tracked aircraft, which is kept until 10 minutes after its newest fix. When a position epoch pauses, both layers coast for five minutes on the last speed and heading (at most 1,500 kt), then stop dimmed as SIGNAL LOST; contacts on or near known ground hold instead. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
|
||||
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
|
||||
- **Satellites**: core catalog from CelesTrak's stations, visual, GPS, GLONASS, Galileo, GEO, resource, weather, planet, SAR and science groups, tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
@@ -5805,7 +5930,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.
|
||||
|
||||
@@ -6068,12 +6193,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.
|
||||
@@ -6318,6 +6442,7 @@ 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, 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.
|
||||
@@ -6494,6 +6619,7 @@ 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.
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
# Translating God's Eye View
|
||||
|
||||
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Hebrew** (`he`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -45,9 +45,10 @@ Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change,
|
||||
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
|
||||
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
|
||||
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`; the chrome mirrors through logical CSS properties (`inset-inline-*`, `margin-inline-*`), and `src/ui/styles/rtl.css` holds what those cannot express (see the Arabic notes). If the language should keep Western digits, map it in `INTL_TAGS` in `locale.js`.
|
||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
|
||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json` and in `scripts/format-scope.json`.
|
||||
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
|
||||
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||
6. If the language is not written in Latin letters, also add it to `NON_LATIN_LOCALES` in `src/hudSummaryResponse.js` — that guard falls back to the translated text when a summary provider answers in English.
|
||||
7. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -63,4 +64,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
|
||||
|
||||
## Language notes
|
||||
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Hebrew](./translations/he.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Hebrew (`he`)
|
||||
|
||||
Catalog: `src/i18n/locales/he.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||
|
||||
## Style
|
||||
|
||||
- Address the user with a masculine imperative, Google style: הסתר, הצג, נסה שוב, התקרב. Menu items and tooltips take the noun form: הסרת {name}, הרחבת {name}, שינוי הצבע של {name}.
|
||||
- Sentence case: Hebrew has no case forms, so UPPERCASE English is normal sentence style here; short HUD labels stay short, favor an abbreviation over a wrapped line.
|
||||
- Units follow the catalog: ק"מ, מ׳, קמ"ש, שנ׳, דק׳, שע׳; an abbreviation always ends in the geresh (׳). Instrument readouts and formatted shared-unit labels keep the Latin units (km, NM, FT, KTS, MW, KM/S); catalog prose outside those readouts keeps ק"מ/מ׳. Codes like HDG, FOV, CAL, SRC, REC stay exactly as in English — see `same-as-english.json`.
|
||||
|
||||
## Digits and direction
|
||||
|
||||
- Western digits 0–9 only, like every catalog: "5 מטוסים", "26.08.2026". Dates in scene labels use dd.mm.yyyy.
|
||||
- `{direction}` values are adverbs (שמאלה, ימינה, ישר); maneuver templates are written to place them after the verb: פנה {direction} אל {road}.
|
||||
- Values inserted by t() are isolated (U+2068…U+2069); do not add manual RTL marks — write the sentence as natural Hebrew and let isolation do the rest.
|
||||
|
||||
## Plurals
|
||||
|
||||
A `tn()` entry needs three forms under `Intl.PluralRules('he')`:
|
||||
|
||||
| Form | Counts | Example |
|
||||
| ------- | ---------------------------- | -------------- |
|
||||
| `one` | 1 and fractions below 1 | 1 מצלמה · 0.5 |
|
||||
| `two` | 2 | 2 מצלמות |
|
||||
| `other` | 0, 3, 10, 100, integers… | 5 מצלמות |
|
||||
|
||||
With {count} shown as a digit, `two` and `other` share the plural noun (2 מצלמות, 5 מצלמות); `one` takes the singular (1 מצלמה). For constructs that resist agreement with a noun (אין, הכול, זמין), phrase around the number: "{count} תחנות מדווחות" is fine, but prefer "ממתין: {count}" over a mismatched genitive chain.
|
||||
|
||||
## Glossary
|
||||
|
||||
| English | עברית |
|
||||
| ---------------------------------------------- | ----------------------------------------------------- |
|
||||
| layer / data layer | שכבה / שכבת נתונים |
|
||||
| feed | פיד |
|
||||
| source / data source / Provider Settings | מקור / מקור נתונים / הגדרות הספקים |
|
||||
| LIVE / live data / LIVE TRACK | חי / נתונים חיים / מעקב חי |
|
||||
| SIMULATED | מדומה |
|
||||
| stale / degraded / unavailable | מיושן / מופחת / לא זמין |
|
||||
| key (API key) / POWER UP | מפתח / שדרוג |
|
||||
| globe / map / map source / basemap | גלובוס / מפה / מקור מפה / מפת בסיס |
|
||||
| aircraft / flight / military flight | מטוס (כלי טיס in class lists) / טיסה / טיסה צבאית |
|
||||
| vessel / ship / ships | אונייה / אוניות |
|
||||
| satellite | לוויין |
|
||||
| launch (rocket) / space mission | שיגור / משימת חלל |
|
||||
| contact / tracking | מגע / עקיבה |
|
||||
| installation / site | מתקן / אתר |
|
||||
| camera / CCTV camera / ALPR camera | מצלמה / מצלמת CCTV / מצלמת ALPR |
|
||||
| earthquake / active fire / cyclone | רעידת אדמה / שריפה פעילה / ציקלון |
|
||||
| scene / shot | סצנה / שוט |
|
||||
| briefing / panel / dock | תדריך / פאנל / עגינה |
|
||||
| visual preset / style | ערכת ראייה / סגנון |
|
||||
| Scope (circular viewport mask) / keyhole | סקופ / חור המנעול |
|
||||
| heading (compass °) / course | כיוון / מהלך |
|
||||
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | בלי שינוי — קודי מכשיר |
|
||||
| advisory (NHC/CPHC cyclone) | עדכון; Advisory center — מרכז הסערה לפי העדכון |
|
||||
| Street View (Google) | Street View, בלי תרגום |
|
||||
| Street Level / Street Level layer | Street Level, בלי תרגום (שם שכבה) |
|
||||
| WHOT/BHOT, Ironbow, CRT, NVG, FLIR | בלי שינוי — קודים ושמות מצבים |
|
||||
| PREV / NEXT | הקודם / הבא |
|
||||
| anchor / data pack / asset bundle (scene) | עוגן / חבילת נתונים / חבילת נכסים |
|
||||
| forecast (weather) | תחזית |
|
||||
| witness / geolocated source | עד ראייה / מקור מאוכן |
|
||||
| contained (wildland fire) / FRP | הכלה / FRP, בלי שינוי |
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&family=Noto+Sans+Hebrew:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
|
||||
@@ -99,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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -1075,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",
|
||||
@@ -1291,6 +1294,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -242,6 +243,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"],
|
||||
@@ -319,6 +325,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"
|
||||
],
|
||||
@@ -326,7 +333,7 @@
|
||||
},
|
||||
"traffic-source": {
|
||||
"exports": ["./sources/traffic"],
|
||||
"modules": ["src/data/tomtomTiles.js"],
|
||||
"modules": ["src/data/tomtomTiles.js", "src/data/tileBudget.js"],
|
||||
"external": []
|
||||
},
|
||||
"firms-csv": {
|
||||
@@ -650,6 +657,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -698,6 +706,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -739,6 +748,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -782,6 +792,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -809,6 +820,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -839,6 +851,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -876,6 +889,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -945,6 +959,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -972,6 +987,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1174,6 +1190,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1366,6 +1383,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1394,7 +1412,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": []
|
||||
},
|
||||
@@ -1440,6 +1459,7 @@
|
||||
"src/layers/flights/state.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
@@ -1452,6 +1472,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1461,7 +1482,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"]
|
||||
},
|
||||
@@ -1502,6 +1524,7 @@
|
||||
"src/layers/military/state.js",
|
||||
"src/layers/military/testing.js",
|
||||
"src/layers/military/tracking.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
@@ -1513,6 +1536,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1520,6 +1544,7 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
],
|
||||
@@ -1590,6 +1615,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1627,6 +1653,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1668,6 +1695,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1708,6 +1736,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1756,6 +1785,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1787,6 +1817,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",
|
||||
@@ -1807,6 +1838,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1854,6 +1886,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1875,6 +1908,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",
|
||||
@@ -1916,6 +1950,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1953,6 +1988,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2010,6 +2046,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2081,6 +2118,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2105,8 +2143,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",
|
||||
@@ -2120,6 +2160,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2170,6 +2211,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2200,6 +2242,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2264,6 +2307,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2342,6 +2386,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -2578,6 +2623,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",
|
||||
@@ -2589,6 +2635,7 @@
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
@@ -3011,6 +3058,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",
|
||||
@@ -3290,6 +3338,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -3433,7 +3482,8 @@
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3574,6 +3624,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",
|
||||
@@ -3584,6 +3635,7 @@
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/keylessGeocoder.js",
|
||||
@@ -3857,6 +3909,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",
|
||||
@@ -4025,6 +4078,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4092,7 +4146,8 @@
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4171,6 +4226,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",
|
||||
@@ -4207,6 +4263,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4240,6 +4297,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4294,6 +4352,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4332,6 +4391,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4438,6 +4498,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4511,6 +4572,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4531,9 +4593,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",
|
||||
@@ -4572,6 +4636,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",
|
||||
@@ -4614,6 +4679,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",
|
||||
@@ -4799,6 +4865,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4854,6 +4921,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4917,6 +4985,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -4941,6 +5010,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5011,6 +5081,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5060,6 +5131,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5188,6 +5260,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",
|
||||
@@ -5226,6 +5299,7 @@
|
||||
"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",
|
||||
@@ -5233,7 +5307,8 @@
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/planespottersPhoto.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -5264,6 +5339,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -5310,6 +5386,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Trail freeze gate: freezing a trail chunk never shows a blank frame.
|
||||
*
|
||||
* A tracked aircraft's trail is cut into chunks; every chunk but the newest
|
||||
* is frozen into static geometry, which Cesium builds asynchronously. Until
|
||||
* that geometry is in the scene, the chunk keeps being drawn dynamically.
|
||||
*
|
||||
* This gate drives the real app and a real Cesium scene: it draws a trail of
|
||||
* CHUNK_POINTS points (all dynamic), then adds one point so the first chunk
|
||||
* freezes, and on every rendered frame after that it checks two things:
|
||||
* 1. every frozen chunk is either still drawn by a dynamic polyline or its
|
||||
* static geometry is in the scene (DataSourceDisplay reports DONE);
|
||||
* 2. the number of trail-coloured pixels never drops below 90% of the
|
||||
* count measured just before the freeze.
|
||||
* It also checks that clearing the trail releases every chunk entity.
|
||||
*
|
||||
* The camera is held still while the gate runs.
|
||||
*
|
||||
* Needs a running dev server:
|
||||
* node scripts/qa-trail-freeze.mjs [--url http://localhost:4173]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
const urlFlag = process.argv.indexOf('--url');
|
||||
const APP_URL =
|
||||
urlFlag > 0 ? process.argv[urlFlag + 1] : 'http://localhost:4173';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=960,640',
|
||||
],
|
||||
});
|
||||
|
||||
let failed = false;
|
||||
const report = (name, ok, detail) => {
|
||||
if (!ok) failed = true;
|
||||
console.log(
|
||||
` [${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 960, height: 640 });
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.viewer &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
// The exact Cesium module the renderer uses, so classes are shared.
|
||||
const source = await (await fetch('/src/data/trailRenderer.js')).text();
|
||||
const cesiumUrl = source.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1];
|
||||
const Cesium = await import(cesiumUrl);
|
||||
const { createTrail, CHUNK_POINTS } =
|
||||
await import('/src/data/trailRenderer.js');
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
const nextFrame = () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
|
||||
// A line across the middle of the current view, in mid-air in front of
|
||||
// the camera, so terrain and buildings never hide it.
|
||||
viewer.camera.cancelFlight?.();
|
||||
const camera = viewer.camera;
|
||||
const ahead = Cesium.Cartesian3.add(
|
||||
camera.positionWC,
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.directionWC,
|
||||
3_000,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const points = Array.from({ length: CHUNK_POINTS + 1 }, (_, i) =>
|
||||
Cesium.Cartesian3.add(
|
||||
ahead,
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.rightWC,
|
||||
-1_200 + (2_400 * i) / CHUNK_POINTS,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
);
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const add = viewer.entities.add.bind(viewer.entities);
|
||||
const remove = viewer.entities.remove.bind(viewer.entities);
|
||||
viewer.entities.add = (definition) => {
|
||||
const entity = add(definition);
|
||||
if (String(entity.id).startsWith('gev-trail:')) added.push(entity);
|
||||
return entity;
|
||||
};
|
||||
viewer.entities.remove = (entity) => {
|
||||
if (String(entity?.id).startsWith('gev-trail:')) removed.push(entity);
|
||||
return remove(entity);
|
||||
};
|
||||
|
||||
const gl = scene.context._gl;
|
||||
const width = gl.drawingBufferWidth;
|
||||
const height = gl.drawingBufferHeight;
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
// Magenta trail: count pixels that are clearly magenta.
|
||||
const countTrailPixels = () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let count = 0;
|
||||
for (let p = 0; p < pixels.length; p += 4) {
|
||||
const r = pixels[p];
|
||||
const g = pixels[p + 1];
|
||||
const b = pixels[p + 2];
|
||||
if (r > 150 && b > 150 && g < 110) count++;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const trail = createTrail(viewer, { color: '#ff00ff', width: 8 });
|
||||
trail.setPositions(points.slice(0, CHUNK_POINTS));
|
||||
for (let i = 0; i < 20; i++) await nextFrame();
|
||||
const before = trail._chunksForTest();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let lit = 0;
|
||||
for (let p = 0; p < pixels.length; p += 4)
|
||||
if (pixels[p] || pixels[p + 1] || pixels[p + 2]) lit++;
|
||||
const cam = viewer.camera.positionCartographic;
|
||||
const diagnostics = {
|
||||
lit,
|
||||
size: [width, height],
|
||||
camera: [
|
||||
Cesium.Math.toDegrees(cam.longitude),
|
||||
Cesium.Math.toDegrees(cam.latitude),
|
||||
cam.height,
|
||||
],
|
||||
preserveDrawingBuffer: gl.getContextAttributes().preserveDrawingBuffer,
|
||||
};
|
||||
let baseline = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await nextFrame();
|
||||
baseline = Math.max(baseline, countTrailPixels());
|
||||
}
|
||||
|
||||
trail.setPositions(points);
|
||||
const frames = [];
|
||||
const sphere = new Cesium.BoundingSphere();
|
||||
let resolvedAt = null;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await nextFrame();
|
||||
const state = trail._chunksForTest();
|
||||
const covered = state.frozen.every(
|
||||
(chunk) =>
|
||||
chunk.standIn ||
|
||||
viewer.dataSourceDisplay.getBoundingSphere(
|
||||
chunk.entity,
|
||||
false,
|
||||
sphere,
|
||||
) === Cesium.BoundingSphereState.DONE,
|
||||
);
|
||||
frames.push({ covered, pixels: countTrailPixels() });
|
||||
if (resolvedAt == null && state.frozen.every((c) => !c.standIn))
|
||||
resolvedAt = i;
|
||||
if (resolvedAt != null && i > resolvedAt + 30) break;
|
||||
}
|
||||
|
||||
trail.clear();
|
||||
for (let i = 0; i < 3; i++) await nextFrame();
|
||||
const frozenAfterClear = trail._chunksForTest().frozen.length;
|
||||
trail.destroy();
|
||||
const leaked = added.filter((entity) => !removed.includes(entity)).length;
|
||||
viewer.entities.add = add;
|
||||
viewer.entities.remove = remove;
|
||||
return {
|
||||
chunkPoints: CHUNK_POINTS,
|
||||
diagnostics,
|
||||
frozenBefore: before.frozen.length,
|
||||
baseline,
|
||||
frames,
|
||||
resolvedAt,
|
||||
frozenAfterClear,
|
||||
entities: added.length,
|
||||
leaked,
|
||||
};
|
||||
});
|
||||
|
||||
if (process.env.GEV_TEST_VERBOSE)
|
||||
console.log(JSON.stringify(result.diagnostics));
|
||||
const minPixels = Math.min(...result.frames.map((frame) => frame.pixels));
|
||||
report(
|
||||
'the trail draws before the freeze',
|
||||
result.baseline > 200 && result.frozenBefore === 0,
|
||||
`baseline=${result.baseline} trail pixels, frozen chunks=${result.frozenBefore}`,
|
||||
);
|
||||
report(
|
||||
'the frozen chunk reaches the scene as static geometry',
|
||||
result.resolvedAt != null,
|
||||
`stand-in dropped after ${result.resolvedAt} frames`,
|
||||
);
|
||||
report(
|
||||
'every frame after the freeze draws the frozen chunk (dynamic copy or static geometry)',
|
||||
result.frames.every((frame) => frame.covered),
|
||||
`${result.frames.length} frames checked`,
|
||||
);
|
||||
report(
|
||||
'no blank frame: trail pixels never drop below 90% of the pre-freeze count',
|
||||
minPixels >= 0.9 * result.baseline,
|
||||
`min=${minPixels} baseline=${result.baseline}`,
|
||||
);
|
||||
report(
|
||||
'clearing and destroying release every trail entity',
|
||||
result.frozenAfterClear === 0 && result.leaked === 0,
|
||||
`entities created=${result.entities} leaked=${result.leaked}`,
|
||||
);
|
||||
} catch (error) {
|
||||
report('gate ran', false, error.message);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(failed ? 'RESULT: FAIL' : 'RESULT: PASS');
|
||||
process.exit(failed ? 1 : 0);
|
||||
+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;
|
||||
}
|
||||
|
||||
+156
-21
@@ -42,8 +42,9 @@
|
||||
* and a real mouse click on the tracked plane is a
|
||||
* NO-OP (no deselect, no camera spike).
|
||||
* M3. AGE-OUT RELEASE — when the tracked plane's fixes stop arriving
|
||||
* (3 missed polls via the shim), tracking clears and
|
||||
* the camera is RELEASED IN PLACE: viewer.trackedEntity
|
||||
* it stays tracked (STALE, then SIGNAL LOST) until
|
||||
* 10 min after its newest fix; then tracking clears
|
||||
* and the camera is RELEASED IN PLACE: viewer.trackedEntity
|
||||
* undefined, NO jump (owner decision 2026-07-02 —
|
||||
* the old ~80 km overview flyTo is gone).
|
||||
*
|
||||
@@ -94,6 +95,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 +289,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,
|
||||
@@ -359,7 +363,12 @@ async function main() {
|
||||
'Synthetica', // 2 origin_country
|
||||
Math.floor(Date.now() / 1000), // 3 time_position
|
||||
Math.floor(Date.now() / 1000), // 4 last_contact
|
||||
f.lon, // 5 longitude
|
||||
// A plane given `moveFromMs` flies east at its reported speed
|
||||
// (the trail-head check needs a moving icon); others hold still.
|
||||
Number.isFinite(f.moveFromMs)
|
||||
? f.lon + (f.vel * (Date.now() - f.moveFromMs)) / 1000
|
||||
/ (111320 * Math.cos((f.lat * Math.PI) / 180))
|
||||
: f.lon, // 5 longitude
|
||||
f.lat, // 6 latitude
|
||||
f.alt, // 7 baro_altitude (m)
|
||||
f.onGround === true, // 8 on_ground (ground-traffic phase flips this live)
|
||||
@@ -1495,6 +1504,110 @@ async function main() {
|
||||
`max=${maxDelta.toFixed(3)} m over ${samples.length} frames (tol ${JITTER_TOL_M} m, mode=${modelUp ? 'model-matrix' : 'display-contract'})`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TRAIL HEAD ENDS AT THE DRAWN ICON, every rendered frame.
|
||||
// The head's last vertex is where the tracked plane is drawn (the
|
||||
// display position, or the model's tail anchor while the model is the
|
||||
// visual). getTrackedInfo reports the dead-reckoned position without
|
||||
// the brief post-report easing; its gap is logged, not asserted.
|
||||
// ============================================================
|
||||
console.log('\nTrail head — ends at the drawn icon on every frame');
|
||||
// The fixture planes repeat one position, so the icon holds and draws no
|
||||
// head. Fly the tracked one east for this check; the display runs 30 s
|
||||
// behind, so give it time to reach the moving reports.
|
||||
await evalPage((icao) => {
|
||||
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
|
||||
f.moveFromMs = Date.now();
|
||||
}, trackedIcao);
|
||||
await sleep(55_000);
|
||||
const headSamples = await sampleFrames(page, 40, (icao) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const ent = viewer.trackedEntity;
|
||||
const time = viewer.clock.currentTime;
|
||||
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
|
||||
const line = head?.polyline?.positions?.getValue(time);
|
||||
const drawn = ent?.position?.getValue(time);
|
||||
if (!line?.length || !drawn) return null;
|
||||
const end = line[line.length - 1];
|
||||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
const ti = fl.getTrackedInfo();
|
||||
let infoGap = null;
|
||||
if (ti && ti.icao24 === icao) {
|
||||
// WGS84 lat/lon → ECEF on the ellipsoid; the angle at the centre to
|
||||
// the drawn position, times the mean radius, is the ground gap.
|
||||
const a = 6378137, e2 = 6.69437999014e-3;
|
||||
const lat = (ti.latitude * Math.PI) / 180, lon = (ti.longitude * Math.PI) / 180;
|
||||
const n = a / Math.sqrt(1 - e2 * Math.sin(lat) ** 2);
|
||||
const p = {
|
||||
x: n * Math.cos(lat) * Math.cos(lon),
|
||||
y: n * Math.cos(lat) * Math.sin(lon),
|
||||
z: n * (1 - e2) * Math.sin(lat),
|
||||
};
|
||||
const dot = p.x * drawn.x + p.y * drawn.y + p.z * drawn.z;
|
||||
const cos = dot / (Math.hypot(p.x, p.y, p.z) * Math.hypot(drawn.x, drawn.y, drawn.z));
|
||||
infoGap = 6371008.8 * Math.acos(Math.min(1, cos));
|
||||
}
|
||||
// While the 3D model is the visual the head ends at the model's tail
|
||||
// anchor, which sits on the drawn (pixel-size-enlarged) model.
|
||||
const model = window.__findTrackedModel ? window.__findTrackedModel() : null;
|
||||
const sphere = model && model.ready && model.show ? model.boundingSphere : null;
|
||||
// boundingSphere leaves out the minimum-pixel-size enlargement that
|
||||
// computedScale carries; the drawn model is that much larger.
|
||||
const enlarge = sphere && Number.isFinite(model.computedScale) && model.scale > 0
|
||||
? Math.max(1, model.computedScale / model.scale)
|
||||
: 1;
|
||||
const radius = sphere ? sphere.radius * enlarge : null;
|
||||
return {
|
||||
end: dist(end, drawn),
|
||||
onModel: sphere ? dist(end, sphere.center) <= radius + 50 : false,
|
||||
modelRadius: radius,
|
||||
infoGap,
|
||||
vertices: line.length,
|
||||
};
|
||||
}, trackedIcao);
|
||||
// The same reading between frames, the way a page script polls it: the
|
||||
// head holds the last rendered frame while getTrackedInfo dead-reckons
|
||||
// the current instant. Logged only, to tell the two apart.
|
||||
const betweenFrames = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await sleep(600);
|
||||
betweenFrames.push(await evalPage((icao) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
|
||||
const line = head?.polyline?.positions?.getValue(viewer.clock.currentTime);
|
||||
const drawn = viewer.trackedEntity?.position?.getValue(viewer.clock.currentTime);
|
||||
if (!line?.length || !drawn) return null;
|
||||
const end = line[line.length - 1];
|
||||
return Math.hypot(end.x - drawn.x, end.y - drawn.y, end.z - drawn.z);
|
||||
}, trackedIcao));
|
||||
}
|
||||
console.log(` (between frames: head end vs entity position, m: ${
|
||||
betweenFrames.map((v) => (v == null ? '-' : v.toFixed(1))).join(' ')})`);
|
||||
// Freeze the plane where it got to, so later checks see a still contact.
|
||||
await evalPage((icao) => {
|
||||
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
|
||||
f.lon += (f.vel * (Date.now() - f.moveFromMs)) / 1000
|
||||
/ (111320 * Math.cos((f.lat * Math.PI) / 180));
|
||||
delete f.moveFromMs;
|
||||
}, trackedIcao);
|
||||
const headUsable = headSamples.values.filter((v) => v != null);
|
||||
if (headUsable.length < 10) {
|
||||
skip('trail head: ends at the drawn icon on every frame',
|
||||
`only ${headUsable.length} frames with a drawn head (timedOut=${headSamples.timedOut})`);
|
||||
} else {
|
||||
const worst = Math.max(...headUsable.map((v) => v.end));
|
||||
const off = headUsable.filter((v) => !(v.end <= 50 || v.onModel));
|
||||
const onModel = headUsable.filter((v) => v.end > 50 && v.onModel).length;
|
||||
const radii = headUsable.map((v) => v.modelRadius).filter((v) => v != null);
|
||||
const gaps = headUsable.map((v) => v.infoGap).filter((v) => v != null);
|
||||
record('trail head: last vertex on the drawn icon (within 50 m, or on the drawn 3D model) on every frame',
|
||||
off.length === 0,
|
||||
`${off.length} of ${headUsable.length} frames off; max end-to-centre=${worst.toFixed(1)} m, ${onModel} frames ending on the model's tail (model radius ${
|
||||
radii.length ? Math.max(...radii).toFixed(0) : 'n/a'} m); getTrackedInfo vs drawn icon max=${
|
||||
gaps.length ? Math.max(...gaps).toFixed(1) : 'n/a'} m (logged only)`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INVARIANT 2: NO PULL-OUT on a track switch
|
||||
// tracking B while tracking A must not fly to ~80 km overview.
|
||||
@@ -1741,7 +1854,8 @@ async function main() {
|
||||
|
||||
// ============================================================
|
||||
// REGRESSION M3 (rewritten 2026-07-02 — deliberate behavior change):
|
||||
// when the TRACKED plane ages out (3 missed polls), tracking must clear
|
||||
// when the TRACKED plane ages out (10 min after its newest fix, past the
|
||||
// 3 missed polls that remove an untracked plane), tracking must clear
|
||||
// and the camera must be RELEASED IN PLACE — viewer.trackedEntity
|
||||
// undefined and NO jump (the old contract flew an ~80 km overview; the
|
||||
// owner field-ruled that wrong: "it randomly zooms way up and loses my
|
||||
@@ -1762,27 +1876,48 @@ async function main() {
|
||||
const beforeTracking = !!fl.getTrackedInfo();
|
||||
// Simulate the age-out via the shim: the plane stops arriving.
|
||||
window.__SYNTH.flights = window.__SYNTH.flights.filter((f) => f.icao !== icao);
|
||||
// MISSING_POLL_LIMIT = 3 consecutive absent polls → removal + clear.
|
||||
// Capture the camera pose right BEFORE the removing poll — the follow
|
||||
// camera drifts with the coasting plane until the instant of release,
|
||||
// so this is the "where the user was looking" reference.
|
||||
// The tracked plane outlives MISSING_POLL_LIMIT (3 absent polls): it is
|
||||
// kept until 10 min after its newest fix, so the camera keeps its target.
|
||||
await fl.update(v);
|
||||
await fl.update(v);
|
||||
const c0 = v.camera.positionCartographic;
|
||||
const atAgeOut = {
|
||||
lonDeg: (c0.longitude * 180) / Math.PI,
|
||||
latDeg: (c0.latitude * 180) / Math.PI,
|
||||
height: c0.height,
|
||||
};
|
||||
await fl.update(v); // third miss → removal + clear + in-place release
|
||||
return {
|
||||
beforeTracking,
|
||||
atAgeOut,
|
||||
stillTrackedAfter: !!fl.getTrackedInfo(),
|
||||
viewerStillTracking: !!v.trackedEntity,
|
||||
};
|
||||
await fl.update(v);
|
||||
const trackedThroughMisses = !!fl.getTrackedInfo();
|
||||
// Jump the page clock past the 10 min retention instead of waiting.
|
||||
const realNow = Date.now;
|
||||
Date.now = () => realNow() + 11 * 60 * 1000;
|
||||
try {
|
||||
// Let the follow camera settle on the stopped plane, then capture the
|
||||
// pose right BEFORE the removing poll: the "where the user was
|
||||
// looking" reference.
|
||||
await new Promise((res) => {
|
||||
const stop = v.scene.postRender.addEventListener(() => {
|
||||
stop();
|
||||
res();
|
||||
});
|
||||
v.scene.requestRender();
|
||||
});
|
||||
const c0 = v.camera.positionCartographic;
|
||||
const atAgeOut = {
|
||||
lonDeg: (c0.longitude * 180) / Math.PI,
|
||||
latDeg: (c0.latitude * 180) / Math.PI,
|
||||
height: c0.height,
|
||||
};
|
||||
await fl.update(v); // past retention → removal + clear + in-place release
|
||||
return {
|
||||
beforeTracking,
|
||||
trackedThroughMisses,
|
||||
atAgeOut,
|
||||
stillTrackedAfter: !!fl.getTrackedInfo(),
|
||||
viewerStillTracking: !!v.trackedEntity,
|
||||
};
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
}, m3Icao);
|
||||
|
||||
record('M3: the tracked plane stays tracked through 3 missed polls (kept 10 min after its newest fix)',
|
||||
m3.trackedThroughMisses,
|
||||
`trackedThroughMisses=${m3.trackedThroughMisses}`);
|
||||
record('M3: tracking cleared after the tracked plane ages out (getTrackedInfo null)',
|
||||
m3.beforeTracking && !m3.stillTrackedAfter,
|
||||
`before=${m3.beforeTracking} trackedAfter=${m3.stillTrackedAfter}`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -496,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,
|
||||
|
||||
@@ -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}
|
||||
@@ -148,17 +150,17 @@ export async function fetchFirms(
|
||||
* Keyless (no FIRMS_MAP_KEY): /api/firms → 503 {error:'no_key'}; status →
|
||||
* {hasKey:false}. Upstream is never touched without a key.
|
||||
*
|
||||
* /api/firms answers the trailing 24 hours to now. A source may instead
|
||||
* answer the 24 hours to a set time, naming its end as `at` (epoch ms); the
|
||||
* layer's row then shows that time rather than LIVE. This proxy never sets
|
||||
* `at`.
|
||||
*
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
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 }));
|
||||
|
||||
+18
-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';
|
||||
@@ -245,5 +250,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
+31
-4
@@ -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:
|
||||
@@ -673,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.
|
||||
@@ -190,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,
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+181
-8
@@ -198,6 +198,157 @@ export function staleCoastLimitSeconds({
|
||||
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
||||
}
|
||||
|
||||
/** @constant {number} Dead reckoning never moves a contact faster than this
|
||||
* (1,500 kt, the trail's continuity limit), so a corrupt reported speed
|
||||
* cannot fly a coasting plane hundreds of kilometres. */
|
||||
export const COAST_MAX_SPEED_MPS = (1_500 * 1852) / 3600;
|
||||
|
||||
/** @constant {number} A contact with no fresh report coasts at most this long past it, whatever its source limit; there is no distance cap. */
|
||||
export const STALE_COAST_MAX_SEC = 300;
|
||||
|
||||
/** @constant {number} A report this close to a known ground height holds
|
||||
* instead of coasting. */
|
||||
export const STALE_HOLD_MAX_HEIGHT_M = 150;
|
||||
|
||||
/**
|
||||
* Whether a contact whose reports have stopped holds at its last reported
|
||||
* position instead of coasting: it reported on the ground, or a known ground
|
||||
* floor puts it within STALE_HOLD_MAX_HEIGHT_M of the ground (landing or
|
||||
* taking off). A plane that low has no evidence of being anywhere but where
|
||||
* it was last heard. Where the ground is unknown, barometric altitude is not
|
||||
* read as height above it; the contact coasts under the stale cap.
|
||||
* @param {object} report
|
||||
* @param {boolean} [report.onGround]
|
||||
* @param {number} [report.heightM] - Render height (ellipsoidal metres).
|
||||
* @param {number} [report.groundM] - Ellipsoidal ground under the report, if known.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function staleHoldsAtLastReport({ onGround, heightM, groundM } = {}) {
|
||||
if (onGround === true) return true;
|
||||
return (
|
||||
Number.isFinite(heightM) &&
|
||||
Number.isFinite(groundM) &&
|
||||
heightM - groundM < STALE_HOLD_MAX_HEIGHT_M
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hold-or-coast decision for a report, made once and kept on the report,
|
||||
* so a ground floor that resolves while the contact coasts cannot flip it and
|
||||
* snap the icon back. Height and position come from the report itself.
|
||||
* @param {{position: Cesium.Cartesian3, staleHolds?: boolean}} fix - History
|
||||
* fix; `staleHolds` is stored on it.
|
||||
* @param {object} args
|
||||
* @param {boolean} [args.onGround] - The contact's ground flag.
|
||||
* @param {(latDeg: number, lonDeg: number) => number|null} [args.cachedGroundFloor]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function staleHoldsForFix(fix, { onGround, cachedGroundFloor } = {}) {
|
||||
if (typeof fix.staleHolds === 'boolean') return fix.staleHolds;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
fix.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_holdCarto,
|
||||
);
|
||||
const groundM =
|
||||
carto && typeof cachedGroundFloor === 'function'
|
||||
? cachedGroundFloor(
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
)
|
||||
: null;
|
||||
fix.staleHolds = staleHoldsAtLastReport({
|
||||
onGround,
|
||||
heightM: carto?.height,
|
||||
groundM,
|
||||
});
|
||||
return fix.staleHolds;
|
||||
}
|
||||
|
||||
const _holdCarto = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Seconds a contact coasts past `newest` at a display time `elapsedSec`
|
||||
* after it, counting any coast a synthetic fix already took since the real
|
||||
* report it was built from (`reportEpochMs`), so a chain of synthetic fixes
|
||||
* never coasts further than one report would.
|
||||
* @param {object} args
|
||||
* @param {{epochMs: number, reportEpochMs?: number, velocity?: number}} args.newest
|
||||
* @param {number} args.elapsedSec - Display time past `newest`.
|
||||
* @param {number} args.coastLimitSec - Source limit past `newest`.
|
||||
* @param {boolean} args.holds
|
||||
* @returns {number}
|
||||
*/
|
||||
export function staleCoastSecondsFrom({
|
||||
newest,
|
||||
elapsedSec,
|
||||
coastLimitSec,
|
||||
holds,
|
||||
}) {
|
||||
const alreadySec = Number.isFinite(newest?.reportEpochMs)
|
||||
? Math.max(0, (newest.epochMs - newest.reportEpochMs) / 1000)
|
||||
: 0;
|
||||
return Math.max(
|
||||
0,
|
||||
staleCoastSeconds({
|
||||
elapsedSec: elapsedSec + alreadySec,
|
||||
coastLimitSec: coastLimitSec + alreadySec,
|
||||
holds,
|
||||
}) - alreadySec,
|
||||
);
|
||||
}
|
||||
|
||||
/** @constant {number} A coasted position sits at least this far (m) above the ground. */
|
||||
export const COAST_FLOOR_LIFT_M = 1.5;
|
||||
|
||||
/**
|
||||
* Lift a coasted position to COAST_FLOOR_LIFT_M above the ground where the
|
||||
* ground is known; a coast runs level from the report and terrain may rise
|
||||
* under it.
|
||||
* @param {Cesium.Cartesian3} position - Mutated in place.
|
||||
* @param {(latDeg: number, lonDeg: number) => number|null} [cachedGroundFloor]
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
export function floorCoastedPosition(position, cachedGroundFloor) {
|
||||
if (typeof cachedGroundFloor !== 'function') return position;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_coastCarto,
|
||||
);
|
||||
if (!carto) return position;
|
||||
const ground = cachedGroundFloor(
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
);
|
||||
if (!Number.isFinite(ground) || carto.height >= ground + COAST_FLOOR_LIFT_M)
|
||||
return position;
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
ground + COAST_FLOOR_LIFT_M,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
position,
|
||||
);
|
||||
}
|
||||
|
||||
const _coastCarto = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* Seconds to coast past the last report: the elapsed time, bounded by the
|
||||
* source's coast limit and STALE_COAST_MAX_SEC; none for a contact that
|
||||
* holds.
|
||||
* @param {object} args
|
||||
* @param {number} args.elapsedSec - Display time past the last report.
|
||||
* @param {number} args.coastLimitSec - From `staleCoastLimitSeconds`.
|
||||
* @param {boolean} args.holds - From `staleHoldsAtLastReport`.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function staleCoastSeconds({ elapsedSec, coastLimitSec, holds }) {
|
||||
if (holds || !(elapsedSec > 0)) return 0;
|
||||
return Math.max(0, Math.min(elapsedSec, coastLimitSec, STALE_COAST_MAX_SEC));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest-arc rate limiter: step `prevDeg` toward `targetDeg` by at most
|
||||
* `maxDegPerSec * dtSec`. Seeds directly on the target when prev is null.
|
||||
@@ -446,12 +597,21 @@ const _forwardFixOffset = new Cesium.Cartesian3();
|
||||
* kinematics. This prevents a late course change from reprojecting the entire
|
||||
* stale interval and snapping the rendered contact by kilometres.
|
||||
*
|
||||
* The same stale rules as the display apply: no fix for a contact that holds
|
||||
* at its report (`staleHoldsForFix`, decided before calling), a coast of at
|
||||
* most STALE_COAST_MAX_SEC counted from the real report
|
||||
* (`reportEpochMs` carries it along a chain), and never below known ground.
|
||||
* The result is a motion fix only, never a report for the trail.
|
||||
*
|
||||
* @param {{time: Cesium.JulianDate, epochMs?: number, position: Cesium.Cartesian3,
|
||||
* velocity?: number, track?: number}} newest Existing immutable history fix.
|
||||
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number}} next
|
||||
* velocity?: number, track?: number, staleHolds?: boolean, reportEpochMs?: number}} newest
|
||||
* Existing immutable history fix.
|
||||
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number,
|
||||
* cachedGroundFloor?: Function}} next
|
||||
* New kinematics and the wall/source epoch at which they become authoritative.
|
||||
* @returns {{time: Cesium.JulianDate, epochMs: number, position: Cesium.Cartesian3,
|
||||
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
||||
* velocity: number, track: number, staleHolds?: boolean, reportEpochMs: number}|null}
|
||||
* Forward synthetic fix, or null.
|
||||
*/
|
||||
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
|
||||
@@ -461,12 +621,18 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
|
||||
return null;
|
||||
if (newest.staleHolds === true) return null;
|
||||
|
||||
const previousVelocity = Number.isFinite(newest.velocity)
|
||||
? Math.max(0, newest.velocity)
|
||||
: 0;
|
||||
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
||||
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
||||
const dtSec = staleCoastSecondsFrom({
|
||||
newest: { epochMs: startEpochMs, reportEpochMs: newest.reportEpochMs },
|
||||
elapsedSec: (next.epochMs - startEpochMs) / 1000,
|
||||
coastLimitSec: Infinity,
|
||||
holds: false,
|
||||
});
|
||||
arcOffsetEnu(
|
||||
previousVelocity,
|
||||
previousTrack,
|
||||
@@ -485,14 +651,21 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_forwardFixEnu,
|
||||
);
|
||||
const position = Cesium.Matrix4.multiplyByPoint(
|
||||
enu,
|
||||
_forwardFixOffset,
|
||||
new Cesium.Cartesian3(),
|
||||
const position = floorCoastedPosition(
|
||||
Cesium.Matrix4.multiplyByPoint(
|
||||
enu,
|
||||
_forwardFixOffset,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
next.cachedGroundFloor,
|
||||
);
|
||||
return {
|
||||
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
||||
epochMs: next.epochMs,
|
||||
reportEpochMs: Number.isFinite(newest.reportEpochMs)
|
||||
? newest.reportEpochMs
|
||||
: startEpochMs,
|
||||
staleHolds: newest.staleHolds,
|
||||
position,
|
||||
velocity: Number.isFinite(next.velocity)
|
||||
? Math.max(0, next.velocity)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @module planespottersPhoto
|
||||
* @description Reads a Planespotters photo answer into the one credited
|
||||
* thumbnail every surface shows. Planespotters lets the thumbnail be hotlinked
|
||||
* on the condition that the photographer is credited next to it and the image
|
||||
* links back to the photo's page, so a photo without a photographer, or whose
|
||||
* image or page is not on Planespotters' own hosts, is "no photo": nothing
|
||||
* arbitrary is ever shown or linked.
|
||||
*
|
||||
* Accepts the upstream answer (`GET https://api.planespotters.net/pub/photos/hex/{hex}`,
|
||||
* `{photos: [...]}`), the cached server shape
|
||||
* (`{found, thumbnail_large?, thumbnail: {src, width, height}, photographer,
|
||||
* link}`), and an
|
||||
* already-read photo (`{found, src, width, height, photographer, link}`).
|
||||
* Pure: no fetch, no DOM.
|
||||
*/
|
||||
|
||||
/** The image host: Planespotters' thumbnail CDN and its subdomains. */
|
||||
const IMAGE_HOST = 'plnspttrs.net';
|
||||
/** The only page host a photo may link to. */
|
||||
const PAGE_HOST = 'www.planespotters.net';
|
||||
|
||||
const NO_PHOTO = Object.freeze({ found: false });
|
||||
|
||||
const isObject = (value) => Boolean(value) && typeof value === 'object';
|
||||
|
||||
/** The parsed `https:` URL, or null for anything else or with userinfo/port. */
|
||||
function httpsUrl(value) {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port)
|
||||
return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
function imageSrc(value) {
|
||||
const url = httpsUrl(value);
|
||||
return url &&
|
||||
(url.hostname === IMAGE_HOST || url.hostname.endsWith(`.${IMAGE_HOST}`))
|
||||
? url.href
|
||||
: null;
|
||||
}
|
||||
|
||||
function pageLink(value) {
|
||||
const url = httpsUrl(value);
|
||||
return url && url.hostname === PAGE_HOST ? url.href : null;
|
||||
}
|
||||
|
||||
function dimension(value) {
|
||||
const number = Number(value);
|
||||
return value != null && Number.isFinite(number) && number > 0
|
||||
? Math.round(number)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** One candidate `{src, width, height, photographer, link}`, checked. */
|
||||
function checked({ src, width, height, photographer, link }) {
|
||||
const credit = typeof photographer === 'string' ? photographer.trim() : '';
|
||||
const image = imageSrc(src);
|
||||
const page = pageLink(link);
|
||||
if (!credit || !image || !page) return null;
|
||||
return {
|
||||
found: true,
|
||||
src: image,
|
||||
width: dimension(width),
|
||||
height: dimension(height),
|
||||
photographer: credit,
|
||||
link: page,
|
||||
};
|
||||
}
|
||||
|
||||
/** The upstream photo's candidates, large thumbnail first. */
|
||||
function upstreamCandidates(photo) {
|
||||
if (!photo || typeof photo !== 'object') return [];
|
||||
return [photo.thumbnail_large, photo.thumbnail]
|
||||
.filter((thumb) => thumb && typeof thumb === 'object')
|
||||
.map((thumb) => ({
|
||||
src: thumb.src,
|
||||
width: thumb.size?.width,
|
||||
height: thumb.size?.height,
|
||||
photographer: photo.photographer,
|
||||
link: photo.link,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a Planespotters answer into the first photo that passes the checks.
|
||||
* @param {*} payload Upstream, cached-server, or already-read photo.
|
||||
* @returns {{found: true, src: string, width: ?number, height: ?number,
|
||||
* photographer: string, link: string} | {found: false}}
|
||||
*/
|
||||
export function parsePlanespottersPhoto(payload) {
|
||||
if (!payload || typeof payload !== 'object') return NO_PHOTO;
|
||||
let candidates;
|
||||
if (Array.isArray(payload.photos)) {
|
||||
candidates = payload.photos.flatMap(upstreamCandidates);
|
||||
} else if (payload.found !== true) {
|
||||
return NO_PHOTO;
|
||||
} else if (isObject(payload.thumbnail) || isObject(payload.thumbnail_large)) {
|
||||
// Large first, as for the upstream answer; each must pass the checks.
|
||||
candidates = [payload.thumbnail_large, payload.thumbnail]
|
||||
.filter(isObject)
|
||||
.map((thumb) => ({
|
||||
...thumb,
|
||||
photographer: payload.photographer,
|
||||
link: payload.link,
|
||||
}));
|
||||
} else {
|
||||
candidates = [payload];
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const photo = checked(candidate);
|
||||
if (photo) return photo;
|
||||
}
|
||||
return NO_PHOTO;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// src/data/planespottersPhoto.test.mjs — the Planespotters photo answer is read
|
||||
// into one credited thumbnail, and nothing outside Planespotters' own hosts is
|
||||
// ever shown or linked.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parsePlanespottersPhoto } from './planespottersPhoto.js';
|
||||
|
||||
const UPSTREAM = Object.freeze({
|
||||
photos: [
|
||||
{
|
||||
id: '1745410',
|
||||
thumbnail: {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
size: { width: 200, height: 133 },
|
||||
},
|
||||
thumbnail_large: {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
|
||||
size: { width: 420, height: 280 },
|
||||
},
|
||||
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
|
||||
photographer: 'Jan Seler',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const PHOTO = Object.freeze({
|
||||
found: true,
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
|
||||
width: 420,
|
||||
height: 280,
|
||||
photographer: 'Jan Seler',
|
||||
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
|
||||
});
|
||||
|
||||
const photo = (overrides) => ({
|
||||
photos: [{ ...UPSTREAM.photos[0], ...overrides }],
|
||||
});
|
||||
|
||||
test('the upstream answer maps to the large thumbnail, its size, credit and link', () => {
|
||||
assert.deepEqual(parsePlanespottersPhoto(UPSTREAM), PHOTO);
|
||||
});
|
||||
|
||||
test('the small thumbnail stands in when the large one is missing', () => {
|
||||
assert.deepEqual(parsePlanespottersPhoto(photo({ thumbnail_large: null })), {
|
||||
...PHOTO,
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
width: 200,
|
||||
height: 133,
|
||||
});
|
||||
});
|
||||
|
||||
test('a server’s cached shape reads the same way', () => {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
found: true,
|
||||
thumbnail: { src: PHOTO.src, width: 420, height: 280 },
|
||||
photographer: 'Jan Seler',
|
||||
link: PHOTO.link,
|
||||
}),
|
||||
PHOTO,
|
||||
);
|
||||
assert.deepEqual(parsePlanespottersPhoto({ found: false }), {
|
||||
found: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a server’s cached shape prefers its large thumbnail', () => {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
found: true,
|
||||
thumbnail: {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
width: 200,
|
||||
height: 133,
|
||||
},
|
||||
thumbnail_large: { src: PHOTO.src, width: 420, height: 280 },
|
||||
photographer: 'Jan Seler',
|
||||
link: PHOTO.link,
|
||||
}),
|
||||
PHOTO,
|
||||
);
|
||||
});
|
||||
|
||||
test('a server’s cached shape falls back to its small thumbnail when the large one fails the checks', () => {
|
||||
const small = {
|
||||
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
|
||||
width: 200,
|
||||
height: 133,
|
||||
};
|
||||
for (const thumbnail_large of [
|
||||
{ src: 'https://evil.example/a.jpg', width: 420, height: 280 },
|
||||
null,
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
found: true,
|
||||
thumbnail: small,
|
||||
thumbnail_large,
|
||||
photographer: 'Jan Seler',
|
||||
link: PHOTO.link,
|
||||
}),
|
||||
{ ...PHOTO, ...small },
|
||||
JSON.stringify(thumbnail_large),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no photo, junk, and missing fields are "no photo"', () => {
|
||||
for (const payload of [
|
||||
{ photos: [] },
|
||||
{},
|
||||
null,
|
||||
undefined,
|
||||
'nope',
|
||||
{ photos: 'nope' },
|
||||
{ photos: [null] },
|
||||
photo({ photographer: '' }),
|
||||
photo({ photographer: ' ' }),
|
||||
photo({ photographer: 42 }),
|
||||
photo({ link: '' }),
|
||||
photo({ thumbnail: null, thumbnail_large: null }),
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(payload),
|
||||
{ found: false },
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a src outside https *.plnspttrs.net is dropped', () => {
|
||||
for (const src of [
|
||||
'http://t.plnspttrs.net/a.jpg',
|
||||
'https://plnspttrs.net.evil.example/a.jpg',
|
||||
'https://evil.example/t.plnspttrs.net/a.jpg',
|
||||
'https://t.plnspttrs.net.evil.example/a.jpg',
|
||||
'https://[email protected]/a.jpg',
|
||||
'https://t.plnspttrs.net:8443/a.jpg',
|
||||
'javascript:alert(1)',
|
||||
'//t.plnspttrs.net/a.jpg',
|
||||
'not a url',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(
|
||||
photo({
|
||||
thumbnail_large: { src, size: { width: 420, height: 280 } },
|
||||
thumbnail: { src, size: { width: 200, height: 133 } },
|
||||
}),
|
||||
),
|
||||
{ found: false },
|
||||
src,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a link anywhere but https://www.planespotters.net is dropped', () => {
|
||||
for (const link of [
|
||||
'http://www.planespotters.net/photo/1',
|
||||
'https://planespotters.net/photo/1',
|
||||
'https://www.planespotters.net.evil.example/photo/1',
|
||||
'https://evil.example/?u=https://www.planespotters.net/',
|
||||
'https://user:[email protected]/photo/1',
|
||||
'https://www.planespotters.net:444/photo/1',
|
||||
'javascript:alert(1)',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(photo({ link })),
|
||||
{ found: false },
|
||||
link,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the first photo that passes the checks is the one shown', () => {
|
||||
const second = {
|
||||
...UPSTREAM.photos[0],
|
||||
photographer: 'Second Spotter',
|
||||
};
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto({
|
||||
photos: [
|
||||
{ ...UPSTREAM.photos[0], link: 'https://evil.example/' },
|
||||
second,
|
||||
],
|
||||
}),
|
||||
{ ...PHOTO, photographer: 'Second Spotter' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a missing size is null and the credit is trimmed', () => {
|
||||
assert.deepEqual(
|
||||
parsePlanespottersPhoto(
|
||||
photo({
|
||||
photographer: ' Jan Seler ',
|
||||
thumbnail_large: { src: PHOTO.src },
|
||||
}),
|
||||
),
|
||||
{ ...PHOTO, width: null, height: null },
|
||||
);
|
||||
});
|
||||
@@ -46,7 +46,13 @@ function alongTrackKm(position, origin, destination) {
|
||||
}
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
/** Least allowed distance off the great circle, and how far past either
|
||||
* endpoint the plane may be along it. */
|
||||
const CROSS_TRACK_KM = 200;
|
||||
/** On a long route the allowed distance off the great circle grows to this
|
||||
* share of its length: airways and winds take a coast-to-coast flight several
|
||||
* hundred kilometres off it (JFK–SFO: 623 km). */
|
||||
const CROSS_TRACK_ROUTE_FRACTION = 0.15;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOCAL_AIRPORT_KM = 150;
|
||||
@@ -90,6 +96,9 @@ export function routePlausible({
|
||||
// existing loose check when the segment cannot be judged reliably.
|
||||
const segmentKnown =
|
||||
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
|
||||
const corridorKm = segmentKnown
|
||||
? Math.max(CROSS_TRACK_KM, CROSS_TRACK_ROUTE_FRACTION * routeKm)
|
||||
: CROSS_TRACK_KM;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
@@ -100,7 +109,7 @@ export function routePlausible({
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM &&
|
||||
) < corridorKm &&
|
||||
(!segmentKnown ||
|
||||
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||
|
||||
@@ -27,6 +27,24 @@ test('plane in London with an SFO→LAX route: implausible', () => {
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('a long route allows a wider corridor; a short one does not', () => {
|
||||
// JFK→SFO over Kansas, 384 km south of the great circle.
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 38.21, lonDeg: -99.69, altitudeM: 10370, verticalRateMps: 0,
|
||||
origin: JFK, destination: SFO,
|
||||
}), true);
|
||||
// 516 km off the 543 km SFO→LAX route is still the wrong route.
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 37.9, lonDeg: -115.0, altitudeM: 10000, verticalRateMps: 0,
|
||||
origin: SFO, destination: LAX,
|
||||
}), false);
|
||||
// 960 km off, past 15 % of the 4,152 km JFK→SFO route, is still wrong.
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 33.0, lonDeg: -99.69, altitudeM: 10000, verticalRateMps: 0,
|
||||
origin: JFK, destination: SFO,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('vertical trend: low climbing plane near SFO — origin must be local', () => {
|
||||
const nearSfo = { latDeg: 37.7, lonDeg: -122.4, altitudeM: 2500 };
|
||||
// JFK→SFO passes geometry (near destination), but a CLIMBING plane here just
|
||||
|
||||
@@ -140,7 +140,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
services.aircraftPresentation = { isTr3b: () => false };
|
||||
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||
const parts = {
|
||||
queries: { _contactLabel: (_, info) => info.callsign },
|
||||
motion: { _signalLost: () => null },
|
||||
};
|
||||
const tracking = createTracking({ flightState, services, parts });
|
||||
for (const [position, expected] of [
|
||||
[beyond, false],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Upstream-request accounting shared by the keyed tile proxies.
|
||||
*
|
||||
* A soft cap per UTC period, persisted as `{date, count}` so it survives a
|
||||
* restart. Nothing here is provider-specific: the caller picks the period by
|
||||
* the key it passes, and decides what happens over the cap.
|
||||
*
|
||||
* Zero dependencies and Cesium-free, so providers and browser helpers can
|
||||
* import it and node:test can exercise it directly.
|
||||
*
|
||||
* @module data/tileBudget
|
||||
*/
|
||||
|
||||
/**
|
||||
* UTC calendar-day key for budget bucketing.
|
||||
*
|
||||
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
|
||||
* @returns {string} 'YYYY-MM-DD' in UTC.
|
||||
*/
|
||||
export function utcDayKey(epochMs = Date.now()) {
|
||||
return new Date(epochMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a persisted budget state against the current period key.
|
||||
* Rolls the counter to zero when the period changes; replaces missing/corrupt
|
||||
* state. Returns the SAME object when it is already valid for `periodKey`
|
||||
* (cheap to call on every request).
|
||||
*
|
||||
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
|
||||
* @param {string} periodKey - Current period key, e.g. from `utcDayKey`.
|
||||
* @returns {{date:string, count:number}} Valid state for `periodKey`.
|
||||
*/
|
||||
export function normalizeBudget(state, periodKey) {
|
||||
const valid =
|
||||
Boolean(state) &&
|
||||
state.date === periodKey &&
|
||||
Number.isFinite(state.count) &&
|
||||
state.count >= 0;
|
||||
return valid ? state : { date: periodKey, count: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the soft cap for the current period has been reached.
|
||||
*
|
||||
* @param {{count:number}} state - Normalized budget state.
|
||||
* @param {number} limit - Budget per period; non-positive/invalid never blocks.
|
||||
* @returns {boolean} True when `count >= limit`.
|
||||
*/
|
||||
export function isOverBudget(state, limit) {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return false;
|
||||
return state.count >= limit;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isOverBudget, normalizeBudget, utcDayKey } from './tileBudget.js';
|
||||
import * as tomtom from './tomtomTiles.js';
|
||||
|
||||
test('tile budget helpers live in one module and TomTom re-exports them', () => {
|
||||
assert.equal(tomtom.utcDayKey, utcDayKey);
|
||||
assert.equal(tomtom.normalizeBudget, normalizeBudget);
|
||||
assert.equal(tomtom.isOverBudget, isOverBudget);
|
||||
});
|
||||
+6
-40
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
|
||||
* @file Pure tile math for the TomTom traffic-flow proxy; budget helpers are
|
||||
* re-exported from `./tileBudget.js`.
|
||||
*
|
||||
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation,
|
||||
* daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles
|
||||
* cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
|
||||
* cover the current traffic fetch bounds). No external dependencies, Cesium-free, so
|
||||
* both sides can unit-test against it with node:test.
|
||||
*
|
||||
* Slippy scheme: standard Web Mercator XYZ, y grows southward
|
||||
@@ -131,42 +132,7 @@ export function tilesForBounds(bounds, zoom = 12, { maxTiles = 64 } = {}) {
|
||||
// ─── Daily budget accounting ───────────────────────────────
|
||||
|
||||
/**
|
||||
* UTC calendar-day key for budget bucketing.
|
||||
*
|
||||
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
|
||||
* @returns {string} 'YYYY-MM-DD' in UTC.
|
||||
* Re-exported from `./tileBudget.js`, which keyed tile proxies share. The
|
||||
* traffic provider and its tests keep one import for tile math and budget.
|
||||
*/
|
||||
export function utcDayKey(epochMs = Date.now()) {
|
||||
return new Date(epochMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a persisted budget state against today's UTC day key.
|
||||
* Rolls the counter to zero on day change; replaces missing/corrupt state.
|
||||
* Returns the SAME object when it is already valid for `dayKey` (cheap to
|
||||
* call on every request).
|
||||
*
|
||||
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
|
||||
* @param {string} dayKey - Today's UTC day key (from `utcDayKey`).
|
||||
* @returns {{date:string, count:number}} Valid state for `dayKey`.
|
||||
*/
|
||||
export function normalizeBudget(state, dayKey) {
|
||||
const valid =
|
||||
Boolean(state) &&
|
||||
state.date === dayKey &&
|
||||
Number.isFinite(state.count) &&
|
||||
state.count >= 0;
|
||||
return valid ? state : { date: dayKey, count: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the daily soft cap has been reached.
|
||||
*
|
||||
* @param {{count:number}} state - Normalized budget state.
|
||||
* @param {number} limit - Daily tile budget; non-positive/invalid never blocks.
|
||||
* @returns {boolean} True when `count >= limit`.
|
||||
*/
|
||||
export function isOverBudget(state, limit) {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return false;
|
||||
return state.count >= limit;
|
||||
}
|
||||
export { utcDayKey, normalizeBudget, isOverBudget } from './tileBudget.js';
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* @module trailContinuity
|
||||
* @description Where a tracked aircraft's trail lifts the pen.
|
||||
*
|
||||
* A trail is a chronological list of fixes `{position, timeMs, nullAltitude,
|
||||
* breakBefore, drawn}`: reports, and (`drawn`) points where the tracked icon
|
||||
* was drawn between them. Between two consecutive fixes that are not one path, a new
|
||||
* segment starts and nothing is drawn between them: no dashes, no arcs.
|
||||
* Nothing is dropped except the oldest fixes past the layer's point limit.
|
||||
*
|
||||
* Two points are not one path when time does not advance, when the gap is
|
||||
* longer than 48 minutes (a coverage hole or a separate flight), when the
|
||||
* implied great-circle speed is over 1,500 kt, or when either point has no
|
||||
* reported altitude. A parked aircraft is the exception: a pair under three
|
||||
* hours apart within 0.1 nm stays one path.
|
||||
*
|
||||
* Civil and military flight tracking share these rules.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** @constant {number} A gap longer than this is a coverage hole or a separate flight. */
|
||||
export const TRAIL_CONTINUITY_MAX_GAP_MS = 48 * 60_000;
|
||||
|
||||
/** @constant {number} A pair implying more than this great-circle speed is not one path. */
|
||||
export const TRAIL_CONTINUITY_MAX_SPEED_KT = 1_500;
|
||||
|
||||
/** @constant {number} A parked aircraft may stop reporting this long and stay one path. */
|
||||
export const TRAIL_STATIONARY_HOLD_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||
|
||||
/** @constant {number} How close (nautical miles) two points must be to count as parked. */
|
||||
export const TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM = 0.1;
|
||||
|
||||
/** @constant {number} The head never joins a body point farther than this from the aircraft. */
|
||||
export const TRAIL_HEAD_JOIN_MAX_M = 150_000;
|
||||
|
||||
const METRES_PER_NM = 1852;
|
||||
|
||||
/** @constant {number} Mean Earth radius (m) for great-circle trail distances. */
|
||||
const EARTH_MEAN_RADIUS_M = 6_371_008.8;
|
||||
|
||||
/**
|
||||
* Whether two consecutive trail points belong to different paths. Time that
|
||||
* does not advance, a gap over TRAIL_CONTINUITY_MAX_GAP_MS or an implied
|
||||
* speed over TRAIL_CONTINUITY_MAX_SPEED_KT breaks the line, except for a
|
||||
* parked aircraft: a pair under three hours apart within 0.1 nm.
|
||||
* @param {number} deltaMs - Later point's time minus the earlier one's.
|
||||
* @param {number} distanceM - Great-circle distance between the points.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailPairBreaks(deltaMs, distanceM) {
|
||||
if (!(deltaMs > 0) || !Number.isFinite(distanceM)) return true;
|
||||
const distanceNm = distanceM / METRES_PER_NM;
|
||||
if (
|
||||
deltaMs <= TRAIL_STATIONARY_HOLD_MAX_GAP_MS &&
|
||||
distanceNm <= TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
deltaMs > TRAIL_CONTINUITY_MAX_GAP_MS ||
|
||||
distanceNm / (deltaMs / 3_600_000) > TRAIL_CONTINUITY_MAX_SPEED_KT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the head segment may join the last body point to the aircraft:
|
||||
* within TRAIL_HEAD_JOIN_MAX_M and not broken under `trailPairBreaks`.
|
||||
* @param {number} deltaMs - Current time minus the body point's time.
|
||||
* @param {number} distanceM - Distance from the body point to the aircraft.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailHeadJoins(deltaMs, distanceM) {
|
||||
return (
|
||||
distanceM <= TRAIL_HEAD_JOIN_MAX_M && !trailPairBreaks(deltaMs, distanceM)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance between two trail positions, in metres.
|
||||
* @param {Cesium.Cartesian3} a
|
||||
* @param {Cesium.Cartesian3} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trailDistanceM(a, b) {
|
||||
return Cesium.Cartesian3.angleBetween(a, b) * EARTH_MEAN_RADIUS_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pen lifts between two consecutive fixes.
|
||||
* @param {object|undefined} prev - Earlier fix, if any.
|
||||
* @param {object} next - Later fix.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailFixBreaks(prev, next) {
|
||||
if (!prev || prev.nullAltitude || next.nullAltitude) return true;
|
||||
// A recorded point is where the icon was drawn; the speed between it and a
|
||||
// report it was eased onto is not a flight speed, so only the gap counts.
|
||||
if (prev.drawn || next.drawn)
|
||||
return Math.abs(next.timeMs - prev.timeMs) > TRAIL_CONTINUITY_MAX_GAP_MS;
|
||||
return trailPairBreaks(
|
||||
next.timeMs - prev.timeMs,
|
||||
trailDistanceM(prev.position, next.position),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build backfill fixes from chronological waypoints. Every waypoint with a
|
||||
* position is kept. A waypoint without one (no altitude and no known ground)
|
||||
* is skipped, and the line still breaks there.
|
||||
* @param {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean}[]} waypoints
|
||||
* @returns {{fixes: object[], breakAfter: boolean}} The fixes, and whether the
|
||||
* line breaks after the last one because trailing waypoints were skipped.
|
||||
*/
|
||||
export function trailBackfillFixes(waypoints) {
|
||||
const fixes = [];
|
||||
let skipped = false;
|
||||
for (const { position, timeMs, nullAltitude } of waypoints) {
|
||||
if (!position) {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
const fix = { position, timeMs, nullAltitude: !!nullAltitude };
|
||||
fix.breakBefore = skipped || trailFixBreaks(fixes[fixes.length - 1], fix);
|
||||
skipped = false;
|
||||
fixes.push(fix);
|
||||
}
|
||||
return { fixes, breakAfter: skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Put backfill fixes ahead of the live trail. The join follows the same
|
||||
* rule, so a distant history tail never connects to the first live fix.
|
||||
* Past `maxPoints` the oldest recorded points go first, then the oldest
|
||||
* reports.
|
||||
* @param {{fixes: object[], breakAfter: boolean}} backfill
|
||||
* @param {object[]} live - Live fixes; the first one's break is recomputed.
|
||||
* @param {number} maxPoints - Point limit across all segments.
|
||||
* @returns {object[]} The joined trail.
|
||||
*/
|
||||
export function joinTrailBackfill({ fixes, breakAfter }, live, maxPoints) {
|
||||
if (!fixes.length) return live;
|
||||
if (live.length)
|
||||
live[0].breakBefore =
|
||||
breakAfter || trailFixBreaks(fixes[fixes.length - 1], live[0]);
|
||||
return keepTrailLimit(fixes.concat(live), maxPoints);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many fixes the drawn body holds: every fix at or before the display
|
||||
* time, where the aircraft is drawn. Fixes ahead of it (reports newer than
|
||||
* the delayed display) are reached by the head segment instead, so the trail
|
||||
* never runs past the aircraft, and once the display passes the newest
|
||||
* report the body ends there.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} displayMs - Time the aircraft is drawn at.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trailBodyCount(fixes, displayMs) {
|
||||
let count = fixes.length;
|
||||
while (count > 0 && fixes[count - 1].timeMs > displayMs) count -= 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave out the newest recorded points that lie inside the aircraft's 3D
|
||||
* model, so the body never runs through the fuselage; the head then runs
|
||||
* from the newest point outside the model to the plane. Reports are kept.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} count - From `trailBodyCount`.
|
||||
* @param {Cesium.Cartesian3|null} modelCenter - The model's centre, or null
|
||||
* when no model is the visual.
|
||||
* @param {number|null} envelopeM - The model's radius (m).
|
||||
* @returns {number}
|
||||
*/
|
||||
export function trailBodyCountOutsideModel(
|
||||
fixes,
|
||||
count,
|
||||
modelCenter,
|
||||
envelopeM,
|
||||
) {
|
||||
if (!modelCenter || !(envelopeM > 0)) return count;
|
||||
while (
|
||||
count > 0 &&
|
||||
fixes[count - 1].drawn &&
|
||||
Cesium.Cartesian3.distance(fixes[count - 1].position, modelCenter) <
|
||||
envelopeM
|
||||
)
|
||||
count -= 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn trail body, split at breaks: the first `count` fixes.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} count - From `trailBodyCount`.
|
||||
* @returns {Cesium.Cartesian3[][]}
|
||||
*/
|
||||
export function trailBodySegments(fixes, count) {
|
||||
const segments = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const fix = fixes[i];
|
||||
// A live report with no altitude keeps no position; it only breaks the line.
|
||||
if (!fix.position) continue;
|
||||
if (fix.breakBefore || !segments.length) segments.push([]);
|
||||
segments[segments.length - 1].push(fix.position);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw body segments. A trail without breaks support draws only the newest
|
||||
* segment, never a line across a break.
|
||||
* @param {{setSegments?: Function, setPositions: Function}} trail
|
||||
* @param {Cesium.Cartesian3[][]} segments
|
||||
*/
|
||||
export function drawTrailSegments(trail, segments) {
|
||||
if (typeof trail.setSegments === 'function') trail.setSegments(segments);
|
||||
else trail.setPositions(segments[segments.length - 1] ?? []);
|
||||
}
|
||||
|
||||
/** @constant {number} A head shorter than this (m) is not drawn: the aircraft is holding at its last report. */
|
||||
const TRAIL_HEAD_MIN_M = 0.5;
|
||||
|
||||
/**
|
||||
* The body fix the head segment starts from, or null when the head is not
|
||||
* drawn: no body yet, the aircraft holding at that fix, a body point more
|
||||
* than 150 km from the aircraft, or one too long ago or too far away to be
|
||||
* one path.
|
||||
* @param {object[]} fixes
|
||||
* @param {Cesium.Cartesian3} head - Displayed aircraft position.
|
||||
* @param {number} nowMs - Current time.
|
||||
* @param {number} count - Body fixes, from `trailBodyCount`.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function trailHeadStartFix(fixes, head, nowMs, count) {
|
||||
if (count < 1 || !head) return null;
|
||||
const last = fixes[count - 1];
|
||||
if (!last.position) return null;
|
||||
if (Cesium.Cartesian3.distance(last.position, head) < TRAIL_HEAD_MIN_M)
|
||||
return null;
|
||||
return trailHeadJoins(
|
||||
nowMs - last.timeMs,
|
||||
trailDistanceM(last.position, head),
|
||||
)
|
||||
? last
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms of a position-history fix (`{epochMs?, time}`).
|
||||
* @param {object|undefined} fix
|
||||
* @returns {number} NaN when unknown.
|
||||
*/
|
||||
export function historyFixEpochMs(fix) {
|
||||
if (Number.isFinite(fix?.epochMs)) return fix.epochMs;
|
||||
return fix?.time ? Cesium.JulianDate.toDate(fix.time).getTime() : NaN;
|
||||
}
|
||||
|
||||
/** @constant {number} The tracked icon's drawn position is recorded at least this often (ms of display time)... */
|
||||
export const TRAIL_RECORD_STEP_MS = 1_000;
|
||||
|
||||
/** @constant {number} ...or whenever it has moved this far (m), whichever comes first. */
|
||||
export const TRAIL_RECORD_STEP_M = 150;
|
||||
|
||||
/** @constant {number} A recorded point is kept only where dropping it would move the line by more than this (m) across the track or in height. */
|
||||
export const TRAIL_THIN_TOLERANCE_M = 30;
|
||||
|
||||
/** @constant {number} A recorded point is kept where dropping it would leave a straight piece longer than this (m), so a long leg still has vertices. */
|
||||
export const TRAIL_THIN_MAX_SPAN_M = 10_000;
|
||||
|
||||
/** @constant {number} A kept point stands in for at most this many dropped positions; past it the point is kept. */
|
||||
const TRAIL_THIN_MAX_ABSORBED = 512;
|
||||
|
||||
/** @constant {number} A recorded point is dropped where the line turns back sharper than this (degrees)... */
|
||||
export const TRAIL_SPUR_TURN_DEG = 120;
|
||||
|
||||
/** @constant {number} ...over a step shorter than this (m). */
|
||||
export const TRAIL_SPUR_MAX_STEP_M = 500;
|
||||
|
||||
/** @constant {number} A recorded point closer than this (m) to the previous one is not kept: the aircraft is holding. */
|
||||
const TRAIL_RECORD_MIN_STEP_M = 1;
|
||||
|
||||
const _u = new Cesium.Cartesian3();
|
||||
const _v = new Cesium.Cartesian3();
|
||||
|
||||
/** Cosine of the turn at `b` on the way a → b → c, or NaN for a zero step. */
|
||||
function turnCos(a, b, c) {
|
||||
Cesium.Cartesian3.subtract(b, a, _u);
|
||||
Cesium.Cartesian3.subtract(c, b, _v);
|
||||
const lu = Cesium.Cartesian3.magnitude(_u);
|
||||
const lv = Cesium.Cartesian3.magnitude(_v);
|
||||
if (!(lu > 0 && lv > 0)) return NaN;
|
||||
return Cesium.Cartesian3.dot(_u, _v) / (lu * lv);
|
||||
}
|
||||
|
||||
const SPUR_COS = Math.cos(Cesium.Math.toRadians(TRAIL_SPUR_TURN_DEG));
|
||||
|
||||
/** Whether entry `i` and its neighbours are one drawn piece of line. */
|
||||
function joinedAt(fixes, i) {
|
||||
const a = fixes[i - 1];
|
||||
const b = fixes[i];
|
||||
const c = fixes[i + 1];
|
||||
return (
|
||||
a?.position &&
|
||||
b?.position &&
|
||||
c?.position &&
|
||||
!b.breakBefore &&
|
||||
!c.breakBefore
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the line turns back on itself at entry `i` over a short step. */
|
||||
function spurAt(fixes, i) {
|
||||
if (!joinedAt(fixes, i)) return false;
|
||||
const a = fixes[i - 1].position;
|
||||
const b = fixes[i].position;
|
||||
const c = fixes[i + 1].position;
|
||||
if (
|
||||
Math.min(
|
||||
Cesium.Cartesian3.distance(a, b),
|
||||
Cesium.Cartesian3.distance(b, c),
|
||||
) > TRAIL_SPUR_MAX_STEP_M
|
||||
)
|
||||
return false;
|
||||
return turnCos(a, b, c) < SPUR_COS;
|
||||
}
|
||||
|
||||
/** Remove entry `i` and re-judge the break before the entry that takes its place. */
|
||||
function removeAt(fixes, i) {
|
||||
fixes.splice(i, 1);
|
||||
if (i < fixes.length)
|
||||
fixes[i].breakBefore = trailFixBreaks(fixes[i - 1], fixes[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the point limit: drop the oldest recorded point first, and the oldest
|
||||
* report only when no recorded point is left.
|
||||
* @param {object[]} fixes - The trail, mutated.
|
||||
* @param {number} maxPoints
|
||||
* @returns {object[]} `fixes`.
|
||||
*/
|
||||
function keepTrailLimit(fixes, maxPoints) {
|
||||
let excess = fixes.length - maxPoints;
|
||||
if (excess <= 0) return fixes;
|
||||
for (let i = 0; i < fixes.length && excess > 0;) {
|
||||
if (fixes[i].drawn) {
|
||||
removeAt(fixes, i);
|
||||
excess -= 1;
|
||||
} else i += 1;
|
||||
}
|
||||
if (excess > 0) fixes.splice(0, excess);
|
||||
return fixes;
|
||||
}
|
||||
|
||||
const _ab = new Cesium.Cartesian3();
|
||||
const _ap = new Cesium.Cartesian3();
|
||||
const _off = new Cesium.Cartesian3();
|
||||
const _up = new Cesium.Cartesian3();
|
||||
|
||||
/** Whether `p` lies within the thinning tolerance of segment a → b, across the track and in height. */
|
||||
function nearSegment(p, a, b) {
|
||||
Cesium.Cartesian3.subtract(b, a, _ab);
|
||||
Cesium.Cartesian3.subtract(p, a, _ap);
|
||||
const len2 = Cesium.Cartesian3.magnitudeSquared(_ab);
|
||||
const t =
|
||||
len2 > 0
|
||||
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(_ap, _ab) / len2))
|
||||
: 0;
|
||||
Cesium.Cartesian3.multiplyByScalar(_ab, t, _off);
|
||||
Cesium.Cartesian3.subtract(_ap, _off, _off);
|
||||
Cesium.Cartesian3.normalize(p, _up);
|
||||
const vertical = Cesium.Cartesian3.dot(_off, _up);
|
||||
const across = Math.sqrt(
|
||||
Math.max(0, Cesium.Cartesian3.magnitudeSquared(_off) - vertical * vertical),
|
||||
);
|
||||
return (
|
||||
Math.abs(vertical) <= TRAIL_THIN_TOLERANCE_M &&
|
||||
across <= TRAIL_THIN_TOLERANCE_M
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop recorded point `i` when the line without it stays within the
|
||||
* tolerance of every position it stood for, and the straight piece left is
|
||||
* no longer than TRAIL_THIN_MAX_SPAN_M. The positions it stood for go on to
|
||||
* the next entry. Reports are never dropped.
|
||||
* @returns {boolean} Whether it was dropped.
|
||||
*/
|
||||
function thinAt(fixes, i) {
|
||||
const entry = fixes[i];
|
||||
if (!entry?.drawn || entry.sealed || !joinedAt(fixes, i)) return false;
|
||||
const a = fixes[i - 1].position;
|
||||
const next = fixes[i + 1];
|
||||
if (next.sealed) return false;
|
||||
const b = next.position;
|
||||
if (Cesium.Cartesian3.distance(a, b) > TRAIL_THIN_MAX_SPAN_M) return false;
|
||||
const stood = entry.absorbed ?? [];
|
||||
const nextStood = next.absorbed ?? [];
|
||||
if (stood.length + nextStood.length + 1 > TRAIL_THIN_MAX_ABSORBED)
|
||||
return false;
|
||||
if (!nearSegment(entry.position, a, b)) return false;
|
||||
for (const p of stood) if (!nearSegment(p.position, a, b)) return false;
|
||||
for (const p of nextStood) if (!nearSegment(p.position, a, b)) return false;
|
||||
next.absorbed = stood.concat(
|
||||
[{ position: entry.position, timeMs: entry.timeMs }],
|
||||
nextStood,
|
||||
);
|
||||
removeAt(fixes, i);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin the recorded points either side of entry `i`, reaching past a
|
||||
* neighbouring report: the point recorded just before a report is only
|
||||
* judged once the line goes on beyond it.
|
||||
*/
|
||||
function thinAround(fixes, i) {
|
||||
if (!thinAt(fixes, i + 1) && fixes[i + 1] && !fixes[i + 1].drawn)
|
||||
thinAt(fixes, i + 2);
|
||||
if (thinAt(fixes, i - 1)) i -= 1;
|
||||
else if (fixes[i - 1] && !fixes[i - 1].drawn) thinAt(fixes, i - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop recorded points that make a spur next to entry `i`. Reports are never
|
||||
* dropped; at a report the recorded neighbour is dropped instead.
|
||||
* @returns {number} The index entry `i` ends up at.
|
||||
*/
|
||||
function removeSpursAround(fixes, i) {
|
||||
for (let guard = 0; guard < 64; guard++) {
|
||||
let changed = false;
|
||||
for (const j of [i - 1, i, i + 1]) {
|
||||
if (!spurAt(fixes, j)) continue;
|
||||
let drop = -1;
|
||||
if (fixes[j].drawn) drop = j;
|
||||
else if (fixes[j + 1]?.drawn) drop = j + 1;
|
||||
else if (fixes[j - 1]?.drawn) drop = j - 1;
|
||||
if (drop < 0) continue;
|
||||
removeAt(fixes, drop);
|
||||
if (drop < i) i -= 1;
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Index a fix at `timeMs` goes in at: after every entry at or before it. */
|
||||
function insertionIndex(fixes, timeMs) {
|
||||
let i = fixes.length;
|
||||
while (i > 0 && fixes[i - 1].timeMs > timeMs) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an entry at `i`, judge both breaks, remove spurs, thin the recorded
|
||||
* points either side and keep the limit.
|
||||
*/
|
||||
function insertAt(fixes, i, entry, maxPoints) {
|
||||
fixes.splice(i, 0, entry);
|
||||
entry.breakBefore = trailFixBreaks(fixes[i - 1], entry);
|
||||
const after = fixes[i + 1];
|
||||
if (after) {
|
||||
after.breakBefore = trailFixBreaks(entry, after);
|
||||
// Positions the next entry stood for that come before this one are now
|
||||
// on this entry's side.
|
||||
if (after.absorbed?.length) {
|
||||
const mine = after.absorbed.filter((p) => p.timeMs < entry.timeMs);
|
||||
if (mine.length) {
|
||||
entry.absorbed = mine;
|
||||
after.absorbed = after.absorbed.filter((p) => p.timeMs >= entry.timeMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
i = removeSpursAround(fixes, i);
|
||||
thinAround(fixes, i);
|
||||
keepTrailLimit(fixes, maxPoints);
|
||||
}
|
||||
|
||||
/** Index of the newest report before entry `i`, or -1. */
|
||||
function reportBefore(fixes, i) {
|
||||
let j = i - 1;
|
||||
while (j >= 0 && fixes[j].drawn) j -= 1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/** Index of the oldest report at or after `i`, or `fixes.length`. */
|
||||
function reportFrom(fixes, i) {
|
||||
let j = i;
|
||||
while (j < fixes.length && fixes[j].drawn) j += 1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a report into the trail in time order. Reports are the record: they
|
||||
* are kept as anchors among the recorded points.
|
||||
*
|
||||
* - A report the display has already passed replaces the guess drawn after
|
||||
* it: the recorded points timed after it go, and nothing is recorded until
|
||||
* the icon has eased onto the correction (`displayMs` + `settleMs`), so the
|
||||
* line reads arc, report, corrected path.
|
||||
* - Recorded points never join two reports that break under
|
||||
* `trailPairBreaks` (too long or too fast apart to be one flight): the
|
||||
* points between them go and the line breaks.
|
||||
* - A live report with no altitude keeps no position and only breaks the
|
||||
* line.
|
||||
* @param {object[]} fixes - The trail, mutated.
|
||||
* @param {Cesium.Cartesian3} position - Owned position.
|
||||
* @param {number} timeMs - The report's epoch ms.
|
||||
* @param {number} maxPoints - Point limit across all segments.
|
||||
* @param {{nullAltitude?: boolean, displayMs?: number, settleMs?: number}} [options]
|
||||
* `displayMs`: the time the aircraft is drawn at as the report arrives;
|
||||
* `settleMs`: how long the icon takes to ease onto a correction.
|
||||
* @returns {object[]} `fixes`.
|
||||
*/
|
||||
export function insertTrailFix(
|
||||
fixes,
|
||||
position,
|
||||
timeMs,
|
||||
maxPoints,
|
||||
{ nullAltitude = false, displayMs = NaN, settleMs = 0 } = {},
|
||||
) {
|
||||
const entry = {
|
||||
position: nullAltitude ? null : position,
|
||||
timeMs,
|
||||
nullAltitude,
|
||||
drawn: false,
|
||||
breakBefore: false,
|
||||
};
|
||||
let i = insertionIndex(fixes, timeMs);
|
||||
// The guess drawn after a report the display already passed.
|
||||
fixes.splice(i, reportFrom(fixes, i) - i);
|
||||
if (displayMs > timeMs) entry.settleUntilMs = displayMs + settleMs;
|
||||
// Recorded points across a pair of reports that are not one flight.
|
||||
const before = reportBefore(fixes, i);
|
||||
if (before >= 0 && trailFixBreaks(fixes[before], entry)) {
|
||||
fixes.splice(before + 1, i - before - 1);
|
||||
i = before + 1;
|
||||
}
|
||||
insertAt(fixes, i, entry, maxPoints);
|
||||
return fixes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record where the tracked aircraft was drawn at a display time. Between
|
||||
* reports this recorded path is the trail, so the line follows every turn
|
||||
* the icon made instead of a straight chord between two reports. Points are
|
||||
* thinned by shape: one stays only where dropping it would move the line by
|
||||
* more than TRAIL_THIN_TOLERANCE_M across the track or in height, or leave a
|
||||
* straight piece longer than TRAIL_THIN_MAX_SPAN_M. Nothing is recorded
|
||||
* while the aircraft holds.
|
||||
* @param {object[]} fixes - The trail, mutated.
|
||||
* @param {Cesium.Cartesian3} position - Owned drawn position.
|
||||
* @param {number} timeMs - Display time it was drawn for.
|
||||
* @param {number} maxPoints - Point limit across all segments.
|
||||
* @returns {boolean} Whether the trail changed.
|
||||
*/
|
||||
export function recordTrailPoint(fixes, position, timeMs, maxPoints) {
|
||||
const i = insertionIndex(fixes, timeMs);
|
||||
const prev = fixes[i - 1];
|
||||
if (
|
||||
prev?.position &&
|
||||
Cesium.Cartesian3.distance(prev.position, position) <
|
||||
TRAIL_RECORD_MIN_STEP_M
|
||||
)
|
||||
return false;
|
||||
const entry = {
|
||||
position,
|
||||
timeMs,
|
||||
nullAltitude: false,
|
||||
drawn: true,
|
||||
breakBefore: false,
|
||||
};
|
||||
insertAt(fixes, i, entry, maxPoints);
|
||||
// Entries before the second report behind this point can never be thinned
|
||||
// again, so they let go of the positions they stood for.
|
||||
const passed = reportBefore(fixes, fixes.lastIndexOf(entry));
|
||||
if (passed >= 0) sealThrough(fixes, reportBefore(fixes, passed));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release the absorbed positions of entry `i` and everything before it. */
|
||||
function sealThrough(fixes, i) {
|
||||
for (let j = i; j >= 0 && !fixes[j].sealed; j--) {
|
||||
fixes[j].sealed = true;
|
||||
delete fixes[j].absorbed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the icon's drawn position must not be recorded at a display time:
|
||||
* a report on either side of it has no altitude (the icon is drawn at an
|
||||
* invented height), the reports on either side are not one flight
|
||||
* (`trailPairBreaks`), or the icon is still easing onto a report that
|
||||
* arrived after the display had passed it.
|
||||
* @param {object[]} fixes
|
||||
* @param {number} displayMs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function trailRecordingBlocked(fixes, displayMs) {
|
||||
const i = insertionIndex(fixes, displayMs);
|
||||
const before = fixes[reportBefore(fixes, i)];
|
||||
const after = fixes[reportFrom(fixes, i)];
|
||||
if (before?.nullAltitude || after?.nullAltitude) return true;
|
||||
if (before?.settleUntilMs > displayMs) return true;
|
||||
return !!(before && after && trailFixBreaks(before, after));
|
||||
}
|
||||
+269
-58
@@ -14,6 +14,18 @@
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* A trail with breaks draws each segment separately and nothing between
|
||||
* them. Every segment is cut into chunks of up to CHUNK_POINTS points;
|
||||
* adjacent chunks share their boundary point, so the line has no seam. A
|
||||
* chunk that can no longer change (every chunk except the newest one of the
|
||||
* newest segment) is FROZEN: an entity with constant positions, built once as
|
||||
* static geometry, so it costs nothing per frame. Only the newest chunk stays
|
||||
* dynamic. A freshly frozen chunk is also drawn dynamically until its static
|
||||
* geometry is in the scene, so freezing never shows a blank frame. A chunk is
|
||||
* identified by its position objects, so a chunk survives every update that
|
||||
* keeps those points: appends, a backfill prepended ahead of it, or the
|
||||
* oldest points dropping off the front (which rebuilds only the first chunk).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
@@ -42,6 +54,13 @@ const TRAIL_ALPHA = 0.85;
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
/** @constant {Cesium.Cartesian3[]} What a spare dynamic polyline draws. */
|
||||
const NO_POSITIONS = Object.freeze([]);
|
||||
/** @constant {number} Points per chunk; adjacent chunks share one point. */
|
||||
export const CHUNK_POINTS = 128;
|
||||
/** @constant {number} Visible frames a frozen chunk's stand-in waits for its
|
||||
* static geometry before it is dropped anyway. */
|
||||
const STAND_IN_MAX_FRAMES = 600;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
@@ -49,93 +68,285 @@ const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setSegments: function(Cesium.Cartesian3[][]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry with one line,
|
||||
* setSegments with one line per segment, setVisible temporarily hides it
|
||||
* without discarding history, clear empties it, and destroy removes the
|
||||
* entities permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
const material = baseColor.withAlpha(TRAIL_ALPHA);
|
||||
const depthFailMaterial = baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA);
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
/** @type {{positions: Cesium.Cartesian3[], entity: Cesium.Entity, standIn: boolean, frames: number}[]} */
|
||||
let frozen = [];
|
||||
/** Newest chunk of the newest segment: the only part that changes. */
|
||||
let tail = NO_POSITIONS;
|
||||
/** What the dynamic polylines draw: the tail, then frozen chunks whose
|
||||
* static geometry is not in the scene yet. */
|
||||
let dynamicRuns = [];
|
||||
/** @type {Cesium.Entity[]} Pooled dynamic polylines; spares draw nothing. */
|
||||
const dynamicEntities = [];
|
||||
let removeReadyListener = null;
|
||||
const scratchSphere = new Cesium.BoundingSphere();
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
const usable = () => !destroyed && viewer && !viewer.isDestroyed();
|
||||
|
||||
function polyline(positions) {
|
||||
return {
|
||||
positions,
|
||||
width,
|
||||
material,
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial,
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
};
|
||||
}
|
||||
|
||||
function addEntity(positions) {
|
||||
return viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
polyline: polyline(positions),
|
||||
});
|
||||
}
|
||||
|
||||
function removeEntity(entity) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
|
||||
function syncDynamic() {
|
||||
dynamicRuns = [];
|
||||
if (tail.length >= 2) dynamicRuns.push(tail);
|
||||
for (const chunk of frozen)
|
||||
if (chunk.standIn) dynamicRuns.push(chunk.positions);
|
||||
while (dynamicEntities.length < Math.max(1, dynamicRuns.length)) {
|
||||
const index = dynamicEntities.length;
|
||||
dynamicEntities.push(
|
||||
addEntity(
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
new Cesium.CallbackProperty(
|
||||
() => dynamicRuns[index] ?? NO_POSITIONS,
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a frozen chunk's static geometry is drawn in the scene. */
|
||||
function staticReady(chunk) {
|
||||
const display = viewer.dataSourceDisplay;
|
||||
if (typeof display?.getBoundingSphere !== 'function') return true;
|
||||
if (!visible) return false;
|
||||
chunk.frames += 1;
|
||||
if (chunk.frames > STAND_IN_MAX_FRAMES) return true;
|
||||
try {
|
||||
return (
|
||||
display.getBoundingSphere(chunk.entity, false, scratchSphere) ===
|
||||
Cesium.BoundingSphereState.DONE
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop stand-ins whose static geometry is in; stop listening when none are left. */
|
||||
function resolveStandIns() {
|
||||
if (!usable()) return;
|
||||
let changed = false;
|
||||
for (const chunk of frozen) {
|
||||
if (chunk.standIn && staticReady(chunk)) {
|
||||
chunk.standIn = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) syncDynamic();
|
||||
if (!frozen.some((chunk) => chunk.standIn)) stopListening();
|
||||
}
|
||||
|
||||
function stopListening() {
|
||||
if (removeReadyListener) removeReadyListener();
|
||||
removeReadyListener = null;
|
||||
}
|
||||
|
||||
function listenForStandIns() {
|
||||
if (removeReadyListener) return;
|
||||
const event = viewer.scene?.postRender;
|
||||
if (typeof event?.addEventListener !== 'function') {
|
||||
resolveStandIns();
|
||||
return;
|
||||
}
|
||||
removeReadyListener = event.addEventListener(resolveStandIns);
|
||||
}
|
||||
|
||||
/** Drop consecutive near-duplicates: zero-length segments add nothing. */
|
||||
function dedupe(cartesians) {
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** Whether `chunk` holds exactly the same position objects as `seg` from `start`. */
|
||||
function sameRun(chunk, seg, start) {
|
||||
const positions = chunk.positions;
|
||||
if (start + positions.length > seg.length) return false;
|
||||
for (let k = 0; k < positions.length; k++)
|
||||
if (positions[k] !== seg[start + k]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut segments into chunks, reusing every frozen chunk whose points are
|
||||
* still drawn in the same order. New chunks end where a reusable one begins,
|
||||
* so dropping the oldest point rebuilds only the first chunk.
|
||||
*/
|
||||
function reconcile(segments) {
|
||||
const byStart = new Map();
|
||||
for (const chunk of frozen) byStart.set(chunk.positions[0], chunk);
|
||||
const kept = new Set();
|
||||
const reusable = (seg, j) => {
|
||||
const chunk = byStart.get(seg[j]);
|
||||
return chunk && !kept.has(chunk) && sameRun(chunk, seg, j) ? chunk : null;
|
||||
};
|
||||
const next = [];
|
||||
let nextTail = NO_POSITIONS;
|
||||
segments.forEach((seg, s) => {
|
||||
const newest = s === segments.length - 1;
|
||||
let i = 0;
|
||||
while (i < seg.length - 1) {
|
||||
const existing = reusable(seg, i);
|
||||
if (existing) {
|
||||
kept.add(existing);
|
||||
next.push(existing);
|
||||
i += existing.positions.length - 1;
|
||||
continue;
|
||||
}
|
||||
if (newest && seg.length - i <= CHUNK_POINTS) {
|
||||
nextTail = seg.slice(i);
|
||||
break;
|
||||
}
|
||||
let end = Math.min(seg.length - 1, i + CHUNK_POINTS - 1);
|
||||
for (let j = i + 1; j < end; j++) {
|
||||
if (reusable(seg, j)) {
|
||||
end = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const positions = seg.slice(i, end + 1);
|
||||
next.push({
|
||||
positions,
|
||||
entity: addEntity(positions),
|
||||
standIn: true,
|
||||
frames: 0,
|
||||
});
|
||||
i = end;
|
||||
}
|
||||
});
|
||||
for (const chunk of frozen)
|
||||
if (!kept.has(chunk)) removeEntity(chunk.entity);
|
||||
frozen = next;
|
||||
tail = nextTail;
|
||||
syncDynamic();
|
||||
if (frozen.some((chunk) => chunk.standIn)) listenForStandIns();
|
||||
else stopListening();
|
||||
}
|
||||
|
||||
function release() {
|
||||
stopListening();
|
||||
for (const chunk of frozen) removeEntity(chunk.entity);
|
||||
frozen = [];
|
||||
tail = NO_POSITIONS;
|
||||
dynamicRuns = [];
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* Replace the trail geometry with one chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
this.setSegments([cartesians]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the trail geometry with chronological segments (oldest first).
|
||||
* Nothing is drawn between segments. A segment with fewer than 2 distinct
|
||||
* positions draws nothing. Pass the same position objects on every
|
||||
* update: a chunk whose points are unchanged is never rebuilt.
|
||||
* @param {Cesium.Cartesian3[][]} list - Segments, each oldest -> newest.
|
||||
*/
|
||||
setSegments(list) {
|
||||
if (!usable()) return;
|
||||
reconcile(
|
||||
(Array.isArray(list) ? list : [])
|
||||
.map(dedupe)
|
||||
.filter((positions) => positions.length >= 2),
|
||||
);
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
for (const entity of dynamicEntities) entity.show = visible;
|
||||
for (const chunk of frozen) chunk.entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
/** Empty the trail and release its frozen geometry; dynamic polylines stay for re-arm. */
|
||||
clear() {
|
||||
current = [];
|
||||
if (!usable()) return;
|
||||
release();
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
/** Remove every trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
if (usable()) {
|
||||
release();
|
||||
for (const entity of dynamicEntities) removeEntity(entity);
|
||||
}
|
||||
entity = null;
|
||||
destroyed = true;
|
||||
frozen = [];
|
||||
tail = NO_POSITIONS;
|
||||
dynamicRuns = [];
|
||||
dynamicEntities.length = 0;
|
||||
stopListening();
|
||||
},
|
||||
|
||||
/** Test seam: frozen chunks (positions, stand-in state) and the dynamic runs. */
|
||||
_chunksForTest() {
|
||||
return {
|
||||
frozen: frozen.map((chunk) => ({
|
||||
positions: chunk.positions,
|
||||
standIn: chunk.standIn,
|
||||
entity: chunk.entity,
|
||||
})),
|
||||
dynamic: dynamicRuns.slice(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createTrail } from './trailRenderer.js';
|
||||
import { CHUNK_POINTS, createTrail } from './trailRenderer.js';
|
||||
|
||||
test('trail visibility can change without discarding its accumulated geometry', () => {
|
||||
const added = [];
|
||||
@@ -37,3 +37,253 @@ test('trail visibility can change without discarding its accumulated geometry',
|
||||
trail.destroy();
|
||||
assert.deepEqual(removed, [added[0]]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A fake viewer. `ready` holds the frozen-chunk entities whose static
|
||||
* geometry is in the scene; `frame()` runs one post-render pass.
|
||||
*/
|
||||
function fakeViewer({ scene = true } = {}) {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const ready = new Set();
|
||||
let listeners = [];
|
||||
const viewer = {
|
||||
added,
|
||||
removed,
|
||||
ready,
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add(definition) {
|
||||
added.push(definition);
|
||||
return definition;
|
||||
},
|
||||
remove(entity) {
|
||||
removed.push(entity);
|
||||
},
|
||||
},
|
||||
listenerCount: () => listeners.length,
|
||||
frame() {
|
||||
for (const listener of listeners.slice()) listener();
|
||||
},
|
||||
};
|
||||
if (scene) {
|
||||
viewer.scene = {
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
viewer.dataSourceDisplay = {
|
||||
getBoundingSphere: (entity) =>
|
||||
ready.has(entity)
|
||||
? Cesium.BoundingSphereState.DONE
|
||||
: Cesium.BoundingSphereState.PENDING,
|
||||
};
|
||||
}
|
||||
return viewer;
|
||||
}
|
||||
|
||||
const valueOf = (positions) =>
|
||||
typeof positions?.getValue === 'function' ? positions.getValue() : positions;
|
||||
|
||||
/** Every line the scene can draw now: live entities with two or more points. */
|
||||
function drawnRuns(viewer) {
|
||||
return viewer.added
|
||||
.filter((entity) => !viewer.removed.includes(entity))
|
||||
.map((entity) => valueOf(entity.polyline.positions))
|
||||
.filter((positions) => positions.length >= 2);
|
||||
}
|
||||
|
||||
/** Distinct position objects across all drawn lines. */
|
||||
function drawnPoints(viewer) {
|
||||
return new Set(drawnRuns(viewer).flat());
|
||||
}
|
||||
|
||||
function line(count, offset = 0) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => new Cesium.Cartesian3(6_378_137 + offset, i * 1000, 0),
|
||||
);
|
||||
}
|
||||
|
||||
test('a trail with breaks draws each segment and nothing between them', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const first = line(2, 0);
|
||||
const lone = line(1, 50_000);
|
||||
const second = line(3, 100_000);
|
||||
|
||||
trail.setSegments([first, lone, second]);
|
||||
assert.deepEqual(
|
||||
drawnRuns(viewer).sort((a, b) => a[0].x - b[0].x),
|
||||
[first, second],
|
||||
);
|
||||
for (const entity of viewer.added) {
|
||||
assert.match(String(entity.id), /^gev-trail:/);
|
||||
assert.equal(entity.polyline.width, 2.5);
|
||||
}
|
||||
|
||||
// The first segment is gone later: its geometry is released.
|
||||
trail.setSegments([second]);
|
||||
assert.deepEqual(drawnRuns(viewer), [second]);
|
||||
|
||||
trail.setVisible(false);
|
||||
assert.ok(
|
||||
viewer.added
|
||||
.filter((entity) => !viewer.removed.includes(entity))
|
||||
.every((entity) => entity.show === false),
|
||||
);
|
||||
trail.destroy();
|
||||
assert.equal(new Set(viewer.removed).size, viewer.added.length);
|
||||
});
|
||||
|
||||
test('chunks share their boundary points and only the newest chunk is dynamic', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(300);
|
||||
trail.setPositions(points);
|
||||
const { frozen, dynamic } = trail._chunksForTest();
|
||||
assert.deepEqual(
|
||||
frozen.map((chunk) => chunk.positions.length),
|
||||
[CHUNK_POINTS, CHUNK_POINTS],
|
||||
);
|
||||
assert.equal(frozen[0].positions.at(-1), frozen[1].positions[0]);
|
||||
assert.equal(dynamic.length, 1);
|
||||
assert.equal(frozen[1].positions.at(-1), dynamic[0][0]);
|
||||
assert.equal(dynamic[0].at(-1), points.at(-1));
|
||||
assert.equal(drawnPoints(viewer).size, points.length);
|
||||
// A frozen chunk is constant geometry, not a per-frame callback.
|
||||
for (const chunk of frozen)
|
||||
assert.ok(Array.isArray(chunk.entity.polyline.positions));
|
||||
});
|
||||
|
||||
test('freezing a chunk keeps every point drawn, and the dynamic copy goes only once the static geometry is in', () => {
|
||||
const viewer = fakeViewer();
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(CHUNK_POINTS + 20);
|
||||
for (let n = 2; n <= points.length; n++) {
|
||||
trail.setPositions(points.slice(0, n));
|
||||
assert.equal(drawnPoints(viewer).size, n, `points drawn at ${n}`);
|
||||
}
|
||||
const { frozen } = trail._chunksForTest();
|
||||
assert.equal(frozen.length, 1);
|
||||
// Not in the scene yet: the chunk is still drawn by a dynamic polyline.
|
||||
viewer.frame();
|
||||
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
|
||||
assert.ok(
|
||||
trail._chunksForTest().dynamic.includes(frozen[0].positions),
|
||||
'the dynamic copy stays until the static geometry is ready',
|
||||
);
|
||||
viewer.ready.add(frozen[0].entity);
|
||||
viewer.frame();
|
||||
const after = trail._chunksForTest();
|
||||
assert.equal(after.frozen[0].standIn, false);
|
||||
assert.ok(!after.dynamic.includes(frozen[0].positions));
|
||||
assert.equal(after.dynamic.length, 1);
|
||||
assert.equal(viewer.listenerCount(), 0, 'no listener once nothing waits');
|
||||
// The frozen entity is the same object: freezing built it once.
|
||||
assert.equal(after.frozen[0].entity, frozen[0].entity);
|
||||
});
|
||||
|
||||
test('a hidden trail keeps its dynamic copy until it is shown and the static geometry is in', () => {
|
||||
const viewer = fakeViewer();
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
trail.setVisible(false);
|
||||
trail.setPositions(line(CHUNK_POINTS + 5));
|
||||
const [chunk] = trail._chunksForTest().frozen;
|
||||
viewer.ready.add(chunk.entity);
|
||||
viewer.frame();
|
||||
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
|
||||
trail.setVisible(true);
|
||||
viewer.frame();
|
||||
assert.equal(trail._chunksForTest().frozen[0].standIn, false);
|
||||
});
|
||||
|
||||
test('dropping the oldest point rebuilds only the first chunk', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(400);
|
||||
trail.setPositions(points);
|
||||
const before = trail._chunksForTest().frozen;
|
||||
trail.setPositions(points.slice(1));
|
||||
const after = trail._chunksForTest().frozen;
|
||||
assert.equal(after.length, before.length);
|
||||
assert.notEqual(after[0].entity, before[0].entity);
|
||||
assert.ok(viewer.removed.includes(before[0].entity));
|
||||
for (let k = 1; k < before.length; k++)
|
||||
assert.equal(after[k].entity, before[k].entity);
|
||||
assert.equal(after[0].positions.at(-1), after[1].positions[0]);
|
||||
assert.equal(drawnPoints(viewer).size, 399);
|
||||
});
|
||||
|
||||
test('a backfill prepended ahead of frozen chunks keeps them and joins at a shared point', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const live = line(300, 0);
|
||||
trail.setPositions(live);
|
||||
const before = trail._chunksForTest().frozen;
|
||||
// History older than every live point, joined as one path.
|
||||
const history = Array.from(
|
||||
{ length: 200 },
|
||||
(_, i) => new Cesium.Cartesian3(6_378_137, -(200 - i) * 1000, 0),
|
||||
);
|
||||
trail.setPositions(history.concat(live));
|
||||
const after = trail._chunksForTest().frozen;
|
||||
for (const chunk of before)
|
||||
assert.ok(after.some((c) => c.entity === chunk.entity));
|
||||
for (let k = 0; k + 1 < after.length; k++)
|
||||
assert.equal(after[k].positions.at(-1), after[k + 1].positions[0]);
|
||||
assert.equal(drawnPoints(viewer).size, 500);
|
||||
|
||||
// A backfill that breaks from the live trail is its own segment.
|
||||
trail.setSegments([history, live]);
|
||||
assert.equal(drawnPoints(viewer).size, 500);
|
||||
const runs = drawnRuns(viewer);
|
||||
assert.ok(
|
||||
!runs.some((run) => run.includes(history.at(-1)) && run.includes(live[0])),
|
||||
'no line across the break',
|
||||
);
|
||||
});
|
||||
|
||||
test('clearing and re-tracking release every frozen chunk', () => {
|
||||
const viewer = fakeViewer();
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
trail.setPositions(line(600));
|
||||
const frozen = trail._chunksForTest().frozen.map((chunk) => chunk.entity);
|
||||
assert.ok(frozen.length >= 4);
|
||||
assert.equal(viewer.listenerCount(), 1);
|
||||
trail.clear();
|
||||
for (const entity of frozen) assert.ok(viewer.removed.includes(entity));
|
||||
assert.equal(viewer.listenerCount(), 0);
|
||||
assert.equal(drawnRuns(viewer).length, 0);
|
||||
|
||||
// Another aircraft: nothing of the first one survives.
|
||||
const other = line(200, 900_000);
|
||||
trail.setPositions(other);
|
||||
assert.equal(drawnPoints(viewer).size, 200);
|
||||
trail.destroy();
|
||||
assert.equal(new Set(viewer.removed).size, viewer.added.length);
|
||||
assert.equal(viewer.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('replacing the newest point and removing points inside the trail draws exactly the new trail', () => {
|
||||
const viewer = fakeViewer({ scene: false });
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const points = line(400);
|
||||
trail.setPositions(points.slice());
|
||||
// A recorded point replaces the newest one; a spur point inside a frozen
|
||||
// chunk and one at a chunk boundary go.
|
||||
points[points.length - 1] = new Cesium.Cartesian3(6_378_137, 399_500, 10);
|
||||
points.splice(200, 1);
|
||||
points.splice(CHUNK_POINTS - 1, 1);
|
||||
trail.setPositions(points.slice());
|
||||
assert.deepEqual(drawnPoints(viewer), new Set(points));
|
||||
// Every drawn line runs through consecutive trail points only.
|
||||
const next = new Map(points.slice(0, -1).map((p, i) => [p, points[i + 1]]));
|
||||
for (const run of drawnRuns(viewer))
|
||||
for (let i = 1; i < run.length; i++) assert.equal(next.get(run[i - 1]), run[i]);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
insertTrailFix,
|
||||
recordTrailPoint,
|
||||
trailBodySegments,
|
||||
trailPairBreaks,
|
||||
trailDistanceM,
|
||||
trailRecordingBlocked,
|
||||
} from './trailContinuity.js';
|
||||
|
||||
// Reports decide where the line may run: recorded points never join two
|
||||
// reports too far apart to be one flight, a report the display has already
|
||||
// passed replaces the guess drawn after it, and old entries let go of the
|
||||
// positions they stood for.
|
||||
|
||||
const SEC = 1000;
|
||||
const LIMIT = 2_000;
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
Cesium.Cartesian3.fromDegrees(-100, 40, 0),
|
||||
);
|
||||
const at = (e, n, u = 10) =>
|
||||
Cesium.Matrix4.multiplyByPoint(
|
||||
enu,
|
||||
new Cesium.Cartesian3(e, n, u),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
|
||||
/** No two neighbours in one drawn segment are too far apart to be one flight. */
|
||||
function assertNoBrokenJoin(fixes) {
|
||||
const reports = fixes.filter((fix) => !fix.drawn && fix.position);
|
||||
for (const segment of trailBodySegments(fixes, fixes.length)) {
|
||||
const inSegment = reports.filter((r) =>
|
||||
segment.some((p) => Cesium.Cartesian3.distance(p, r.position) < 0.01),
|
||||
);
|
||||
for (let i = 1; i < inSegment.length; i++)
|
||||
assert.equal(
|
||||
trailPairBreaks(
|
||||
inSegment[i].timeMs - inSegment[i - 1].timeMs,
|
||||
trailDistanceM(inSegment[i - 1].position, inSegment[i].position),
|
||||
),
|
||||
false,
|
||||
`one segment joins reports at ${inSegment[i - 1].timeMs / SEC} s and ${
|
||||
inSegment[i].timeMs / SEC
|
||||
} s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('two reports 60 s and 51 km apart are not joined, even by recorded points', () => {
|
||||
const A = Cesium.Cartesian3.fromDegrees(-100, 40, 9_000);
|
||||
const B = Cesium.Cartesian3.fromDegrees(-99.4, 40, 9_000);
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, A, 0, LIMIT);
|
||||
insertTrailFix(fixes, B, 60 * SEC, LIMIT); // ahead of the display
|
||||
for (let t = 1; t < 60; t++) {
|
||||
// Nothing is recorded across the pair.
|
||||
assert.equal(trailRecordingBlocked(fixes, t * SEC), true);
|
||||
}
|
||||
assert.equal(trailBodySegments(fixes, fixes.length).length, 2);
|
||||
// Points recorded before the far report was known are dropped.
|
||||
const late = [];
|
||||
insertTrailFix(late, A, 0, LIMIT);
|
||||
for (let t = 1; t < 60; t++)
|
||||
recordTrailPoint(
|
||||
late,
|
||||
Cesium.Cartesian3.lerp(A, B, t / 60, new Cesium.Cartesian3()),
|
||||
t * SEC,
|
||||
LIMIT,
|
||||
);
|
||||
insertTrailFix(late, B, 60 * SEC, LIMIT);
|
||||
assert.equal(late.filter((fix) => fix.drawn).length, 0);
|
||||
assert.equal(trailBodySegments(late, late.length).length, 2);
|
||||
assertNoBrokenJoin(late);
|
||||
});
|
||||
|
||||
test('a coast, a hold and then a far report: nothing is drawn across', () => {
|
||||
const A = at(0, 0, 9_000);
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, A, 0, LIMIT);
|
||||
// The icon coasts east for 90 s at 230 m/s, then holds.
|
||||
for (let t = 1; t <= 200; t++)
|
||||
recordTrailPoint(
|
||||
fixes,
|
||||
at(230 * Math.min(t, 90), 0, 9_000),
|
||||
t * SEC,
|
||||
LIMIT,
|
||||
);
|
||||
assert.ok(fixes.filter((fix) => fix.drawn).length > 0);
|
||||
// The next report is 150 km away two minutes later: over 1,500 kt.
|
||||
const B = at(0, 150_000, 9_000);
|
||||
insertTrailFix(fixes, B, 120 * SEC, LIMIT);
|
||||
assertNoBrokenJoin(fixes);
|
||||
const segments = trailBodySegments(fixes, fixes.length);
|
||||
const segmentOf = (p) =>
|
||||
segments.findIndex((s) =>
|
||||
s.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
|
||||
);
|
||||
assert.notEqual(segmentOf(A), segmentOf(B));
|
||||
// No recorded point between the two reports remains.
|
||||
assert.equal(
|
||||
fixes.filter((fix) => fix.drawn && fix.timeMs < 120 * SEC).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
for (const off of [200, 400, 600, 1_000]) {
|
||||
test(`a report the display already passed, ${off} m off a 2 km turn, leaves no spike`, () => {
|
||||
const R = 2_000;
|
||||
const w = 100 / R;
|
||||
const pos = (t, r = R) =>
|
||||
at(r * Math.sin(w * t), R - r * Math.cos(w * t), 3_000);
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, pos(0), 0, LIMIT);
|
||||
for (let t = 1; t <= 60; t++)
|
||||
recordTrailPoint(fixes, pos(t), t * SEC, LIMIT);
|
||||
const report = pos(50, R - off);
|
||||
// It arrives with the display at 60 s; the ease takes 900 ms.
|
||||
insertTrailFix(fixes, report, 50 * SEC, LIMIT, {
|
||||
displayMs: 60 * SEC,
|
||||
settleMs: 900,
|
||||
});
|
||||
// The guess drawn after the report is gone...
|
||||
assert.deepEqual(
|
||||
fixes.filter((fix) => fix.drawn && fix.timeMs > 50 * SEC),
|
||||
[],
|
||||
);
|
||||
// ...nothing is recorded while the icon eases onto the correction...
|
||||
assert.equal(trailRecordingBlocked(fixes, 60.5 * SEC), true);
|
||||
assert.equal(trailRecordingBlocked(fixes, 61 * SEC), false);
|
||||
// ...and the line reads arc, report, corrected path.
|
||||
for (let t = 61; t <= 70; t++)
|
||||
recordTrailPoint(fixes, pos(t, R - off), t * SEC, LIMIT);
|
||||
const line = trailBodySegments(fixes, fixes.length).flat();
|
||||
const r = line.findIndex(
|
||||
(p) => Cesium.Cartesian3.distance(p, report) < 0.01,
|
||||
);
|
||||
assert.ok(r > 0);
|
||||
for (const p of line.slice(0, r))
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - R) < 31,
|
||||
'a point before the report is off the arc',
|
||||
);
|
||||
for (const p of line.slice(r + 1))
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - (R - off)) <
|
||||
31,
|
||||
'a point after the report is off the corrected path',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('entries behind the report before the display let go of the positions they stood for', () => {
|
||||
const fixes = [];
|
||||
for (let s = 0; s <= 2 * 3_600; s++) {
|
||||
const p = at(230 * s, 0, 10_000);
|
||||
if (s % 15 === 0) insertTrailFix(fixes, p, s * SEC, LIMIT);
|
||||
else recordTrailPoint(fixes, p, s * SEC, LIMIT);
|
||||
}
|
||||
let held = 0;
|
||||
for (const fix of fixes) held += fix.absorbed?.length ?? 0;
|
||||
// Only the newest stretch, up to two reports back, keeps what it stood for.
|
||||
assert.ok(held <= 30, `${held} positions still held`);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
insertTrailFix,
|
||||
joinTrailBackfill,
|
||||
recordTrailPoint,
|
||||
TRAIL_THIN_MAX_SPAN_M,
|
||||
TRAIL_THIN_TOLERANCE_M,
|
||||
} from './trailContinuity.js';
|
||||
|
||||
// Recorded points are thinned by shape: a point stays only where the line
|
||||
// would move without it. Reports always stay.
|
||||
|
||||
const SEC = 1000;
|
||||
const LIMIT = 2_000;
|
||||
const LAT0 = 40;
|
||||
const LON0 = -100;
|
||||
const M_PER_DEG_LAT = 111_320;
|
||||
const mPerDegLon = (lat) => M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180);
|
||||
|
||||
/** Straight and level east at `speed` m/s, `tSec` after the start. */
|
||||
const eastbound = (speed, tSec, heightM = 10_000) =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
LON0 + (speed * tSec) / mPerDegLon(LAT0),
|
||||
LAT0,
|
||||
heightM,
|
||||
);
|
||||
|
||||
/** A standard-rate (3°/s) left turn from north, radius v / ω, `tSec` in. */
|
||||
function turning(speed, tSec, heightM = 10_000) {
|
||||
const omega = (3 * Math.PI) / 180;
|
||||
const radius = speed / omega;
|
||||
// Centre west of the start; heading north, turning left (towards west).
|
||||
const angle = omega * tSec;
|
||||
const east = -radius + radius * Math.cos(angle);
|
||||
const north = radius * Math.sin(angle);
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
LON0 + east / mPerDegLon(LAT0),
|
||||
LAT0 + north / M_PER_DEG_LAT,
|
||||
heightM,
|
||||
);
|
||||
}
|
||||
|
||||
/** Distance from p to the nearest point of a polyline. */
|
||||
function distanceToPath(p, path) {
|
||||
let best = Infinity;
|
||||
const ab = new Cesium.Cartesian3();
|
||||
const ap = new Cesium.Cartesian3();
|
||||
const closest = new Cesium.Cartesian3();
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
|
||||
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
|
||||
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
|
||||
const t =
|
||||
len2 > 0
|
||||
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
|
||||
: 0;
|
||||
Cesium.Cartesian3.add(
|
||||
path[i - 1],
|
||||
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
|
||||
closest,
|
||||
);
|
||||
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const drawnCount = (fixes) => fixes.filter((fix) => fix.drawn).length;
|
||||
|
||||
test('one hour straight and level with a report every 15 s adds under 60 recorded points', () => {
|
||||
const speed = 230;
|
||||
const fixes = [];
|
||||
for (const t of [0, 15, 30])
|
||||
insertTrailFix(fixes, eastbound(speed, t), t * SEC, LIMIT);
|
||||
// Live order: a report arrives 30 s before the display reaches it, and the
|
||||
// drawn icon is recorded once a second.
|
||||
for (let t = 1; t <= 3_600; t++) {
|
||||
const reportAt = t + 30;
|
||||
if (reportAt % 15 === 0 && reportAt <= 3_600)
|
||||
insertTrailFix(fixes, eastbound(speed, reportAt), reportAt * SEC, LIMIT);
|
||||
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
|
||||
}
|
||||
assert.ok(
|
||||
drawnCount(fixes) < 60,
|
||||
`${drawnCount(fixes)} recorded points kept`,
|
||||
);
|
||||
assert.equal(fixes.filter((fix) => !fix.drawn).length, 241);
|
||||
});
|
||||
|
||||
test('with no report at all, a straight leg keeps one point per 10 km and no more', () => {
|
||||
const speed = 230;
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, eastbound(speed, 0), 0, LIMIT);
|
||||
for (let t = 1; t <= 3_600; t++)
|
||||
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
|
||||
const legKm = (speed * 3_600) / 1_000;
|
||||
assert.equal(TRAIL_THIN_MAX_SPAN_M, 10_000);
|
||||
assert.ok(
|
||||
drawnCount(fixes) <= Math.ceil(legKm / 10) + 1,
|
||||
`${drawnCount(fixes)} points over ${legKm} km`,
|
||||
);
|
||||
for (let i = 1; i < fixes.length; i++)
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(fixes[i - 1].position, fixes[i].position) <=
|
||||
TRAIL_THIN_MAX_SPAN_M + speed,
|
||||
);
|
||||
});
|
||||
|
||||
test('a standard-rate 180° turn keeps its curve: every recorded position stays within 30 m of the kept line', () => {
|
||||
const speed = 230;
|
||||
const fixes = [];
|
||||
const recorded = [];
|
||||
insertTrailFix(fixes, turning(speed, 0), 0, LIMIT);
|
||||
for (let t = 1; t <= 60; t++) {
|
||||
const position = turning(speed, t);
|
||||
recorded.push(position);
|
||||
recordTrailPoint(fixes, position, t * SEC, LIMIT);
|
||||
}
|
||||
const line = fixes.map((fix) => fix.position);
|
||||
assert.equal(TRAIL_THIN_TOLERANCE_M, 30);
|
||||
for (const position of recorded) {
|
||||
const off = distanceToPath(position, line);
|
||||
assert.ok(off <= TRAIL_THIN_TOLERANCE_M + 1, `${off.toFixed(1)} m off`);
|
||||
}
|
||||
assert.ok(drawnCount(fixes) < recorded.length, 'nothing was thinned');
|
||||
assert.ok(drawnCount(fixes) >= 6, 'the curve was flattened');
|
||||
});
|
||||
|
||||
test('a climb off the straight line keeps its vertices', () => {
|
||||
const fixes = [];
|
||||
insertTrailFix(fixes, eastbound(200, 0, 1_000), 0, LIMIT);
|
||||
// 30 s level, then a 60 s climb of 20 m/s, then level again.
|
||||
const height = (t) => 1_000 + 20 * Math.min(60, Math.max(0, t - 30));
|
||||
const recorded = [];
|
||||
for (let t = 1; t <= 150; t++) {
|
||||
const position = eastbound(200, t, height(t));
|
||||
recorded.push(position);
|
||||
recordTrailPoint(fixes, position, t * SEC, LIMIT);
|
||||
}
|
||||
const line = fixes.map((fix) => fix.position);
|
||||
for (const position of recorded)
|
||||
assert.ok(distanceToPath(position, line) <= TRAIL_THIN_TOLERANCE_M + 1);
|
||||
});
|
||||
|
||||
test('at the limit, old recorded points go before any report from the backfill', () => {
|
||||
const max = 300;
|
||||
const backfill = [];
|
||||
for (let i = 0; i < 100; i++)
|
||||
backfill.push({
|
||||
position: eastbound(200, i * 60 - 20_000),
|
||||
timeMs: (i * 60 - 20_000) * SEC,
|
||||
nullAltitude: false,
|
||||
});
|
||||
let fixes = [];
|
||||
insertTrailFix(fixes, turning(150, 0), 0, max);
|
||||
// A long series of turns keeps many recorded points.
|
||||
for (let t = 1; t <= 2_000; t++)
|
||||
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
|
||||
assert.ok(drawnCount(fixes) > 150);
|
||||
fixes = joinTrailBackfill(
|
||||
{
|
||||
fixes: backfill.map((b) => ({ ...b, breakBefore: false })),
|
||||
breakAfter: false,
|
||||
},
|
||||
fixes,
|
||||
max,
|
||||
);
|
||||
assert.ok(fixes.length <= max);
|
||||
const reports = fixes.filter((fix) => !fix.drawn);
|
||||
assert.equal(reports.length, 101, 'a report was dropped');
|
||||
// More recording at the limit drops recorded points, never reports.
|
||||
for (let t = 2_001; t <= 2_400; t++)
|
||||
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
|
||||
assert.ok(fixes.length <= max);
|
||||
assert.equal(fixes.filter((fix) => !fix.drawn).length, 101);
|
||||
// The recorded points that remain are the newest.
|
||||
const drawnTimes = fixes.filter((fix) => fix.drawn).map((fix) => fix.timeMs);
|
||||
assert.ok(Math.min(...drawnTimes) > 1_000 * SEC);
|
||||
});
|
||||
@@ -389,6 +389,179 @@ function rioRouteLabel(routeId) {
|
||||
: routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carris Metropolitana realtime route ids wrap the GTFS route id in an
|
||||
* operator-area prefix and keep its pattern suffix: `[LA77N]1708_0` is GTFS
|
||||
* route `1708_0`, whose `short_name` (the number on the bus) is `1708`, per
|
||||
* the operator's own `/v2/routes` API. Anything else passes through.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function carrisMetropolitanaRouteLabel(routeId) {
|
||||
const match = /^\[[A-Z0-9]+\](\d{4})_\d+$/.exec(routeId ?? '');
|
||||
return match ? match[1] : routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route ids that wrap the rider-facing line in a fixed prefix, suffix or
|
||||
* zero-padding. The returned function yields the captured line, or the id
|
||||
* unchanged when it does not match the operator's pattern.
|
||||
* @param {RegExp} pattern One capture group: the line a rider would say.
|
||||
* @returns {(routeId: string|null) => string|null}
|
||||
*/
|
||||
function routeIdPattern(pattern) {
|
||||
return (routeId) => {
|
||||
const match = pattern.exec(routeId ?? '');
|
||||
return match ? match[1] : routeId;
|
||||
};
|
||||
}
|
||||
|
||||
/** Zero-padded numbers (Edmonton `005`, Toulon `0002`, Bordeaux `01`). */
|
||||
const unpaddedRouteLabel = routeIdPattern(/^0+(\d+\w*)$/);
|
||||
/** Turin GTT appends `U` (urban) to the line: `10NU` is night line 10N. */
|
||||
const gttRouteLabel = routeIdPattern(/^(\w+?)U$/);
|
||||
/** Venice ACTV appends the network: `5.1_UN` is waterbus line 5.1, `53E_ES` bus 53E. */
|
||||
const actvRouteLabel = routeIdPattern(/^(.+)_[A-Z]{2}$/);
|
||||
/** BC Transit appends the system: `95-VIC` is Victoria route 95. */
|
||||
const bcTransitRouteLabel = routeIdPattern(/^(.+)-VIC$/);
|
||||
/** Carris (Lisbon city) appends a variant: `172_1` is route 172. */
|
||||
const carrisRouteLabel = routeIdPattern(/^(\w+)_\d+$/);
|
||||
/** Astuce prefixes the network: `TCAR:93` is Rouen line 93. */
|
||||
const astuceNetworkLabel = routeIdPattern(/^[A-Z]+:(.+)$/);
|
||||
/** BAS.MY Johor appends a fixed operator code: `J33CWLMYJB` is route J33. */
|
||||
const mybasJohorRouteLabel = routeIdPattern(/^(.+?)CWLMYJB$/);
|
||||
|
||||
/** LiA prefixes its network: `8-C3` is Le Havre line C3, `8-09` line 9. */
|
||||
const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
|
||||
|
||||
/**
|
||||
* Le Havre LiA runs its two tram lines as A and B and the funicular as
|
||||
* `FUNI`; every other line is a bus.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function liaRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
const line = liaRouteLabel(routeId);
|
||||
if (line === 'FUNI') return 'rail';
|
||||
return /^[AB]$/.test(line) ? 'tram' : 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rouen Astuce lines that are not buses, by route id, from the TCAR
|
||||
* routes.txt (checked 2026-11-09): line 90 is the Métro (GTFS type 1, a light
|
||||
* rail that runs mostly on the street) and 99 the Calypso river boat.
|
||||
*/
|
||||
const ASTUCE_ROUTES = Object.freeze({
|
||||
// i18n-ignore: line name, as on the vehicle
|
||||
'TCAR:90': Object.freeze({ mode: 'tram', label: 'Métro' }),
|
||||
// i18n-ignore: line name, as on the vehicle
|
||||
'TCAR:99': Object.freeze({ mode: 'ferry', label: 'Calypso' }),
|
||||
});
|
||||
|
||||
/** @param {string|null} routeId @returns {string} */
|
||||
function astuceRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
return ASTUCE_ROUTES[routeId]?.mode ?? 'bus';
|
||||
}
|
||||
|
||||
/** `TCAR:93` is Rouen line 93; the Métro and Calypso keep their names. */
|
||||
function astuceRouteLabel(routeId) {
|
||||
return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prague PID route ids are `L` plus a number whose range says what runs it,
|
||||
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
|
||||
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
|
||||
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
|
||||
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
|
||||
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
|
||||
* 2000 more buses (`L2841` is 841). A label the range cannot give stays the
|
||||
* route id.
|
||||
* @param {string|null} routeId
|
||||
* @returns {{mode: string, label: string|null}}
|
||||
*/
|
||||
function pidRoute(routeId) {
|
||||
const match = /^L(\d+)$/.exec(routeId ?? '');
|
||||
if (!match) return { mode: 'unknown', label: routeId };
|
||||
const n = Number(match[1]);
|
||||
// i18n-ignore: line code, as signed
|
||||
if (n === 49) return { mode: 'rail', label: 'LD' };
|
||||
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) };
|
||||
if (n < 100) return { mode: 'tram', label: String(n) };
|
||||
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
|
||||
if (n < 991) return { mode: 'bus', label: String(n) };
|
||||
// i18n-ignore: metro line letters, as signed
|
||||
if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] };
|
||||
if (n >= 1000 && n < 1100) return { mode: 'rail', label: `S${n - 1000}` };
|
||||
if (n >= 1100 && n < 1200) return { mode: 'rail', label: `R${n - 1100}` };
|
||||
if (n >= 1200 && n < 1400) return { mode: 'rail', label: routeId };
|
||||
if (n >= 1800 && n < 1900) return { mode: 'ferry', label: `P${n - 1800}` };
|
||||
if (n >= 2800 && n < 2900) return { mode: 'bus', label: String(n - 2000) };
|
||||
return { mode: n >= 1700 ? 'bus' : 'unknown', label: routeId };
|
||||
}
|
||||
|
||||
/** @param {string|null} routeId @returns {string} */
|
||||
function pidRouteMode(routeId) {
|
||||
return pidRoute(routeId).mode;
|
||||
}
|
||||
|
||||
/** @param {string|null} routeId @returns {string|null} */
|
||||
function pidRouteLabel(routeId) {
|
||||
return pidRoute(routeId).label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS
|
||||
* routes.txt (checked 2026-11-09): the trams are numbered ids, not letters.
|
||||
*/
|
||||
const TBM_TRAM_LINES = Object.freeze({
|
||||
59: 'A',
|
||||
60: 'B',
|
||||
61: 'C',
|
||||
62: 'D',
|
||||
163: 'E',
|
||||
164: 'F',
|
||||
});
|
||||
const TBM_BOAT_LINES = new Set(['951', '952', '953']);
|
||||
|
||||
/**
|
||||
* Bordeaux TBM: trams A–F and the BAT³ river shuttles; every other line is a
|
||||
* bus.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function tbmRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (Object.hasOwn(TBM_TRAM_LINES, routeId)) return 'tram';
|
||||
return TBM_BOAT_LINES.has(routeId) ? 'ferry' : 'bus';
|
||||
}
|
||||
|
||||
/** `59` is tram A; zero-padded bus ids lose their padding (`01` is 1). */
|
||||
function tbmRouteLabel(routeId) {
|
||||
return Object.hasOwn(TBM_TRAM_LINES, routeId ?? '')
|
||||
? TBM_TRAM_LINES[routeId]
|
||||
: unpaddedRouteLabel(routeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry entry, frozen with its centre and terms, and switched on.
|
||||
* @param {object} fields
|
||||
* @returns {Readonly<object>}
|
||||
*/
|
||||
function transitFeed(fields) {
|
||||
return Object.freeze({
|
||||
defaultEnabled: true,
|
||||
...fields,
|
||||
center: Object.freeze({ ...fields.center }),
|
||||
terms: Object.freeze({ ...fields.terms }),
|
||||
...(fields.headers
|
||||
? { headers: Object.freeze({ ...fields.headers }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
@@ -398,7 +571,14 @@ function rioRouteLabel(routeId) {
|
||||
* url: string, headers?: Record<string, string>,
|
||||
* license: string, licenseUrl: string, attribution: string,
|
||||
* defaultMode: string, routeMode?: (routeId: string|null) => string,
|
||||
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
|
||||
* creditDated?: boolean,
|
||||
* }>>}
|
||||
* `speedUnit` marks a feed that publishes km/h where the spec requires m/s;
|
||||
* the proxy converts it before the snapshot leaves the server. `refreshMs`
|
||||
* stretches the proxy's cache for an upstream that rate-limits below one
|
||||
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
|
||||
* to the credit for licences that ask for them (`transitFeedCredit`).
|
||||
*/
|
||||
export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
@@ -788,6 +968,624 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
defaultMode: 'bus',
|
||||
routeLabel: rioRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'zditm-szczecin',
|
||||
name: 'Szczecin buses and trams',
|
||||
operator:
|
||||
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM), Szczecin',
|
||||
region: 'Szczecin, Poland',
|
||||
// Every vehicle in a 2026-11-05 snapshot sat between 53.34–53.55 N and
|
||||
// 14.44–14.77 E, inside 20 km of this point.
|
||||
center: { lat: 53.44, lon: 14.56 },
|
||||
loadRadiusKm: 25,
|
||||
url: 'https://www.zditm.szczecin.pl/storage/gtfs/gtfs-rt-vehicles.pb',
|
||||
license: 'CC0 1.0',
|
||||
licenseUrl: 'https://www.zditm.szczecin.pl/en/zditm/for-developers',
|
||||
attribution:
|
||||
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM Szczecin)',
|
||||
terms: {
|
||||
quote:
|
||||
'The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.',
|
||||
note: 'CC0 needs no credit; it is given anyway as a courtesy. ZDiTM gives no guarantee of accuracy or completeness. The developer page says the feed updates every minute; measured on 2026-11-05 the file changed about every 20 seconds, with vehicle timestamps up to 50 seconds old. Speeds are m/s: checked against vehicle movement between two snapshots. The feed carries no route id at all, so every vehicle takes the feed default mode even though the city also runs trams.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'carris-metropolitana-lisboa',
|
||||
name: 'Carris Metropolitana',
|
||||
operator: 'Carris Metropolitana (TML), Lisbon Metropolitan Area',
|
||||
region: 'Lisbon Metropolitan Area, Portugal',
|
||||
// Every vehicle in a 2026-11-07 snapshot, from Mafra to Setúbal, sat
|
||||
// between 38.47–39.00 N and 9.47–8.79 W, inside 40 km of this point.
|
||||
center: { lat: 38.73, lon: -9.13 },
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://api.carrismetropolitana.pt/v2/vehicles.pb',
|
||||
license: 'No separate licence for the realtime feed',
|
||||
licenseUrl: 'https://github.com/carrismetropolitana/api',
|
||||
attribution: 'Carris Metropolitana',
|
||||
terms: {
|
||||
quote:
|
||||
'GET /vehicles.pb — Returns vehicle locations in Protobuf format, following the GTFS-RT Vehicle Locations standard.',
|
||||
note: 'The operator publishes this feed keyless and documented for developers in its public API repository, but states no licence for the realtime endpoint; its static GTFS (github.com/carrismetropolitana/gtfs) is CC BY 4.0. Credited as a courtesy, on the same basis as the Metro Transit and OVapi feeds; switch it off if Carris objects. The quote above is the API README, not a licence. `/vehicles.pb` answers with a 307 to `/v2/vehicles.pb`, so the registry points at the target. Speeds are km/h, not the m/s the spec requires: 2026-11-07 readings of 31–33 matched 6–9 m/s of measured movement, so the proxy converts them. Vehicle reports arrive every 30–50 seconds. Route ids are the GTFS route id inside an area prefix and are read as the line number.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: carrisMetropolitanaRouteLabel,
|
||||
speedUnit: 'km/h',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'pid-praha',
|
||||
name: 'Prague PID',
|
||||
operator: 'Pražská integrovaná doprava (PID), via Golemio',
|
||||
region: 'Prague and Central Bohemia, Czechia',
|
||||
// PID reaches across Central Bohemia: on 2026-11-08, 95% of 923 vehicles
|
||||
// sat within 58 km of Prague, the farthest at 98 km.
|
||||
center: { lat: 50.08, lon: 14.45 },
|
||||
loadRadiusKm: 60,
|
||||
url: 'https://api.golemio.cz/v2/vehiclepositions/gtfsrt/vehicle_positions.pb',
|
||||
license: 'CC BY',
|
||||
licenseUrl: 'https://pid.cz/o-systemu/opendata/',
|
||||
attribution: 'Pražská integrovaná doprava (PID) / Golemio',
|
||||
terms: {
|
||||
quote:
|
||||
'Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY',
|
||||
note: '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); `pidRoute` holds the table, read from PID’s GTFS routes.txt on 2026-11-09.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: pidRouteLabel,
|
||||
routeMode: pidRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztm-warszawa',
|
||||
name: 'Warsaw buses and trams',
|
||||
operator: 'Miasto Stołeczne Warszawa, relayed by mkuran.pl',
|
||||
region: 'Warsaw, Poland',
|
||||
center: { lat: 52.25, lon: 21.02 },
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://mkuran.pl/gtfs/warsaw/vehicles.pb',
|
||||
license: 'Warsaw Open Data Project Usage Terms',
|
||||
licenseUrl: 'https://mkuran.pl/gtfs/',
|
||||
attribution: 'Miasto Stołeczne Warszawa (api.um.warszawa.pl)',
|
||||
sourceUrl: 'http://api.um.warszawa.pl',
|
||||
creditDated: true,
|
||||
terms: {
|
||||
quote:
|
||||
'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.',
|
||||
note: '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 (`sourceUrl`) and carries the date of the feed timestamp (`creditDated`), the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'tbm-bordeaux',
|
||||
name: 'Bordeaux TBM',
|
||||
operator: 'Transports Bordeaux Métropole (TBM), Bordeaux Métropole',
|
||||
region: 'Bordeaux, France',
|
||||
center: { lat: 44.85, lon: -0.65 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt',
|
||||
license: 'Licence Ouverte / Etalab 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite',
|
||||
attribution: 'Bordeaux Métropole (TBM)',
|
||||
terms: {
|
||||
quote: 'licence: fr-lo',
|
||||
note: '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`), so `tbmRouteMode` and `tbmRouteLabel` map them from TBM’s static GTFS; other numbered lines are buses.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: tbmRouteLabel,
|
||||
routeMode: tbmRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'sendai-city-bus',
|
||||
name: 'Sendai City Bus',
|
||||
operator: 'Sendai City Transportation Bureau (仙台市交通局), via ODPT',
|
||||
region: 'Sendai, Japan',
|
||||
center: { lat: 38.27, lon: 140.82 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://api-public.odpt.org/api/v4/gtfs/realtime/odpt_SendaiMunicipal_bus_realtime_information_vehicle',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
|
||||
attribution: '仙台市交通局 (Sendai City Transportation Bureau) / ODPT',
|
||||
terms: {
|
||||
quote: 'https://creativecommons.org/licenses/by/4.0/',
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztm-poznan',
|
||||
name: 'Poznań buses and trams',
|
||||
operator: 'Zarząd Transportu Miejskiego w Poznaniu (ZTM Poznań)',
|
||||
region: 'Poznań, Poland',
|
||||
center: { lat: 52.39, lon: 16.92 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://www.ztm.poznan.pl/pl/dla-deweloperow/getGtfsRtFile/?file=vehicle_positions.pb',
|
||||
license: 'ZTM Poznań open-data terms',
|
||||
licenseUrl: 'https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/',
|
||||
attribution: 'Zarząd Transportu Miejskiego w Poznaniu (processed)',
|
||||
creditDated: true,
|
||||
terms: {
|
||||
quote:
|
||||
'Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.',
|
||||
note: '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 (`creditDated`) as the update date. The feed carries no bearing, so heading comes from movement between fixes.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztm-gdansk',
|
||||
name: 'Gdańsk buses and trams',
|
||||
operator: 'Zarząd Transportu Miejskiego w Gdańsku (ZTM Gdańsk)',
|
||||
region: 'Gdańsk, Poland',
|
||||
center: { lat: 54.35, lon: 18.6 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://ckan2.multimediagdansk.pl/gtfs-rt?feed=vehiclePositions',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl:
|
||||
'https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt',
|
||||
attribution: 'Zarząd Transportu Miejskiego w Gdańsku',
|
||||
terms: {
|
||||
note: 'The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ets-edmonton',
|
||||
name: 'Edmonton Transit Service',
|
||||
operator: 'City of Edmonton (Edmonton Transit Service)',
|
||||
region: 'Edmonton, AB',
|
||||
center: { lat: 53.53, lon: -113.52 },
|
||||
loadRadiusKm: 25,
|
||||
url: 'https://gtfs.edmonton.ca/TMGTFSRealTimeWebService/Vehicle/VehiclePositions.pb',
|
||||
license: 'City of Edmonton Open Data Agreement',
|
||||
licenseUrl:
|
||||
'https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Data Agreement – City of Edmonton',
|
||||
terms: {
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: unpaddedRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'halifax-transit',
|
||||
name: 'Halifax Transit',
|
||||
operator: 'Halifax Regional Municipality (Halifax Transit)',
|
||||
region: 'Halifax, NS',
|
||||
center: { lat: 44.7, lon: -63.6 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://gtfs.halifax.ca/realtime/Vehicle/VehiclePositions.pb',
|
||||
license: 'Open Government Licence – Halifax',
|
||||
licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-license',
|
||||
attribution:
|
||||
'Contains information licenced under the Open Government Licence – Halifax',
|
||||
terms: {
|
||||
quote:
|
||||
'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.',
|
||||
note: 'Version 1.0, modelled on the Open Government Licence – Canada 2.0. The attribution is the licence’s own standard statement, with a link to the licence where possible. Logos and crests are excluded and no endorsement may be implied, so the credit stays plain text. The page refuses automated requests (403); its text was read in a browser on 2026-11-08. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'bc-transit-victoria',
|
||||
name: 'Victoria Regional Transit',
|
||||
operator: 'BC Transit (Victoria Regional Transit System)',
|
||||
region: 'Victoria, BC',
|
||||
center: { lat: 48.53, lon: -123.43 },
|
||||
loadRadiusKm: 28,
|
||||
url: 'https://bct.tmix.se/gtfs-realtime/vehicleupdates.pb?operatorIds=48',
|
||||
license: 'BC Transit Open Data Terms of Use',
|
||||
licenseUrl: 'https://www.bctransit.com/open-data/terms-of-use',
|
||||
attribution: 'BC Transit',
|
||||
terms: {
|
||||
quote:
|
||||
'BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.',
|
||||
note: 'The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: bcTransitRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'tam-montpellier',
|
||||
name: 'Montpellier TaM',
|
||||
operator: 'Montpellier Méditerranée Métropole (TaM)',
|
||||
region: 'Montpellier, France',
|
||||
center: { lat: 43.61, lon: 3.87 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb',
|
||||
license: 'ODbL',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain',
|
||||
attribution: 'Montpellier Méditerranée Métropole (TaM)',
|
||||
terms: {
|
||||
quote: 'licence: odc-odbl',
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
speedUnit: 'km/h',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztp-krakow-bus',
|
||||
name: 'Kraków buses',
|
||||
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
|
||||
region: 'Kraków, Poland',
|
||||
center: { lat: 50.06, lon: 19.94 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_A.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
|
||||
attribution: 'Zarząd Transportu Publicznego w Krakowie',
|
||||
terms: {
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ztp-krakow-tram',
|
||||
name: 'Kraków trams',
|
||||
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
|
||||
region: 'Kraków, Poland',
|
||||
center: { lat: 50.06, lon: 19.94 },
|
||||
loadRadiusKm: 22,
|
||||
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_T.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
|
||||
attribution: 'Zarząd Transportu Publicznego w Krakowie',
|
||||
terms: { note: 'Same server and basis as the bus feed.' },
|
||||
defaultMode: 'tram',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'vilnius-transport',
|
||||
name: 'Vilnius public transport',
|
||||
operator: 'SĮ Susisiekimo paslaugos, Vilnius',
|
||||
region: 'Vilnius, Lithuania',
|
||||
center: { lat: 54.69, lon: 25.28 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://www.stops.lt/vilnius/vehicle_positions.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383',
|
||||
attribution: 'SĮ Susisiekimo paslaugos (stops.lt)',
|
||||
terms: {
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'tpbi-bucuresti',
|
||||
name: 'Bucharest public transport',
|
||||
operator: 'Asociația de Dezvoltare Intercomunitară TPBI, Bucharest–Ilfov',
|
||||
region: 'Bucharest, Romania',
|
||||
center: { lat: 44.43, lon: 26.1 },
|
||||
loadRadiusKm: 25,
|
||||
url: 'https://gtfs.tpbi.ro/api/gtfs-rt/vehiclePositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104',
|
||||
attribution: 'TPBI (Transport Public București–Ilfov)',
|
||||
terms: {
|
||||
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'roma-mobilita',
|
||||
name: 'Rome public transport',
|
||||
operator: 'Roma Servizi per la Mobilità',
|
||||
region: 'Rome, Italy',
|
||||
center: { lat: 41.89, lon: 12.5 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://romamobilita.it/wp-content/uploads/shared/rome_rtgtfs_vehicle_positions_feed.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776',
|
||||
attribution: 'Roma Servizi per la Mobilità',
|
||||
terms: {
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'gtt-torino',
|
||||
name: 'Turin GTT',
|
||||
operator: 'Gruppo Torinese Trasporti (GTT)',
|
||||
region: 'Turin, Italy',
|
||||
center: { lat: 45.07, lon: 7.68 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696',
|
||||
attribution: 'Gruppo Torinese Trasporti (GTT)',
|
||||
terms: {
|
||||
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: gttRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'actv-venezia-bus',
|
||||
name: 'Venice ACTV buses',
|
||||
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV)',
|
||||
region: 'Venice, Italy',
|
||||
center: { lat: 45.4, lon: 12.2 },
|
||||
loadRadiusKm: 38,
|
||||
url: 'https://tpl.actv.it/aut/GTFSRT/vehiclePositions',
|
||||
license: 'ACTV open-data catalogue',
|
||||
licenseUrl:
|
||||
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
|
||||
attribution: 'ACTV S.p.A.',
|
||||
terms: {
|
||||
note: '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`).',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: actvRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'actv-venezia-waterbus',
|
||||
name: 'Venice ACTV waterbuses',
|
||||
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV) Navigazione',
|
||||
region: 'Venice, Italy',
|
||||
center: { lat: 45.43, lon: 12.33 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://tpl.actv.it/nav/GTFSRT/vehiclePositions',
|
||||
license: 'ACTV open-data catalogue',
|
||||
licenseUrl:
|
||||
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
|
||||
attribution: 'ACTV S.p.A.',
|
||||
terms: { note: 'Same catalogue and basis as the ACTV bus feed.' },
|
||||
defaultMode: 'ferry',
|
||||
routeLabel: actvRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'adelaide-metro',
|
||||
name: 'Adelaide Metro',
|
||||
operator: 'Adelaide Metro (Government of South Australia)',
|
||||
region: 'Adelaide, SA',
|
||||
center: { lat: -34.93, lon: 138.6 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions',
|
||||
// Any Accept naming a protobuf type gets base64 text labelled JSON; only
|
||||
// */* returns the binary feed (checked 2026-11-08).
|
||||
headers: { Accept: '*/*' },
|
||||
license: 'No licence stated for the realtime feed',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772',
|
||||
attribution: 'Adelaide Metro',
|
||||
terms: {
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'marta-atlanta',
|
||||
name: 'MARTA buses',
|
||||
operator: 'Metropolitan Atlanta Rapid Transit Authority (MARTA)',
|
||||
region: 'Atlanta, GA',
|
||||
center: { lat: 33.78, lon: -84.39 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601',
|
||||
attribution: 'MARTA',
|
||||
terms: {
|
||||
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'rtd-denver',
|
||||
name: 'Denver RTD',
|
||||
operator: 'Regional Transportation District (RTD), Denver',
|
||||
region: 'Denver, CO',
|
||||
center: { lat: 39.75, lon: -104.99 },
|
||||
loadRadiusKm: 55,
|
||||
url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb',
|
||||
license: 'RTD GTFS-realtime licence agreement',
|
||||
licenseUrl:
|
||||
'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement',
|
||||
attribution: 'Regional Transportation District (RTD)',
|
||||
terms: {
|
||||
quote:
|
||||
'RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data',
|
||||
note: 'Read 2026-11-09. The agreement is a click-through (“By clicking to download the Data files, you confirm that you have read and agree”) that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from being used with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ctp-cluj',
|
||||
name: 'Cluj-Napoca CTP',
|
||||
operator: 'Compania de Transport Public Cluj-Napoca',
|
||||
region: 'Cluj-Napoca, Romania',
|
||||
center: { lat: 46.77, lon: 23.59 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://cluj-rt-feed.gtfs.ro/vehiclePositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614',
|
||||
attribution: 'Compania de Transport Public Cluj-Napoca',
|
||||
terms: {
|
||||
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'ctp-iasi',
|
||||
name: 'Iași CTP',
|
||||
operator: 'Compania de Transport Public Iași',
|
||||
region: 'Iași, Romania',
|
||||
center: { lat: 47.16, lon: 27.59 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://iasi-rt-feed.gtfs.ro/vehiclePositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118',
|
||||
attribution: 'Compania de Transport Public Iași',
|
||||
terms: {
|
||||
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mpk-rzeszow',
|
||||
name: 'Rzeszów city transport',
|
||||
operator: 'Rzeszowski Transport Miejski (MPK Rzeszów)',
|
||||
region: 'Rzeszów, Poland',
|
||||
center: { lat: 50.04, lon: 22.0 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://www.mpkrzeszow.pl/gtfs/rt/gtfsrt.pb',
|
||||
license: 'MPK Rzeszów open data',
|
||||
licenseUrl: 'https://www.mpkrzeszow.pl/otwartedane/',
|
||||
attribution: 'MPK Rzeszów',
|
||||
terms: {
|
||||
note: 'Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'emt-malaga',
|
||||
name: 'Málaga EMT',
|
||||
operator: 'Empresa Malagueña de Transportes (EMT)',
|
||||
region: 'Málaga, Spain',
|
||||
center: { lat: 36.72, lon: -4.42 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://gtfs.emtmalaga.es/GTFSRTapi/api/vehicleposition',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213',
|
||||
attribution: 'EMT Málaga',
|
||||
terms: {
|
||||
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'carris-lisboa',
|
||||
name: 'Carris (Lisbon city)',
|
||||
operator: 'Carris – Companhia Carris de Ferro de Lisboa',
|
||||
region: 'Lisbon, Portugal',
|
||||
center: { lat: 38.74, lon: -9.15 },
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://gateway.carris.pt/gateway/gtfs/api/v2.11/GTFS/realtime/vehiclepositions',
|
||||
license: 'No licence stated',
|
||||
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930',
|
||||
attribution: 'Carris',
|
||||
terms: {
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: carrisRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mybas-johor',
|
||||
name: 'BAS.MY Johor Bahru',
|
||||
operator: 'BAS.MY, via data.gov.my',
|
||||
region: 'Johor Bahru, Malaysia',
|
||||
center: { lat: 1.51, lon: 103.75 },
|
||||
loadRadiusKm: 35,
|
||||
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-johor/',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
|
||||
attribution: 'BAS.MY / data.gov.my',
|
||||
// data.gov.my allows 4 GTFS-Realtime requests a minute and refreshes
|
||||
// vehicle positions every 30 seconds, so the proxy asks half as often as
|
||||
// the default 15 s TTL would.
|
||||
refreshMs: 30_000,
|
||||
terms: {
|
||||
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
|
||||
note: 'Served keyless by Malaysia’s national open-data API (developer.data.gov.my, read 2026-11-09). Its rate-limit page caps GTFS Realtime at 4 requests a minute and answers 429 above that; the feed updates every 30 seconds, hence `refreshMs`. Without the trailing slash the API redirects. Route ids append an operator code.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: mybasJohorRouteLabel,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mybas-kota-bharu',
|
||||
name: 'BAS.MY Kota Bharu',
|
||||
operator: 'BAS.MY, via data.gov.my',
|
||||
region: 'Kota Bharu, Malaysia',
|
||||
// Routes run south into Kelantan: 95% of vehicles within 58 km on 2026-11-08.
|
||||
center: { lat: 6.06, lon: 102.22 },
|
||||
loadRadiusKm: 60,
|
||||
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-kota-bharu/',
|
||||
license: 'CC BY 4.0',
|
||||
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
|
||||
attribution: 'BAS.MY / data.gov.my',
|
||||
refreshMs: 30_000,
|
||||
terms: {
|
||||
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
|
||||
note: 'Same API, licence and rate limit as Johor Bahru.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'astuce-rouen',
|
||||
name: 'Rouen Astuce',
|
||||
operator: 'Métropole Rouen Normandie (Astuce), réseau TCAR',
|
||||
region: 'Rouen, France',
|
||||
center: { lat: 49.44, lon: 1.1 },
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://api.mrn.cityway.fr/dataflow/vehicle-tc-tr/download?provider=TCAR&dataFormat=gtfs-rt',
|
||||
license: 'Licence Ouverte 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie',
|
||||
attribution: 'Métropole Rouen Normandie (Astuce)',
|
||||
terms: {
|
||||
quote: 'licence: lov2',
|
||||
note: 'The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted), and this is its own TCAR vehicle feed from that record (178 vehicles on 2026-11-09). The suburban TAE and TNI networks are separate feeds in the same record and are not read here. Route ids carry the network prefix (`TCAR:`); `TCAR:90` is the Métro (a street-running light rail, drawn as a tram) and `TCAR:99` the Calypso boat.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: astuceRouteLabel,
|
||||
routeMode: astuceRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'mpm-toulon',
|
||||
name: 'Toulon Mistral',
|
||||
operator: 'Métropole Toulon Provence Méditerranée (réseau Mistral)',
|
||||
region: 'Toulon, France',
|
||||
center: { lat: 43.12, lon: 5.94 },
|
||||
loadRadiusKm: 18,
|
||||
url: 'https://feed-rdtpm-toulon.ratpdev.com/VehiclePosition/GTFS-RT',
|
||||
license: 'Licence Ouverte 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee',
|
||||
attribution: 'Métropole Toulon Provence Méditerranée (Mistral)',
|
||||
terms: {
|
||||
quote: 'licence: lov2',
|
||||
note: '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.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: unpaddedRouteLabel,
|
||||
speedUnit: 'km/h',
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'lia-le-havre',
|
||||
name: 'Le Havre LiA',
|
||||
operator: 'Le Havre Seine Métropole (LiA)',
|
||||
region: 'Le Havre, France',
|
||||
center: { lat: 49.49, lon: 0.11 },
|
||||
loadRadiusKm: 18,
|
||||
url: 'https://opendata.transports-lia.fr/gtfs-rt/VehiclePosition.pb',
|
||||
license: 'ODbL',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri',
|
||||
attribution: 'Le Havre Seine Métropole (LiA)',
|
||||
terms: {
|
||||
quote: 'licence: odc-odbl',
|
||||
note: 'The network publishes under ODbL (dataset record quoted), and this is its own GTFS-RT vehicle feed from that record (105 vehicles on 2026-11-09). The layer draws positions and redistributes no database. Route ids carry the network prefix `8-`; lines A and B are the tramway.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
routeLabel: liaRouteLabel,
|
||||
routeMode: liaRouteMode,
|
||||
}),
|
||||
transitFeed({
|
||||
id: 'filbleu-tours',
|
||||
name: 'Tours Fil Bleu',
|
||||
operator: 'Syndicat des Mobilités de Touraine (Fil Bleu)',
|
||||
region: 'Tours, France',
|
||||
center: { lat: 47.39, lon: 0.69 },
|
||||
loadRadiusKm: 18,
|
||||
url: 'https://data.filbleu.fr/ws-tr/gtfs-rt/opendata/vehicle-positions',
|
||||
license: 'Licence Ouverte 2.0',
|
||||
licenseUrl:
|
||||
'https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt',
|
||||
attribution: 'Fil Bleu – Syndicat des Mobilités de Touraine',
|
||||
terms: {
|
||||
quote: 'licence: lov2',
|
||||
note: 'Dataset record’s licence field quoted.',
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -552,3 +552,229 @@ test('Rio feeds are CC BY 4.0 and live', () => {
|
||||
'Rio has a DATA_SOURCES row',
|
||||
);
|
||||
});
|
||||
|
||||
test('Szczecin is CC0, live, covers the city and not Berlin', () => {
|
||||
const feed = getTransitFeed('zditm-szczecin');
|
||||
assert.equal(feed.defaultEnabled, true);
|
||||
assert.equal(feed.license, 'CC0 1.0');
|
||||
assert.equal(
|
||||
feed.licenseUrl,
|
||||
'https://www.zditm.szczecin.pl/en/zditm/for-developers',
|
||||
);
|
||||
assert.match(feed.terms.quote, /CC0 1\.0 licence/);
|
||||
assert.match(feed.attribution, /ZDiTM/);
|
||||
assert.equal(feed.routeLabel, undefined, 'the feed has no route ids');
|
||||
assert.equal(transitModeFor(feed, null), 'bus');
|
||||
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
|
||||
assert.ok(ids(53.4285, 14.5528).includes('zditm-szczecin'), 'city centre');
|
||||
assert.ok(ids(53.5, 14.7).includes('zditm-szczecin'), 'north-east edge');
|
||||
assert.ok(!ids(52.52, 13.405).includes('zditm-szczecin'), 'Berlin');
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
sources.split('\n').some((l) => l.startsWith('| **ZDiTM Szczecin')),
|
||||
'Szczecin has a DATA_SOURCES row',
|
||||
);
|
||||
});
|
||||
|
||||
test('Carris Metropolitana covers Lisbon from Mafra to Setúbal, not Porto', () => {
|
||||
const feed = getTransitFeed('carris-metropolitana-lisboa');
|
||||
assert.equal(feed.defaultEnabled, true);
|
||||
assert.equal(feed.url, 'https://api.carrismetropolitana.pt/v2/vehicles.pb');
|
||||
assert.equal(feed.speedUnit, 'km/h');
|
||||
assert.match(feed.terms.note, /no licence for the realtime endpoint/);
|
||||
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
|
||||
for (const [place, lat, lon] of [
|
||||
['Lisbon', 38.7223, -9.1393],
|
||||
['Mafra', 38.9369, -9.3277],
|
||||
['Setúbal', 38.5244, -8.8882],
|
||||
]) {
|
||||
assert.ok(ids(lat, lon).includes(feed.id), place);
|
||||
}
|
||||
assert.ok(!ids(41.1579, -8.6291).includes(feed.id), 'Porto');
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
sources.split('\n').some((l) => l.startsWith('| **Carris Metropolitana')),
|
||||
'Lisbon has a DATA_SOURCES row',
|
||||
);
|
||||
});
|
||||
|
||||
test('Carris Metropolitana route ids read as the line number on the bus', () => {
|
||||
const feed = getTransitFeed('carris-metropolitana-lisboa');
|
||||
assert.equal(transitRouteLabel(feed, '[LA77N]1708_0'), '1708');
|
||||
assert.equal(transitRouteLabel(feed, '[BNA17]2772_1'), '2772');
|
||||
assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected');
|
||||
assert.equal(transitRouteLabel(feed, null), null);
|
||||
});
|
||||
|
||||
// The 2026-11 batch: one landmark per feed, inside its own radius.
|
||||
const NEW_CITY_LANDMARKS = {
|
||||
'pid-praha': [50.0875, 14.4213],
|
||||
'ztm-warszawa': [52.2297, 21.0122],
|
||||
'tbm-bordeaux': [44.8378, -0.5792],
|
||||
'sendai-city-bus': [38.2602, 140.8824],
|
||||
'ztm-poznan': [52.4064, 16.9252],
|
||||
'ztm-gdansk': [54.352, 18.6466],
|
||||
'ets-edmonton': [53.5461, -113.4938],
|
||||
'halifax-transit': [44.6488, -63.5752],
|
||||
'bc-transit-victoria': [48.4284, -123.3656],
|
||||
'tam-montpellier': [43.6108, 3.8767],
|
||||
'ztp-krakow-bus': [50.0614, 19.9366],
|
||||
'ztp-krakow-tram': [50.0614, 19.9366],
|
||||
'vilnius-transport': [54.6872, 25.2797],
|
||||
'tpbi-bucuresti': [44.4268, 26.1025],
|
||||
'roma-mobilita': [41.9028, 12.4964],
|
||||
'gtt-torino': [45.0703, 7.6869],
|
||||
'actv-venezia-bus': [45.4408, 12.3155],
|
||||
'actv-venezia-waterbus': [45.4408, 12.3155],
|
||||
'adelaide-metro': [-34.9285, 138.6007],
|
||||
'marta-atlanta': [33.749, -84.388],
|
||||
'rtd-denver': [39.7392, -104.9903],
|
||||
'ctp-cluj': [46.7712, 23.6236],
|
||||
'ctp-iasi': [47.1585, 27.6014],
|
||||
'mpk-rzeszow': [50.0412, 21.9991],
|
||||
'emt-malaga': [36.7213, -4.4214],
|
||||
'carris-lisboa': [38.7223, -9.1393],
|
||||
'mybas-johor': [1.4927, 103.7414],
|
||||
'mybas-kota-bharu': [6.1254, 102.2381],
|
||||
'astuce-rouen': [49.4432, 1.0999],
|
||||
'mpm-toulon': [43.1242, 5.928],
|
||||
'lia-le-havre': [49.4944, 0.1079],
|
||||
'filbleu-tours': [47.3941, 0.6848],
|
||||
};
|
||||
|
||||
test('every city in the 2026-11 batch is live, licensed and covers its own centre', () => {
|
||||
for (const [id, [lat, lon]] of Object.entries(NEW_CITY_LANDMARKS)) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.ok(feed, `${id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((f) => f.id === id),
|
||||
`${id} is offered to the browser`,
|
||||
);
|
||||
assert.ok(feed.terms?.note, `${id} records the basis it ships on`);
|
||||
assert.ok(
|
||||
transitFeedsInRange(lat, lon).some((f) => f.id === id),
|
||||
`${id} covers its city`,
|
||||
);
|
||||
}
|
||||
// A feed's radius stays local: Warsaw is not polled for Kraków.
|
||||
assert.ok(
|
||||
!transitFeedsInRange(50.0614, 19.9366).some((f) => f.id === 'ztm-warszawa'),
|
||||
);
|
||||
});
|
||||
|
||||
test('feeds with no stated licence say so and name the basis they ship on', () => {
|
||||
for (const feed of TRANSIT_FEED_REGISTRY.filter((f) =>
|
||||
/^No licence stated/.test(f.license),
|
||||
)) {
|
||||
assert.match(
|
||||
feed.terms.note,
|
||||
/Metro Transit and OVapi|Same .*basis/,
|
||||
`${feed.id} names its basis`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('route ids in the 2026-11 batch read as the line on the vehicle', () => {
|
||||
const label = (id, routeId) => transitRouteLabel(getTransitFeed(id), routeId);
|
||||
assert.equal(label('pid-praha', 'L135'), '135');
|
||||
assert.equal(label('ets-edmonton', '009'), '9');
|
||||
assert.equal(label('mpm-toulon', '0002'), '2');
|
||||
assert.equal(label('gtt-torino', '10NU'), '10N');
|
||||
assert.equal(label('actv-venezia-waterbus', '5.1_UN'), '5.1');
|
||||
assert.equal(label('actv-venezia-bus', '53E_ES'), '53E');
|
||||
assert.equal(label('bc-transit-victoria', '95-VIC'), '95');
|
||||
assert.equal(label('carris-lisboa', '172_1'), '172');
|
||||
assert.equal(label('astuce-rouen', 'TCAR:93'), '93');
|
||||
assert.equal(label('mybas-johor', 'J33CWLMYJB'), 'J33');
|
||||
// Unmatched and absent ids pass through untouched.
|
||||
assert.equal(label('pid-praha', 'S4'), 'S4');
|
||||
assert.equal(label('gtt-torino', null), null);
|
||||
});
|
||||
|
||||
test('trams, waterbuses and buses are told apart where the ids allow it', () => {
|
||||
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
|
||||
assert.equal(mode('pid-praha', 'L22'), 'tram');
|
||||
assert.equal(mode('pid-praha', 'L135'), 'bus');
|
||||
assert.equal(mode('tbm-bordeaux', '16'), 'bus');
|
||||
assert.equal(mode('lia-le-havre', '8-B'), 'tram');
|
||||
assert.equal(mode('lia-le-havre', '8-C3'), 'bus');
|
||||
assert.equal(mode('ztp-krakow-tram', null), 'tram');
|
||||
assert.equal(mode('actv-venezia-waterbus', '1_UN'), 'ferry');
|
||||
});
|
||||
|
||||
test('feeds that publish km/h are marked for conversion, and only those', () => {
|
||||
const kmh = TRANSIT_FEED_REGISTRY.filter((f) => f.speedUnit === 'km/h')
|
||||
.map((f) => f.id)
|
||||
.sort();
|
||||
assert.deepEqual(kmh, [
|
||||
'carris-metropolitana-lisboa',
|
||||
'mpm-toulon',
|
||||
'tam-montpellier',
|
||||
]);
|
||||
});
|
||||
|
||||
// Route ids as the live feeds and the operators' own GTFS routes.txt carried
|
||||
// them on 2026-11-09: the ranges and numbered tram ids are not guessable from
|
||||
// the line names riders see.
|
||||
test('Prague route-id ranges give metro, trains, ferries and trolleybuses their mode', () => {
|
||||
const feed = getTransitFeed('pid-praha');
|
||||
const read = (routeId) => [
|
||||
transitModeFor(feed, routeId),
|
||||
transitRouteLabel(feed, routeId),
|
||||
];
|
||||
assert.deepEqual(read('L22'), ['tram', '22']);
|
||||
assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus');
|
||||
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
|
||||
assert.deepEqual(read('L135'), ['bus', '135']);
|
||||
assert.deepEqual(read('L803'), ['bus', 'X3']);
|
||||
assert.deepEqual(read('L991'), ['subway', 'A']);
|
||||
assert.deepEqual(read('L993'), ['subway', 'C']);
|
||||
assert.deepEqual(read('L1004'), ['rail', 'S4']);
|
||||
assert.deepEqual(read('L1109'), ['rail', 'R9']);
|
||||
assert.deepEqual(read('L1204'), ['rail', 'L1204'], 'no label to derive');
|
||||
assert.deepEqual(read('L1801'), ['ferry', 'P1']);
|
||||
assert.deepEqual(read('L1703'), ['bus', 'L1703']);
|
||||
assert.deepEqual(read('L2841'), ['bus', '841']);
|
||||
assert.deepEqual(read('S4'), ['bus', 'S4'], 'unknown ids keep the default');
|
||||
});
|
||||
|
||||
test('Bordeaux trams ride numbered route ids and keep their letters', () => {
|
||||
const feed = getTransitFeed('tbm-bordeaux');
|
||||
for (const [routeId, line] of [
|
||||
['59', 'A'],
|
||||
['62', 'D'],
|
||||
['163', 'E'],
|
||||
]) {
|
||||
assert.equal(transitModeFor(feed, routeId), 'tram', routeId);
|
||||
assert.equal(transitRouteLabel(feed, routeId), line);
|
||||
}
|
||||
assert.equal(transitModeFor(feed, '952'), 'ferry');
|
||||
assert.equal(transitModeFor(feed, '01'), 'bus');
|
||||
assert.equal(transitRouteLabel(feed, '01'), '1');
|
||||
});
|
||||
|
||||
test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () => {
|
||||
const feed = getTransitFeed('astuce-rouen');
|
||||
assert.equal(transitModeFor(feed, 'TCAR:90'), 'tram');
|
||||
assert.equal(transitRouteLabel(feed, 'TCAR:90'), 'Métro');
|
||||
assert.equal(transitModeFor(feed, 'TCAR:99'), 'ferry');
|
||||
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
|
||||
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
|
||||
const lia = getTransitFeed('lia-le-havre');
|
||||
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
|
||||
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
|
||||
assert.equal(transitRouteLabel(lia, '8-09'), '9');
|
||||
assert.equal(transitRouteLabel(lia, '8-A'), 'A');
|
||||
});
|
||||
|
||||
test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
|
||||
for (const id of ['mybas-johor', 'mybas-kota-bharu']) {
|
||||
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
|
||||
}
|
||||
});
|
||||
@@ -257,24 +257,43 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
|
||||
truncated: decoded.truncated,
|
||||
count: decoded.vehicles.length,
|
||||
vehicles: repairVehicleTimestamps(
|
||||
decoded.vehicles,
|
||||
feed.speedUnit === 'km/h'
|
||||
? decoded.vehicles.map((v) =>
|
||||
v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 },
|
||||
)
|
||||
: decoded.vehicles,
|
||||
decoded.timestamp,
|
||||
Math.floor(now / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh window for one feed's cached snapshot: the shared TTL, stretched by a
|
||||
* registry `refreshMs` for upstreams that rate-limit below one request per
|
||||
* TTL (data.gov.my allows 4 a minute). Never shorter than the shared TTL.
|
||||
* @param {{ refreshMs?: number }|null|undefined} feed Registry entry.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function transitFeedTtlMs(feed) {
|
||||
const refresh = feed?.refreshMs;
|
||||
return Number.isFinite(refresh) && refresh > TRANSIT_PROXY_TTL_MS
|
||||
? refresh
|
||||
: TRANSIT_PROXY_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a cache entry for the request policy.
|
||||
* @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms).
|
||||
* @param {number} now
|
||||
* @param {number} [ttlMs] Fresh window; `transitFeedTtlMs(feed)` for a feed.
|
||||
* @returns {'none'|'fresh'|'stale'|'expired'}
|
||||
*/
|
||||
export function transitCacheState(entry, now) {
|
||||
export function transitCacheState(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) {
|
||||
if (!entry || !Number.isFinite(entry.at)) return 'none';
|
||||
const age = now - entry.at;
|
||||
if (age < 0) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_TTL_MS) return 'fresh';
|
||||
if (age < ttlMs) return 'fresh';
|
||||
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
repairVehicleTimestamps,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitFeedTtlMs,
|
||||
transitRedirectDecision,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
@@ -294,6 +295,27 @@ test('Rio speeds pass through as the m/s they are published in', () => {
|
||||
assert.equal(rio.vehicles[0].speedMps, 10);
|
||||
});
|
||||
|
||||
test('Carris Metropolitana km/h speeds leave the proxy as m/s', () => {
|
||||
// 2026-11-07: readings of 31-33 matched 6-9 m/s of measured movement.
|
||||
const bytes = encodeFeed({
|
||||
entities: [
|
||||
{ id: 'moving', lat: 38.72, lon: -9.14, speed: 36 },
|
||||
{ id: 'parked', lat: 38.73, lon: -9.15 },
|
||||
],
|
||||
});
|
||||
const feed = getTransitFeed('carris-metropolitana-lisboa');
|
||||
const lisbon = buildTransitSnapshot(feed, bytes, 1);
|
||||
const byId = Object.fromEntries(lisbon.vehicles.map((v) => [v.id, v]));
|
||||
assert.ok(Math.abs(byId.moving.speedMps - 10) < 1e-6);
|
||||
assert.equal(byId.parked.speedMps, null, 'no speed stays no speed');
|
||||
const szczecin = buildTransitSnapshot(
|
||||
getTransitFeed('zditm-szczecin'),
|
||||
encodeFeed({ entities: [{ id: 'm', lat: 53.43, lon: 14.55, speed: 10 }] }),
|
||||
1,
|
||||
);
|
||||
assert.equal(szczecin.vehicles[0].speedMps, 10, 'm/s feeds are untouched');
|
||||
});
|
||||
|
||||
test('conditional-request validators and gzip ride along with feed identification', () => {
|
||||
const entur = getTransitFeed('entur-norway');
|
||||
const plain = transitUpstreamHeaders(entur);
|
||||
@@ -375,3 +397,24 @@ test('client history validates response bounds, epochs and cancellation through
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('Adelaide asks for */* because a protobuf Accept gets base64 text back', () => {
|
||||
const headers = transitUpstreamHeaders(getTransitFeed('adelaide-metro'));
|
||||
assert.equal(headers.Accept, '*/*');
|
||||
// Every other feed keeps the protobuf-first Accept.
|
||||
assert.match(
|
||||
transitUpstreamHeaders(getTransitFeed('mbta')).Accept,
|
||||
/protobuf/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a feed refreshMs stretches its fresh window, never shortens it', () => {
|
||||
const johor = getTransitFeed('mybas-johor');
|
||||
assert.equal(transitFeedTtlMs(johor), 30_000);
|
||||
assert.equal(transitFeedTtlMs(getTransitFeed('mbta')), TRANSIT_PROXY_TTL_MS);
|
||||
assert.equal(transitFeedTtlMs({ refreshMs: 1_000 }), TRANSIT_PROXY_TTL_MS);
|
||||
const now = 1_000_000;
|
||||
const entry = { at: now - 20_000 };
|
||||
assert.equal(transitCacheState(entry, now), 'stale');
|
||||
assert.equal(transitCacheState(entry, now, transitFeedTtlMs(johor)), 'fresh');
|
||||
});
|
||||
@@ -143,6 +143,7 @@ for (const [locale, summary] of [
|
||||
['ja', '東京上空のLive Flights'],
|
||||
['zh-CN', '北京航班stale数据'],
|
||||
['ru', 'Остин Live-Flights частично'],
|
||||
['he', 'תל אביב stale טיסות'],
|
||||
]) {
|
||||
test(`English flush against other text is an echo: ${summary}`, () => {
|
||||
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
|
||||
|
||||
@@ -110,7 +110,7 @@ const SUMMARY_LANGUAGES = Object.freeze({
|
||||
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
|
||||
|
||||
/** Shipped locales not written in Latin letters. */
|
||||
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
|
||||
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh|he)(?:-|$)/;
|
||||
|
||||
/** Scripts whose letters extend a word; any other neighbour ends it. */
|
||||
const WORD_SCRIPTS = [
|
||||
|
||||
@@ -41,6 +41,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
|
||||
assert.equal(normalizeLocale('de'), 'de');
|
||||
assert.equal(normalizeLocale('de-AT'), 'de');
|
||||
assert.equal(normalizeLocale('de_CH'), 'de');
|
||||
// Hebrew keeps Western digits like its catalog; regional tags fold to he,
|
||||
// and the legacy tag iw is still what some browsers and OSes label Hebrew.
|
||||
assert.equal(normalizeLocale('he'), 'he');
|
||||
assert.equal(normalizeLocale('he-IL'), 'he');
|
||||
assert.equal(normalizeLocale('iw'), 'he');
|
||||
assert.equal(normalizeLocale('iw-IL'), 'he');
|
||||
assert.equal(normalizeLocale('fr'), 'fr');
|
||||
assert.equal(normalizeLocale('fr-CA'), 'fr');
|
||||
assert.equal(normalizeLocale('FR_be'), 'fr');
|
||||
|
||||
@@ -22,6 +22,7 @@ import ar from './locales/ar.json' with { type: 'json' };
|
||||
import de from './locales/de.json' with { type: 'json' };
|
||||
import es from './locales/es.json' with { type: 'json' };
|
||||
import fr from './locales/fr.json' with { type: 'json' };
|
||||
import he from './locales/he.json' with { type: 'json' };
|
||||
import it from './locales/it.json' with { type: 'json' };
|
||||
import ja from './locales/ja.json' with { type: 'json' };
|
||||
import pt from './locales/pt.json' with { type: 'json' };
|
||||
@@ -35,6 +36,7 @@ const CATALOGS = Object.freeze({
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
he,
|
||||
it,
|
||||
ja,
|
||||
pt,
|
||||
|
||||
+6
-2
@@ -14,6 +14,7 @@ export const LOCALES = Object.freeze([
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'he',
|
||||
'it',
|
||||
'ja',
|
||||
'pt',
|
||||
@@ -30,6 +31,7 @@ export const LOCALE_NAMES = Object.freeze({
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
he: 'עברית',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
pt: 'Português (Brasil)',
|
||||
@@ -40,7 +42,7 @@ export const LOCALE_NAMES = Object.freeze({
|
||||
});
|
||||
|
||||
/** Right-to-left locales. */
|
||||
const RTL_LOCALES = Object.freeze(['ar']);
|
||||
const RTL_LOCALES = Object.freeze(['ar', 'he']);
|
||||
|
||||
/** Whether a shipped locale reads right to left. */
|
||||
export function isRtlLocale(locale) {
|
||||
@@ -62,7 +64,8 @@ export function intlLocale(locale) {
|
||||
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
|
||||
* 'ar-EG' and 'ar-SA' → 'ar',
|
||||
* 'ar-EG' and 'ar-SA' → 'ar', 'he-IL' and 'he' → 'he', and the
|
||||
* legacy tag 'iw'/'iw-IL' → 'he',
|
||||
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
|
||||
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
|
||||
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
|
||||
@@ -78,6 +81,7 @@ export function normalizeLocale(candidate) {
|
||||
? 'zh-Hant'
|
||||
: 'zh-CN';
|
||||
}
|
||||
if (primary === 'iw') return 'he';
|
||||
return LOCALES.includes(primary) ? primary : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -615,6 +615,7 @@
|
||||
"LOADING": "جارٍ التحميل",
|
||||
"DEGRADED": "متدهور",
|
||||
"STALE": "متقادم",
|
||||
"SIGNAL LOST · {time}": "انقطعت الإشارة · {time}",
|
||||
"FALLBACK": "بديل",
|
||||
"PARTIAL": "جزئي",
|
||||
"UNCERTAIN": "غير مؤكد",
|
||||
@@ -879,6 +880,7 @@
|
||||
"KEY REQUIRED": "المفتاح مطلوب",
|
||||
"STALE · cached {age}": "متقادم · مخزّن {age}",
|
||||
"LIVE · updated {age}": "مباشر · حُدّث {age}",
|
||||
"24 h to {day} {time} UTC": "24 ساعة حتى {day} {time} UTC",
|
||||
"<1m ago": "قبل أقل من دقيقة",
|
||||
"FIRE · {frp} MW": "حريق · {frp} MW",
|
||||
"▲ {frp} MW": "▲ {frp} MW",
|
||||
@@ -3005,6 +3007,15 @@
|
||||
"many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة",
|
||||
"other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح",
|
||||
"one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})",
|
||||
"two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})",
|
||||
"few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة",
|
||||
"many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا",
|
||||
"other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح"
|
||||
},
|
||||
"degraded": "متدهور",
|
||||
"stale": "متقادم",
|
||||
"partial": "جزئي",
|
||||
@@ -3610,5 +3621,6 @@
|
||||
"1 wk/s": "أسبوع/ث",
|
||||
"Play speed": "سرعة التشغيل",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||
"1 min/s": "دقيقة/ث"
|
||||
"1 min/s": "دقيقة/ث",
|
||||
"Photo © {photographer}": "صورة © {photographer}"
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
"LOADING": "LÄDT",
|
||||
"DEGRADED": "EINGESCHRÄNKT",
|
||||
"STALE": "VERALTET",
|
||||
"SIGNAL LOST · {time}": "SIGNAL VERLOREN · {time}",
|
||||
"PARTIAL": "TEILWEISE",
|
||||
"FALLBACK": "FALLBACK",
|
||||
"SUMMARY": "ÜBERSICHT",
|
||||
@@ -164,6 +165,7 @@
|
||||
"loading...": "lädt...",
|
||||
"KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH",
|
||||
"LIVE · updated {age}": "LIVE · aktualisiert {age}",
|
||||
"24 h to {day} {time} UTC": "24 Std. bis {day} {time} UTC",
|
||||
"loading mapped installation context": "Kontext der kartierten Anlage wird geladen",
|
||||
"MAPPED INSTALLATION": "KARTIERTE ANLAGE",
|
||||
"LAUNCH SITE · {site}": "STARTPLATZ · {site}",
|
||||
@@ -2751,6 +2753,11 @@
|
||||
"one": "Hier noch kein Feed · {n} Region verfügbar",
|
||||
"other": "Hier noch kein Feed · {n} Regionen verfügbar"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Hier noch kein Bikesharing · {n} System verfügbar",
|
||||
"other": "Hier noch kein Bikesharing · {n} Systeme verfügbar"
|
||||
},
|
||||
"degraded": "eingeschränkt",
|
||||
"stale": "veraltet",
|
||||
"partial": "teilweise",
|
||||
@@ -3306,5 +3313,6 @@
|
||||
"1 wk/s": "1 Wo./s",
|
||||
"Play speed": "Wiedergabegeschwindigkeit",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
|
||||
"1 min/s": "1 Min./s"
|
||||
"1 min/s": "1 Min./s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
@@ -500,6 +500,7 @@
|
||||
"LOADING": "CARGANDO",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"STALE": "DESACTUALIZADO",
|
||||
"SIGNAL LOST · {time}": "SEÑAL PERDIDA · {time}",
|
||||
"FALLBACK": "RESPALDO",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"UNCERTAIN": "INCIERTO",
|
||||
@@ -737,6 +738,7 @@
|
||||
"KEY REQUIRED": "CLAVE REQUERIDA",
|
||||
"STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}",
|
||||
"LIVE · updated {age}": "EN VIVO · actualizado {age}",
|
||||
"24 h to {day} {time} UTC": "24 h hasta {day} {time} UTC",
|
||||
"<1m ago": "hace <1 min",
|
||||
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
|
||||
"▲ {frp} MW": "▲ {frp} MW",
|
||||
@@ -2915,6 +2917,12 @@
|
||||
"many": "Aún no hay fuente aquí · {n} de regiones disponibles",
|
||||
"other": "Aún no hay fuente aquí · {n} regiones disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Aún no hay bicis compartidas aquí · {n} sistema disponible",
|
||||
"many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles",
|
||||
"other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles"
|
||||
},
|
||||
"Wind speed field unavailable": "Campo de velocidad del viento no disponible",
|
||||
"Decoder feeds": "Fuentes del decodificador",
|
||||
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
|
||||
@@ -3382,5 +3390,6 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidad de reproducción",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
@@ -329,6 +329,7 @@
|
||||
"LOADING": "CHARGEMENT",
|
||||
"DEGRADED": "DÉGRADÉ",
|
||||
"STALE": "OBSOLÈTE",
|
||||
"SIGNAL LOST · {time}": "SIGNAL PERDU · {time}",
|
||||
"PARTIAL": "PARTIEL",
|
||||
"FALLBACK": "SECOURS",
|
||||
"view band|STREET": "RUE",
|
||||
@@ -734,6 +735,7 @@
|
||||
"loading...": "chargement…",
|
||||
"KEY REQUIRED": "CLÉ REQUISE",
|
||||
"LIVE · updated {age}": "DIRECT · mis à jour {age}",
|
||||
"24 h to {day} {time} UTC": "24 h jusqu'au {day} {time} UTC",
|
||||
"Source snapshot time unavailable": "Heure de l'instantané source indisponible",
|
||||
"Live data unavailable": "Données en direct indisponibles",
|
||||
"Intel Picture": "Situation renseignement",
|
||||
@@ -2584,6 +2586,12 @@
|
||||
"many": "Pas encore de flux ici · {n} de régions disponibles",
|
||||
"other": "Pas encore de flux ici · {n} régions disponibles"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Pas encore de vélos partagés ici · {n} système disponible",
|
||||
"many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles",
|
||||
"other": "Pas encore de vélos partagés ici · {n} systèmes disponibles"
|
||||
},
|
||||
"degraded": "dégradé",
|
||||
"stale": "obsolète",
|
||||
"partial": "partiel",
|
||||
@@ -3382,5 +3390,6 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Vitesse de lecture",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Photo © {photographer}"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -329,6 +329,7 @@
|
||||
"LOADING": "CARICAMENTO",
|
||||
"DEGRADED": "DEGRADATO",
|
||||
"STALE": "OBSOLETO",
|
||||
"SIGNAL LOST · {time}": "SEGNALE PERSO · {time}",
|
||||
"PARTIAL": "PARZIALE",
|
||||
"FALLBACK": "RISERVA",
|
||||
"view band|STREET": "STRADA",
|
||||
@@ -734,6 +735,7 @@
|
||||
"loading...": "caricamento...",
|
||||
"KEY REQUIRED": "CHIAVE RICHIESTA",
|
||||
"LIVE · updated {age}": "LIVE · aggiornato {age}",
|
||||
"24 h to {day} {time} UTC": "24 h fino al {day} {time} UTC",
|
||||
"Source snapshot time unavailable": "Ora dello snapshot della fonte non disponibile",
|
||||
"Live data unavailable": "Dati live non disponibili",
|
||||
"Intel Picture": "Quadro intelligence",
|
||||
@@ -2584,6 +2586,12 @@
|
||||
"many": "Ancora nessun feed qui · {n} di regioni disponibili",
|
||||
"other": "Ancora nessun feed qui · {n} regioni disponibili"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ancora nessun bike sharing qui · {n} sistema disponibile",
|
||||
"many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili",
|
||||
"other": "Ancora nessun bike sharing qui · {n} sistemi disponibili"
|
||||
},
|
||||
"degraded": "degradato",
|
||||
"stale": "obsoleto",
|
||||
"partial": "parziale",
|
||||
@@ -3382,5 +3390,6 @@
|
||||
"1 wk/s": "1 sett/s",
|
||||
"Play speed": "Velocità di riproduzione",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
@@ -323,6 +323,7 @@
|
||||
"LOADING": "読込中",
|
||||
"DEGRADED": "性能低下",
|
||||
"STALE": "更新遅延",
|
||||
"SIGNAL LOST · {time}": "信号途絶 · {time}",
|
||||
"PARTIAL": "部分的",
|
||||
"FALLBACK": "フォールバック",
|
||||
"view band|STREET": "街路",
|
||||
@@ -716,6 +717,7 @@
|
||||
"loading...": "読込中…",
|
||||
"KEY REQUIRED": "キーが必要",
|
||||
"LIVE · updated {age}": "ライブ · 更新 {age}",
|
||||
"24 h to {day} {time} UTC": "{day} {time} UTC までの24時間",
|
||||
"Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません",
|
||||
"Live data unavailable": "ライブデータを利用できません",
|
||||
"Intel Picture": "情勢図",
|
||||
@@ -2454,6 +2456,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件"
|
||||
},
|
||||
"degraded": "性能低下",
|
||||
"stale": "更新遅延",
|
||||
"partial": "部分的",
|
||||
@@ -3230,5 +3236,6 @@
|
||||
"1 wk/s": "1週間/秒",
|
||||
"Play speed": "再生速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||
"1 min/s": "1分/秒"
|
||||
"1 min/s": "1分/秒",
|
||||
"Photo © {photographer}": "写真 © {photographer}"
|
||||
}
|
||||
@@ -329,6 +329,7 @@
|
||||
"LOADING": "CARREGANDO",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"STALE": "DESATUALIZADO",
|
||||
"SIGNAL LOST · {time}": "SINAL PERDIDO · {time}",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"FALLBACK": "ALTERNATIVO",
|
||||
"view band|STREET": "RUA",
|
||||
@@ -732,6 +733,7 @@
|
||||
"loading...": "carregando...",
|
||||
"KEY REQUIRED": "CHAVE NECESSÁRIA",
|
||||
"LIVE · updated {age}": "AO VIVO · atualizado {age}",
|
||||
"24 h to {day} {time} UTC": "24 h até {day} {time} UTC",
|
||||
"Source snapshot time unavailable": "Horário do snapshot da fonte indisponível",
|
||||
"Live data unavailable": "Dados ao vivo indisponíveis",
|
||||
"Intel Picture": "Panorama de inteligência",
|
||||
@@ -2586,6 +2588,12 @@
|
||||
"many": "Ainda não há feed aqui · {n} de regiões disponíveis",
|
||||
"other": "Ainda não há feed aqui · {n} regiões disponíveis"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível",
|
||||
"many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis",
|
||||
"other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis"
|
||||
},
|
||||
"degraded": "degradado",
|
||||
"stale": "desatualizado",
|
||||
"partial": "parcial",
|
||||
@@ -3382,5 +3390,6 @@
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidade de reprodução",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
|
||||
"1 min/s": "1 min/s"
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
}
|
||||
@@ -143,10 +143,12 @@
|
||||
"{count}h ago": "{count} ч назад",
|
||||
"STALE · cached {age}": "УСТАРЕЛ · кэш {age}",
|
||||
"STALE": "УСТАРЕЛ",
|
||||
"SIGNAL LOST · {time}": "СИГНАЛ ПОТЕРЯН · {time}",
|
||||
"refreshing...": "обновление...",
|
||||
"loading...": "загрузка...",
|
||||
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
|
||||
"LIVE · updated {age}": "ОНЛАЙН · обновлено {age}",
|
||||
"24 h to {day} {time} UTC": "24 ч до {day} {time} UTC",
|
||||
"Retrying mapped sites…": "Повторный запрос объектов…",
|
||||
"Fetching mapped sites…": "Загрузка объектов…",
|
||||
"Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты",
|
||||
@@ -2979,6 +2981,13 @@
|
||||
"many": "Здесь пока нет потока данных · доступно {n} регионов",
|
||||
"other": "Здесь пока нет потока данных · доступно {n} региона"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Здесь пока нет проката велосипедов · доступна {n} система",
|
||||
"few": "Здесь пока нет проката велосипедов · доступны {n} системы",
|
||||
"many": "Здесь пока нет проката велосипедов · доступно {n} систем",
|
||||
"other": "Здесь пока нет проката велосипедов · доступно {n} системы"
|
||||
},
|
||||
"Wind speed field unavailable": "Поле скорости ветра недоступно",
|
||||
"Decoder feeds": "Потоки декодера",
|
||||
"WebUSB + decoder feeds": "WebUSB + потоки декодера",
|
||||
@@ -3458,5 +3467,6 @@
|
||||
"1 wk/s": "1 нед/с",
|
||||
"Play speed": "Скорость воспроизведения",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||
"1 min/s": "1 мин/с"
|
||||
"1 min/s": "1 мин/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
}
|
||||
@@ -332,6 +332,7 @@
|
||||
"LOADING": "ЗАВАНТАЖЕННЯ",
|
||||
"DEGRADED": "ПЕРЕБОЇ",
|
||||
"STALE": "ЗАСТАРІЛО",
|
||||
"SIGNAL LOST · {time}": "СИГНАЛ ВТРАЧЕНО · {time}",
|
||||
"PARTIAL": "ЧАСТКОВО",
|
||||
"FALLBACK": "РЕЗЕРВ",
|
||||
"view band|STREET": "ВУЛИЦЯ",
|
||||
@@ -742,6 +743,7 @@
|
||||
"loading...": "завантаження...",
|
||||
"KEY REQUIRED": "ПОТРІБЕН КЛЮЧ",
|
||||
"LIVE · updated {age}": "ОНЛАЙН · оновлено {age}",
|
||||
"24 h to {day} {time} UTC": "24 год до {day} {time} UTC",
|
||||
"Source snapshot time unavailable": "Час знімка джерела невідомий",
|
||||
"Live data unavailable": "Дані в реальному часі недоступні",
|
||||
"Intel Picture": "Розвідувальна обстановка",
|
||||
@@ -2648,6 +2650,13 @@
|
||||
"many": "Тут поки немає потоку даних · доступно {n} регіонів",
|
||||
"other": "Тут поки немає потоку даних · доступно {n} регіону"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"one": "Тут поки немає прокату велосипедів · доступна {n} система",
|
||||
"few": "Тут поки немає прокату велосипедів · доступні {n} системи",
|
||||
"many": "Тут поки немає прокату велосипедів · доступно {n} систем",
|
||||
"other": "Тут поки немає прокату велосипедів · доступно {n} системи"
|
||||
},
|
||||
"degraded": "з перебоями",
|
||||
"stale": "застарів",
|
||||
"partial": "частково",
|
||||
@@ -3458,5 +3467,6 @@
|
||||
"1 wk/s": "1 тиж/с",
|
||||
"Play speed": "Швидкість відтворення",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||
"1 min/s": "1 хв/с"
|
||||
"1 min/s": "1 хв/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
}
|
||||
@@ -323,6 +323,7 @@
|
||||
"LOADING": "加载中",
|
||||
"DEGRADED": "降级",
|
||||
"STALE": "过期",
|
||||
"SIGNAL LOST · {time}": "信号丢失 · {time}",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "备用",
|
||||
"view band|STREET": "街道",
|
||||
@@ -716,6 +717,7 @@
|
||||
"loading...": "加载中…",
|
||||
"KEY REQUIRED": "需要密钥",
|
||||
"LIVE · updated {age}": "实时 · 更新于 {age}",
|
||||
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小时",
|
||||
"Source snapshot time unavailable": "来源快照时间不可用",
|
||||
"Live data unavailable": "实时数据不可用",
|
||||
"Low-accuracy score < 2%": "低精度评分 < 2%",
|
||||
@@ -2355,6 +2357,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此处暂无数据源 · 有 {n} 个覆盖区域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此处暂无共享单车 · 有 {n} 个可用系统"
|
||||
},
|
||||
"degraded": "降级",
|
||||
"stale": "过期",
|
||||
"partial": "部分可用",
|
||||
@@ -3230,5 +3236,6 @@
|
||||
"1 wk/s": "1 周/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||
"1 min/s": "1 分钟/秒"
|
||||
"1 min/s": "1 分钟/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
}
|
||||
@@ -323,6 +323,7 @@
|
||||
"LOADING": "加載中",
|
||||
"DEGRADED": "降級",
|
||||
"STALE": "過期",
|
||||
"SIGNAL LOST · {time}": "訊號遺失 · {time}",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "備用",
|
||||
"view band|STREET": "街道",
|
||||
@@ -711,6 +712,7 @@
|
||||
"loading...": "加載中…",
|
||||
"KEY REQUIRED": "需要密鑰",
|
||||
"LIVE · updated {age}": "實時 · 更新於 {age}",
|
||||
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小時",
|
||||
"Source snapshot time unavailable": "來源快照時間不可用",
|
||||
"Live data unavailable": "實時數據不可用",
|
||||
"Low-accuracy score < 2%": "低精度評分 < 2%",
|
||||
@@ -2343,6 +2345,10 @@
|
||||
"No feed here yet · {n} regions available": {
|
||||
"other": "此處暫無數據源 · 有 {n} 個覆蓋區域"
|
||||
},
|
||||
"Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市",
|
||||
"No bike share here yet · {n} systems available": {
|
||||
"other": "此處暫無共享單車 · 有 {n} 個可用系統"
|
||||
},
|
||||
"degraded": "降級",
|
||||
"stale": "過期",
|
||||
"partial": "部分可用",
|
||||
@@ -3230,5 +3236,6 @@
|
||||
"1 wk/s": "1 週/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||
"1 min/s": "1 分鐘/秒"
|
||||
"1 min/s": "1 分鐘/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
}
|
||||
@@ -586,7 +586,8 @@
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW",
|
||||
"SATELLITE"
|
||||
"SATELLITE",
|
||||
"Photo © {photographer}"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
@@ -1189,5 +1190,102 @@
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
],
|
||||
"he": [
|
||||
"{speed} kn",
|
||||
"GEO",
|
||||
"SAR",
|
||||
"EARTH WATCH",
|
||||
"CRT",
|
||||
"NVG",
|
||||
"FLIR",
|
||||
"AIS: --",
|
||||
"REC",
|
||||
"HDG {value}°",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CRS {value}°",
|
||||
"FOV {value}°",
|
||||
"CAL {value}",
|
||||
"SRC {value}",
|
||||
"▲ {frp} MW",
|
||||
"≥ {value} MW",
|
||||
"Intel Picture",
|
||||
"Kinetic 30d",
|
||||
"Border Crisis",
|
||||
"Iran Picture",
|
||||
"Iran Kinetic",
|
||||
"Yemen FLOT",
|
||||
"Latest Drop",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
|
||||
"V/S {rate} FPM",
|
||||
"Sentinel-2",
|
||||
"Landsat 8/9",
|
||||
"Google Street View",
|
||||
"GOOGLE STREET VIEW",
|
||||
"AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"POS: {time}Z",
|
||||
"{source} HTTP {status}",
|
||||
"NNE",
|
||||
"NE",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"SE",
|
||||
"SSE",
|
||||
"SSW",
|
||||
"SW",
|
||||
"WSW",
|
||||
"WNW",
|
||||
"NW",
|
||||
"NNW",
|
||||
"WHOT/BHOT",
|
||||
"HDG",
|
||||
"FOV",
|
||||
"HGT",
|
||||
"ΔN",
|
||||
"ΔE",
|
||||
"CAL · {badge}",
|
||||
"DEST ---°",
|
||||
"SQUAWK {code} · {status}",
|
||||
"DEST {bearing}",
|
||||
"CCTV",
|
||||
"S2 GhostMaps",
|
||||
"Street Level",
|
||||
"STREET LEVEL",
|
||||
"km/h",
|
||||
"mph",
|
||||
"hPa",
|
||||
"kt",
|
||||
"kts",
|
||||
"ft",
|
||||
"km",
|
||||
"min",
|
||||
"dB",
|
||||
"KTS",
|
||||
"FT",
|
||||
"FLT",
|
||||
"MIL",
|
||||
"WX",
|
||||
"MSG/S",
|
||||
"FM MHZ",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"Salish Sea",
|
||||
"iNaturalist",
|
||||
"Be Whale Wise",
|
||||
"MIC",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"{range} NM",
|
||||
"NORAD {id}",
|
||||
"EPOCH {time}",
|
||||
"TLE",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"Ironbow"
|
||||
]
|
||||
}
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user