diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 3fff9e557..5c0e80afb 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -103,6 +103,40 @@ How to read this: | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy | +| **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana | +| **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio | +| **Warsaw buses and trams** (mkuran.pl) | Transit layer live vehicles, Warsaw, Poland | Warsaw Open Data Project Usage Terms (https://mkuran.pl/gtfs/). Stated: “The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.” Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API and carries the date of the feed timestamp, the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs. | Miasto Stołeczne Warszawa (api.um.warszawa.pl) | +| **Bordeaux TBM** (bdx.mecatran.com) | Transit layer live vehicles, Bordeaux, France | Licence Ouverte / Etalab 2.0 (https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite). The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), mapped from TBM’s static GTFS; other numbered lines are buses. | Bordeaux Métropole (TBM) | +| **Sendai City Bus** (api-public.odpt.org) | Transit layer live vehicles, Sendai, Japan | CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s. | 仙台市交通局 (Sendai City Transportation Bureau) / ODPT | +| **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp as the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) | +| **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku | +| **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton | +| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Open Government Licence – Halifax v1.0 (https://www.halifax.ca/home/open-data/open-data-license), modelled on OGL – Canada 2.0. Stated: “The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.” No logos or implied endorsement. Speeds are m/s. | Contains information licenced under the Open Government Licence – Halifax | +| **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit | +| **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) | +| **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie | +| **Kraków trams** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). Same server and basis as the bus feed. | Zarząd Transportu Publicznego w Krakowie | +| **Vilnius public transport** (stops.lt) | Transit layer live vehicles, Vilnius, Lithuania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383). Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since. | SĮ Susisiekimo paslaugos (stops.lt) | +| **Bucharest public transport** (gtfs.tpbi.ro) | Transit layer live vehicles, Bucharest, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104). Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi. | TPBI (Transport Public București–Ilfov) | +| **Rome public transport** (romamobilita.it) | Transit layer live vehicles, Rome, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776). Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement. | Roma Servizi per la Mobilità | +| **Turin GTT** (percorsieorari.gtt.to.it) | Transit layer live vehicles, Turin, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines. | Gruppo Torinese Trasporti (GTT) | +| **Venice ACTV buses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`). | ACTV S.p.A. | +| **Venice ACTV waterbuses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). Same catalogue and basis as the ACTV bus feed. | ACTV S.p.A. | +| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Transit layer live vehicles, Adelaide, SA | No licence stated for the realtime feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772). Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override. | Adelaide Metro | +| **MARTA buses** (gtfs-rt.itsmarta.com) | Transit layer live vehicles, Atlanta, GA | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s. | MARTA | +| **Denver RTD** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). Stated: “RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data”. Read 2026-11-09: a click-through agreement that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from use with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s. | Regional Transportation District (RTD) | +| **Cluj-Napoca CTP** (cluj-rt-feed.gtfs.ro) | Transit layer live vehicles, Cluj-Napoca, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Cluj-Napoca | +| **Iași CTP** (iasi-rt-feed.gtfs.ro) | Transit layer live vehicles, Iași, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Iași | +| **Rzeszów city transport** (mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | MPK Rzeszów open data (https://www.mpkrzeszow.pl/otwartedane/). Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement. | MPK Rzeszów | +| **Málaga EMT** (gtfs.emtmalaga.es) | Transit layer live vehicles, Málaga, Spain | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213). Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement. | EMT Málaga | +| **Carris (Lisbon city)** (gateway.carris.pt) | Transit layer live vehicles, Lisbon, Portugal | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930). Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant. | Carris | +| **BAS.MY Johor Bahru** (api.data.gov.my) | Transit layer live vehicles, Johor Bahru, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Stated: “All GTFS Realtime feeds are licensed under CC BY 4.0.” Served keyless by Malaysia’s national open-data API, which caps GTFS Realtime at 4 requests a minute and refreshes positions every 30 seconds, so the proxy caches this feed for 30 seconds. Without the trailing slash the API redirects. Route ids append an operator code. | BAS.MY / data.gov.my | +| **BAS.MY Kota Bharu** (api.data.gov.my) | Transit layer live vehicles, Kota Bharu, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Same API, licence and rate limit as Johor Bahru. | BAS.MY / data.gov.my | +| **Rouen Astuce** (api.mrn.cityway.fr) | Transit layer live vehicles, Rouen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie). The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); and this is its own TCAR vehicle feed from that record; the suburban TAE and TNI networks are separate feeds and are not read. Route ids carry the network prefix (`TCAR:`); line 90 is the Métro (a street-running light rail, drawn as a tram) and 99 the Calypso boat. | Métropole Rouen Normandie (Astuce) | +| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) | +| **Le Havre LiA** (opendata.transports-lia.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); and this is its own GTFS-RT vehicle feed from that record; route ids carry the network prefix `8-`. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) | +| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | @@ -194,7 +228,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/.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.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index d4b53dc24..fa9749edf 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | diff --git a/changelog.d/1628-transit-more-cities.md b/changelog.d/1628-transit-more-cities.md new file mode 100644 index 000000000..24e55817e --- /dev/null +++ b/changelog.d/1628-transit-more-cities.md @@ -0,0 +1,14 @@ +- Add 34 keyless feeds to the Transit layer, the first in Central and Eastern + Europe, Italy, Spain, France, Japan and Malaysia: Szczecin, Lisbon (Carris + Metropolitana and the city's Carris), Prague, Warsaw, Kraków, Poznań, + Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice + (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, + Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, + Edmonton, Halifax and Victoria. Each feed records its licence, or that none + is stated, in the registry and DATA_SOURCES.md. Speeds published in km/h + (Lisbon, Montpellier, Toulon) are converted to m/s by the proxy; route ids + read as the line on the vehicle; Prague metro, trains, trams and ferries, + Bordeaux trams and boats, the Rouen Métro, Le Havre trams and Venice + waterbuses take their own mode. The Warsaw and Poznań credits carry the + data date their terms ask for, and the data.gov.my feeds are fetched at + most twice a minute to stay inside the API's rate limit (IMBrandao). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6a3b07c5d..a1da40b99 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5118,9 +5118,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +gate. Forty-nine keyless regions are available: Boston, Austin, Minneapolis–St Paul, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro -(municipal buses and the Mobi-Rio BRT, two feeds), and nine in +(municipal buses and the Mobi-Rio BRT, two feeds), Szczecin, the Lisbon Metropolitan Area and the city of Lisbon, Prague and Central Bohemia, Warsaw, Kraków (buses and trams, two feeds), Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses, two feeds), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston. Mode silhouettes diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index ba06a52f6..6b972fc28 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -673,20 +673,40 @@ export const OSM_PLACES_CREDIT = { /** * Per-feed transit credit, registered the first time that feed's vehicles * render (see `src/data/transitFeeds.js` for the license of each). - * @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed + * + * Two optional registry fields serve licences that ask for more than a name: + * `sourceUrl` links the attribution to the publisher (Warsaw asks for a link + * to its API), and `creditDated` adds the UTC date of the data, from the feed + * header or else the fetch time (Warsaw and Poznań ask for the download or + * update date). A dated credit is keyed by its date, so a new day registers a + * new line instead of keeping yesterday's. + * @param {{ id: string, attribution: string, license: string, licenseUrl: string, + * sourceUrl?: string, creditDated?: boolean }} feed + * @param {{ feedTimestamp?: number|null, fetchedAt?: number|null }} [snapshot] + * `feedTimestamp` in epoch seconds, `fetchedAt` in epoch ms. * @returns {{ key: string, html: string }} */ -export function transitFeedCredit(feed) { +export function transitFeedCredit(feed, snapshot = null) { const escape = (text) => String(text) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); + const source = feed.sourceUrl + ? `${escape(feed.attribution)}` + : escape(feed.attribution); + const dataMs = Number.isFinite(snapshot?.feedTimestamp) + ? snapshot.feedTimestamp * 1000 + : snapshot?.fetchedAt; + const date = + feed.creditDated === true && Number.isFinite(dataMs) && dataMs > 0 + ? new Date(dataMs).toISOString().slice(0, 10) + : null; return { - key: `transit-${feed.id}`, + key: date ? `transit-${feed.id}-${date}` : `transit-${feed.id}`, html: - `Transit (${escape(feed.attribution)}): ` + + `Transit (${source}${date ? `, data of ${date}` : ''}): ` + `${escape(feed.license)}`, }; } diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index f288ec0bc..ef20250fc 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -1,6 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js'; +import { + DATA_CREDITS, + NYC_DOT_DISCLAIMER, + transitFeedCredit, +} from './dataCredits.js'; +import { getTransitFeed } from './transitFeeds.js'; test('every credit carries a unique key and some markup to render', () => { const keys = DATA_CREDITS.map((entry) => entry.key); @@ -190,3 +195,24 @@ test('the PR #1506 camera packs are credited with their sources and terms', () = /href="https:\/\/www\.river\.go\.jp\//, ); }); + +test('Warsaw and Poznań credits carry the data date their licences ask for', () => { + const warsaw = getTransitFeed('ztm-warszawa'); + // 2026-11-09T06:00:00Z as a GTFS-RT header timestamp (seconds). + const credit = transitFeedCredit(warsaw, { feedTimestamp: 1_794_204_000 }); + assert.match(credit.html, /, data of 2026-11-09\)/); + assert.match(credit.html, /href="http:\/\/api\.um\.warszawa\.pl"/); + assert.equal(credit.key, 'transit-ztm-warszawa-2026-11-09'); + // Without a feed timestamp the fetch time is the download date. + const poznan = transitFeedCredit(getTransitFeed('ztm-poznan'), { + feedTimestamp: null, + fetchedAt: Date.UTC(2026, 10, 10, 12), + }); + assert.match(poznan.html, /data of 2026-11-10/); + // Undated feeds keep one credit line for the session. + const mbta = transitFeedCredit(getTransitFeed('mbta'), { + feedTimestamp: 1_794_204_000, + }); + assert.equal(mbta.key, 'transit-mbta'); + assert.doesNotMatch(mbta.html, /data of/); +}); diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 48ca450a1..42f399d77 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -389,6 +389,179 @@ function rioRouteLabel(routeId) { : routeId; } +/** + * Carris Metropolitana realtime route ids wrap the GTFS route id in an + * operator-area prefix and keep its pattern suffix: `[LA77N]1708_0` is GTFS + * route `1708_0`, whose `short_name` (the number on the bus) is `1708`, per + * the operator's own `/v2/routes` API. Anything else passes through. + * @param {string|null} routeId + * @returns {string|null} + */ +function carrisMetropolitanaRouteLabel(routeId) { + const match = /^\[[A-Z0-9]+\](\d{4})_\d+$/.exec(routeId ?? ''); + return match ? match[1] : routeId; +} + +/** + * Route ids that wrap the rider-facing line in a fixed prefix, suffix or + * zero-padding. The returned function yields the captured line, or the id + * unchanged when it does not match the operator's pattern. + * @param {RegExp} pattern One capture group: the line a rider would say. + * @returns {(routeId: string|null) => string|null} + */ +function routeIdPattern(pattern) { + return (routeId) => { + const match = pattern.exec(routeId ?? ''); + return match ? match[1] : routeId; + }; +} + +/** Zero-padded numbers (Edmonton `005`, Toulon `0002`, Bordeaux `01`). */ +const unpaddedRouteLabel = routeIdPattern(/^0+(\d+\w*)$/); +/** Turin GTT appends `U` (urban) to the line: `10NU` is night line 10N. */ +const gttRouteLabel = routeIdPattern(/^(\w+?)U$/); +/** Venice ACTV appends the network: `5.1_UN` is waterbus line 5.1, `53E_ES` bus 53E. */ +const actvRouteLabel = routeIdPattern(/^(.+)_[A-Z]{2}$/); +/** BC Transit appends the system: `95-VIC` is Victoria route 95. */ +const bcTransitRouteLabel = routeIdPattern(/^(.+)-VIC$/); +/** Carris (Lisbon city) appends a variant: `172_1` is route 172. */ +const carrisRouteLabel = routeIdPattern(/^(\w+)_\d+$/); +/** Astuce prefixes the network: `TCAR:93` is Rouen line 93. */ +const astuceNetworkLabel = routeIdPattern(/^[A-Z]+:(.+)$/); +/** BAS.MY Johor appends a fixed operator code: `J33CWLMYJB` is route J33. */ +const mybasJohorRouteLabel = routeIdPattern(/^(.+?)CWLMYJB$/); + +/** LiA prefixes its network: `8-C3` is Le Havre line C3, `8-09` line 9. */ +const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/); + +/** + * Le Havre LiA runs its two tram lines as A and B and the funicular as + * `FUNI`; every other line is a bus. + * @param {string|null} routeId + * @returns {string} + */ +function liaRouteMode(routeId) { + if (!routeId) return 'unknown'; + const line = liaRouteLabel(routeId); + if (line === 'FUNI') return 'rail'; + return /^[AB]$/.test(line) ? 'tram' : 'bus'; +} + +/** + * Rouen Astuce lines that are not buses, by route id, from the TCAR + * routes.txt (checked 2026-11-09): line 90 is the Métro (GTFS type 1, a light + * rail that runs mostly on the street) and 99 the Calypso river boat. + */ +const ASTUCE_ROUTES = Object.freeze({ + // i18n-ignore: line name, as on the vehicle + 'TCAR:90': Object.freeze({ mode: 'tram', label: 'Métro' }), + // i18n-ignore: line name, as on the vehicle + 'TCAR:99': Object.freeze({ mode: 'ferry', label: 'Calypso' }), +}); + +/** @param {string|null} routeId @returns {string} */ +function astuceRouteMode(routeId) { + if (!routeId) return 'unknown'; + return ASTUCE_ROUTES[routeId]?.mode ?? 'bus'; +} + +/** `TCAR:93` is Rouen line 93; the Métro and Calypso keep their names. */ +function astuceRouteLabel(routeId) { + return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId); +} + +/** + * Prague PID route ids are `L` plus a number whose range says what runs it, + * per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams + * (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the + * 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C, + * 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest + * of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from + * 2000 more buses (`L2841` is 841). A label the range cannot give stays the + * route id. + * @param {string|null} routeId + * @returns {{mode: string, label: string|null}} + */ +function pidRoute(routeId) { + const match = /^L(\d+)$/.exec(routeId ?? ''); + if (!match) return { mode: 'unknown', label: routeId }; + const n = Number(match[1]); + // i18n-ignore: line code, as signed + if (n === 49) return { mode: 'rail', label: 'LD' }; + if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) }; + if (n < 100) return { mode: 'tram', label: String(n) }; + if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` }; + if (n < 991) return { mode: 'bus', label: String(n) }; + // i18n-ignore: metro line letters, as signed + if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] }; + if (n >= 1000 && n < 1100) return { mode: 'rail', label: `S${n - 1000}` }; + if (n >= 1100 && n < 1200) return { mode: 'rail', label: `R${n - 1100}` }; + if (n >= 1200 && n < 1400) return { mode: 'rail', label: routeId }; + if (n >= 1800 && n < 1900) return { mode: 'ferry', label: `P${n - 1800}` }; + if (n >= 2800 && n < 2900) return { mode: 'bus', label: String(n - 2000) }; + return { mode: n >= 1700 ? 'bus' : 'unknown', label: routeId }; +} + +/** @param {string|null} routeId @returns {string} */ +function pidRouteMode(routeId) { + return pidRoute(routeId).mode; +} + +/** @param {string|null} routeId @returns {string|null} */ +function pidRouteLabel(routeId) { + return pidRoute(routeId).label; +} + +/** + * Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS + * routes.txt (checked 2026-11-09): the trams are numbered ids, not letters. + */ +const TBM_TRAM_LINES = Object.freeze({ + 59: 'A', + 60: 'B', + 61: 'C', + 62: 'D', + 163: 'E', + 164: 'F', +}); +const TBM_BOAT_LINES = new Set(['951', '952', '953']); + +/** + * Bordeaux TBM: trams A–F and the BAT³ river shuttles; every other line is a + * bus. + * @param {string|null} routeId + * @returns {string} + */ +function tbmRouteMode(routeId) { + if (!routeId) return 'unknown'; + if (Object.hasOwn(TBM_TRAM_LINES, routeId)) return 'tram'; + return TBM_BOAT_LINES.has(routeId) ? 'ferry' : 'bus'; +} + +/** `59` is tram A; zero-padded bus ids lose their padding (`01` is 1). */ +function tbmRouteLabel(routeId) { + return Object.hasOwn(TBM_TRAM_LINES, routeId ?? '') + ? TBM_TRAM_LINES[routeId] + : unpaddedRouteLabel(routeId); +} + +/** + * A registry entry, frozen with its centre and terms, and switched on. + * @param {object} fields + * @returns {Readonly} + */ +function transitFeed(fields) { + return Object.freeze({ + defaultEnabled: true, + ...fields, + center: Object.freeze({ ...fields.center }), + terms: Object.freeze({ ...fields.terms }), + ...(fields.headers + ? { headers: Object.freeze({ ...fields.headers }) } + : {}), + }); +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -398,7 +571,14 @@ function rioRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, + * speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string, + * creditDated?: boolean, * }>>} + * `speedUnit` marks a feed that publishes km/h where the spec requires m/s; + * the proxy converts it before the snapshot leaves the server. `refreshMs` + * stretches the proxy's cache for an upstream that rate-limits below one + * request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date + * to the credit for licences that ask for them (`transitFeedCredit`). */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -788,6 +968,624 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: rioRouteLabel, }), + transitFeed({ + id: 'zditm-szczecin', + name: 'Szczecin buses and trams', + operator: + 'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM), Szczecin', + region: 'Szczecin, Poland', + // Every vehicle in a 2026-11-05 snapshot sat between 53.34–53.55 N and + // 14.44–14.77 E, inside 20 km of this point. + center: { lat: 53.44, lon: 14.56 }, + loadRadiusKm: 25, + url: 'https://www.zditm.szczecin.pl/storage/gtfs/gtfs-rt-vehicles.pb', + license: 'CC0 1.0', + licenseUrl: 'https://www.zditm.szczecin.pl/en/zditm/for-developers', + attribution: + 'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM Szczecin)', + terms: { + quote: + 'The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.', + note: 'CC0 needs no credit; it is given anyway as a courtesy. ZDiTM gives no guarantee of accuracy or completeness. The developer page says the feed updates every minute; measured on 2026-11-05 the file changed about every 20 seconds, with vehicle timestamps up to 50 seconds old. Speeds are m/s: checked against vehicle movement between two snapshots. The feed carries no route id at all, so every vehicle takes the feed default mode even though the city also runs trams.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'carris-metropolitana-lisboa', + name: 'Carris Metropolitana', + operator: 'Carris Metropolitana (TML), Lisbon Metropolitan Area', + region: 'Lisbon Metropolitan Area, Portugal', + // Every vehicle in a 2026-11-07 snapshot, from Mafra to Setúbal, sat + // between 38.47–39.00 N and 9.47–8.79 W, inside 40 km of this point. + center: { lat: 38.73, lon: -9.13 }, + loadRadiusKm: 45, + url: 'https://api.carrismetropolitana.pt/v2/vehicles.pb', + license: 'No separate licence for the realtime feed', + licenseUrl: 'https://github.com/carrismetropolitana/api', + attribution: 'Carris Metropolitana', + terms: { + quote: + 'GET /vehicles.pb — Returns vehicle locations in Protobuf format, following the GTFS-RT Vehicle Locations standard.', + note: 'The operator publishes this feed keyless and documented for developers in its public API repository, but states no licence for the realtime endpoint; its static GTFS (github.com/carrismetropolitana/gtfs) is CC BY 4.0. Credited as a courtesy, on the same basis as the Metro Transit and OVapi feeds; switch it off if Carris objects. The quote above is the API README, not a licence. `/vehicles.pb` answers with a 307 to `/v2/vehicles.pb`, so the registry points at the target. Speeds are km/h, not the m/s the spec requires: 2026-11-07 readings of 31–33 matched 6–9 m/s of measured movement, so the proxy converts them. Vehicle reports arrive every 30–50 seconds. Route ids are the GTFS route id inside an area prefix and are read as the line number.', + }, + defaultMode: 'bus', + routeLabel: carrisMetropolitanaRouteLabel, + speedUnit: 'km/h', + }), + transitFeed({ + id: 'pid-praha', + name: 'Prague PID', + operator: 'Pražská integrovaná doprava (PID), via Golemio', + region: 'Prague and Central Bohemia, Czechia', + // PID reaches across Central Bohemia: on 2026-11-08, 95% of 923 vehicles + // sat within 58 km of Prague, the farthest at 98 km. + center: { lat: 50.08, lon: 14.45 }, + loadRadiusKm: 60, + url: 'https://api.golemio.cz/v2/vehiclepositions/gtfsrt/vehicle_positions.pb', + license: 'CC BY', + licenseUrl: 'https://pid.cz/o-systemu/opendata/', + attribution: 'Pražská integrovaná doprava (PID) / Golemio', + terms: { + quote: + 'Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY', + note: 'The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1); `pidRoute` holds the table, read from PID’s GTFS routes.txt on 2026-11-09.', + }, + defaultMode: 'bus', + routeLabel: pidRouteLabel, + routeMode: pidRouteMode, + }), + transitFeed({ + id: 'ztm-warszawa', + name: 'Warsaw buses and trams', + operator: 'Miasto Stołeczne Warszawa, relayed by mkuran.pl', + region: 'Warsaw, Poland', + center: { lat: 52.25, lon: 21.02 }, + loadRadiusKm: 30, + url: 'https://mkuran.pl/gtfs/warsaw/vehicles.pb', + license: 'Warsaw Open Data Project Usage Terms', + licenseUrl: 'https://mkuran.pl/gtfs/', + attribution: 'Miasto Stołeczne Warszawa (api.um.warszawa.pl)', + sourceUrl: 'http://api.um.warszawa.pl', + creditDated: true, + terms: { + quote: + 'The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.', + note: 'Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API (`sourceUrl`) and carries the date of the feed timestamp (`creditDated`), the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'tbm-bordeaux', + name: 'Bordeaux TBM', + operator: 'Transports Bordeaux Métropole (TBM), Bordeaux Métropole', + region: 'Bordeaux, France', + center: { lat: 44.85, lon: -0.65 }, + loadRadiusKm: 20, + url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + license: 'Licence Ouverte / Etalab 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite', + attribution: 'Bordeaux Métropole (TBM)', + terms: { + quote: 'licence: fr-lo', + note: 'The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), so `tbmRouteMode` and `tbmRouteLabel` map them from TBM’s static GTFS; other numbered lines are buses.', + }, + defaultMode: 'bus', + routeLabel: tbmRouteLabel, + routeMode: tbmRouteMode, + }), + transitFeed({ + id: 'sendai-city-bus', + name: 'Sendai City Bus', + operator: 'Sendai City Transportation Bureau (仙台市交通局), via ODPT', + region: 'Sendai, Japan', + center: { lat: 38.27, lon: 140.82 }, + loadRadiusKm: 22, + url: 'https://api-public.odpt.org/api/v4/gtfs/realtime/odpt_SendaiMunicipal_bus_realtime_information_vehicle', + license: 'CC BY 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by/4.0/', + attribution: '仙台市交通局 (Sendai City Transportation Bureau) / ODPT', + terms: { + quote: 'https://creativecommons.org/licenses/by/4.0/', + note: 'Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ztm-poznan', + name: 'Poznań buses and trams', + operator: 'Zarząd Transportu Miejskiego w Poznaniu (ZTM Poznań)', + region: 'Poznań, Poland', + center: { lat: 52.39, lon: 16.92 }, + loadRadiusKm: 20, + url: 'https://www.ztm.poznan.pl/pl/dla-deweloperow/getGtfsRtFile/?file=vehicle_positions.pb', + license: 'ZTM Poznań open-data terms', + licenseUrl: 'https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/', + attribution: 'Zarząd Transportu Miejskiego w Poznaniu (processed)', + creditDated: true, + terms: { + quote: + 'Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.', + note: 'Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp (`creditDated`) as the update date. The feed carries no bearing, so heading comes from movement between fixes.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ztm-gdansk', + name: 'Gdańsk buses and trams', + operator: 'Zarząd Transportu Miejskiego w Gdańsku (ZTM Gdańsk)', + region: 'Gdańsk, Poland', + center: { lat: 54.35, lon: 18.6 }, + loadRadiusKm: 20, + url: 'https://ckan2.multimediagdansk.pl/gtfs-rt?feed=vehiclePositions', + license: 'CC BY 4.0', + licenseUrl: + 'https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt', + attribution: 'Zarząd Transportu Miejskiego w Gdańsku', + terms: { + note: 'The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ets-edmonton', + name: 'Edmonton Transit Service', + operator: 'City of Edmonton (Edmonton Transit Service)', + region: 'Edmonton, AB', + center: { lat: 53.53, lon: -113.52 }, + loadRadiusKm: 25, + url: 'https://gtfs.edmonton.ca/TMGTFSRealTimeWebService/Vehicle/VehiclePositions.pb', + license: 'City of Edmonton Open Data Agreement', + licenseUrl: + 'https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf', + attribution: + 'Contains information licensed under the Open Data Agreement – City of Edmonton', + terms: { + note: 'The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s.', + }, + defaultMode: 'bus', + routeLabel: unpaddedRouteLabel, + }), + transitFeed({ + id: 'halifax-transit', + name: 'Halifax Transit', + operator: 'Halifax Regional Municipality (Halifax Transit)', + region: 'Halifax, NS', + center: { lat: 44.7, lon: -63.6 }, + loadRadiusKm: 22, + url: 'https://gtfs.halifax.ca/realtime/Vehicle/VehiclePositions.pb', + license: 'Open Government Licence – Halifax', + licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-license', + attribution: + 'Contains information licenced under the Open Government Licence – Halifax', + terms: { + quote: + 'The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.', + note: 'Version 1.0, modelled on the Open Government Licence – Canada 2.0. The attribution is the licence’s own standard statement, with a link to the licence where possible. Logos and crests are excluded and no endorsement may be implied, so the credit stays plain text. The page refuses automated requests (403); its text was read in a browser on 2026-11-08. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'bc-transit-victoria', + name: 'Victoria Regional Transit', + operator: 'BC Transit (Victoria Regional Transit System)', + region: 'Victoria, BC', + center: { lat: 48.53, lon: -123.43 }, + loadRadiusKm: 28, + url: 'https://bct.tmix.se/gtfs-realtime/vehicleupdates.pb?operatorIds=48', + license: 'BC Transit Open Data Terms of Use', + licenseUrl: 'https://www.bctransit.com/open-data/terms-of-use', + attribution: 'BC Transit', + terms: { + quote: + 'BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.', + note: 'The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s.', + }, + defaultMode: 'bus', + routeLabel: bcTransitRouteLabel, + }), + transitFeed({ + id: 'tam-montpellier', + name: 'Montpellier TaM', + operator: 'Montpellier Méditerranée Métropole (TaM)', + region: 'Montpellier, France', + center: { lat: 43.61, lon: 3.87 }, + loadRadiusKm: 20, + url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb', + license: 'ODbL', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain', + attribution: 'Montpellier Méditerranée Métropole (TaM)', + terms: { + quote: 'licence: odc-odbl', + note: 'The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them.', + }, + defaultMode: 'bus', + speedUnit: 'km/h', + }), + transitFeed({ + id: 'ztp-krakow-bus', + name: 'Kraków buses', + operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)', + region: 'Kraków, Poland', + center: { lat: 50.06, lon: 19.94 }, + loadRadiusKm: 22, + url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_A.pb', + license: 'No licence stated', + licenseUrl: 'https://gtfs.ztp.krakow.pl/', + attribution: 'Zarząd Transportu Publicznego w Krakowie', + terms: { + note: 'ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ztp-krakow-tram', + name: 'Kraków trams', + operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)', + region: 'Kraków, Poland', + center: { lat: 50.06, lon: 19.94 }, + loadRadiusKm: 22, + url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_T.pb', + license: 'No licence stated', + licenseUrl: 'https://gtfs.ztp.krakow.pl/', + attribution: 'Zarząd Transportu Publicznego w Krakowie', + terms: { note: 'Same server and basis as the bus feed.' }, + defaultMode: 'tram', + }), + transitFeed({ + id: 'vilnius-transport', + name: 'Vilnius public transport', + operator: 'SĮ Susisiekimo paslaugos, Vilnius', + region: 'Vilnius, Lithuania', + center: { lat: 54.69, lon: 25.28 }, + loadRadiusKm: 20, + url: 'https://www.stops.lt/vilnius/vehicle_positions.pb', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383', + attribution: 'SĮ Susisiekimo paslaugos (stops.lt)', + terms: { + note: 'Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'tpbi-bucuresti', + name: 'Bucharest public transport', + operator: 'Asociația de Dezvoltare Intercomunitară TPBI, Bucharest–Ilfov', + region: 'Bucharest, Romania', + center: { lat: 44.43, lon: 26.1 }, + loadRadiusKm: 25, + url: 'https://gtfs.tpbi.ro/api/gtfs-rt/vehiclePositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104', + attribution: 'TPBI (Transport Public București–Ilfov)', + terms: { + note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'roma-mobilita', + name: 'Rome public transport', + operator: 'Roma Servizi per la Mobilità', + region: 'Rome, Italy', + center: { lat: 41.89, lon: 12.5 }, + loadRadiusKm: 35, + url: 'https://romamobilita.it/wp-content/uploads/shared/rome_rtgtfs_vehicle_positions_feed.pb', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776', + attribution: 'Roma Servizi per la Mobilità', + terms: { + note: 'Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'gtt-torino', + name: 'Turin GTT', + operator: 'Gruppo Torinese Trasporti (GTT)', + region: 'Turin, Italy', + center: { lat: 45.07, lon: 7.68 }, + loadRadiusKm: 20, + url: 'https://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696', + attribution: 'Gruppo Torinese Trasporti (GTT)', + terms: { + note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.', + }, + defaultMode: 'bus', + routeLabel: gttRouteLabel, + }), + transitFeed({ + id: 'actv-venezia-bus', + name: 'Venice ACTV buses', + operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV)', + region: 'Venice, Italy', + center: { lat: 45.4, lon: 12.2 }, + loadRadiusKm: 38, + url: 'https://tpl.actv.it/aut/GTFSRT/vehiclePositions', + license: 'ACTV open-data catalogue', + licenseUrl: + 'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv', + attribution: 'ACTV S.p.A.', + terms: { + note: 'ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`).', + }, + defaultMode: 'bus', + routeLabel: actvRouteLabel, + }), + transitFeed({ + id: 'actv-venezia-waterbus', + name: 'Venice ACTV waterbuses', + operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV) Navigazione', + region: 'Venice, Italy', + center: { lat: 45.43, lon: 12.33 }, + loadRadiusKm: 15, + url: 'https://tpl.actv.it/nav/GTFSRT/vehiclePositions', + license: 'ACTV open-data catalogue', + licenseUrl: + 'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv', + attribution: 'ACTV S.p.A.', + terms: { note: 'Same catalogue and basis as the ACTV bus feed.' }, + defaultMode: 'ferry', + routeLabel: actvRouteLabel, + }), + transitFeed({ + id: 'adelaide-metro', + name: 'Adelaide Metro', + operator: 'Adelaide Metro (Government of South Australia)', + region: 'Adelaide, SA', + center: { lat: -34.93, lon: 138.6 }, + loadRadiusKm: 35, + url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions', + // Any Accept naming a protobuf type gets base64 text labelled JSON; only + // */* returns the binary feed (checked 2026-11-08). + headers: { Accept: '*/*' }, + license: 'No licence stated for the realtime feed', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772', + attribution: 'Adelaide Metro', + terms: { + note: 'Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'marta-atlanta', + name: 'MARTA buses', + operator: 'Metropolitan Atlanta Rapid Transit Authority (MARTA)', + region: 'Atlanta, GA', + center: { lat: 33.78, lon: -84.39 }, + loadRadiusKm: 35, + url: 'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601', + attribution: 'MARTA', + terms: { + note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'rtd-denver', + name: 'Denver RTD', + operator: 'Regional Transportation District (RTD), Denver', + region: 'Denver, CO', + center: { lat: 39.75, lon: -104.99 }, + loadRadiusKm: 55, + url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb', + license: 'RTD GTFS-realtime licence agreement', + licenseUrl: + 'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement', + attribution: 'Regional Transportation District (RTD)', + terms: { + quote: + 'RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data', + note: 'Read 2026-11-09. The agreement is a click-through (“By clicking to download the Data files, you confirm that you have read and agree”) that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from being used with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ctp-cluj', + name: 'Cluj-Napoca CTP', + operator: 'Compania de Transport Public Cluj-Napoca', + region: 'Cluj-Napoca, Romania', + center: { lat: 46.77, lon: 23.59 }, + loadRadiusKm: 15, + url: 'https://cluj-rt-feed.gtfs.ro/vehiclePositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614', + attribution: 'Compania de Transport Public Cluj-Napoca', + terms: { + note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'ctp-iasi', + name: 'Iași CTP', + operator: 'Compania de Transport Public Iași', + region: 'Iași, Romania', + center: { lat: 47.16, lon: 27.59 }, + loadRadiusKm: 15, + url: 'https://iasi-rt-feed.gtfs.ro/vehiclePositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118', + attribution: 'Compania de Transport Public Iași', + terms: { + note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'mpk-rzeszow', + name: 'Rzeszów city transport', + operator: 'Rzeszowski Transport Miejski (MPK Rzeszów)', + region: 'Rzeszów, Poland', + center: { lat: 50.04, lon: 22.0 }, + loadRadiusKm: 15, + url: 'https://www.mpkrzeszow.pl/gtfs/rt/gtfsrt.pb', + license: 'MPK Rzeszów open data', + licenseUrl: 'https://www.mpkrzeszow.pl/otwartedane/', + attribution: 'MPK Rzeszów', + terms: { + note: 'Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'emt-malaga', + name: 'Málaga EMT', + operator: 'Empresa Malagueña de Transportes (EMT)', + region: 'Málaga, Spain', + center: { lat: 36.72, lon: -4.42 }, + loadRadiusKm: 15, + url: 'https://gtfs.emtmalaga.es/GTFSRTapi/api/vehicleposition', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213', + attribution: 'EMT Málaga', + terms: { + note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'carris-lisboa', + name: 'Carris (Lisbon city)', + operator: 'Carris – Companhia Carris de Ferro de Lisboa', + region: 'Lisbon, Portugal', + center: { lat: 38.74, lon: -9.15 }, + loadRadiusKm: 15, + url: 'https://gateway.carris.pt/gateway/gtfs/api/v2.11/GTFS/realtime/vehiclepositions', + license: 'No licence stated', + licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930', + attribution: 'Carris', + terms: { + note: 'Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant.', + }, + defaultMode: 'bus', + routeLabel: carrisRouteLabel, + }), + transitFeed({ + id: 'mybas-johor', + name: 'BAS.MY Johor Bahru', + operator: 'BAS.MY, via data.gov.my', + region: 'Johor Bahru, Malaysia', + center: { lat: 1.51, lon: 103.75 }, + loadRadiusKm: 35, + url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-johor/', + license: 'CC BY 4.0', + licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime', + attribution: 'BAS.MY / data.gov.my', + // data.gov.my allows 4 GTFS-Realtime requests a minute and refreshes + // vehicle positions every 30 seconds, so the proxy asks half as often as + // the default 15 s TTL would. + refreshMs: 30_000, + terms: { + quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.', + note: 'Served keyless by Malaysia’s national open-data API (developer.data.gov.my, read 2026-11-09). Its rate-limit page caps GTFS Realtime at 4 requests a minute and answers 429 above that; the feed updates every 30 seconds, hence `refreshMs`. Without the trailing slash the API redirects. Route ids append an operator code.', + }, + defaultMode: 'bus', + routeLabel: mybasJohorRouteLabel, + }), + transitFeed({ + id: 'mybas-kota-bharu', + name: 'BAS.MY Kota Bharu', + operator: 'BAS.MY, via data.gov.my', + region: 'Kota Bharu, Malaysia', + // Routes run south into Kelantan: 95% of vehicles within 58 km on 2026-11-08. + center: { lat: 6.06, lon: 102.22 }, + loadRadiusKm: 60, + url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-kota-bharu/', + license: 'CC BY 4.0', + licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime', + attribution: 'BAS.MY / data.gov.my', + refreshMs: 30_000, + terms: { + quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.', + note: 'Same API, licence and rate limit as Johor Bahru.', + }, + defaultMode: 'bus', + }), + transitFeed({ + id: 'astuce-rouen', + name: 'Rouen Astuce', + operator: 'Métropole Rouen Normandie (Astuce), réseau TCAR', + region: 'Rouen, France', + center: { lat: 49.44, lon: 1.1 }, + loadRadiusKm: 20, + url: 'https://api.mrn.cityway.fr/dataflow/vehicle-tc-tr/download?provider=TCAR&dataFormat=gtfs-rt', + license: 'Licence Ouverte 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie', + attribution: 'Métropole Rouen Normandie (Astuce)', + terms: { + quote: 'licence: lov2', + note: 'The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted), and this is its own TCAR vehicle feed from that record (178 vehicles on 2026-11-09). The suburban TAE and TNI networks are separate feeds in the same record and are not read here. Route ids carry the network prefix (`TCAR:`); `TCAR:90` is the Métro (a street-running light rail, drawn as a tram) and `TCAR:99` the Calypso boat.', + }, + defaultMode: 'bus', + routeLabel: astuceRouteLabel, + routeMode: astuceRouteMode, + }), + transitFeed({ + id: 'mpm-toulon', + name: 'Toulon Mistral', + operator: 'Métropole Toulon Provence Méditerranée (réseau Mistral)', + region: 'Toulon, France', + center: { lat: 43.12, lon: 5.94 }, + loadRadiusKm: 18, + url: 'https://feed-rdtpm-toulon.ratpdev.com/VehiclePosition/GTFS-RT', + license: 'Licence Ouverte 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee', + attribution: 'Métropole Toulon Provence Méditerranée (Mistral)', + terms: { + quote: 'licence: lov2', + note: 'Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded.', + }, + defaultMode: 'bus', + routeLabel: unpaddedRouteLabel, + speedUnit: 'km/h', + }), + transitFeed({ + id: 'lia-le-havre', + name: 'Le Havre LiA', + operator: 'Le Havre Seine Métropole (LiA)', + region: 'Le Havre, France', + center: { lat: 49.49, lon: 0.11 }, + loadRadiusKm: 18, + url: 'https://opendata.transports-lia.fr/gtfs-rt/VehiclePosition.pb', + license: 'ODbL', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri', + attribution: 'Le Havre Seine Métropole (LiA)', + terms: { + quote: 'licence: odc-odbl', + note: 'The network publishes under ODbL (dataset record quoted), and this is its own GTFS-RT vehicle feed from that record (105 vehicles on 2026-11-09). The layer draws positions and redistributes no database. Route ids carry the network prefix `8-`; lines A and B are the tramway.', + }, + defaultMode: 'bus', + routeLabel: liaRouteLabel, + routeMode: liaRouteMode, + }), + transitFeed({ + id: 'filbleu-tours', + name: 'Tours Fil Bleu', + operator: 'Syndicat des Mobilités de Touraine (Fil Bleu)', + region: 'Tours, France', + center: { lat: 47.39, lon: 0.69 }, + loadRadiusKm: 18, + url: 'https://data.filbleu.fr/ws-tr/gtfs-rt/opendata/vehicle-positions', + license: 'Licence Ouverte 2.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt', + attribution: 'Fil Bleu – Syndicat des Mobilités de Touraine', + terms: { + quote: 'licence: lov2', + note: 'Dataset record’s licence field quoted.', + }, + defaultMode: 'bus', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index d6dc93eb6..75a902728 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -552,3 +552,229 @@ test('Rio feeds are CC BY 4.0 and live', () => { 'Rio has a DATA_SOURCES row', ); }); + +test('Szczecin is CC0, live, covers the city and not Berlin', () => { + const feed = getTransitFeed('zditm-szczecin'); + assert.equal(feed.defaultEnabled, true); + assert.equal(feed.license, 'CC0 1.0'); + assert.equal( + feed.licenseUrl, + 'https://www.zditm.szczecin.pl/en/zditm/for-developers', + ); + assert.match(feed.terms.quote, /CC0 1\.0 licence/); + assert.match(feed.attribution, /ZDiTM/); + assert.equal(feed.routeLabel, undefined, 'the feed has no route ids'); + assert.equal(transitModeFor(feed, null), 'bus'); + const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id); + assert.ok(ids(53.4285, 14.5528).includes('zditm-szczecin'), 'city centre'); + assert.ok(ids(53.5, 14.7).includes('zditm-szczecin'), 'north-east edge'); + assert.ok(!ids(52.52, 13.405).includes('zditm-szczecin'), 'Berlin'); + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources.split('\n').some((l) => l.startsWith('| **ZDiTM Szczecin')), + 'Szczecin has a DATA_SOURCES row', + ); +}); + +test('Carris Metropolitana covers Lisbon from Mafra to Setúbal, not Porto', () => { + const feed = getTransitFeed('carris-metropolitana-lisboa'); + assert.equal(feed.defaultEnabled, true); + assert.equal(feed.url, 'https://api.carrismetropolitana.pt/v2/vehicles.pb'); + assert.equal(feed.speedUnit, 'km/h'); + assert.match(feed.terms.note, /no licence for the realtime endpoint/); + const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id); + for (const [place, lat, lon] of [ + ['Lisbon', 38.7223, -9.1393], + ['Mafra', 38.9369, -9.3277], + ['Setúbal', 38.5244, -8.8882], + ]) { + assert.ok(ids(lat, lon).includes(feed.id), place); + } + assert.ok(!ids(41.1579, -8.6291).includes(feed.id), 'Porto'); + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources.split('\n').some((l) => l.startsWith('| **Carris Metropolitana')), + 'Lisbon has a DATA_SOURCES row', + ); +}); + +test('Carris Metropolitana route ids read as the line number on the bus', () => { + const feed = getTransitFeed('carris-metropolitana-lisboa'); + assert.equal(transitRouteLabel(feed, '[LA77N]1708_0'), '1708'); + assert.equal(transitRouteLabel(feed, '[BNA17]2772_1'), '2772'); + assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected'); + assert.equal(transitRouteLabel(feed, null), null); +}); + +// The 2026-11 batch: one landmark per feed, inside its own radius. +const NEW_CITY_LANDMARKS = { + 'pid-praha': [50.0875, 14.4213], + 'ztm-warszawa': [52.2297, 21.0122], + 'tbm-bordeaux': [44.8378, -0.5792], + 'sendai-city-bus': [38.2602, 140.8824], + 'ztm-poznan': [52.4064, 16.9252], + 'ztm-gdansk': [54.352, 18.6466], + 'ets-edmonton': [53.5461, -113.4938], + 'halifax-transit': [44.6488, -63.5752], + 'bc-transit-victoria': [48.4284, -123.3656], + 'tam-montpellier': [43.6108, 3.8767], + 'ztp-krakow-bus': [50.0614, 19.9366], + 'ztp-krakow-tram': [50.0614, 19.9366], + 'vilnius-transport': [54.6872, 25.2797], + 'tpbi-bucuresti': [44.4268, 26.1025], + 'roma-mobilita': [41.9028, 12.4964], + 'gtt-torino': [45.0703, 7.6869], + 'actv-venezia-bus': [45.4408, 12.3155], + 'actv-venezia-waterbus': [45.4408, 12.3155], + 'adelaide-metro': [-34.9285, 138.6007], + 'marta-atlanta': [33.749, -84.388], + 'rtd-denver': [39.7392, -104.9903], + 'ctp-cluj': [46.7712, 23.6236], + 'ctp-iasi': [47.1585, 27.6014], + 'mpk-rzeszow': [50.0412, 21.9991], + 'emt-malaga': [36.7213, -4.4214], + 'carris-lisboa': [38.7223, -9.1393], + 'mybas-johor': [1.4927, 103.7414], + 'mybas-kota-bharu': [6.1254, 102.2381], + 'astuce-rouen': [49.4432, 1.0999], + 'mpm-toulon': [43.1242, 5.928], + 'lia-le-havre': [49.4944, 0.1079], + 'filbleu-tours': [47.3941, 0.6848], +}; + +test('every city in the 2026-11 batch is live, licensed and covers its own centre', () => { + for (const [id, [lat, lon]] of Object.entries(NEW_CITY_LANDMARKS)) { + const feed = getTransitFeed(id); + assert.ok(feed, `${id} is routable`); + assert.ok( + publicTransitCatalog().some((f) => f.id === id), + `${id} is offered to the browser`, + ); + assert.ok(feed.terms?.note, `${id} records the basis it ships on`); + assert.ok( + transitFeedsInRange(lat, lon).some((f) => f.id === id), + `${id} covers its city`, + ); + } + // A feed's radius stays local: Warsaw is not polled for Kraków. + assert.ok( + !transitFeedsInRange(50.0614, 19.9366).some((f) => f.id === 'ztm-warszawa'), + ); +}); + +test('feeds with no stated licence say so and name the basis they ship on', () => { + for (const feed of TRANSIT_FEED_REGISTRY.filter((f) => + /^No licence stated/.test(f.license), + )) { + assert.match( + feed.terms.note, + /Metro Transit and OVapi|Same .*basis/, + `${feed.id} names its basis`, + ); + } +}); + +test('route ids in the 2026-11 batch read as the line on the vehicle', () => { + const label = (id, routeId) => transitRouteLabel(getTransitFeed(id), routeId); + assert.equal(label('pid-praha', 'L135'), '135'); + assert.equal(label('ets-edmonton', '009'), '9'); + assert.equal(label('mpm-toulon', '0002'), '2'); + assert.equal(label('gtt-torino', '10NU'), '10N'); + assert.equal(label('actv-venezia-waterbus', '5.1_UN'), '5.1'); + assert.equal(label('actv-venezia-bus', '53E_ES'), '53E'); + assert.equal(label('bc-transit-victoria', '95-VIC'), '95'); + assert.equal(label('carris-lisboa', '172_1'), '172'); + assert.equal(label('astuce-rouen', 'TCAR:93'), '93'); + assert.equal(label('mybas-johor', 'J33CWLMYJB'), 'J33'); + // Unmatched and absent ids pass through untouched. + assert.equal(label('pid-praha', 'S4'), 'S4'); + assert.equal(label('gtt-torino', null), null); +}); + +test('trams, waterbuses and buses are told apart where the ids allow it', () => { + const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId); + assert.equal(mode('pid-praha', 'L22'), 'tram'); + assert.equal(mode('pid-praha', 'L135'), 'bus'); + assert.equal(mode('tbm-bordeaux', '16'), 'bus'); + assert.equal(mode('lia-le-havre', '8-B'), 'tram'); + assert.equal(mode('lia-le-havre', '8-C3'), 'bus'); + assert.equal(mode('ztp-krakow-tram', null), 'tram'); + assert.equal(mode('actv-venezia-waterbus', '1_UN'), 'ferry'); +}); + +test('feeds that publish km/h are marked for conversion, and only those', () => { + const kmh = TRANSIT_FEED_REGISTRY.filter((f) => f.speedUnit === 'km/h') + .map((f) => f.id) + .sort(); + assert.deepEqual(kmh, [ + 'carris-metropolitana-lisboa', + 'mpm-toulon', + 'tam-montpellier', + ]); +}); + +// Route ids as the live feeds and the operators' own GTFS routes.txt carried +// them on 2026-11-09: the ranges and numbered tram ids are not guessable from +// the line names riders see. +test('Prague route-id ranges give metro, trains, ferries and trolleybuses their mode', () => { + const feed = getTransitFeed('pid-praha'); + const read = (routeId) => [ + transitModeFor(feed, routeId), + transitRouteLabel(feed, routeId), + ]; + assert.deepEqual(read('L22'), ['tram', '22']); + assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus'); + assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular'); + assert.deepEqual(read('L135'), ['bus', '135']); + assert.deepEqual(read('L803'), ['bus', 'X3']); + assert.deepEqual(read('L991'), ['subway', 'A']); + assert.deepEqual(read('L993'), ['subway', 'C']); + assert.deepEqual(read('L1004'), ['rail', 'S4']); + assert.deepEqual(read('L1109'), ['rail', 'R9']); + assert.deepEqual(read('L1204'), ['rail', 'L1204'], 'no label to derive'); + assert.deepEqual(read('L1801'), ['ferry', 'P1']); + assert.deepEqual(read('L1703'), ['bus', 'L1703']); + assert.deepEqual(read('L2841'), ['bus', '841']); + assert.deepEqual(read('S4'), ['bus', 'S4'], 'unknown ids keep the default'); +}); + +test('Bordeaux trams ride numbered route ids and keep their letters', () => { + const feed = getTransitFeed('tbm-bordeaux'); + for (const [routeId, line] of [ + ['59', 'A'], + ['62', 'D'], + ['163', 'E'], + ]) { + assert.equal(transitModeFor(feed, routeId), 'tram', routeId); + assert.equal(transitRouteLabel(feed, routeId), line); + } + assert.equal(transitModeFor(feed, '952'), 'ferry'); + assert.equal(transitModeFor(feed, '01'), 'bus'); + assert.equal(transitRouteLabel(feed, '01'), '1'); +}); + +test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () => { + const feed = getTransitFeed('astuce-rouen'); + assert.equal(transitModeFor(feed, 'TCAR:90'), 'tram'); + assert.equal(transitRouteLabel(feed, 'TCAR:90'), 'Métro'); + assert.equal(transitModeFor(feed, 'TCAR:99'), 'ferry'); + assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus'); + assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F'); + const lia = getTransitFeed('lia-le-havre'); + assert.equal(transitModeFor(lia, '8-FUNI'), 'rail'); + assert.equal(transitRouteLabel(lia, '8-C3'), 'C3'); + assert.equal(transitRouteLabel(lia, '8-09'), '9'); + assert.equal(transitRouteLabel(lia, '8-A'), 'A'); +}); + +test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => { + for (const id of ['mybas-johor', 'mybas-kota-bharu']) { + assert.ok(getTransitFeed(id).refreshMs >= 30_000, id); + } +}); diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 458240e9d..6b328e5a5 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -257,24 +257,43 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - decoded.vehicles, + feed.speedUnit === 'km/h' + ? decoded.vehicles.map((v) => + v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 }, + ) + : decoded.vehicles, decoded.timestamp, Math.floor(now / 1000), ), }; } +/** + * Fresh window for one feed's cached snapshot: the shared TTL, stretched by a + * registry `refreshMs` for upstreams that rate-limit below one request per + * TTL (data.gov.my allows 4 a minute). Never shorter than the shared TTL. + * @param {{ refreshMs?: number }|null|undefined} feed Registry entry. + * @returns {number} + */ +export function transitFeedTtlMs(feed) { + const refresh = feed?.refreshMs; + return Number.isFinite(refresh) && refresh > TRANSIT_PROXY_TTL_MS + ? refresh + : TRANSIT_PROXY_TTL_MS; +} + /** * Classify a cache entry for the request policy. * @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms). * @param {number} now + * @param {number} [ttlMs] Fresh window; `transitFeedTtlMs(feed)` for a feed. * @returns {'none'|'fresh'|'stale'|'expired'} */ -export function transitCacheState(entry, now) { +export function transitCacheState(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) { if (!entry || !Number.isFinite(entry.at)) return 'none'; const age = now - entry.at; if (age < 0) return 'fresh'; - if (age < TRANSIT_PROXY_TTL_MS) return 'fresh'; + if (age < ttlMs) return 'fresh'; if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale'; return 'expired'; } diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index f03ea2e30..63f950f58 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -12,6 +12,7 @@ import { repairVehicleTimestamps, resolveTransitRoute, transitCacheState, + transitFeedTtlMs, transitRedirectDecision, transitResponseHeaders, transitUpstreamHeaders, @@ -294,6 +295,27 @@ test('Rio speeds pass through as the m/s they are published in', () => { assert.equal(rio.vehicles[0].speedMps, 10); }); +test('Carris Metropolitana km/h speeds leave the proxy as m/s', () => { + // 2026-11-07: readings of 31-33 matched 6-9 m/s of measured movement. + const bytes = encodeFeed({ + entities: [ + { id: 'moving', lat: 38.72, lon: -9.14, speed: 36 }, + { id: 'parked', lat: 38.73, lon: -9.15 }, + ], + }); + const feed = getTransitFeed('carris-metropolitana-lisboa'); + const lisbon = buildTransitSnapshot(feed, bytes, 1); + const byId = Object.fromEntries(lisbon.vehicles.map((v) => [v.id, v])); + assert.ok(Math.abs(byId.moving.speedMps - 10) < 1e-6); + assert.equal(byId.parked.speedMps, null, 'no speed stays no speed'); + const szczecin = buildTransitSnapshot( + getTransitFeed('zditm-szczecin'), + encodeFeed({ entities: [{ id: 'm', lat: 53.43, lon: 14.55, speed: 10 }] }), + 1, + ); + assert.equal(szczecin.vehicles[0].speedMps, 10, 'm/s feeds are untouched'); +}); + test('conditional-request validators and gzip ride along with feed identification', () => { const entur = getTransitFeed('entur-norway'); const plain = transitUpstreamHeaders(entur); @@ -375,3 +397,24 @@ test('client history validates response bounds, epochs and cancellation through { name: 'AbortError' }, ); }); + +test('Adelaide asks for */* because a protobuf Accept gets base64 text back', () => { + const headers = transitUpstreamHeaders(getTransitFeed('adelaide-metro')); + assert.equal(headers.Accept, '*/*'); + // Every other feed keeps the protobuf-first Accept. + assert.match( + transitUpstreamHeaders(getTransitFeed('mbta')).Accept, + /protobuf/, + ); +}); + +test('a feed refreshMs stretches its fresh window, never shortens it', () => { + const johor = getTransitFeed('mybas-johor'); + assert.equal(transitFeedTtlMs(johor), 30_000); + assert.equal(transitFeedTtlMs(getTransitFeed('mbta')), TRANSIT_PROXY_TTL_MS); + assert.equal(transitFeedTtlMs({ refreshMs: 1_000 }), TRANSIT_PROXY_TTL_MS); + const now = 1_000_000; + const entry = { at: now - 20_000 }; + assert.equal(transitCacheState(entry, now), 'stale'); + assert.equal(transitCacheState(entry, now, transitFeedTtlMs(johor)), 'fresh'); +}); diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 22b179120..afce6a4aa 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -401,7 +401,13 @@ export function createIngestion({ state, services, parts, source }) { ); } if (state._viewer) - registerDynamicCredit(state._viewer, transitFeedCredit(feed)); + registerDynamicCredit( + state._viewer, + transitFeedCredit(feed, { + feedTimestamp: snapshot?.feedTimestamp, + fetchedAt, + }), + ); if ( state._selectedKey && state._vehicles.get(state._selectedKey)?.feedId === feed.id diff --git a/src/sources/transitService.js b/src/sources/transitService.js index 1b6b46d78..328b82711 100644 --- a/src/sources/transitService.js +++ b/src/sources/transitService.js @@ -25,6 +25,7 @@ import { nextTransitBackoffMs, resolveTransitRoute, transitCacheState, + transitFeedTtlMs, transitRedirectDecision, transitResponseHeaders, transitUpstreamHeaders, @@ -266,7 +267,7 @@ export function createTransitService({ fetchImpl = fetch } = {}) { ); } const cached = cache.get(feed.id); - const state = transitCacheState(cached, now); + const state = transitCacheState(cached, now, transitFeedTtlMs(feed)); if (state === 'fresh') { return reply( 200,