Compare commits

..
Author SHA1 Message Date
Sameh Khamis 0600d86584 docs(changelog): Draw hands one shape back (#1641) 2026-11-09 07:16:52 -08:00
Sameh Khamis 4da477a654 feat(draw): hand one drawn area or line back to the application
createApplicationTools returns drawShape({ shape, signal }): it opens Draw
for one area or line and resolves with its Polygon or LineString without
placing a mark, or null when draw mode is left, the signal aborts, a newer
request replaces it, the pointer is held elsewhere or the tool goes. The
shape controls cannot change the asked-for shape, and a shape across the
antimeridian is refused as a row's actions refuse it and drawn again. It
also returns pointerFree, so an application's own click handlers yield to a
tool holding the pointer, as the layers' do.
2026-11-09 07:16:14 -08:00
85 changed files with 396 additions and 3221 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## [Unreleased]
- Applications can ask Draw for one area or line and get its geometry back
without placing a mark (`drawShape` from `createApplicationTools`), and their
own click handlers can yield while a tool holds the pointer, as the layers'
do (`pointerFree`) (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
+1 -35
View File
@@ -103,40 +103,6 @@ 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)" |
@@ -228,7 +194,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 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.
- **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.
- **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.
+1 -1
View File
@@ -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), 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 | 🟢 |
| 🚌 **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 | 🟢 |
| 🚲 **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 |
-3
View File
@@ -1,3 +0,0 @@
- The agent and voice query tools wrap whole-degree directions into 0–359, so
a vessel course or heading, aircraft course, transit bearing, camera heading
or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524).
@@ -1,3 +0,0 @@
- The Nominatim disk-cache test checks the cache directory and file modes
only where POSIX modes exist, so it no longer always fails on Windows
(quanticsoul4772, #1547).
@@ -1,3 +0,0 @@
- Show a fire perimeter under one acre at its reported size. The incident card
rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac"
(Rudy Celekli, @rudycelekli, #1569).
@@ -1,4 +0,0 @@
- 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).
@@ -1,3 +0,0 @@
- 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).
-2
View File
@@ -1,2 +0,0 @@
- French catalog review: missing narrow no-break spaces, apostrophes, one
mistranslated message and the Starlink shell wording (abiyeenzo, #1611).
-14
View File
@@ -1,14 +0,0 @@
- 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).
-4
View File
@@ -1,4 +0,0 @@
- 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).
@@ -1,5 +0,0 @@
- Compress the live flight snapshot on the wire. `/api/flights` gzips its
cached global snapshot and its regional adsb.lol fallback once per refresh
and sends that frame to clients that offer gzip: about 860 KB becomes about
250 KB. Other clients get the same bytes uncompressed, and both answers carry
`Vary: Accept-Encoding` (daikaginza, #795).
+5 -21
View File
@@ -2284,17 +2284,6 @@ 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,
@@ -4718,10 +4707,6 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
The incident card rounds the WFIGS size to whole acres, except under one acre,
where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not
`0 ac`.
Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in
Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA
FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last
@@ -5129,9 +5114,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. Forty-nine keyless regions are available: Boston, Austin, Minneapolis–St Paul,
gate. Seventeen 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), 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
(municipal buses and the Mobi-Rio BRT, two feeds), 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
@@ -5813,7 +5798,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'`; 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.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/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.
@@ -6326,6 +6311,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.
- **One shape, handed back.** `createApplicationTools` also returns `drawShape({ shape, signal })` (`drawTool.drawShape`): it opens draw mode for one `area` or `line` and resolves with its geometry, a closed `[lon, lat]` `Polygon` or a `LineString`, without placing a mark; the shape chosen before comes back and draw mode ends. While it is open the controls cannot change the shape, and one across the antimeridian is refused as a row's actions refuse it ("That shape could not be placed.") and drawn again. It resolves `null` when the drawing is cancelled (the second Escape), draw mode is left, `signal` aborts, another request replaces it, the pointer is held elsewhere, the tool is destroyed, or the page has no Draw controls. It returns `pointerFree` (`data/inputOwnership.js` `isPointerFree`) too, so an application's own click handlers yield to the tool holding the pointer, as the layers' do.
- **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.
@@ -6502,8 +6488,6 @@ apply to `public/` assets or built-preview serving.
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
- `/api/flights`, `/api/military`, and `/api/route` answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET`, before rate limiting and any upstream call.
- `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding.
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail).
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
@@ -6605,7 +6589,7 @@ apply to `public/` assets or built-preview serving.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
-1
View File
@@ -1126,7 +1126,6 @@
"src/sky/starrySky.test.mjs",
"src/tooling/skyAssets.test.mjs",
"src/tooling/skyAssets.paintOut.test.mjs",
"src/tooling/responseCompression.test.mjs",
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
-1
View File
@@ -205,7 +205,6 @@
"modules": [
"src/sources/adsbFields.js",
"server/providers/live.js",
"server/providers/common/compress.js",
"server/providers/common/http.js",
"server/providers/common/query.js",
"server/providers/aircraft/opensky.js",
-9
View File
@@ -83,15 +83,6 @@ 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');
+22 -69
View File
@@ -1,7 +1,6 @@
import fs from 'node:fs';
import os from 'node:os';
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import { encodedBody, gzipBody } from '../common/compress.js';
import {
coalesceProxyRequest,
readResponseJsonCapped,
@@ -19,11 +18,6 @@ let _openskyTokenExpiry = 0;
let _openskyTokenPromise = null;
/** @type {string|null} Cached upstream response body (JSON text). */
let _openskyCacheBody = null;
/**
* @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it.
* Null when the body is not worth compressing.
*/
let _openskyCacheGzip = null;
/** @type {number} HTTP status of the cached response. */
let _openskyCacheStatus = 0;
/** @type {number} Epoch-ms when the response was cached. */
@@ -405,12 +399,8 @@ async function fetchAdsbLolPointFallback(req) {
ADSBLOL_POINT_MAX_RESPONSE_BYTES,
);
const normalized = normalizeAdsbLolPointResponse(payload);
const body = JSON.stringify(normalized);
const record = {
body,
// Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the
// extra memory.
gzip: await gzipBody(body),
body: JSON.stringify(normalized),
cachedAt: Date.now(),
count: normalized.states.length,
};
@@ -439,7 +429,6 @@ async function fetchAdsbLolPointFallback(req) {
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
const fallback = await fetchAdsbLolPointFallback(req);
if (!fallback) return false;
const encoded = encodedBody(req, fallback.body, fallback.gzip);
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: fallback.cacheStatus,
@@ -450,9 +439,8 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
'X-Flight-Source': 'adsb.lol',
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
'X-Flight-Count': String(fallback.count),
...encoded.headers,
});
res.end(encoded.body);
res.end(fallback.body);
return true;
}
@@ -496,15 +484,6 @@ 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,
@@ -535,13 +514,9 @@ export function openSkyProxy() {
reason: 'cached',
};
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
cacheStatus: isStale ? 'STALE' : 'HIT',
requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown',
@@ -557,9 +532,8 @@ export function openSkyProxy() {
? (_openskyCooldownUntil - now) / 1000
: undefined,
}),
...encoded.headers,
});
res.end(encoded.body);
);
res.end(_openskyCacheBody);
return;
}
// Cooling down with nothing cached (cold start into a rate limit):
@@ -654,11 +628,7 @@ export function openSkyProxy() {
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
// Compress before writing either slot, so a request arriving
// mid-refresh still reads a body with its own frame.
const frame = await gzipBody(body);
_openskyCacheBody = body;
_openskyCacheGzip = frame;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
@@ -711,13 +681,9 @@ export function openSkyProxy() {
// Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) {
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(200, {
...buildOpenSkyHeaders({
res.writeHead(
200,
buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode,
usedMode,
@@ -725,9 +691,8 @@ export function openSkyProxy() {
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
...encoded.headers,
});
res.end(encoded.body);
);
res.end(_openskyCacheBody);
return;
}
}
@@ -769,23 +734,16 @@ export function openSkyProxy() {
}
}
// The frame belongs to this body only if this body was cached. An
// uncached error must not borrow the previous snapshot's frame.
const encoded = encodedBody(
req,
body,
_openskyCacheBody === body ? _openskyCacheGzip : null,
);
res.writeHead(upstream.status, {
...buildOpenSkyHeaders({
res.writeHead(
upstream.status,
buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode,
reason,
}),
...encoded.headers,
});
res.end(encoded.body);
);
res.end(body);
} catch (e) {
console.error('[OpenSky Proxy]', e.message);
if (_openskyCacheBody) {
@@ -796,22 +754,17 @@ export function openSkyProxy() {
usedMode: 'unknown',
reason: 'cached_stale',
};
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode:
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
usedMode: cachedMeta.usedMode || 'unknown',
reason: cachedMeta.reason || 'cached_stale',
}),
...encoded.headers,
});
res.end(encoded.body);
);
res.end(_openskyCacheBody);
return;
}
const requestedMode = normalizeOpenSkyAuthMode(
-81
View File
@@ -1,81 +0,0 @@
import { gzip } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
/**
* Opt-in gzip for text bodies a proxy already holds in memory.
*
* Not a middleware: `cctv/media.js` streams bodies and answers range requests,
* and several providers forward binary upstream types that gzip only grows.
* `encodedBody` returns the body with its headers so the two cannot disagree.
*/
/** Skip compression for bodies smaller than 1 KiB. */
const MIN_COMPRESSED_BYTES = 1024;
/**
* RFC 9110 section 12.4.2 `qvalue`. Matching the whole token keeps `q=1oops`
* and `q=2` unreadable instead of rounding them into acceptance.
*/
const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
/**
* Whether `Accept-Encoding` offers gzip. Anything unreadable (no header, a bad
* q value, `x-gzip`) reads as no: every client can decode identity.
*
* @param {import('node:http').IncomingMessage} request
* @returns {boolean}
*/
export function acceptsGzip(request) {
const header = request?.headers?.['accept-encoding'];
if (typeof header !== 'string') return false;
return header.split(',').some((part) => {
const [token, ...parameters] = part.split(';');
if (token.trim().toLowerCase() !== 'gzip') return false;
const quality = parameters
.map((parameter) => parameter.trim().toLowerCase())
.find((parameter) => parameter.startsWith('q='));
if (quality === undefined) return true;
// `q=0` names gzip and refuses it.
const value = quality.slice(2);
return QVALUE.test(value) && Number(value) > 0;
});
}
/**
* Gzip a body once for a caller that caches it. Async, so the ~860 KB OpenSky
* snapshot does not block other requests while it compresses.
*
* Returns null, meaning "serve identity", when the body is under 1 KiB or gzip
* does not shrink it.
*
* @param {string|Buffer} body - the exact bytes the identity response sends.
* @returns {Promise<Buffer|null>}
*/
export async function gzipBody(body) {
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body);
if (raw.byteLength < MIN_COMPRESSED_BYTES) return null;
const compressed = await gzipAsync(raw);
return compressed.byteLength < raw.byteLength ? compressed : null;
}
/**
* Pick the body and the headers that describe it. `Vary` is on both branches
* so a shared cache never serves one encoding to a client that asked for the
* other.
*
* @param {import('node:http').IncomingMessage} request
* @param {string|Buffer} identity - the uncompressed body.
* @param {Buffer|null} compressed - `gzipBody(identity)`, or null.
* @returns {{body: string|Buffer, headers: Record<string,string>}}
*/
export function encodedBody(request, identity, compressed) {
if (compressed && acceptsGzip(request)) {
return {
body: compressed,
headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' },
};
}
return { body: identity, headers: { Vary: 'Accept-Encoding' } };
}
-5
View File
@@ -227,11 +227,6 @@ 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 }));
+65 -1
View File
@@ -54,6 +54,7 @@ import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkGeometry,
drawMarkActionsFor,
drawMarkFallbackName,
presentDrawMarks,
@@ -120,6 +121,9 @@ export function initDrawTool({
let savedDoubleClick = null;
let savedSingleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band
// One shape asked for by `drawShape`: `{ resolve, restoreShape, off }`. Its
// finish hands the geometry back instead of placing a mark.
let request = null;
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
// one of those must not write its outcome over the newer state.
@@ -280,6 +284,20 @@ export function initDrawTool({
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
if (request) {
// As a row's actions get it: a shape across the antimeridian has none,
// and is drawn again rather than handed back wrapped round the world.
const geometry = drawMarkGeometry(spec);
if (!geometry) {
session = createDrawSession(shape);
cursor = null;
syncPreview();
setHint(() => t('That shape could not be placed.'));
return null;
}
endRequest(geometry);
return geometry;
}
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
@@ -407,6 +425,7 @@ export function initDrawTool({
generation += 1;
session = null;
cursor = null;
endRequest(null);
releaseSceneHandler();
releasePointer(lease);
lease = null;
@@ -532,6 +551,46 @@ export function initDrawTool({
return true;
}
/**
* Close the asked-for shape with `geometry` (null when there is none): the
* shape chosen before it comes back, and draw mode ends.
*/
function endRequest(geometry) {
const open = request;
if (!open) return;
request = null;
open.off();
chooseShape(open.restoreShape);
open.resolve(geometry);
if (active) setActive(false);
}
/**
* Draw one area or line and hand back its geometry (a Polygon or a
* LineString) without placing a mark. Escape works as in Draw: the first
* clears a shape in progress, and one on an empty shape leaves. Resolves null
* when draw mode is left, `signal` aborts, or the pointer is taken.
* A second request ends the first with null.
* @param {{shape?: 'area'|'line', signal?: AbortSignal}} [options]
* @returns {Promise<{type: 'Polygon', coordinates: number[][][]}|{type: 'LineString', coordinates: number[][]}|null>}
*/
function drawShape({ shape: wanted = 'area', signal } = {}) {
if (destroyed || signal?.aborted) return Promise.resolve(null);
endRequest(null);
const restoreShape = shape;
return new Promise((resolve) => {
const abort = () => endRequest(null);
signal?.addEventListener('abort', abort, { once: true });
request = {
resolve,
restoreShape,
off: () => signal?.removeEventListener('abort', abort),
};
chooseShape(normalizeShape(wanted) === 'line' ? 'line' : 'area');
if (!active && !setActive(true)) endRequest(null);
});
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -545,7 +604,10 @@ export function initDrawTool({
}
const disconnect = connect({
toggleDraw: () => setActive(!active),
setDrawShape: chooseShape,
// An asked-for shape is the one asked for.
setDrawShape: (next) => {
if (!request) chooseShape(next);
},
setDrawColor: (value) => {
color = DRAW_COLORS.includes(value) ? value : 'primary';
present();
@@ -602,6 +664,7 @@ export function initDrawTool({
finish,
cancel,
clearAll,
drawShape,
shapes: DRAW_SHAPES,
/** What this tool currently holds — for teardown and leak assertions. */
diagnostics() {
@@ -638,6 +701,7 @@ export function initDrawTool({
if (destroyed) return attaching;
// Supersede any finish still in flight before anything is torn down.
generation += 1;
endRequest(null);
if (active) setActive(false);
destroyed = true;
offLocale();
+152
View File
@@ -704,6 +704,158 @@ test('what a row action answers is its row\'s note; the row is busy until then,
}
});
test('drawShape hands back the drawn area as a polygon and places no mark', async () => {
const h = harness();
try {
h.act('setDrawShape', 'line');
const drawn = h.tool.drawShape({ shape: 'area' });
assert.equal(h.tool.active, true, 'it opens draw mode');
assert.equal(h.tool.shape, 'area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
const geometry = await drawn;
assert.deepEqual(geometry, {
type: 'Polygon',
coordinates: [
[
[-97.75, 30.26],
[-97.74, 30.26],
[-97.74, 30.27],
[-97.75, 30.26],
],
],
});
assert.equal(h.annotations.calls.annotate.length, 0, 'no mark is placed');
assert.equal(h.tool.active, false, 'draw mode ends');
assert.equal(isPointerFree(), true);
assert.equal(h.tool.shape, 'line', 'the shape chosen before comes back');
} finally {
h.restore();
}
});
test('drawShape hands back a line as a LineString', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'line' });
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
assert.deepEqual(await drawn, {
type: 'LineString',
coordinates: [
[-97.75, 30.26],
[-97.74, 30.26],
],
});
} finally {
h.restore();
}
});
test('drawShape resolves null on Escape, on abort, on a newer request and when the pointer is taken', async () => {
const h = harness();
try {
// Escape with a shape started clears it; a second Escape leaves.
const escaped = h.tool.drawShape({ shape: 'area' });
h.tool.addVertex(-97.75, 30.26);
h.documentDouble.emit('keydown', {
key: 'Escape',
target: h.documentDouble.body,
});
assert.equal(h.tool.active, true, 'the first Escape only clears the shape');
h.documentDouble.emit('keydown', {
key: 'Escape',
target: h.documentDouble.body,
});
assert.equal(await escaped, null);
assert.equal(h.tool.active, false);
const controller = new AbortController();
const aborted = h.tool.drawShape({
shape: 'area',
signal: controller.signal,
});
controller.abort();
assert.equal(await aborted, null);
assert.equal(isPointerFree(), true, 'an abort gives the pointer back');
const first = h.tool.drawShape({ shape: 'area' });
const second = h.tool.drawShape({ shape: 'line' });
assert.equal(await first, null, 'a newer request ends the older one');
h.act('toggleDraw');
assert.equal(await second, null, 'leaving draw mode ends it');
const done = new AbortController();
done.abort();
assert.equal(await h.tool.drawShape({ signal: done.signal }), null);
const other = claimPointer('directions');
assert.equal(
await h.tool.drawShape({ shape: 'area' }),
null,
'a held pointer refuses it',
);
releasePointer(other);
// Drawing by hand afterwards places marks as before.
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1);
} finally {
h.restore();
}
});
test('drawShape keeps its shape, and a shape across the antimeridian is drawn again', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'line' });
h.act('setDrawShape', 'pin');
assert.equal(h.tool.shape, 'line', 'the controls cannot change the asked-for shape');
h.tool.addVertex(179.5, 10);
h.tool.addVertex(-179.5, 10);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
assert.equal(h.tool.active, true, 'still drawing');
assert.equal(h.tool.session.vertices.length, 0, 'drawn again from the start');
assert.equal(h.shown.view.hint, 'That shape could not be placed.');
h.tool.addVertex(29, 41);
h.tool.addVertex(29.1, 41);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
assert.equal((await drawn).type, 'LineString');
h.act('setDrawShape', 'pin');
assert.equal(h.tool.shape, 'pin', 'after it, the controls choose again');
} finally {
h.restore();
}
});
test('destroy ends a drawShape request with null', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'area' });
h.tool.destroy();
assert.equal(await drawn, null);
} finally {
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
+14 -1
View File
@@ -1,6 +1,7 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { isPointerFree } 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 +246,17 @@ export function createApplicationTools({
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
// Draw one area or line and get its geometry back, without placing a mark
// (annotations/drawTool.js `drawShape`); null without the Draw controls.
const drawShape = (options) =>
drawTool ? drawTool.drawShape(options) : Promise.resolve(null);
// An application's own click handlers yield while a tool holds the pointer,
// as the layers' do (data/inputOwnership.js).
return {
sceneDirector,
annotations,
voiceCommands,
drawShape,
pointerFree: isPointerFree,
};
}
-39
View File
@@ -1802,42 +1802,3 @@ 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 });
}
});
+4 -24
View File
@@ -673,40 +673,20 @@ 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).
*
* 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.
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
* @returns {{ key: string, html: string }}
*/
export function transitFeedCredit(feed, snapshot = null) {
export function transitFeedCredit(feed) {
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
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: date ? `transit-${feed.id}-${date}` : `transit-${feed.id}`,
key: `transit-${feed.id}`,
html:
`Transit (${source}${date ? `, data of ${date}` : ''}): ` +
`Transit (${escape(feed.attribution)}): ` +
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
};
}
+1 -27
View File
@@ -1,11 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DATA_CREDITS,
NYC_DOT_DISCLAIMER,
transitFeedCredit,
} from './dataCredits.js';
import { getTransitFeed } from './transitFeeds.js';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -195,24 +190,3 @@ 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/);
});
+3 -24
View File
@@ -21,10 +21,7 @@ import {
getKeyholeFadeTuning,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import {
getQueuedOverlayEntryAlpha,
registerWorldOverlayPaintLane,
} from '../overlays/worldOverlay.js';
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
import {
DETECTION_STYLE,
DETECTION_THEME_MAP,
@@ -110,8 +107,6 @@ 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. */
@@ -581,8 +576,7 @@ export function getDetectionDiagnostics() {
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.filter((row) => row.paintAlpha > 0.001)
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
return result;
}
@@ -1083,9 +1077,6 @@ function _stashCallout(entry, acquireFade, keyhole) {
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
cardSourceId: '',
cardEntryId: '',
paintAlpha: 1,
};
_calloutPool[_calloutCount] = row;
}
@@ -1112,8 +1103,6 @@ function _stashCallout(entry, acquireFade, keyhole) {
row.font = FONT;
row.microFont = MICRO_FONT;
row.alpha = alpha;
row.cardSourceId = candidate.cardSourceId;
row.cardEntryId = candidate.cardEntryId;
_calloutCount++;
}
@@ -1145,15 +1134,7 @@ function _paintCalloutLane(frame) {
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
const row = _calloutPool[i];
// 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);
paintDetectionCallout(ctx, row, row.alpha);
}
ctx.globalAlpha = 1;
}
@@ -1230,8 +1211,6 @@ function _materializeCandidate(
primary,
micro,
hasMicro: card.hasMicro,
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
};
}
-179
View File
@@ -20,16 +20,9 @@ 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,
@@ -586,178 +579,6 @@ 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
+1 -17
View File
@@ -2385,27 +2385,11 @@ 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(layerId) {
this._dropRecoveredRefreshError(layerId);
refreshLayerStats() {
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 {
+10
View File
@@ -15,6 +15,16 @@ 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);
}
-25
View File
@@ -2677,31 +2677,6 @@ 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 = {
-798
View File
@@ -389,179 +389,6 @@ 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.
@@ -571,14 +398,7 @@ function transitFeed(fields) {
* 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({
@@ -968,624 +788,6 @@ 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',
}),
]);
/**
-226
View File
@@ -552,229 +552,3 @@ 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);
}
});
+3 -22
View File
@@ -257,43 +257,24 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
truncated: decoded.truncated,
count: decoded.vehicles.length,
vehicles: repairVehicleTimestamps(
feed.speedUnit === 'km/h'
? decoded.vehicles.map((v) =>
v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 },
)
: decoded.vehicles,
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, ttlMs = TRANSIT_PROXY_TTL_MS) {
export function transitCacheState(entry, now) {
if (!entry || !Number.isFinite(entry.at)) return 'none';
const age = now - entry.at;
if (age < 0) return 'fresh';
if (age < ttlMs) return 'fresh';
if (age < TRANSIT_PROXY_TTL_MS) return 'fresh';
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
return 'expired';
}
-43
View File
@@ -12,7 +12,6 @@ import {
repairVehicleTimestamps,
resolveTransitRoute,
transitCacheState,
transitFeedTtlMs,
transitRedirectDecision,
transitResponseHeaders,
transitUpstreamHeaders,
@@ -295,27 +294,6 @@ 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);
@@ -397,24 +375,3 @@ 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');
});
-9
View File
@@ -3005,15 +3005,6 @@
"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": "جزئي",
-5
View File
@@ -2751,11 +2751,6 @@
"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",
-6
View File
@@ -2915,12 +2915,6 @@
"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",
+23 -29
View File
@@ -820,7 +820,7 @@
"FLIGHT {count}": "VOL {count}",
"REUSED": "RÉUTILISÉ",
"NO STAGE RE-ENTRY / RECOVERY DATA": "AUCUNE DONNÉE DE RENTRÉE / RÉCUPÉRATION D'ÉTAGE",
"{count} / 30D": "{count} / 30 J",
"{count} / 30D": "{count} / 30J",
"NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "AUCUNE MISSION DISPONIBLE DANS LA FENÊTRE ACTUELLE DE 30 JOURS",
"DATE UNAVAILABLE": "DATE INDISPONIBLE",
"UNSPECIFIED OPERATOR": "OPÉRATEUR NON PRÉCISÉ",
@@ -1052,11 +1052,11 @@
"Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°",
"Select one side of the dateline": "Ne traversez pas la ligne de changement de date",
"Box must enclose an area": "La zone doit délimiter une surface",
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter la couche complète de la constellation Starlink (des milliers de points en plus)",
"Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…",
"Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)",
"Loading the Starlink shell…": "Chargement de la constellation Starlink…",
"Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer",
"load failed": "échec du chargement",
"Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement",
"Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement",
"DENSE": "DENSE",
"CelesTrak unreachable": "CelesTrak injoignable",
"DOCKED · {name} · +{extra}": "AMARRÉ · {name} · +{extra}",
@@ -1137,7 +1137,7 @@
"Hybrid": "Hybride",
"Lines": "Lignes",
"Dots": "Points",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
"Moving dots": "Points mobiles",
"TomTom key unavailable": "Clé TomTom indisponible",
@@ -1153,7 +1153,7 @@
"LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue",
"LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %",
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache",
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %",
@@ -1250,7 +1250,7 @@
"Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³",
"Color: {units}": "Couleur : {units}",
"Color: {units}": "Couleur : {units}",
"RADAR · {units}": "RADAR · {units}",
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
@@ -1382,7 +1382,7 @@
"{label} is unavailable": "{label} est indisponible",
"This map stack": "Ce fond de carte",
"{label} unavailable: {hint}": "{label} indisponible : {hint}",
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau",
"Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion",
"Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra",
"Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu",
@@ -1521,7 +1521,7 @@
"Apply details": "Appliquer les détails",
"Select a scene first": "Sélectionnez d'abord une scène",
"Share selected scene": "Partager la scène sélectionnée",
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
"Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.",
"Download scene JSON": "Télécharger le JSON de scène",
"Choose data-pack files": "Choisir des fichiers de paquet de données",
"Or choose a data-pack folder": "Ou choisir un dossier de paquet de données",
@@ -2584,12 +2584,6 @@
"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",
@@ -2897,7 +2891,7 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
@@ -2918,7 +2912,7 @@
"Radio volume": "Volume de la radio",
"STATION SITE": "SITE DE LA STATION",
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous la demandez par la voix. Votre adresse IP est visible par ce diffuseur.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
@@ -3281,7 +3275,7 @@
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
"SIGHTING": "OBSERVATION",
"Show {name} sighting": "Afficher l'observation : {name}",
"Show {name} sighting": "Afficher l’observation de {name}",
"seen {age} ago": "vu il y a {age}",
"observed {age} ago": "observé il y a {age}",
"Be Whale Wise": "Respectez les cétacés",
@@ -3319,7 +3313,7 @@
"Cetacean": "Cétacé",
"Wildlife Sightings": "Observations naturalistes",
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation",
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
"Wildlife": "Faune et flore",
"Zoom in for local sightings": "Zoomez pour les observations locales",
"Loading photo...": "Chargement de la photo…",
@@ -3329,26 +3323,26 @@
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} observations dans la vue",
"Close sighting": "Fermer l'observation",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.",
"Close sighting": "Fermer l’observation",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
"{days}d": "{days} j",
"{days}d": "{days}j",
"All markers": "Tous les marqueurs",
"Show markers of every age": "Afficher les marqueurs de tout âge",
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"event age filter|All": "Tous",
"Event age": "Ancienneté",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
"Close camera preview": "Fermer l'aperçu de la caméra",
"Refresh map still": "Actualiser l'image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
"Close camera preview": "Fermer l’aperçu de la caméra",
"Refresh map still": "Actualiser l’image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
"STATIONS": "STATIONS",
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s’y rendent",
"IMAGING": "IMAGERIE",
"Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science",
"TELECOM": "TÉLÉCOM",
@@ -3361,7 +3355,7 @@
"Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service",
"Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache",
"Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible",
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l’état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM",
-6
View File
@@ -2584,12 +2584,6 @@
"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",
-4
View File
@@ -2454,10 +2454,6 @@
"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": "部分的",
-6
View File
@@ -2586,12 +2586,6 @@
"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",
-7
View File
@@ -2979,13 +2979,6 @@
"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 + потоки декодера",
-7
View File
@@ -2648,13 +2648,6 @@
"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": "частково",
-4
View File
@@ -2355,10 +2355,6 @@
"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": "部分可用",
-4
View File
@@ -2343,10 +2343,6 @@
"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": "部分可用",
+4 -37
View File
@@ -1,6 +1,5 @@
import { msg, t } from '../../i18n/index.js';
import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js';
import { GBFS_CITY_REGISTRY } from './registry.js';
import { STATUS_POLL_MS } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
@@ -14,14 +13,6 @@ export function createControls({ state: layerState, services, parts, source }) {
updateInterval: STATUS_POLL_MS,
/**
* Kept so camera moves can repaint the row before the next status poll.
* @param {object} dataManager DataLayerManager instance.
*/
attachDataManager(dataManager) {
layerState._dataManager = dataManager;
},
/**
* Return a sampled array of detectable station objects for HUD overlay rendering.
* @param {Object} [options] - Sampling options (maxCount, seed).
@@ -66,39 +57,15 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }}
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
*/
getStats() {
// A city in range without points is loading even before its fetch
// starts: the row repaints on entry, ahead of beginLoading().
const loading =
layerState._loading ||
[...layerState._activeCityIds].some(
(cityId) =>
!layerState._cityRuntime.has(cityId) &&
!layerState._failedCityIds.has(cityId),
);
const stats = {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
loading,
loading: layerState._loading,
};
// No system in range: the camera is too high or no system is near. A
// failed city also leaves range empty and reports its failure below.
if (
layerState._enabled &&
!loading &&
layerState._activeCityIds.size === 0 &&
layerState._failedCityIds.size === 0
) {
// English: the analyst's empty note and the row's prompt, both shown
// through translateStatusMessage (ui/statusText.js).
const note = layerState._altitudeGateEnabled
? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`;
return { ...stats, status: 'zoom-in', statusMessage: note };
}
if (loading) {
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? t('syncing {count} city feeds...', {
-1
View File
@@ -169,7 +169,6 @@ export function createLifecycle({
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
layerState._dataManager = null;
layerState._viewer = null;
},
};
-366
View File
@@ -1,366 +0,0 @@
// Bike Share's Layers row. With no system in range it says why instead of
// "never" and keeps no date for data it no longer draws. After the camera
// settles, the last row the panel painted must match what the layer reports
// now. Each case drives the real viewport,
// ingestion, rendering and controls; the manager is a spy that records the
// row text (LayerPanel._buildMetaText) at every refreshLayerStats() call.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { CITY_BY_ID, GBFS_CITY_REGISTRY } from './registry.js';
import { createState } from './state.js';
import { createControls } from './controls.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
const BOSTON_ID = 'boston-bluebikes';
const TORONTO_ID = 'toronto-bike-share';
const BUFFALO_ID = 'buffalo-reddy';
const LONDON = { lat: 51.5072, lon: -0.1276 };
const BOSTON = { lat: 42.3601, lon: -71.0589 };
const BOSTON_NUDGED = { lat: 42.37, lon: -71.05 };
const BETWEEN = { lat: 43.25, lon: -79.1 };
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
const LOW_M = 10_000;
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
function makeSource() {
const cityOf = (url) => {
for (const [id, city] of CITY_BY_ID) {
if (url === city.stationInformationUrl) return { id, kind: 'info' };
if (url === city.stationStatusUrl) return { id, kind: 'status' };
}
throw new Error(`unexpected GBFS url ${url}`);
};
const source = {
dead: new Set(),
/** `${cityId}:${kind}` → deferred that gates the answer. */
gates: new Map(),
async getStations(url, { signal } = {}) {
const { id, kind } = cityOf(url);
const gate = source.gates.get(`${id}:${kind}`);
if (gate) {
await new Promise((resolve, reject) => {
gate.promise.then(resolve);
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
});
} else {
await new Promise((resolve) => setTimeout(resolve, 1));
}
signal?.throwIfAborted();
if (source.dead.has(id)) throw new Error('GBFS HTTP 503');
const city = CITY_BY_ID.get(id);
if (kind === 'info') {
return {
data: {
stations: [
{
station_id: `${id}-1`,
name: `${id} dock`,
lat: city.centerLat,
lon: city.centerLon,
capacity: 10,
},
],
},
};
}
return {
data: {
stations: [
{
station_id: `${id}-1`,
num_bikes_available: 3,
num_docks_available: 7,
is_installed: true,
is_renting: true,
is_returning: true,
},
],
},
};
},
};
return source;
}
/** The row as the panel draws it: state chip, count, meta text. */
const rowText = (raw) => {
const stats = {
count: 0,
lastUpdate: null,
...raw,
loading: raw.loading === true,
};
const meta = LayerPanel.prototype._buildMetaText({
id: 'bikeshare',
source: 'GBFS',
enabled: true,
lifecycleState: 'enabled',
stats,
});
return `${layerFeedState(stats)} | ${stats.count || '—'} | ${meta}`;
};
function makeLiveLayer() {
const source = makeSource();
const services = {
render: { governorRequestRender() {} },
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.ingestion = createIngestion(context);
parts.model = createModel(context);
parts.viewport = createViewport(context);
parts.rendering = createRendering(context);
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.queries = { collectDetectableStations: () => [] };
parts.lifecycle = createLifecycle(context);
const { methods: controls } = createControls(context);
const camera = { ...LONDON, height: LOW_M };
const viewer = {
camera: {
get positionCartographic() {
return Cesium.Cartographic.fromDegrees(
camera.lon,
camera.lat,
camera.height,
);
},
computeViewRectangle: () => undefined,
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: { globe: {}, primitives: { add() {}, remove() {} } },
};
parts.lifecycle.methods.init(viewer);
const painted = [];
controls.attachDataManager({
refreshLayerStats() {
painted.push(rowText(controls.getStats()));
},
});
return {
state,
source,
painted,
lastPainted: () => painted.at(-1),
stats: () => controls.getStats(),
live: () => rowText(controls.getStats()),
moveTo(place, height = LOW_M) {
Object.assign(camera, place, { height });
return parts.viewport.runProximityCheck();
},
async enable() {
parts.lifecycle.methods.enable(viewer);
await settle();
},
};
}
async function settle() {
for (let i = 0; i < 5; i += 1)
await new Promise((resolve) => setTimeout(resolve, 2));
}
test('entering a city paints the loading row, not "never"', async () => {
const layer = makeLiveLayer();
const info = deferred();
layer.source.gates.set(`${BOSTON_ID}:info`, info);
await layer.enable();
const before = layer.painted.length;
const entering = layer.moveTo(BOSTON);
assert.ok(layer.painted.length > before, 'entering repainted');
const atEntry = layer.lastPainted();
const liveAfter = layer.live();
info.resolve();
await entering;
await settle();
assert.match(liveAfter, /syncing 1 city feeds/);
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
assert.doesNotMatch(atEntry, /never/);
});
test('re-entering a city with cached station info paints loading', async () => {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
await layer.moveTo(LONDON);
await settle();
const status = deferred();
layer.source.gates.set(`${BOSTON_ID}:status`, status);
const entering = layer.moveTo(BOSTON);
const atEntry = layer.lastPainted();
await Promise.resolve();
await Promise.resolve();
const liveAfter = layer.live();
status.resolve();
await entering;
await settle();
assert.match(liveAfter, /syncing 1 city feeds/);
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
assert.doesNotMatch(atEntry, /never/);
});
test('leaving a city mid-load does not leave the row on LOADING', async () => {
const layer = makeLiveLayer();
layer.source.gates.set(`${BOSTON_ID}:info`, deferred());
await layer.enable();
const entering = layer.moveTo(BOSTON);
await settle();
assert.equal(layer.state._loading, true);
await layer.moveTo(LONDON);
await entering;
await settle();
assert.equal(layer.state._loading, false);
assert.match(layer.live(), /No bike share here yet/);
assert.equal(layer.lastPainted(), layer.live());
});
test('a camera nudge during the first load still repaints the drawn stations', async () => {
const layer = makeLiveLayer();
const status = deferred();
layer.source.gates.set(`${BOSTON_ID}:status`, status);
await layer.enable();
const entering = layer.moveTo(BOSTON);
await settle();
assert.ok(layer.state._cityRuntime.has(BOSTON_ID), 'points are drawn');
await layer.moveTo(BOSTON_NUDGED);
status.resolve();
await entering;
await settle();
assert.equal(layer.state._count, 1, 'one station on the map');
assert.equal(layer.lastPainted(), layer.live());
});
test('leaving a city whose load failed repaints the error away', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BOSTON_ID);
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
assert.match(layer.lastPainted(), /GBFS fetch error/i);
await layer.moveTo(LONDON);
await settle();
assert.match(layer.live(), /No bike share here yet/);
assert.equal(layer.lastPainted(), layer.live());
});
test('leaving the failed city of two repaints DEGRADED away', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO_ID);
await layer.enable();
await layer.moveTo(BETWEEN);
await settle();
assert.match(layer.lastPainted(), /^degraded/);
await layer.moveTo(TORONTO_ONLY);
await settle();
assert.doesNotMatch(layer.live(), /^degraded/);
assert.equal(layer.lastPainted(), layer.live());
});
test('above the altitude gate the row says how low to fly', async () => {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(LONDON, 2_000_000);
const stats = layer.stats();
assert.equal(stats.status, 'zoom-in');
assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city');
assert.equal(stats.error, undefined);
});
test('a city with no system says so and counts the ones there are', async () => {
const layer = makeLiveLayer();
await layer.enable();
const stats = layer.stats();
assert.equal(stats.status, 'zoom-in');
assert.equal(
stats.statusMessage,
`No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`,
);
assert.equal(stats.count, 0);
});
test('a city in range, loading, failed or a disabled layer gets no prompt', async () => {
const layer = makeLiveLayer();
assert.equal(layer.stats().status, undefined, 'disabled');
const info = deferred();
layer.source.gates.set(`${BOSTON_ID}:info`, info);
await layer.enable();
const entering = layer.moveTo(BOSTON);
assert.equal(layer.stats().status, undefined, 'loading');
info.resolve();
await entering;
await settle();
assert.equal(layer.stats().status, undefined, 'loaded');
// Left mid-load: range is empty, but the aborted load still owns the row.
const leaving = makeLiveLayer();
leaving.source.gates.set(`${BOSTON_ID}:info`, deferred());
await leaving.enable();
const loading = leaving.moveTo(BOSTON);
await settle();
const left = leaving.moveTo(LONDON);
assert.equal(leaving.state._activeCityIds.size, 0);
assert.equal(leaving.stats().loading, true);
assert.equal(leaving.stats().status, undefined, 'leaving mid-load');
await Promise.all([loading, left]);
const failed = makeLiveLayer();
failed.source.dead.add(BOSTON_ID);
await failed.enable();
await failed.moveTo(BOSTON);
await settle();
assert.equal(failed.stats().status, undefined, 'failed');
assert.equal(failed.stats().error, 'GBFS fetch error');
});
test('leaving every system behind drops the date of the data it drew', async () => {
for (const height of [LOW_M, 2_000_000]) {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
assert.ok(layer.stats().lastUpdate, 'Boston is dated');
await layer.moveTo(LONDON, height);
assert.equal(layer.stats().lastUpdate, null, `at ${height} m`);
}
});
-4
View File
@@ -19,10 +19,6 @@ export function createState({ services }) {
layerState._viewer = null;
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */
layerState._dataManager = null;
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
layerState._pointCollection = null;
+3 -25
View File
@@ -116,11 +116,6 @@ export function createViewport({ state: layerState, services, parts, source }) {
parts.rendering.removeCityPoints(cityId);
}
/** Repaint the row now: it follows the camera, the manager tick is 60 s. */
function refreshRow() {
layerState._dataManager?.refreshLayerStats?.('bikeshare');
}
/** Deactivate all currently active cities and clear the active set. */
function deactivateAllCities() {
@@ -130,8 +125,6 @@ export function createViewport({ state: layerState, services, parts, source }) {
// A failure belongs to a city in range; none are once everything is off.
layerState._failedCityIds.clear();
layerState._count = 0;
// Nothing is left on the map to date.
layerState._lastUpdate = null;
}
/**
@@ -210,11 +203,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
const generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude(layerState._viewer);
const wasGateEnabled = layerState._altitudeGateEnabled;
// Altitude gate: disable all cities when camera is too high
if (!shouldActivateAtAltitude(altitude)) {
deactivateAllCities();
if (wasGateEnabled) refreshRow();
return;
}
@@ -229,25 +220,15 @@ export function createViewport({ state: layerState, services, parts, source }) {
}
}
// A failure never outlives its city's range.
let failureCleared = false;
for (const cityId of layerState._failedCityIds) {
if (!nextActive.has(cityId)) {
layerState._failedCityIds.delete(cityId);
failureCleared = true;
}
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
}
const changed =
!wasGateEnabled ||
failureCleared ||
nextActive.size !== layerState._activeCityIds.size ||
[...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId));
layerState._activeCityIds = nextActive;
if (nextActive.size === 0) {
if (layerState._activeCityIds.size === 0) {
layerState._count = 0;
layerState._lastUpdate = null;
return;
}
if (changed) refreshRow();
// Only activate cities that don't already have rendered points
const toActivate = [];
@@ -259,9 +240,6 @@ export function createViewport({ state: layerState, services, parts, source }) {
await Promise.all(
toActivate.map((cityId) => activateCity(cityId, generation)),
);
// Superseded loads repaint too: a newer check may have painted the row
// while this one was still loading, or found nothing to load itself.
if (layerState._enabled) refreshRow();
}
/** Schedule a debounced proximity check after camera movement. */
+1 -2
View File
@@ -159,8 +159,7 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) {
// the known cause, behavior and complexity values and the wording around them.
const facts = [];
if (Number.isFinite(row.acres)) {
const acres =
row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres);
const acres = Math.round(row.acres);
facts.push(
tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }),
);
-31
View File
@@ -302,34 +302,3 @@ test('partial incident details render only the facts that exist', () => {
'Catron County · $85K to date',
]);
});
test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => {
const row = {
stableId: 'x',
name: 'Grey',
acres: 0.25,
containedPct: null,
state: 'US-WA',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
};
assert.equal(
buildIncidentCard(row, 1758000000000).details[0],
'0.25 ac · containment unknown · US-WA',
);
assert.equal(
buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0],
'0.01 ac · containment unknown · US-WA',
);
assert.equal(
buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0],
'1 ac · containment unknown · US-WA',
);
});
+1 -7
View File
@@ -401,13 +401,7 @@ export function createIngestion({ state, services, parts, source }) {
);
}
if (state._viewer)
registerDynamicCredit(
state._viewer,
transitFeedCredit(feed, {
feedTimestamp: snapshot?.feedTimestamp,
fetchedAt,
}),
);
registerDynamicCredit(state._viewer, transitFeedCredit(feed));
if (
state._selectedKey &&
state._vehicles.get(state._selectedKey)?.feedId === feed.id
-51
View File
@@ -3983,54 +3983,3 @@ test('parked transit reprojects held and reported courses at the bounded camera
'selected unknown course also stays screen-up',
);
});
test('a view with no feed reads its coverage prompt, not an undated feed', async (t) => {
const app = harness(t);
app.serve('mbta', () => ({
status: 200,
body: snapshot(
'mbta',
'MBTA',
[vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
assert.ok(app.layer.getStats().lastUpdate, 'Boston has a dated snapshot');
// London has no registered feed: the row prompts instead of dating Boston's
// last snapshot over an empty map.
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
app.advance(1_000);
const away = app.layer.getStats();
assert.equal(away.status, 'zoom-in');
assert.equal(away.count, 0);
assert.equal(away.lastUpdate, null);
assert.match(
away.statusMessage,
/^No feed here yet · \d+ regions available$/,
);
assert.equal(away.coverage, away.statusMessage);
// Above the altitude gate the prompt says how low to fly.
app.viewer.camera.positionCartographic.height = 10_000_000;
app.lookAt({ west: -0.2, south: 51.45, east: -0.05, north: 51.55 });
app.advance(1_000);
assert.equal(
app.layer.getStats().statusMessage,
'Fly below 2,850 km to a covered region',
);
});
test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) => {
// 2,900 km is under ACTIVATION_ALTITUDE_M but above the enter threshold.
const app = harness(t, { altitude: 2_900_000 });
app.layer.enable(app.viewer);
await app.layer.update();
assert.deepEqual(app.requested, [], 'nothing loads over Boston');
assert.equal(
app.layer.getStats().statusMessage,
'Fly below 2,850 km to a covered region',
);
});
+6 -8
View File
@@ -1,5 +1,5 @@
import {
ACTIVATION_ENTER_ALTITUDE_M,
ACTIVATION_ALTITUDE_M,
FEED_STALE_AFTER_MS,
aggregateTransitFeedHealth,
transitDetectionClass,
@@ -245,19 +245,17 @@ export function createQueries({ state, parts }) {
const health = aggregateTransitFeedHealth(statuses, now);
const count = state._vehicles.size;
if (state._enabled && active.length === 0) {
// English: the analyst's empty note and the row's prompt, both shown
// through translateStatusMessage (ui/statusText.js).
const note = state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`;
return {
count: 0,
lastUpdate: state._lastUpdate,
error: null,
source: 'GTFS-RT',
status: 'zoom-in',
statusMessage: note,
coverage: note,
// English data (the analyst's empty note to the model); the voice
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
};
}
// Age is shown by the same rule the row state uses, so the text and the
@@ -1,100 +0,0 @@
// Transit: a manager tick that failed over Boston must not keep its error on
// the row the coverage repaint draws after the camera leaves for London.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createViewport } from './viewport.js';
import { createQueries } from './queries.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
const rad = Math.PI / 180;
const LONDON = { lat: 51.5072, lon: -0.1276 };
const BOSTON = { lat: 42.3601, lon: -71.0589 };
function viewer(at, height) {
return {
camera: {
positionCartographic: {
height,
latitude: at.lat * rad,
longitude: at.lon * rad,
},
computeViewRectangle: () => undefined,
},
scene: { globe: {} },
};
}
test('leaving a feed whose tick failed repaints its error away', async () => {
const services = {
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
render: { governorRequestRender() {} },
};
const state = createState({ services });
const feedStatus = (feedId) => {
if (!state._feedStatus.has(feedId))
state._feedStatus.set(feedId, { count: 0, error: null, loading: false });
return state._feedStatus.get(feedId);
};
const parts = {
ingestion: {
feedStatus,
// As pollFeed's catch does when the proxy answers 503.
pollFeed(feed) {
Object.assign(feedStatus(feed.id), {
error: `${feed.name} feed unavailable`,
loading: false,
});
return Promise.resolve();
},
removeFeedVehicles() {},
sweepAgedVehicles() {},
},
rendering: { requestVisibility() {}, syncRenderHold() {} },
};
parts.viewport = createViewport({ state, services, parts });
const queries = createQueries({ state, parts }).methods;
state._enabled = true;
const lifecycle = new LayerLifecycle(null);
lifecycle.register({
id: 'transit',
name: 'Transit',
source: 'GTFS-RT',
getStats: () => queries.getStats(),
async update() {},
});
const entry = lifecycle.layers.get('transit');
entry.enabled = true;
entry.lifecycleState = 'enabled';
entry.initialized = true;
const row = () => {
const layer = lifecycle.getAll()[0];
return `${layerFeedState(layer.stats)} | ${LayerPanel.prototype._buildMetaText(layer)}`;
};
const painted = [];
state._dataManager = {
refreshLayerStats(layerId) {
lifecycle.refreshLayerStats(layerId);
painted.push(row());
},
};
state._viewer = viewer(BOSTON, 4_000);
parts.viewport.runProximityCheck();
await lifecycle.refreshLayer('transit');
assert.match(row(), /feed unavailable/, 'the tick recorded the failure');
const before = painted.length;
state._viewer = viewer(LONDON, 4_000);
parts.viewport.runProximityCheck();
assert.equal(queries.getStats().status, 'zoom-in');
assert.ok(painted.length > before, 'leaving Boston repainted the row');
assert.doesNotMatch(painted.at(-1), /feed unavailable/);
});
+1 -1
View File
@@ -115,7 +115,7 @@ export function createState({ services }) {
state._cockpitVision = false;
/** @type {Array<[string, EventListener]>} window listeners to remove on destroy. */
state._styleListeners = [];
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} */
/** @type {{ refreshLayerStats?: () => void }|null} */
state._dataManager = null;
/** @type {number|null} */
state._lastUpdate = null;
-8
View File
@@ -83,9 +83,7 @@ export function createViewport({ state, services, parts }) {
if (!state._enabled || !state._viewer) return;
refreshViewBounds();
const altitude = getCameraAltitude();
const wasGateOpen = state._altitudeGateOpen;
state._altitudeGateOpen = altitudeGateOpen(altitude);
let changed = state._altitudeGateOpen !== wasGateOpen;
const center = state._altitudeGateOpen ? getCameraCenterLatLon() : null;
const desired = new Map();
if (center) {
@@ -108,23 +106,17 @@ export function createViewport({ state, services, parts }) {
state._activeFeeds.delete(feedId);
state._feedStatus.delete(feedId);
parts.ingestion.removeFeedVehicles(feedId);
changed = true;
}
}
// Nothing is left on the map to date.
if (desired.size === 0) state._lastUpdate = null;
for (const [feedId, feed] of desired) {
if (!state._activeFeeds.has(feedId)) {
state._activeFeeds.set(feedId, feed);
void parts.ingestion.pollFeed(feed, state._generation);
changed = true;
}
}
parts.ingestion.sweepAgedVehicles(Date.now());
parts.rendering.syncRenderHold();
governorRequestRender('transit-proximity');
// Repaint now: the row follows the camera, the manager tick is 15 s.
if (changed) state._dataManager?.refreshLayerStats?.('transit');
}
function onCameraChanged() {
+1 -5
View File
@@ -25,7 +25,6 @@ import {
FOCUS_EVIDENCE_DEV,
VESSEL_LIFT_M,
} from './policy.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
export function createQueries({
vesselState,
@@ -576,7 +575,7 @@ export function createQueries({
* @param {Object} [options={}] - Options from the detection system.
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @param {number} [options.seed] - Seed offset for stride sampling.
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, cardSourceId: string, cardEntryId: string}>}
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
*/
getDetectableObjects(options = {}) {
if (
@@ -611,9 +610,6 @@ export function createQueries({
id: record.name || record.mmsi || 'VESSEL',
type: 'SEA',
skipLabel: record === selected,
// Detection hides the callout while this card paints.
cardSourceId: VESSEL_OVERLAY_SOURCE_ID,
cardEntryId: components.cards.vesselOverlayEntryId(record),
klass: record.type
? String(record.type).toUpperCase().slice(0, 14)
: undefined,
+14 -73
View File
@@ -212,10 +212,6 @@ const _paintQueue = [];
const _paintItemPool = [];
const _paintRectPool = [];
const _paintRectByKey = new Map();
/** @type {Map<string, Map<string, object>>} This frame's paint items by source and entry id. */
const _queuedItemBySource = new Map();
/** Index rows that stop painting are dropped this many frames apart. */
const QUEUED_ITEM_PRUNE_FRAMES = 600;
const _hitRects = [];
const _paintedBySource = Object.create(null);
const _paintedSourceKeys = [];
@@ -1046,30 +1042,6 @@ export function getOverlayPaintRect(sourceId, entryId) {
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
}
/**
* Opacity an entry paints with this frame, or 0 when it is not queued. Custom
* lanes may call it before later lanes publish paint rects. Leaves out the
* Cyber sonar fade and yield-collision hiding.
* @param {string} sourceId
* @param {string} entryId
* @returns {number}
*/
export function getQueuedOverlayEntryAlpha(sourceId, entryId) {
if (_destroyed) return 0;
const item = _queuedItemBySource.get(sourceId)?.get(entryId);
// Pooled items are reassigned every frame: check the frame and the entry.
if (!item || item.stamp !== _frameStamp) return 0;
const record = item.record;
if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0;
return (
record.sourceAlpha *
item.temporalAlpha *
record.distanceAlpha *
record.altitudeAlpha *
(_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1)
);
}
/**
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
* @param {number} x
@@ -2251,34 +2223,7 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
item.zIndex = record.entry.zIndex;
item.key = record.key;
item.collisionHidden = false;
item.stamp = _frameStamp;
_paintQueue[_paintCount++] = item;
// Keyed by source and id, not `record.key`, so a lookup builds no string.
// Also holds cards fading out after they left `_records`.
let bySource = _queuedItemBySource.get(record.entry.source);
if (!bySource) {
bySource = new Map();
_queuedItemBySource.set(record.entry.source, bySource);
}
bySource.set(record.entry.id, item);
}
/** Drop index rows for entries that stopped painting (bounded, not per frame). */
function pruneQueuedItemIndex() {
for (const [sourceId, bySource] of _queuedItemBySource) {
for (const [entryId, item] of bySource) {
// A pooled item that now paints another entry keeps a fresh stamp.
const entry = item.record.entry;
if (
item.stamp !== _frameStamp ||
entry.id !== entryId ||
entry.source !== sourceId
) {
bySource.delete(entryId);
}
}
if (bySource.size === 0) _queuedItemBySource.delete(sourceId);
}
}
function sourceCanPaint(sourceId) {
@@ -2350,7 +2295,6 @@ function solveDomains(timestamp) {
solveRevision += domain.arbiter.solveRevision;
}
_paintQueue.length = _paintCount;
if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex();
_solveDirty = false;
_diagnostics.solveMs = solveMs;
_diagnostics.selectedCount = selectedCount;
@@ -2479,26 +2423,24 @@ function paintCustomLane(lane) {
}
}
/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */
function entryKeyholeAlpha(entry, placement, keyhole) {
if (entry.edgeFade !== 'keyhole') return 1;
const keyholeX = placement.centerX - keyhole.centerX;
const keyholeY = placement.centerY - keyhole.centerY;
return keyholeX * keyholeX + keyholeY * keyholeY <=
keyhole.radius * keyhole.radius
? 1
: keyholeLabelAlphaFromGeometry(
placement.centerX,
placement.centerY,
keyhole,
);
}
function paintEntryItem(item, keyhole, sonar) {
if (item.collisionHidden) return;
const { record, placement } = item;
const entry = record.entry;
const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole);
let keyholeAlpha = 1;
if (entry.edgeFade === 'keyhole') {
const keyholeX = placement.centerX - keyhole.centerX;
const keyholeY = placement.centerY - keyhole.centerY;
keyholeAlpha =
keyholeX * keyholeX + keyholeY * keyholeY <=
keyhole.radius * keyhole.radius
? 1
: keyholeLabelAlphaFromGeometry(
placement.centerX,
placement.centerY,
keyhole,
);
}
// All five channels are normalized at their source. Keep the multiply on
// the hot paint path so its intermediate doubles remain unboxed; the pure
// `combinedOverlayAlpha` export still specifies/tests the same binding.
@@ -2887,7 +2829,6 @@ export function destroyWorldOverlay() {
_accessibleActivatorByKey.clear();
_accessibilitySignature = '';
_paintRectByKey.clear();
_queuedItemBySource.clear();
_paintCount = 0;
_paintRectCount = 0;
_hitRectCount = 0;
-83
View File
@@ -33,7 +33,6 @@ import {
activateWorldOverlayAction,
initWorldOverlay,
subscribeWorldOverlayActions,
getQueuedOverlayEntryAlpha,
isOverlayPointVisible,
normalizeOverlayEntry,
paintLaneForOverlayEntry,
@@ -1223,42 +1222,6 @@ test('custom detection lane receives the shared host frame and paints below ordi
env.cleanup();
});
test('a detection lane sees which cards this frame will paint before they publish paint rects', () => {
// The detection lane paints before the cards publish this frame's paint
// rects, so it reads the queued alpha instead.
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
initWorldOverlay(env.viewer);
const seen = [];
registerWorldOverlayPaintLane('detection', () => {
seen.push({
shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'),
hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'),
unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'),
rect: getOverlayPaintRect('cards', 'SHOWN'),
});
}, { id: 'detection', active: true, target: 'detection' });
setOverlayEntries('cards', [selectedEntry('SHOWN', {
position: positionAtScreen(200, 150),
})]);
setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', {
position: positionAtScreen(100, 100),
})]);
setOverlaySourceVisible('hidden-cards', false);
env.postRender.raise();
assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null },
'the card is queued while its paint rect is still unpublished');
assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane');
assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null);
clearOverlaySource('cards');
env.postRender.raise();
assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued');
destroyWorldOverlay();
assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0);
env.cleanup();
});
test('custom-lane painter state is bracketed by host save/restore', () => {
// A painter that leaks a clip or alpha must not corrupt later frames: the
// host wraps every custom-lane painter in save/try/finally-restore. Pin the
@@ -2526,49 +2489,3 @@ test('promoting an ambient entry to selected paints it once with no fading dupli
assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown');
} finally { env.cleanup(); }
});
test('the queued-card index stays bounded while card identities churn', () => {
// The index is internal: capture it through Map.prototype.set, like the
// projection-record test.
const originalSet = Map.prototype.set;
let queuedIndex = null;
Map.prototype.set = function captureQueuedIndex(key, value) {
if (typeof key === 'string' && key.startsWith('churn-card-')
&& value && typeof value === 'object' && 'temporalAlpha' in value
&& 'placement' in value && 'stamp' in value) {
queuedIndex = this;
}
return originalSet.call(this, key, value);
};
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
try {
initWorldOverlay(env.viewer);
const FRAMES = 1300;
for (let frame = 0; frame < FRAMES; frame++) {
// One card whose id changes every frame reuses one pooled paint item,
// like a vessel cohort republished as the camera moves.
setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, {
position: positionAtScreen(200, 150),
})]);
env.advanceTime(16);
env.postRender.raise();
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame}`), 1,
'the live card is queued in the frame just painted');
if (frame > 0) {
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame - 1}`), 0,
'a stale row never reports the previous card as queued');
}
}
assert.ok(queuedIndex, 'the queued-card index was never populated');
// Rows are pruned every 600 painted frames, so with one new id per frame
// the index holds at most one prune window of ids plus the live card.
const bound = 600 + 1;
assert.ok(
queuedIndex.size <= bound,
`queued-card index holds ${queuedIndex.size} rows after ${FRAMES} identities (bound ${bound})`,
);
} finally {
Map.prototype.set = originalSet;
env.cleanup();
}
});
@@ -1217,9 +1217,6 @@ function buildPhase6DetectionWorkload(count) {
: `FL${String(180 + (index % 220)).padStart(3, '0')}`,
type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4],
tier: index % 17 === 0 ? 'military' : undefined,
// Vessels name their ambient card, so each callout asks the host for it.
cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined,
cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined,
});
}
return {
+1 -5
View File
@@ -209,11 +209,7 @@ test('routing middleware uses configured OSRM servers while retaining request bo
const request = async (query) => {
let payload;
await handler(
{
url: `/?${query}`,
method: 'GET',
socket: { remoteAddress: '127.0.0.1' },
},
{ url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } },
{
writeHead() {},
end(body) {
+1 -2
View File
@@ -25,7 +25,6 @@ import {
nextTransitBackoffMs,
resolveTransitRoute,
transitCacheState,
transitFeedTtlMs,
transitRedirectDecision,
transitResponseHeaders,
transitUpstreamHeaders,
@@ -267,7 +266,7 @@ export function createTransitService({ fetchImpl = fetch } = {}) {
);
}
const cached = cache.get(feed.id);
const state = transitCacheState(cached, now, transitFeedTtlMs(feed));
const state = transitCacheState(cached, now);
if (state === 'fresh') {
return reply(
200,
+2 -165
View File
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { gunzipSync } from 'node:zlib';
import { WebSocketServer } from 'ws';
import { setTimeout as delay } from 'node:timers/promises';
import * as providers from '../../server/providers/live.js';
@@ -16,7 +15,7 @@ function install(plugin, preview = false) {
},
},
});
return async (route, url = '/', method = 'GET', headers = {}) => {
return async (route, url = '/', method = 'GET') => {
assert.ok(routes.has(route), `registered route: ${route}`);
const response = {
statusCode: 200,
@@ -33,7 +32,7 @@ function install(plugin, preview = false) {
this.body = body;
},
};
await routes.get(route)({ url, method, headers }, response);
await routes.get(route)({ url, method }, response);
return response;
};
}
@@ -585,168 +584,6 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(callCount, 1);
});
test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => {
// A fetch that throws proves the refusal comes before any upstream call.
t.mock.method(globalThis, 'fetch', async (url) => {
throw new Error(`upstream must not be reached: ${url}`);
});
const cases = [
[install(providers.openSkyProxy()), '/api/flights'],
[install(providers.adsbLolProxy()), '/api/military'],
];
for (const [request, route] of cases) {
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
const response = await request(route, '/', method);
assert.equal(response.statusCode, 405, `${method} ${route}`);
// RFC 9110 section 15.5.6: a 405 names the methods it accepts.
assert.equal(response.headers.allow, 'GET', `${method} ${route}`);
assert.deepEqual(JSON.parse(response.body), {
error: 'Method Not Allowed',
});
}
}
});
test('the snapshot method check hands each /track route on to its own handler', async () => {
// The snapshot mount also matches /track, so it must hand /track on
// before it checks the method.
for (const [plugin, route] of [
[providers.openSkyProxy(), '/api/flights'],
[providers.adsbLolProxy(), '/api/military'],
]) {
const routes = new Map();
plugin.configureServer({
middlewares: { use: (path, handler) => routes.set(path, handler) },
});
const res = {
statusCode: 200,
writeHead(s) {
this.statusCode = s;
},
end() {},
};
let handedOn = false;
await routes.get(route)(
{ url: '/track?icao24=abc123', method: 'POST' },
res,
() => {
handedOn = true;
},
);
assert.ok(handedOn, `${route}/track reaches next()`);
assert.equal(
res.statusCode,
200,
`${route}/track is not answered by the snapshot handler`,
);
}
});
test('the OpenSky snapshot is compressed for clients that ask, and an error body never inherits the cached frame', async (t) => {
environment(t, {
OPENSKY_AUTH_MODE: 'anon',
OPENSKY_CLIENT_ID: undefined,
OPENSKY_CLIENT_SECRET: undefined,
OPENSKY_USERNAME: undefined,
OPENSKY_PASSWORD: undefined,
});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'error', () => {});
let upstreamStatus = 200;
let snapshotAgeMs = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
if (url.includes('/lat/'))
return Response.json({
now: now / 1000,
ac: Array.from({ length: 200 }, (_, index) => ({
hex: `abc${index}`,
lat: 30,
lon: -97,
alt_baro: 10000,
})),
});
assert.ok(url.includes('/states/'), `unexpected upstream call: ${url}`);
if (upstreamStatus !== 200)
return new Response('{"error":"upstream"}', { status: upstreamStatus });
const sourceSeconds = Math.floor((now - snapshotAgeMs) / 1000);
// Healthy credits keep the 9 s base TTL, so the clock jumps below clear it.
return Response.json(
{
time: sourceSeconds,
states: Array.from({ length: 200 }, (_, index) => [
`abc${index}`,
'CALLSIGN',
'United States',
sourceSeconds,
-97.7,
30.1,
10000,
]),
},
{ headers: { 'x-rate-limit-remaining': '4000' } },
);
});
// A fresh module: the snapshot cache, its frame and the TTL are module state.
const isolated = await import(
`../../server/providers/aircraft/opensky.js?compression=${now}`
);
const states = install(isolated.openSkyProxy());
const compressed = await states('/api/flights', '/', 'GET', {
'accept-encoding': 'gzip, deflate, br',
});
assert.equal(compressed.statusCode, 200);
assert.equal(compressed.headers['content-encoding'], 'gzip');
assert.equal(compressed.headers.vary, 'Accept-Encoding');
assert.ok(Buffer.isBuffer(compressed.body));
// Same cached snapshot, no Accept-Encoding: identity bytes, which the frame
// above must decode to.
const identity = await states('/api/flights');
assert.equal(identity.headers['x-opensky-cache'], 'HIT');
assert.equal(identity.headers['content-encoding'], undefined);
assert.equal(identity.headers.vary, 'Accept-Encoding');
assert.equal(gunzipSync(compressed.body).toString(), identity.body);
// `gzip;q=0` refuses gzip.
const refused = await states('/api/flights', '/', 'GET', {
'accept-encoding': 'gzip;q=0',
});
assert.equal(refused.headers['content-encoding'], undefined);
assert.equal(refused.body, identity.body);
// An upstream rejection is not cached, so it must not carry the previous
// snapshot's Content-Encoding.
upstreamStatus = 403;
now += 60000; // past the 9 s base TTL
const rejected = await states('/api/flights', '/', 'GET', {
'accept-encoding': 'gzip',
});
assert.equal(rejected.statusCode, 403);
assert.equal(rejected.headers['content-encoding'], undefined);
assert.ok(JSON.parse(rejected.body).error, 'a readable error body');
// A snapshot too old to serve falls back to adsb.lol, which negotiates the
// same way.
upstreamStatus = 200;
snapshotAgeMs = 3600000;
now += 60000;
const regional = await states('/api/flights', '?lat=30&lon=-97', 'GET', {
'accept-encoding': 'gzip',
});
assert.equal(regional.statusCode, 200);
assert.equal(regional.headers['x-flight-source'], 'adsb.lol');
assert.equal(regional.headers['content-encoding'], 'gzip');
assert.equal(regional.headers.vary, 'Accept-Encoding');
assert.equal(
JSON.parse(gunzipSync(regional.body).toString()).states.length,
200,
);
});
test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: undefined,
+13 -22
View File
@@ -684,34 +684,25 @@ test('the disk cache survives a restart and stays within its bounds', async () =
await createNominatimCache({ dir, now: time.now }).get('k0'),
null,
);
// The cache is private only through POSIX mode bits. Windows has none
// (directories stat as 0o666) and the cache keeps the checkout's ACLs,
// so these checks run, and privacy holds, only on POSIX systems.
const posixModes = process.platform !== 'win32';
if (posixModes) {
assert.equal(
fs.statSync(dir).mode & 0o777,
0o700,
'the query cache directory is private (POSIX modes)',
);
}
assert.equal(
fs.statSync(dir).mode & 0o777,
0o700,
'the query cache directory is private',
);
const text = fs
.readdirSync(dir)
.filter((name) => name.endsWith('.json'))
.map((name) => fs.readFileSync(path.join(dir, name), 'utf8'))
.join('\n');
assert.equal(text.includes('k4'), false, 'disk entries never store raw keys');
if (posixModes) {
for (const name of fs
.readdirSync(dir)
.filter((entry) => entry.endsWith('.json'))) {
assert.equal(
fs.statSync(path.join(dir, name)).mode & 0o777,
0o600,
`${name} is private (POSIX modes)`,
);
}
}
for (const name of fs
.readdirSync(dir)
.filter((entry) => entry.endsWith('.json')))
assert.equal(
fs.statSync(path.join(dir, name)).mode & 0o777,
0o600,
`${name} is private`,
);
fs.rmSync(dir, { recursive: true, force: true });
});
-19
View File
@@ -501,25 +501,6 @@ test('the upstream host is pinned: a redirect is refused, not followed', async (
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
});
test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => {
// A fetch that throws proves the refusal comes before any upstream call.
const request = install(installRouteMiddleware);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('upstream must not be reached');
});
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
const response = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
method,
);
assert.equal(response.statusCode, 405, method);
// RFC 9110 section 15.5.6: a 405 names the methods it accepts.
assert.equal(response.headers.allow, 'GET', method);
assert.deepEqual(response.body, { error: 'Method Not Allowed' });
}
});
test('both paid Google routes refuse cross-site requests before calling Google', async (t) => {
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 'key' });
const routes = new Map();
-131
View File
@@ -1,131 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { gunzipSync } from 'node:zlib';
import {
acceptsGzip,
encodedBody,
gzipBody,
} from '../../server/providers/common/compress.js';
/** A request shaped like the one Node hands a middleware. */
function request(acceptEncoding) {
return {
url: '/api/flights',
method: 'GET',
headers:
acceptEncoding === undefined ? {} : { 'accept-encoding': acceptEncoding },
};
}
/** Repetitive JSON shaped like a snapshot body. */
function snapshotBody(states = 200) {
return JSON.stringify({
time: 1700000000,
states: Array.from({ length: states }, (_, index) => [
`abc${index}`,
'CALLSIGN',
'United States',
1700000000,
30.1,
-97.7,
10000,
]),
});
}
/** Seeded high-entropy bytes, so the "gzip made it bigger" check is stable. */
function incompressibleBytes(length) {
const bytes = Buffer.alloc(length);
let state = 123456789;
for (let index = 0; index < length; index += 1) {
state = (state * 1103515245 + 12345) % 2147483648;
bytes[index] = (state >>> 16) & 0xff;
}
return bytes;
}
test('accept-encoding is read the way clients actually write it', () => {
assert.equal(acceptsGzip(request('gzip')), true);
assert.equal(acceptsGzip(request('gzip, deflate, br')), true);
assert.equal(acceptsGzip(request('br;q=1.0, gzip;q=0.8')), true);
assert.equal(acceptsGzip(request('GZIP')), true);
assert.equal(acceptsGzip(request(' gzip ')), true);
assert.equal(acceptsGzip(request('br, deflate')), false);
assert.equal(acceptsGzip(request('identity')), false);
assert.equal(acceptsGzip(request('')), false);
assert.equal(acceptsGzip(request(undefined)), false);
assert.equal(acceptsGzip({}), false);
assert.equal(acceptsGzip(undefined), false);
// `q=0` names gzip and refuses it (RFC 9110 section 12.4.2).
assert.equal(acceptsGzip(request('gzip;q=0')), false);
assert.equal(acceptsGzip(request('gzip;q=0.000')), false);
assert.equal(acceptsGzip(request('gzip;q=0.001')), true);
assert.equal(acceptsGzip(request('gzip;q=1')), true);
assert.equal(acceptsGzip(request('gzip;q=1.000')), true);
// A q that is not a whole `qvalue` is unreadable and must not round into
// acceptance.
assert.equal(acceptsGzip(request('gzip;q=bogus')), false);
assert.equal(acceptsGzip(request('gzip;q=1oops')), false);
assert.equal(acceptsGzip(request('gzip;q=0.5abc')), false);
assert.equal(acceptsGzip(request('gzip;q=2')), false);
assert.equal(acceptsGzip(request('gzip;q=1.5')), false);
assert.equal(acceptsGzip(request('gzip;q=0.0001')), false);
assert.equal(acceptsGzip(request('gzip;q=')), false);
// `gzip` is not matched as a substring of a neighbouring token.
assert.equal(acceptsGzip(request('x-gzip')), false);
assert.equal(acceptsGzip(request('gzipped')), false);
});
test('a body is only compressed when compression pays for itself', async () => {
assert.equal(await gzipBody(''), null);
assert.equal(
await gzipBody('{"states":[]}'),
null,
'below the frame threshold',
);
assert.equal(await gzipBody(Buffer.alloc(1023, 'a')), null, 'one byte short');
const identity = snapshotBody();
assert.ok(identity.length > 1024, 'fixture is worth compressing');
const compressed = await gzipBody(identity);
assert.ok(Buffer.isBuffer(compressed));
assert.ok(compressed.byteLength < Buffer.byteLength(identity));
assert.equal(gunzipSync(compressed).toString(), identity);
// Buffers are accepted as-is; data gzip cannot shrink stays uncompressed.
assert.ok((await gzipBody(Buffer.from(identity))) !== null);
assert.equal(await gzipBody(incompressibleBytes(4096)), null);
});
test('the encoded body and the header that names it are chosen together', async () => {
const identity = snapshotBody();
const compressed = await gzipBody(identity);
const negotiated = encodedBody(request('gzip'), identity, compressed);
assert.equal(negotiated.body, compressed);
assert.equal(negotiated.headers['Content-Encoding'], 'gzip');
assert.equal(negotiated.headers.Vary, 'Accept-Encoding');
assert.equal(gunzipSync(negotiated.body).toString(), identity);
// Offered but refused: identity bytes and no Content-Encoding.
const refused = encodedBody(request('br'), identity, compressed);
assert.equal(refused.body, identity);
assert.equal(refused.headers['Content-Encoding'], undefined);
assert.equal(refused.headers.Vary, 'Accept-Encoding');
// Wanted but not available: same answer.
const unavailable = encodedBody(request('gzip'), identity, null);
assert.equal(unavailable.body, identity);
assert.equal(unavailable.headers['Content-Encoding'], undefined);
// Vary is on both branches, so a shared cache cannot serve the wrong encoding.
assert.equal(unavailable.headers.Vary, 'Accept-Encoding');
assert.equal(
encodedBody(request(undefined), identity, compressed).headers.Vary,
'Accept-Encoding',
);
});
+1 -2
View File
@@ -19,7 +19,6 @@ import {
numericSummary,
thinEvenly,
} from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const ICAO24_SCHEMA = Object.freeze({
type: 'string',
@@ -76,7 +75,7 @@ function aircraftRow(record, from) {
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
on_ground: record.onGround === true,
speed_mps: round(record.speedMps, 1),
course_deg: roundHeadingDegrees(record.courseDeg),
course_deg: round(record.courseDeg, 0),
vertical_rate_mps: round(record.verticalRateMps, 1),
type_code: record.typeCode || null,
registration: record.registration || null,
-13
View File
@@ -146,19 +146,6 @@ test('the military feed is used on request and reported when missing', async ()
);
});
test('an aircraft course that rounds to 360 reads as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: feed([
record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }),
]),
},
});
const result = await catalog.call('find_aircraft', { callsign: 'N360' });
assert.equal(result.data.rows[0].course_deg, 0);
});
test('aircraft are found by exactly one identifier across feeds', async () => {
const catalog = composeCatalog({
tools: coreTools,
-1
View File
@@ -71,7 +71,6 @@ function weatherRow(weather) {
precipitation_mm: weather.precipitationMm ?? null,
cloud_cover_pct: weather.cloudCoverPct ?? null,
wind_kph: weather.windKph ?? null,
// Raw Open-Meteo value, so north reads 360.
wind_direction_deg: weather.windDirectionDeg ?? null,
visibility_m: weather.visibilityM ?? null,
};
+2 -3
View File
@@ -20,7 +20,6 @@ import {
numericSummary,
thinEvenly,
} from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({
@@ -58,8 +57,8 @@ function vesselRow(record, from) {
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1),
course_deg: roundHeadingDegrees(record.courseDeg),
heading_deg: roundHeadingDegrees(record.headingDeg),
course_deg: round(record.courseDeg, 0),
heading_deg: round(record.headingDeg, 0),
observed_at: isoTime(record.observedAtMs),
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
};
-19
View File
@@ -351,25 +351,6 @@ test('find_vessel answers carry normalized types and destinations', async () =>
);
});
test('a vessel course or heading that rounds to 360 reads as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000003', 37.78, -122.4, {
courseDeg: 359.7,
headingDeg: 359.6,
}),
],
}),
},
});
const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data
.rows;
assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]);
});
test('a destination with no letters counts as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
+1 -2
View File
@@ -10,7 +10,6 @@ import {
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const MAX_FRAME_BYTES = 3 * 1024 * 1024;
@@ -86,7 +85,7 @@ export const findCctvCameras = defineTool({
provider: text(camera.provider),
lat: round(camera.lat, 6),
lon: round(camera.lon, 6),
heading_deg: roundHeadingDegrees(camera.headingDeg),
heading_deg: round(camera.headingDeg, 0),
feed_type: text(camera.feedType),
credit: text(camera.credit),
distance_km: round(distanceKm(center, camera), 2),
-14
View File
@@ -77,20 +77,6 @@ test('cameras in an area are listed nearest first', async () => {
assert.equal(result.data.rows[1].credit, null);
});
test('a camera heading that rounds to 360 reads as 0', async () => {
const source = {
async getCatalog() {
return { sources: [{ ...cameras[0], headingDeg: 359.6 }] };
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: source },
});
const result = await catalog.call('find_cctv_cameras', { area: downtown });
assert.equal(result.data.rows[0].heading_deg, 0);
});
test('a snapshot returns the frame as an image with its credit', async () => {
const source = cctv();
const catalog = composeCatalog({
+1 -2
View File
@@ -19,7 +19,6 @@ import {
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const MAX_SYSTEMS = 3;
@@ -245,7 +244,7 @@ export const getTransitVehicles = defineTool({
trip_id: vehicle.tripId ?? null,
lat: vehicle.lat,
lon: vehicle.lon,
bearing_deg: roundHeadingDegrees(vehicle.bearing),
bearing_deg: round(vehicle.bearing, 0),
speed_mps: round(vehicle.speedMps, 1),
status: vehicle.status ?? null,
occupancy: vehicle.occupancy ?? null,
-17
View File
@@ -311,23 +311,6 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as
assert.equal(rapid.data.rows[0].id, 'v2');
});
test('a transit bearing that rounds to 360 reads as 0', async () => {
const north = [{ ...vehicles[0], bearing: 359.6 }];
const catalog = composeCatalog({
tools: coreTools,
services: {
transit: transit({
ok: true,
status: 200,
json: async () => ({ vehicles: north }),
}),
clock,
},
});
const result = await catalog.call('get_transit_vehicles', { area: downtown });
assert.equal(result.data.rows[0].bearing_deg, 0);
});
test('transit reports uncovered areas and unavailable feeds', async () => {
const catalog = composeCatalog({
tools: coreTools,
+2 -3
View File
@@ -10,7 +10,6 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
import { POINT_SCHEMA, resolvePoint } from '../area.js';
import { defineTool, ToolError } from '../catalog.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const text = (value) => (typeof value === 'string' && value ? value : null);
@@ -242,7 +241,7 @@ export const nextSatellitePass = defineTool({
peak: isoTime(pass.maxElevMs),
set: isoTime(pass.setMs),
max_elevation_deg: round(pass.maxElevDeg, 1),
rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg),
rise_azimuth_deg: round(pass.riseAzDeg, 0),
rise_direction: compass(pass.riseAzDeg),
visible: pass.visible,
},
@@ -284,7 +283,7 @@ export const satellitesOverhead = defineTool({
name: entry.name,
norad: entry.norad,
elevation_deg: round(look.elevDeg, 1),
azimuth_deg: roundHeadingDegrees(look.azDeg),
azimuth_deg: round(look.azDeg, 0),
direction: compass(look.azDeg),
},
]
-35
View File
@@ -2,10 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { twoline2satrec } from 'satellite.js';
import { composeCatalog, coreTools } from '../index.js';
import {
findNextSatellitePass,
lookAnglesAt,
} from '../../data/satellitePass.js';
import {
noradIdFromSatnum,
parseTleText,
@@ -300,37 +296,6 @@ test('satellites overhead are those above the elevation, highest first', async (
);
});
test('satellite azimuths that round to 360 read as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { satellites: satellites(), clock: CLOCK },
});
// At these points the ISS's raw azimuths fall just below 360°; check that
// first so the test cannot pass on a raw value near 0.
const [, line1, line2] = TLE.split('\n');
const iss = twoline2satrec(line1, line2);
const nearNorth = (deg) => deg > 359.5 && deg < 360;
const rawPass = findNextSatellitePass({
satrec: iss,
latDeg: 10,
lonDeg: 30,
fromMs: CLOCK.now(),
});
assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`);
const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75);
assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`);
const pass = await catalog.call('next_satellite_pass', {
location: { lat: 10, lon: 30 },
});
assert.equal(pass.data.pass.rise_azimuth_deg, 0);
assert.equal(pass.data.pass.rise_direction, 'N');
const overhead = await catalog.call('satellites_overhead', {
location: { lat: 39.25, lon: 65.75 },
});
assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)');
assert.equal(overhead.data.rows[0].azimuth_deg, 0);
});
test('stale orbit and launch data are reported', async () => {
const stale = {
readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }),
+3 -6
View File
@@ -11,17 +11,14 @@ import {
} from './layerNames.js';
export { localizeLayerName } from './layerNames.js';
import { createWeatherPanel } from './weatherPanel.js';
import {
translateFeedError,
translateStatusLine,
translateStatusMessage,
} from './statusText.js';
import { translateFeedError, translateStatusLine } from './statusText.js';
import {
formatNumber,
getLocale,
msg,
onLocaleChange,
t,
translateKnown,
} from '../i18n/index.js';
// Feed-state values are machine ids (QA hooks, CSS feed-* classes); only the
@@ -698,7 +695,7 @@ export class LayerPanel {
typeof stats.statusMessage === 'string' &&
stats.statusMessage.trim()
) {
return `${source} · ${translateStatusMessage(stats.statusMessage.trim())}`;
return `${source} · ${translateKnown(stats.statusMessage.trim())}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
if (stats.loading) {
-54
View File
@@ -452,57 +452,3 @@ test('without the Utilities list, Directions stays in Data Layers under Utilitie
restore();
}
});
test('a layer with no feed in view says why instead of "never"', async () => {
const { LayerPanel, presentLayerCount, presentLayerToggle } =
await import('./layerPanel.js');
const { setLocale } = await import('../i18n/index.js');
const panel = LayerPanel.prototype;
const rows = [
{
id: 'transit',
name: 'Transit',
source: 'GTFS-RT',
enabled: true,
stats: {
count: 0,
lastUpdate: null,
source: 'GTFS-RT',
status: 'zoom-in',
statusMessage: 'No feed here yet · 16 regions available',
},
meta: 'GTFS-RT · No feed here yet · 16 regions available',
},
{
id: 'bikeshare',
name: 'Bikeshare',
source: 'GBFS',
enabled: true,
stats: {
count: 0,
lastUpdate: null,
loading: false,
status: 'zoom-in',
statusMessage: 'Fly below 48 km to a covered city',
},
meta: 'GBFS · Fly below 48 km to a covered city',
},
];
for (const layer of rows) {
// Guidance is normal operation: the layer is on and nothing has failed.
assert.equal(presentLayerToggle(layer).text, 'ON');
assert.equal(presentLayerCount(layer.stats).empty, true);
assert.equal(panel._buildMetaText(layer), layer.meta);
}
setLocale('ru');
try {
for (const layer of rows) {
const meta = panel._buildMetaText(layer);
assert.doesNotMatch(meta, /never|Fly below|No feed|covered/, meta);
assert.match(meta, /^(GTFS-RT|GBFS) · /, meta);
}
} finally {
setLocale('en');
}
});
-61
View File
@@ -265,64 +265,3 @@ export function translateStatusLine(text) {
.map(translateStatusPiece)
.join(STATUS_SEPARATOR);
}
/** Thousands separators out of an English number ("3,000" → 3000). */
const englishNumber = (text) => Number(text.replace(/,/g, ''));
// Coverage notes (transit/queries.js, bikeshare/controls.js). Their " · " is
// inside one sentence, so they match whole before the line is split.
const COVERAGE_NOTE_PATTERNS = [
[
/^Fly below ([\d,]+) km to a covered region$/,
([, km]) =>
t('Fly below {km} km to a covered region', {
km: formatNumber(englishNumber(km)),
}),
],
[
/^No feed here yet · ([\d,]+) regions available$/,
([, regions]) => {
const count = englishNumber(regions);
return tn(
count,
'No feed here yet · {n} region available',
'No feed here yet · {n} regions available',
{ n: formatNumber(count) },
);
},
],
[
/^Fly below ([\d,]+) km to a covered city$/,
([, km]) =>
t('Fly below {km} km to a covered city', {
km: formatNumber(englishNumber(km)),
}),
],
[
/^No bike share here yet · ([\d,]+) systems available$/,
([, systems]) => {
const count = englishNumber(systems);
return tn(
count,
'No bike share here yet · {n} system available',
'No bike share here yet · {n} systems available',
{ n: formatNumber(count) },
);
},
],
];
/**
* A layer's status message in the UI language: its catalog entry, a coverage
* note by its shape, or a status line translated piece by piece.
*/
export function translateStatusMessage(text) {
if (typeof text !== 'string') return text;
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of COVERAGE_NOTE_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return translateStatusLine(text);
}
+1 -57
View File
@@ -3,11 +3,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t } from '../i18n/index.js';
import {
translateFeedError,
translateStatusLine,
translateStatusMessage,
} from './statusText.js';
import { translateFeedError, translateStatusLine } from './statusText.js';
function inLocale(locale, run) {
setLocale(locale);
@@ -79,55 +75,3 @@ for (const locale of ['es', 'ru']) {
});
});
}
// A layer's coverage note when no feed covers the view: one sentence with a
// " · " in it (transit/queries.js, bikeshare/controls.js).
const COVERAGE_NOTES = [
'Fly below 2,850 km to a covered region',
'No feed here yet · 16 regions available',
'Fly below 48 km to a covered city',
'No bike share here yet · 61 systems available',
];
test('en: coverage notes show as they came', () => {
setLocale('en');
for (const text of COVERAGE_NOTES)
assert.equal(translateStatusMessage(text), text);
assert.equal(
translateStatusMessage('No bike share here yet · 1 systems available'),
'No bike share here yet · 1 system available',
);
});
for (const locale of ['es', 'ru', 'ja']) {
test(`${locale}: a coverage note translates whole, numbers kept`, () => {
inLocale(locale, () => {
for (const text of COVERAGE_NOTES) {
const shown = translateStatusMessage(text);
assert.doesNotMatch(
shown,
/\b(Fly|below|covered|feed|bike share|available)\b/,
shown,
);
const number = /[\d,]+(?= km| regions| systems)/.exec(text)[0];
assert.ok(
shown.replace(/[^\d]/g, '').includes(number.replace(/,/g, '')),
shown,
);
}
});
});
test(`${locale}: a status message without a coverage note keeps its other forms`, () => {
inLocale(locale, () => {
assert.equal(
translateStatusMessage('Upstream response too large'),
t('Upstream response too large'),
);
assert.equal(
translateStatusMessage('2 feeds live · 14 heard'),
translateStatusLine('2 feeds live · 14 heard'),
);
});
});
}
+1 -1
View File
@@ -70,7 +70,7 @@ const cases = [
// Transit's coverage text when nothing is in view (transit/queries.js).
const transitCoverage = [
'transit: Fly below 2,850 km to a covered region',
'transit: Fly below 3,000 km to a covered region',
'transit: No feed here yet · 12 regions available',
];
+26 -5
View File
@@ -29,7 +29,7 @@ import {
} from '../i18n/index.js';
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js';
import { translateStatusMessage } from '../ui/statusText.js';
import { translateStatusLine } from '../ui/statusText.js';
import { formatQuantity } from '../ui/unitLabels.js';
/** Split "a; b" caveat text into separate notes. */
@@ -397,6 +397,27 @@ const NOTE_PATTERNS = [
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
)) + period,
],
// Transit's empty note when no feed covers the view (transit/queries.js);
// the model gets the English.
[
/^Fly below ([\d,]+) km to a covered region$/,
([, km]) =>
t('Fly below {km} km to a covered region', {
km: formatNumber(Number(km.replace(/,/g, ''))),
}),
],
[
/^No feed here yet · ([\d,]+) regions available$/,
([, regions]) => {
const count = Number(regions.replace(/,/g, ''));
return tn(
count,
'No feed here yet · {n} region available',
'No feed here yet · {n} regions available',
{ n: formatNumber(count) },
);
},
],
[
/^([a-z0-9-]+) (off|loading|error)$/,
([, key, status]) =>
@@ -423,10 +444,10 @@ function shownNote(text) {
const shown = match ? show(match) : null;
if (shown) return shown;
}
// A layer's empty note can be its coverage note ("Fly below 2,850 km to a
// covered region") or its status line, English pieces joined by " · "
// ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
return translateStatusMessage(text);
// A layer's empty note can be its status line, English pieces joined by
// " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
const known = translateKnown(text);
return known !== text ? known : translateStatusLine(text);
}
/** Feed states as the card's chips show them; the values stay English. */