diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index cac0b5ab7..ed0895b0a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,6 +98,7 @@ How to read this: | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **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 | | **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)" | @@ -185,7 +186,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 sixteen registered 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 and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index 4d352a4c0..6212f3991 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 📷 **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, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md new file mode 100644 index 000000000..ac245a9c8 --- /dev/null +++ b/changelog.d/1481-rio-transit.md @@ -0,0 +1,5 @@ +- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT + from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin + American region. Rio's internal route keys are joined to SMTR's published + GTFS `routes.txt`, so a card reads "Route SN731" rather than + "Route O0731AAN0A" (IMBrandao, #1481). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a7ecba28a..abfa3fc04 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4644,8 +4644,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. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway, South East Queensland, and nine in +gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro +(municipal buses and the Mobi-Rio BRT, two feeds), 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/scripts/format-scope.json b/scripts/format-scope.json index 2c589d8cc..b390581d0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -786,6 +786,7 @@ "server/providers/transit.js", "server/providers/transitHistory.js", "server/providers/transitHistory.test.mjs", + "src/data/rioRouteNames.js", "src/data/transitFeeds.js", "src/data/transitFeeds.test.mjs", "src/data/transitProxy.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ff0eb6352..b7556f434 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2959,6 +2959,7 @@ "src/app/layers/transit.js", "src/data/transit.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", @@ -3711,6 +3712,7 @@ "src/sources/nominatim.js", "src/app/layers/transit.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", @@ -4472,6 +4474,7 @@ "server/providers/transitHistory.js", "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/httpBody.js", @@ -4488,6 +4491,7 @@ "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" @@ -4521,6 +4525,7 @@ "src/data/terrainHeights.js", "src/data/transit.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/data/transitProxy.js", @@ -4579,6 +4584,7 @@ "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", @@ -4636,6 +4642,7 @@ "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", @@ -4880,6 +4887,7 @@ "src/tools/surfaces.js", "src/layers/transit/policy.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/layers/transit/movement.js", "src/data/contactPlayback.js", "src/data/layerSnapshot.js", diff --git a/src/data/rioRouteNames.js b/src/data/rioRouteNames.js new file mode 100644 index 000000000..1341f873a --- /dev/null +++ b/src/data/rioRouteNames.js @@ -0,0 +1,505 @@ +/** + * Rio de Janeiro route ids to the line a rider would say. + * + * SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731, + * `20000501110` is BRT line 50). They look decodable but are not: + * `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in + * SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0), + * valid to 2026-12-31. An id missing here renders as its raw key. + */ +export const RIO_ROUTE_NAMES = Object.freeze({ + 20000101130: '10', + 20000111110: '11', + 20000121130: '12', + 20000131130: '13', + 20000141130: '14', + 20000151130: '15', + 20000161130: '16', + 20000171110: '17', + 20000181130: '18', + 20000191130: '19', + 20000201130: '20', + 20000221110: '22', + 20000251130: '25', + 20000281130: '28', + 20000311140: '31', + 20000351110: '35', + 20000401130: '40', + 20000411130: '41', + 20000421110: '42', + 20000431130: '43', + 20000461130: '46', + 20000501110: '50', + 20000511110: '51', + 20000521110: '52', + 20000531130: '53', + 20000601110: '60', + 20000611130: '61', + 20000671130: '67', + 20000681130: '68', + 20000701110: '70', + 20000731130: '73', + 20000801110: '80', + 20000901110: '90', + '20000EXEC1110': 'ESP01', + E2110AAA0A: '2110', + E2111AAA0A: '2111', + E2112AAA0A: '2112', + E2114AAA0A: '2114', + E2303AAA0A: '2303', + E2305AAA0A: '2305', + E2307AAA0A: '2307', + E2308AAA0A: '2308', + E2309AAA0A: '2309', + E2310AAA0A: '2310', + E2334AAA0A: '2334', + E2335AAA0A: '2335', + E2336AAA0A: '2336', + E2337AAA0A: '2337', + E2338AAA0A: '2338', + E2339AAA0A: '2339', + E2342AAA0A: '2342', + E2343AAA0A: '2343', + E2344AAA0A: '2344', + E2345AAA0A: '2345', + E2381AAA0A: '2381', + E2383AAA0A: '2383', + E2613AAA0A: '2613', + E2614AAA0A: '2614', + E2802AAA0A: '2802', + E2803AAA0A: '2803', + E2804AAA0A: '2804', + O0006AAA0A: '006', + O0007AAA0A: '007', + O0007AAN0A: 'SN007', + O0010AAA0A: '010', + O0014AAA0A: 'LECD135', + O0100AAA0A: '100', + O0104AAA0A: '104', + O0104AAN0A: 'SN104', + O0107AAA0A: '107', + O0109AAA0A: 'LECD131', + O0111AAA0A: 'LECD134', + O0117AAA0A: '117', + O0133AAA0A: '133', + O0133AAN0A: 'SN133', + O0157AAA0A: 'LECD127', + O0160AAA0A: '160', + O0161AAA0A: '161', + O0162AAA0A: '162', + O0164AAA0A: 'LECD136', + O0165AAA0A: '165', + O0167AAA0A: '167', + O0169AAA0A: '169', + O0201AAA0A: '201', + O0202AAA0A: '202', + O0209AAA0A: '209', + O0209AAN0A: 'SN209', + O0210AAA0A: '210', + O0217AAA0A: '217', + O0220AAA0A: '220', + O0229AAA0A: '229', + O0232AAA0A: '232', + O0232AAN0A: 'SN232', + O0238AAA0A: '238', + O0238AAN0A: 'SN238', + O0239AAA0A: '239', + O0247AAA0A: '247', + O0249AAA0A: '249', + O0254AAA0A: '254', + O0265AAA0A: '265', + O0265AAN0A: 'SN265', + O0265AAP0A: 'SP265', + O0275AAA0A: '275', + O0277AAA0A: '277', + O0292AAA0A: '292', + O0292AAN0A: 'SN292', + O0296AAA0A: '296', + O0298AAA0A: '298', + O0298AAN0A: 'SN298', + O0300AAA0A: '300', + O0300AAR0A: 'SR300', + O0301AAA0A: '301', + O0302AAA0A: '302', + O0302AAN0A: 'SN302', + O0306AAA0A: '306', + O0311AAA0A: '311', + O0312AAA0A: '312', + O0312AAN0A: 'SN312', + O0313AAA0A: '313', + O0315AAA0A: '315', + O0319AAA0A: '319', + O0319AAN0A: 'SN319', + O0319AAV0A: 'SV319', + O0321AAA0A: '321', + O0323AAA0A: '323', + O0324AAA0A: '324', + O0324AAN0A: 'SN324', + O0324AAP0A: 'SP324', + O0325AAA0A: '325', + O0326AAA0A: '326', + O0326AAPBA: 'SPB326', + O0327AAA0A: '327', + O0328AAA0A: '328', + O0328AAN0A: 'SN328', + O0328AAP0A: 'SP328', + O0328AAR0A: 'SR328', + O0329AAA0A: '329', + O0329AAP0A: 'SP329', + O0335AAA0A: '335', + O0338AAA0A: '338', + O0341AAA0A: '341', + O0342AAA0A: '342', + O0342AAR0A: 'SR342', + O0343AAA0A: '343', + O0343AAP0A: 'SP343', + O0348AAA0A: '348', + O0349AAA0A: '349', + O0350AAA0A: '350', + O0352AAA0A: '352', + O0353AAA0A: '353', + O0353AAN0A: 'SN353', + O0355AAA0A: '355', + O0355AAN0A: 'SN355', + O0355AAR0A: 'SR355', + O0361AAA0A: '361', + O0362AAA0A: '362', + O0362AAR0A: 'SR362', + O0363AAA0A: '363', + O0363AAN0A: 'SN363', + O0363AAV0A: 'SV363', + O0368AAA0A: '368', + O0368AAN0A: 'SN368', + O0369AAA0A: '369', + O0371AAA0A: '371', + O0378AAA0A: '378', + O0378AAR0A: 'SR378', + O0380AAA0A: '380', + O0383AAA0A: '383', + O0383AAN0A: 'SN383', + O0384AAA0A: '384', + O0384AAR0A: 'SR384', + O0385AAA0A: '385', + O0386AAA0A: '386', + O0386AAR0A: 'SR386', + O0388AAA0A: '388', + O0388AAN0A: 'SN388', + O0388AAR0A: 'SR388', + O0389AAP0A: 'SP389', + O0390AAA0A: '390', + O0391AAV0A: 'SV391', + O0393AAA0A: '393', + O0393AAN0A: 'SN393', + O0393AAR0A: 'SR393', + O0397AAA0A: '397', + O0397AAN0A: 'SN397', + O0397AAR0A: 'SR397', + O0399AAA0A: '399', + O0399AAN0A: 'SN399', + O0399AAR0A: 'SR399', + O0404AAP0A: 'SP404', + O0409AAA0A: '409', + O0410AAA0A: '410', + O0410AAN0A: 'LECD139', + O0415AAA0A: '415', + O0415AAN0A: 'SN415', + O0416AAA0A: '416', + O0422AAA0A: '422', + O0422AAN0A: 'SN422', + O0426AAA0A: '426', + O0433AAA0A: 'LECD150', + O0435AAA0A: 'LECD151', + O0448AAA0A: '448', + O0455AAA0A: '455', + O0456AAA0A: 'LECD132', + O0457AAA0A: '457', + O0457AAN0A: 'SN457', + O0461AAA0A: '461', + O0472AAA0A: '472', + O0473AAA0A: '473', + O0474AAA0A: '474', + O0474AAN0A: 'SN474', + O0474AAV0A: 'SV474', + O0475AAA0A: 'LECD133', + O0476AAA0A: '476', + O0483AAA0A: '483', + O0483AAN0A: 'SN483', + O0483AAPAA: 'SPA483', + O0483AAPBA: 'SPB483', + O0483AAV0A: 'SV483', + O0484AAA0A: '484', + O0485AAA0A: '485', + O0485AAP0A: 'SP485', + O0486AAA0A: '486', + O0492AAA0A: '492', + O0497AAA0A: '497', + O0497AAN0A: 'SN497', + O0498AAA0A: '498', + O0507AAA0A: '507', + O0513AAA0A: '513', + O0518AAA0A: '518', + O0519AAA0A: '519', + O0536AAA0A: 'LECD138', + O0539AAA0A: '539', + O0550AAA0A: '550', + O0550AAN0A: 'SN550', + O0550AAPAA: 'SPA550', + O0550AAPBA: 'SPB550', + O0552AAA0A: '552', + O0552AAN0A: 'LECD149', + O0553AAA0A: '553', + O0554AAA0A: '554', + O0554AAN0A: 'SN554', + O0555AAA0A: '555', + O0557AAA0A: '557', + O0565AAA0A: '565', + O0565AAN0A: 'SN565', + O0583AAA0A: '583', + O0584AAA0A: '584', + O0585AAA0A: '585', + O0600AAA0A: '600', + O0600AAN0A: 'SN600', + O0601AAA0A: '601', + O0603AAA0A: '603', + O0606AAA0A: '606', + O0606AAV0A: 'SV606', + O0607AAA0A: '607', + O0608AAA0A: '608', + O0610AAA0A: '610', + O0611AAA0A: '611', + O0613AAA0A: '613', + O0613AAPAA: 'SPA613', + O0614AAA0A: '614', + O0616AAA0A: '616', + O0621AAA0A: '621', + O0622AAA0A: '622', + O0623AAA0A: '623', + O0624AAA0A: '624', + O0624AAN0A: 'SN624', + O0624AAV0A: 'SV624', + O0625AAA0A: '625', + O0626AAA0A: '626', + O0627AAA0A: '627', + O0628AAA0A: '628', + O0629AAA0A: '629', + O0629AAN0A: 'SN629', + O0630AAA0A: '630', + O0634AAA0A: '634', + O0635AAA0A: '635', + O0635AAP0A: 'SP635', + O0636AAA0A: '636', + O0636AAN0A: 'SN636', + O0638AAA0A: '638', + O0639AAA0A: '639', + O0639AAN0A: 'SN639', + O0645AAA0A: '645', + O0649AAA0A: 'LECD126', + O0650AAA0A: '650', + O0651AAA0A: '651', + O0652AAA0A: '652', + O0653AAA0A: '653', + O0663AAA0A: '663', + O0665AAA0A: '665', + O0665AAVAA: 'LECD147', + O0665AAVBA: 'SVB665', + O0669AAA0A: '669', + O0669AAV0A: 'SV669', + O0678AAA0A: '678', + O0679AAA0A: '679', + O0685AAA0A: 'LECD140', + O0685AAVAA: 'LECD148', + O0685AAVBA: 'LECD141', + O0687AAA0A: '687', + O0688AAA0A: '688', + O0691AAA0A: '691', + O0692AAA0A: '692', + O0692AAV0A: 'SV692', + O0693AAA0A: '693', + O0696AAA0A: '696', + O0696AAN0A: 'SN696', + O0711AAA0A: '711', + O0712AAA0A: '712', + O0721AAA0A: '721', + O0731AAA0A: '731', + O0731AAN0A: 'SN731', + O0737AAA0A: '737', + O0741AAA0A: '741', + O0743AAA0A: '743', + O0745AAA0A: '745', + O0746AAA0A: '746', + O0753AAA0A: '753', + O0754AAA0A: '754', + O0756AAA0A: '756', + O0757AAA0A: '757', + O0759AAA0A: '759', + O0759AAP0A: 'SP759', + O0764AAA0A: '764', + O0765AAA0A: '765', + O0766AAA0A: '766', + O0766AAP0A: 'SP766', + O0770AAA0A: '770', + O0771AAA0A: '771', + O0774AAA0A: '774', + O0774AAV0A: 'LECD142', + O0775AAA0A: '775', + O0777AAV0A: 'SV777', + O0778AAA0A: '778', + O0779AAA0A: '779', + O0779AAN0A: 'SN779', + O0780AAA0A: '780', + O0783AAA0A: '783', + O0785AAA0A: '785', + O0786AAA0A: '786', + O0788AAA0A: '788', + O0790AAA0A: '790', + O0790AAN0A: 'SN790', + O0790AAV0A: 'SV790', + O0791AAA0A: '791', + O0793AAA0A: '793', + O0794AAA0A: '794', + O0794AAN0A: 'SN794', + O0795AAP0A: 'SP795', + O0796AAA0A: '796', + O0798AAA0A: '798', + O0799AAA0A: '799', + O0801AAA0A: '801', + O0801AAP0A: 'SP801', + O0802AAA0A: '802', + O0803AAA0A: '803', + O0803AAN0A: 'SN803', + O0803AAP0A: 'SP803', + O0804AAA0A: '804', + O0805AAA0A: '805', + O0805AAN0A: 'SN805', + O0805AAP0A: 'SP805', + O0807AAA0A: '807', + O0808AAA0A: '808', + O0809AAA0A: '809', + O0810AAA0A: '810', + O0810AAN0A: 'SN810', + O0810AAP0A: 'SP810', + O0812AAA0A: '812', + O0817AAA0A: '817', + O0819AAA0A: '819', + O0820AAA0A: '820', + O0821AAA0A: '821', + O0821AAN0A: 'SN821', + O0822AAA0A: '822', + O0825AAA0A: '825', + O0825AAN0A: 'SN825', + O0826AAA0A: '826', + O0829AAA0A: '829', + O0830AAA0A: '830', + O0831AAA0A: '831', + O0831AAV0A: 'SV831', + O0833AAA0A: '833', + O0834AAA0A: '834', + O0835AAA0A: '835', + O0836AAA0A: '836', + O0837AAA0A: '837', + O0838AAA0A: '838', + O0840AAA0A: '840', + O0840AAN0A: 'SN840', + O0841AAA0A: '841', + O0842AAA0A: '842', + O0843AAA0A: '843', + O0844AAA0A: '844', + O0845AAA0A: '845', + O0846AAA0A: '846', + O0846AAN0A: 'SN846', + O0847AAA0A: '847', + O0848AAA0A: '848', + O0848AAN0A: 'SN848', + O0849AAA0A: '849', + O0850AAA0A: '850', + O0851AAA0A: '851', + O0852AAA0A: '852', + O0853AAA0A: '853', + O0853AAN0A: 'SN853', + O0853AAV0A: 'SV853', + O0854AAAAA: '854', + O0855AAA0A: '855', + O0856AAA0A: '856', + O0857AAA0A: '857', + O0857AAN0A: 'SN857', + O0859AAA0A: '859', + O0861AAA0A: '861', + O0861AAN0A: 'SN861', + O0862AAA0A: '862', + O0863AAA0A: '863', + O0863AAV0A: 'SV863', + O0864AAA0A: '864', + O0864AAP0A: 'SP864', + O0865AAA0A: '865', + O0866AAA0A: '866', + O0866AAV0A: 'SV866', + O0867AAA0A: '867', + O0867AAN0A: 'SN867', + O0868AAA0A: '868', + O0869AAA0A: '869', + O0870AAA0A: '870', + O0871AAA0A: '871', + O0872AAA0A: '872', + O0878AAA0A: '878', + O0878AAN0A: 'SN878', + O0878AAV0A: 'SV878', + O0880AAA0A: '880', + O0880AAN0A: 'SN880', + O0881AAA0A: '881', + O0883AAA0A: '883', + O0884AAA0A: '884', + O0884AAN0A: 'SN884', + O0885AAA0A: '885', + O0885AAN0A: 'SN885', + O0892AAA0A: '892', + O0893AAA0A: '893', + O0893AAN0A: 'SN893', + O0894AAA0A: '894', + O0895AAA0A: '895', + O0895AAN0A: 'SN895', + O0897AAA0A: '897', + O0897AAN0A: 'SN897', + O0898AAA0A: '898', + O0898AAN0A: 'SN898', + O0899AAA0A: '899', + O0899AAV0A: 'SV899', + O0900AAA0A: '900', + O0901AAA0A: '901', + O0901AAVBA: 'SVB901', + O0905AAA0A: '905', + O0906AAA0A: '906', + O0908AAA0A: '908', + O0908AAN0A: 'SN908', + O0908AAV0A: 'SV908', + O0910AAA0A: '910', + O0913AAA0A: '913', + O0915AAA0A: '915', + O0917AAA0A: '917', + O0917AAV0A: 'SV917', + O0918AAA0A: '918', + O0918AAP0A: 'SP918', + O0919AAA0A: '919', + O0919AAN0A: 'SN919', + O0920AAA0A: '920', + O0921AAA0A: '921', + O0921AAV0A: 'SV921', + O0922AAA0A: '922', + O0922AAV0A: 'SV922', + O0925AAA0A: '925', + O0926AAA0A: '926', + O0928AAA0A: '928', + O0932AAA0A: '932', + O0940AAA0A: '940', + O0944AAA0A: 'LECD143', + O0946AAA0A: '946', + O0950AAA0A: 'LECD144', + O0951AAA0A: 'LECD145', + O0954AAA0A: '954', + O0954AAN0A: 'SN954', + O0959AAA0A: 'LECD146', + O0961AAA0A: '961', + O0975AAA0A: '975', + O0979AAA0A: '979', + O0987AAA0A: '987', + O0990AAA0A: '990', +}); diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index b8476a6ee..48ca450a1 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -1,4 +1,5 @@ import { greatCircleKm } from '../geoDistance.js'; +import { RIO_ROUTE_NAMES } from './rioRouteNames.js'; /** * @module transitFeeds @@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) { return BURLINGTON_ROUTE_NAMES[routeId] || routeId; } +/** + * Rio route key to the line a rider would say. One SMTR `routes.txt` covers + * both feeds (see rioRouteNames.js). + * @param {string|null} routeId + * @returns {string|null} + */ +function rioRouteLabel(routeId) { + return Object.hasOwn(RIO_ROUTE_NAMES, routeId) + ? RIO_ROUTE_NAMES[routeId] + : routeId; +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -729,6 +742,52 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: burlingtonRouteLabel, }), + Object.freeze({ + id: 'smtr-rio-onibus', + name: 'Rio buses (SPPO)', + operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro', + region: 'Rio de Janeiro, RJ', + // Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to + // the Zona Sul, sat within 31 km of this point. + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.', + }), + defaultMode: 'bus', + routeLabel: rioRouteLabel, + }), + Object.freeze({ + id: 'smtr-rio-brt', + name: 'Mobi-Rio BRT', + operator: + 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet', + region: 'Rio de Janeiro, RJ', + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4', + attribution: + 'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.', + }), + defaultMode: 'bus', + routeLabel: rioRouteLabel, + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index fd990af45..d6dc93eb6 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -15,6 +15,7 @@ import { transitModeResolved, transitRouteLabel, } from './transitFeeds.js'; +import { RIO_ROUTE_NAMES } from './rioRouteNames.js'; test('every registered feed is keyless, https, licensed, and uniquely identified', () => { const ids = new Set(); @@ -482,3 +483,72 @@ test('Burlington is live and carries its terms link, which its licence requires' assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/); assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all'); }); + +test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => { + const ids = (lat, lon) => + transitFeedsInRange(lat, lon).map((feed) => feed.id); + for (const [place, lat, lon] of [ + ['Centro', -22.9068, -43.1729], + ['Barra da Tijuca', -23.0004, -43.3659], + ['Santa Cruz', -22.9194, -43.6847], + ]) { + assert.ok( + ids(lat, lon).includes('smtr-rio-onibus'), + `buses reach ${place}`, + ); + assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`); + } + assert.deepEqual( + ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')), + [], + 'São Paulo, 360 km away, never polls Rio', + ); +}); + +test('Rio route keys read as the line on the front of the bus', () => { + // Some keys look parseable and are not (`O0014AAA0A` is LECD135). + const buses = getTransitFeed('smtr-rio-onibus'); + assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868'); + assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731'); + assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759'); + assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135'); + assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303'); + const brt = getTransitFeed('smtr-rio-brt'); + assert.equal(transitRouteLabel(brt, '20000501110'), '50'); + assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01'); + // Unknown ids, prototype names included, keep their raw key. + assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A'); + assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor'); + assert.equal(transitRouteLabel(brt, null), null); +}); + +test('the Rio route table is a clean join of published keys to names', () => { + const entries = Object.entries(RIO_ROUTE_NAMES); + assert.ok(entries.length > 400, 'the whole published table is carried'); + for (const [id, name] of entries) { + assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id); + assert.ok(name && name === name.trim(), `${id} has a name`); + assert.notEqual(name, id, `${id} is translated`); + } +}); + +test('Rio feeds are CC BY 4.0 and live', () => { + for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { + const feed = getTransitFeed(id); + assert.equal(feed.defaultEnabled, true, `${id} is live`); + assert.equal(feed.license, 'CC BY 4.0'); + assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//); + assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/); + assert.equal(transitModeFor(feed, null), 'bus'); + } + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources + .split('\n') + .some((line) => line.startsWith('| **SMTR Rio de Janeiro')), + 'Rio has a DATA_SOURCES row', + ); +}); diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index dea10ab25..f03ea2e30 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -137,6 +137,7 @@ function encodeFeed({ const position = new PbfWriter(); position.writeFloatField(1, entity.lat); position.writeFloatField(2, entity.lon); + if (entity.speed != null) position.writeFloatField(5, entity.speed); vehicle.writeBytesField(2, position.finish()); if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp); const descriptor = new PbfWriter(); @@ -284,6 +285,15 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { assert.equal(snapshot.feedTimestamp, 1_700_000_000); }); +test('Rio speeds pass through as the m/s they are published in', () => { + // Checked against vehicle movement on 2026-11-05. + const bytes = encodeFeed({ + entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }], + }); + const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1); + assert.equal(rio.vehicles[0].speedMps, 10); +}); + test('conditional-request validators and gzip ride along with feed identification', () => { const entur = getTransitFeed('entur-norway'); const plain = transitUpstreamHeaders(entur); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index b86613160..dc3ea4d79 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -12,6 +12,11 @@ import { } from '../../data/transitFeeds.js'; import { transitModeTier } from '../../data/transitPresetStyle.js'; +/** Regions, not feeds: one region can carry several feeds (Rio has two). */ +const TRANSIT_REGION_COUNT = new Set( + TRANSIT_ENABLED_FEEDS.map((feed) => feed.region), +).size; + /** * The registry entry behind a vehicle. Live feeds answer first so a feed the * session disabled stops supplying vocabulary; the registry is the fallback, @@ -249,7 +254,7 @@ export function createQueries({ state, parts }) { // English data (the analyst's empty note to the model); the voice // card shows it through NOTE_PATTERNS (voice/resultDisplay.js). coverage: state._altitudeGateOpen - ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` + ? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available` : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`, }; } diff --git a/src/layers/transit/queries.test.mjs b/src/layers/transit/queries.test.mjs new file mode 100644 index 000000000..a7ac1224c --- /dev/null +++ b/src/layers/transit/queries.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createQueries } from './queries.js'; +import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js'; + +test('the empty-coverage note counts regions, not feeds', () => { + const rio = TRANSIT_ENABLED_FEEDS.filter( + (feed) => feed.region === 'Rio de Janeiro, RJ', + ); + assert.equal(rio.length, 2, 'Rio is one region with two feeds'); + const regions = new Set(TRANSIT_ENABLED_FEEDS.map((feed) => feed.region)); + assert.ok(regions.size < TRANSIT_ENABLED_FEEDS.length); + + const stats = createQueries({ + state: { + _activeFeeds: new Map(), + _vehicles: new Map(), + _enabled: true, + _altitudeGateOpen: true, + }, + parts: {}, + }).methods.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal( + stats.coverage, + `No feed here yet · ${regions.size} regions available`, + ); +});