From a4c7b20ca825a731531b61fc2935f1a47621248a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:11:10 -0300 Subject: [PATCH 01/11] feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0) Register Rio's two keyless GTFS-Realtime VehiclePositions feeds from the city transport secretariat (SMTR): municipal buses (SPPO) and the Mobi-Rio BRT, the Transit layer's first Latin American region. Both feeds publish Position.speed in km/h instead of m/s. A new optional registry field, speedUnit: 'kmh', makes buildTransitSnapshot convert it once, so every speedMps consumer stays correct and other feeds are untouched. Document the source in DATA_SOURCES.md and update the region lists in README.md and docs/CURRENT-STATE.md. Refs #1480 Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 3 +- README.md | 2 +- docs/CURRENT-STATE.md | 8 ++++-- src/data/transitFeeds.js | 50 ++++++++++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 43 +++++++++++++++++++++++++++++ src/data/transitProxy.js | 21 +++++++++++++- src/data/transitProxy.test.mjs | 26 ++++++++++++++++++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index e31d0d3f0..2b989c509 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -95,6 +95,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. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are shown raw. | 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)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -177,7 +178,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_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 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. - **Radio Browser.** `/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; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful 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. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/README.md b/README.md index 745bec3d8..965c0e283 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 📷 **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) | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 966bb3878..2adac270e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4295,11 +4295,13 @@ 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 +Burlington, Barrie and Kingston. Rio's feeds publish speed in km/h, which +the proxy converts to m/s before any card reads it. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index b8476a6ee..e871a2e89 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -385,7 +385,12 @@ function burlingtonRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, + * speedUnit?: 'kmh', * }>>} + * + * `speedUnit: 'kmh'` marks a feed that publishes `Position.speed` in km/h + * instead of the m/s the spec requires; the proxy converts it before anything + * downstream reads `speedMps`. */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -729,6 +734,51 @@ 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 (CC BY 4.0)', + 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. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys: `O0313AAA0A` carried service 313 in the same day’s JSON feed. They are shown raw until a published table is joined.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), + 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 (CC BY 4.0)', + 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 km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000461130`) and are shown raw.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index fd990af45..51903b7db 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -482,3 +482,46 @@ 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 feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { + 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(feed.speedUnit, 'kmh', `${id} publishes km/h`); + 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.js b/src/data/transitProxy.js index 458240e9d..2923c9c32 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -224,6 +224,25 @@ export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) { }); } +/** + * Bring a feed's speeds to the m/s the spec requires. A feed registered with + * `speedUnit: 'kmh'` publishes km/h in `Position.speed`, and read as m/s a + * 44 km/h bus would be reported at 158 km/h. Converting here, once, keeps every + * consumer of `speedMps` honest without each one knowing about the feed. + * + * @param {object} feed Registry entry. + * @param {object[]} vehicles Normalized vehicle records. + * @returns {object[]} + */ +export function normalizeFeedSpeeds(feed, vehicles) { + if (feed?.speedUnit !== 'kmh') return vehicles; + return vehicles.map((vehicle) => + Number.isFinite(vehicle.speedMps) + ? { ...vehicle, speedMps: vehicle.speedMps / 3.6 } + : vehicle, + ); +} + /** * Decode upstream bytes into the JSON snapshot the browser consumes. * @@ -257,7 +276,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - decoded.vehicles, + normalizeFeedSpeeds(feed, decoded.vehicles), decoded.timestamp, Math.floor(now / 1000), ), diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index dea10ab25..445e658f2 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,31 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { assert.equal(snapshot.feedTimestamp, 1_700_000_000); }); +test('a feed that publishes km/h has its speeds converted to m/s, and only that feed', () => { + // Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus + // would be reported at 130 km/h. + const bytes = encodeFeed({ + entities: [ + { id: 'moving', lat: -22.9, lon: -43.2, speed: 36 }, + { id: 'still', lat: -22.9, lon: -43.2, speed: 0 }, + { id: 'silent', lat: -22.9, lon: -43.2 }, + ], + }); + const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1); + const speeds = Object.fromEntries( + rio.vehicles.map((vehicle) => [vehicle.id, vehicle.speedMps]), + ); + assert.ok(Math.abs(speeds.moving - 10) < 1e-6, '36 km/h is 10 m/s'); + assert.equal(speeds.still, 0); + assert.equal(speeds.silent, null, 'an absent speed stays absent'); + const mbta = buildTransitSnapshot(getTransitFeed('mbta'), bytes, 1); + assert.equal( + mbta.vehicles.find((vehicle) => vehicle.id === 'moving').speedMps, + 36, + 'a spec-conforming feed is left alone', + ); +}); + test('conditional-request validators and gzip ride along with feed identification', () => { const entur = getTransitFeed('entur-norway'); const plain = transitUpstreamHeaders(entur); From 53d30c9e80d765b14eb2a2c35a957168cad2bf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:30:47 -0300 Subject: [PATCH 02/11] docs(changelog): add fragment for #1481 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1481-rio-transit.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1481-rio-transit.md diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md new file mode 100644 index 000000000..62b4ede04 --- /dev/null +++ b/changelog.d/1481-rio-transit.md @@ -0,0 +1,4 @@ +- 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. A feed that publishes speed in km/h declares + `speedUnit: 'kmh'` and the proxy converts it to m/s (IMBrandao, #1481). From c68f167acb5b20c692aa0632b5de48c446b05e40 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:07:00 +0400 Subject: [PATCH 03/11] fix(transit): count distinct coverage regions --- src/layers/transit/queries.js | 7 ++++++- src/layers/transit/queries.test.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/layers/transit/queries.test.mjs 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`, + ); +}); From 415f23739f7c81dc9f798fb5107f0e9e1044b2bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:26:55 +0400 Subject: [PATCH 04/11] Name the reporting provider in Contacts and Cockpit counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol, AISStream) while the Layers panel and Contacts named the provider the last snapshot reported, and the vessels layer kept that name out of getStats(), so Contacts always said AISStream. Each cohort now carries the provider and whether it answered as a fallback or from a stale snapshot, and both views show it the way the Layers row does ("adsb.lol · FALLBACK"). Fixes HAL-721. --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 7 ++++ src/layers/awareness/model.js | 1 + src/layers/awareness/panel.js | 3 +- src/layers/awareness/panel.test.mjs | 38 +++++++++++++++++++++ src/layers/awareness/queries.test.mjs | 26 +++++++++++++++ src/layers/awareness/subject.js | 12 +++++++ src/layers/vessels/ownership.test.mjs | 14 ++++++++ src/layers/vessels/queries.js | 2 ++ src/ui/cockpitContext.js | 3 ++ src/ui/cockpitContext.test.mjs | 46 ++++++++++++++++++++++++++ src/ui/cockpitContextState.js | 5 ++- src/ui/layerNames.js | 14 ++++++++ src/ui/layerNames.test.mjs | 17 ++++++++++ src/ui/views/panels/CockpitContext.vue | 8 ++--- 15 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/layers/awareness/panel.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..9f5a40e63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4965,6 +4965,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. +- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6d55213e7..f5529e731 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -871,6 +871,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1713,6 +1714,10 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3208,6 +3213,8 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/feedState.js", + "src/loadingFeedback.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js index 1a399c5af..1ff2c93cf 100644 --- a/src/layers/awareness/model.js +++ b/src/layers/awareness/model.js @@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) { id: cohort.id, label: cohort.label, source: cohort.source, + sourceStatus: cohort.sourceStatus || null, coverage: cohort.coverage || null, relationship: cohort.summary.relationship, count: cohort.summary.count, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index c0c71ed01..5396a0390 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -8,6 +8,7 @@ import { t, translateKnown, } from '../../i18n/index.js'; +import { sourceStatusLabel } from '../../ui/layerNames.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; import { distanceParts } from '../../data/distanceFormat.js'; @@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) { relationship: summary.relationship.toLowerCase(), label: cohort.label, count: `${presentAwarenessCount(summary)}${pageLabel}`, - detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`, + detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`, nearest, }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs new file mode 100644 index 000000000..eaaac70e9 --- /dev/null +++ b/src/layers/awareness/panel.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPanel } from './panel.js'; + +test('a Contacts row names its provider as the Layers panel does, with its feed state', () => { + const cohort = (id, source, sourceStatus) => ({ + id, + label: id, + source, + sourceStatus, + summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' }, + }); + const state = { + enabled: true, + results: { + subject: { label: 'AFR38JF' }, + cohorts: [ + cohort('flights', 'adsb.lol', 'fallback'), + cohort('military', 'adsb.lol', null), + cohort('ais-live-vessels', 'Datalastic', 'stale'), + ], + }, + cohortPages: new Map(), + panelListeners: new Set(), + panelSignature: '', + navigationIndex: 0, + }; + const parts = { + navigation: { canNavigateNext: () => false }, + rendering: { hideDirectionOverlay() {} }, + }; + createPanel({ state, parts }).renderResults(); + assert.deepEqual( + state.panelView.rows.map((row) => row.detail), + ['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'], + ); + state.offPanelLocale?.(); +}); diff --git a/src/layers/awareness/queries.test.mjs b/src/layers/awareness/queries.test.mjs index 04e8a8982..07686dcfc 100644 --- a/src/layers/awareness/queries.test.mjs +++ b/src/layers/awareness/queries.test.mjs @@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj assert.equal(moved.reason, 'mapped matches within 100 km of the subject'); assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 }); }); + +test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => { + const services = { installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } }; + const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source) } }; + parts.queries = createQueries({ state: {}, services, parts }); + const subjectOwner = createSubject({ state: {}, services, parts }); + const states = { + // The OpenSky proxy answers from adsb.lol when OpenSky fails. + flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } }, + military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } }, + 'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } }, + 'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const { cohorts } = subjectOwner.evaluateSubject({ + id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000), + }, states); + const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort])); + assert.equal(byId.flights.source, 'adsb.lol'); + assert.equal(byId.flights.sourceStatus, 'fallback'); + assert.equal(byId.military.sourceStatus, null); + assert.equal(byId['ais-live-vessels'].source, 'Datalastic'); + assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale'); + assert.equal(byId['military-installations'].sourceStatus, null); +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1baf54ec4..a555d332e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -3,6 +3,7 @@ import { AWARENESS_RELATIONSHIP, } from '../../data/militaryAwarenessEngine.js'; import * as Cesium from 'cesium'; +import { layerFeedState } from '../../data/feedState.js'; import { t } from '../../i18n/index.js'; import { AWARENESS_QUERY_LIMIT, @@ -52,6 +53,13 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); + // Whether the provider answered as a fallback or from a stale snapshot. + const sourceStatus = (sourceState) => { + const feedState = layerFeedState(sourceState.stats); + return feedState === 'fallback' || feedState === 'stale' + ? feedState + : null; + }; const retainedProvenance = (id, label, sourceState, fallbackSource) => { const retainedSource = sourceState.stats.source || fallbackSource; return { @@ -94,6 +102,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'flights', label: t('Flights'), source: flightsState.stats.source || SOURCE_LABEL.flights, + sourceStatus: sourceStatus(flightsState), provenance: retainedProvenance( 'flights', 'Flights', @@ -113,6 +122,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'military', label: t('Military flights'), source: militaryState.stats.source || SOURCE_LABEL.military, + sourceStatus: sourceStatus(militaryState), provenance: retainedProvenance( 'military', 'Military flights', @@ -132,6 +142,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'ais-live-vessels', label: t('AIS vessels'), source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], + sourceStatus: sourceStatus(vesselsState), provenance: retainedProvenance( 'ais-live-vessels', 'AIS vessels', @@ -149,6 +160,7 @@ export function createSubject({ state: layerState, services, parts, source }) { source: installationsState.stats.source || SOURCE_LABEL['military-installations'], + sourceStatus: sourceStatus(installationsState), provenance: retainedProvenance( 'military-installations', 'Mapped installations', diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 4ef9b7525..a838a8099 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -409,3 +409,17 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy assert.ok(layer.getStats().error, JSON.stringify(patch)); } }); + +test('vessel stats name the provider the last snapshot reported', async () => { + // HAL-721: Contacts named a fixed AIS provider while this layer showed the + // one its server reported. + const { layer } = setup({ + label: 'Vessels', + async getSnapshot() { + return snapshot([observation('111')], { source: 'Datalastic' }); + }, + }); + assert.equal(layer.getStats().source, 'Vessels'); + await layer.update(); + assert.equal(layer.getStats().source, 'Datalastic'); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..48ded012b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,6 +548,8 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), + // The provider the last snapshot named, as flights and military report it. + source: layer.source, }; }, }; diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..ad73869db 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,7 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; import { formatNumber, t, tn } from '../i18n/index.js'; +import { sourceStatusLabel } from './layerNames.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -110,6 +111,8 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; + // The provider that answered, named as the Layers panel names it. + shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; for (const item of cohort.nearest) nearest.push({ ...item, cohort }); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..a8d6616d5 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t ), ['—', '—', '—', '—'], ); + // No provider is named before a snapshot reports one. + assert.deepEqual( + [...document.querySelectorAll('[data-context-cohort] small')].map( + (source) => source.textContent, + ), + ['—', '—', '—', '—'], + ); assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE'); assert.equal( $('#cockpit-context-nearest-label').hasAttribute('aria-label'), @@ -159,6 +166,45 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => assert.equal(layouts.length, 1); }); +test('each count names the provider its snapshot reports, with its feed state', async (t) => { + // HAL-721: the counters named fixed providers while the layer and the + // Contacts list named the one that answered. + const reported = snapshot(); + reported.cohorts[0].source = 'adsb.lol'; + reported.cohorts[0].sourceStatus = 'fallback'; + reported.cohorts[1].source = 'Plane Finder'; + reported.cohorts.push( + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: 'Datalastic', + sourceStatus: 'stale', + count: 3, + relationship: 'OBSERVED', + nearest: [], + }, + { + id: 'military-installations', + label: 'Mapped installations', + source: 'OpenStreetMap', + sourceStatus: null, + count: 0, + relationship: 'MAPPED', + nearest: [], + }, + ); + const { $, owner } = fixture(t, reported); + updateContext.call(owner, INFO, 0); + await nextTick(); + const source = (id) => $(`[data-context-cohort="${id}"] small`); + assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK'); + assert.equal(source('military').textContent, 'Plane Finder'); + assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE'); + assert.equal(source('military-installations').textContent, 'OpenStreetMap'); + // The tile ellipsizes; the full name stays readable on hover. + assert.equal(source('flights').title, 'adsb.lol · FALLBACK'); +}); + test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => { const { $, text, owner, layouts } = fixture(t, snapshot()); updateContext.call(owner, INFO, 0); diff --git a/src/ui/cockpitContextState.js b/src/ui/cockpitContextState.js index dafffc11f..3c2db3c5a 100644 --- a/src/ui/cockpitContextState.js +++ b/src/ui/cockpitContextState.js @@ -25,7 +25,10 @@ export function createContextState() { canPrevious: true, canNext: true, cohorts: Object.fromEntries( - COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]), + COCKPIT_CONTEXT_COHORTS.map((id) => [ + id, + { count: '—', unknown: false, source: '—' }, + ]), ), nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null }, distance: { text: '—', ariaLabel: null }, diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 2c918ad95..519b3e207 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -137,3 +137,17 @@ export function sourceLabel(source) { ? source.split(' · ').map(translateKnown).join(' · ') : translateKnown(source); } + +/** + * A provider name with the feed state it answered in, so a fallback or stale + * snapshot does not read as the live primary feed. + * @param {string} source English source name. + * @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`. + * @returns {string} + */ +export function sourceStatusLabel(source, status) { + const name = sourceLabel(source); + if (status === 'fallback') return `${name} · ${t('FALLBACK')}`; + if (status === 'stale') return `${name} · ${t('STALE')}`; + return name; +} diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs index df15c79bf..e1815e477 100644 --- a/src/ui/layerNames.test.mjs +++ b/src/ui/layerNames.test.mjs @@ -7,6 +7,7 @@ import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS, sourceLabel, + sourceStatusLabel, } from './layerNames.js'; // Layers whose module name is a proper name and stays as it is in every @@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) { } }); } + +test('a provider name carries the fallback or stale state it answered in', () => { + assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder'); + assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK'); + assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE'); + setLocale('ru'); + try { + assert.equal( + sourceStatusLabel('adsb.lol', 'fallback'), + `adsb.lol · ${t('FALLBACK')}`, + ); + assert.notEqual(t('FALLBACK'), 'FALLBACK'); + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/views/panels/CockpitContext.vue b/src/ui/views/panels/CockpitContext.vue index ed6df8362..2374deb3e 100644 --- a/src/ui/views/panels/CockpitContext.vue +++ b/src/ui/views/panels/CockpitContext.vue @@ -49,10 +49,10 @@ const weatherLabel = computed(() => {{ t('CONTEXT ONLY') }}
-
{{ t('FLT') }}{{ c.cohorts.flights.count }}OpenSky
-
{{ t('MIL') }}{{ c.cohorts.military.count }}adsb.lol
-
AIS{{ c.cohorts['ais-live-vessels'].count }}AISStream
-
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}OpenStreetMap
+
{{ t('FLT') }}{{ c.cohorts.flights.count }}{{ c.cohorts.flights.source }}
+
{{ t('MIL') }}{{ c.cohorts.military.count }}{{ c.cohorts.military.source }}
+
AIS{{ c.cohorts['ais-live-vessels'].count }}{{ c.cohorts['ais-live-vessels'].source }}
+
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}{{ c.cohorts['military-installations'].source }}
From 490c8d1769cc96fd9c93c4daff7986028e9a02e7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:27:25 +0400 Subject: [PATCH 05/11] Add the changelog entry for #1521 --- changelog.d/1521-provider-labels.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1521-provider-labels.md diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md new file mode 100644 index 000000000..d921d9d7b --- /dev/null +++ b/changelog.d/1521-provider-labels.md @@ -0,0 +1,4 @@ +- Name the provider that answered in the Cockpit Contact counts and the + Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when + it is. The counts no longer read OpenSky while flights come from the adsb.lol + fallback, and vessels no longer always read AISStream (German, @gekh, #1521). From 99831ea19b2ff5b9fff9d40e26a11422e277bb99 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 16:34:39 +0400 Subject: [PATCH 06/11] Keep STALE on Contacts while a poll is in flight A poll that returned the same stale snapshot dropped STALE from the Contacts cohort until the next answer, because the in-flight loading flag outranked stale and the source revision ignored the provider name. A source that has answered now reads its status without the loading flag; one that has never answered (AIS first connect) still reads as loading, so it shows no STALE before any snapshot exists. --- src/layers/awareness/queries.js | 1 + .../awareness/staleAfterLoading.test.mjs | 159 ++++++++++++++++++ src/layers/awareness/subject.js | 8 +- src/layers/vessels/ownership.test.mjs | 2 - src/ui/cockpitContext.test.mjs | 2 - 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/layers/awareness/staleAfterLoading.test.mjs diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js index 593c85ebf..0c5bd78f3 100644 --- a/src/layers/awareness/queries.js +++ b/src/layers/awareness/queries.js @@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) { stats.count ?? null, stats.status || null, stats.error || null, + stats.source || null, ]; }) .map((parts) => parts.join(':')) diff --git a/src/layers/awareness/staleAfterLoading.test.mjs b/src/layers/awareness/staleAfterLoading.test.mjs new file mode 100644 index 000000000..7128f4958 --- /dev/null +++ b/src/layers/awareness/staleAfterLoading.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createQueries } from './queries.js'; +import { createSubject } from './subject.js'; +import { createModel } from './model.js'; +import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js'; +import { layerFeedState } from '../../data/feedState.js'; +import aisLiveVesselsLayer, { + _setVesselStateForTest, + _beginAisSessionForTest, + _applyAisFeedSnapshotForTest, + _setAisRuntimeForTest, +} from '../../data/aisLiveVessels.js'; + +test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => { + globalThis.document ??= { body: null }; + const services = { + installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: { getNearby: () => [] }, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + let states; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + const realQueries = createQueries({ state: layerState, services, parts }); + parts.queries = { ...realQueries, collectSourceStates: () => states }; + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + const flights = (lastUpdate) => ({ + available: true, enabled: true, stale: false, + stats: { source: 'OpenSky Network', count: 4, lastUpdate }, + }); + const vessels = (loading) => ({ + available: true, enabled: true, stale: true, + stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading }, + }); + const rest = { + military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } }, + 'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + + // 1. Stale AIS cache, nothing loading. + states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(true); + assert.equal(ais().sourceStatus, 'stale'); + + // 2. AIS re-poll in flight while the flights poll lands (revision changes). + states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest }; + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale'); + + // 3. AIS poll returns the same stale snapshot; only loading flips back. + states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(); + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too'); +}); + +function fakeRuntime() { + return { + now: () => 1000, + setTimeout: () => ({}), + clearTimeout() {}, + }; +} + +test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => { + globalThis.document ??= { body: null }; + _setAisRuntimeForTest(fakeRuntime()); + _setVesselStateForTest({ viewer: {}, records: [] }); + try { + _beginAisSessionForTest(); + // What server/providers/vessels/ais-live.js answers while its socket connects. + _applyAisFeedSnapshotForTest({}, { + rows: [], source: 'AISStream', status: 'connecting', refreshing: true, + }); + const vesselStats = aisLiveVesselsLayer.getStats(); + assert.equal(vesselStats.loading, true); + assert.equal(vesselStats.stale, true); + assert.equal(vesselStats.lastUpdate, null); + assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING'); + + const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 }; + const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 }; + const installStats = { coverage: { kind: 'subject', radiusM: 100000 } }; + const statsById = { + flights: flightsStats, + military: militaryStats, + 'ais-live-vessels': vesselStats, + 'military-installations': installStats, + }; + const services = { + installations: { getNearby: () => [], getStats: () => installStats }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: aisLiveVesselsLayer, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + dataManager: { + isEnabled: () => true, + getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }), + layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])), + }, + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + parts.queries = createQueries({ state: layerState, services, parts }); + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + + const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels']; + assert.equal(vesselsSource.available, false, 'never-answered source is unavailable'); + + subject.refreshSelectedSubject(true); + const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING'); + } finally { + _setVesselStateForTest({ enabled: false }); + _setAisRuntimeForTest(); + } +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index a555d332e..32123d248 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -54,8 +54,14 @@ export function createSubject({ state: layerState, services, parts, source }) { const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); // Whether the provider answered as a fallback or from a stale snapshot. + // A poll in flight does not change that, and the source revision ignores it. + // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { - const feedState = layerFeedState(sourceState.stats); + const feedState = layerFeedState( + sourceState.available + ? { ...sourceState.stats, loading: false } + : sourceState.stats, + ); return feedState === 'fallback' || feedState === 'stale' ? feedState : null; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index a838a8099..2404434a0 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -411,8 +411,6 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy }); test('vessel stats name the provider the last snapshot reported', async () => { - // HAL-721: Contacts named a fixed AIS provider while this layer showed the - // one its server reported. const { layer } = setup({ label: 'Vessels', async getSnapshot() { diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index a8d6616d5..c2f20abcf 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -167,8 +167,6 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => }); test('each count names the provider its snapshot reports, with its feed state', async (t) => { - // HAL-721: the counters named fixed providers while the layer and the - // Contacts list named the one that answered. const reported = snapshot(); reported.cohorts[0].source = 'adsb.lol'; reported.cohorts[0].sourceStatus = 'fallback'; From 55e6c0017c79215220421c6a727742ec8005839d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 18:15:16 +0400 Subject: [PATCH 07/11] Tighten the provider-label comments and docs --- changelog.d/1521-provider-labels.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/subject.js | 3 +-- src/layers/vessels/queries.js | 2 +- src/ui/cockpitContext.js | 1 - 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md index d921d9d7b..887d6a775 100644 --- a/changelog.d/1521-provider-labels.md +++ b/changelog.d/1521-provider-labels.md @@ -1,4 +1,4 @@ -- Name the provider that answered in the Cockpit Contact counts and the - Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when - it is. The counts no longer read OpenSky while flights come from the adsb.lol - fallback, and vessels no longer always read AISStream (German, @gekh, #1521). +- Cockpit Contact counts and the Contacts list name the provider that + answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from + the adsb.lol fallback no longer read OpenSky, and vessels no longer always + read AISStream (German, @gekh, #1521). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e845b1472..c6abbf74a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5112,7 +5112,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. +- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`. - The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 32123d248..60f622e29 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -53,8 +53,7 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); - // Whether the provider answered as a fallback or from a stale snapshot. - // A poll in flight does not change that, and the source revision ignores it. + // A poll in flight keeps FALLBACK or STALE: the revision key ignores loading. // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { const feedState = layerFeedState( diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 48ded012b..d4d9ef796 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,7 +548,7 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), - // The provider the last snapshot named, as flights and military report it. + // Same field as flights and military, so Contacts names the provider. source: layer.source, }; }, diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index ad73869db..6dc209977 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -111,7 +111,6 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; - // The provider that answered, named as the Layers panel names it. shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; From ad48305dac0f4f008a0b94da7db91e6169faf771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:49:54 -0300 Subject: [PATCH 08/11] feat(transit): name Rio routes from SMTR's published GTFS SMTR's realtime feeds carry internal route keys, so a Rio card read "Route O0731AAN0A" where riders know line SN731. Join both feeds to the route_short_name in SMTR's static GTFS routes.txt (CC BY 4.0, same publisher and terms). The keys cannot be parsed: O0014AAA0A is LECD135. A 2026-11-04 snapshot found all 282 bus and 21 BRT routes in service in the table; an unknown key still falls through raw. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 2 +- changelog.d/1481-rio-transit.md | 4 +- src/data/rioRouteNames.js | 515 ++++++++++++++++++++++++++++++++ src/data/transitFeeds.js | 19 +- src/data/transitFeeds.test.mjs | 31 ++ 5 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 src/data/rioRouteNames.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 743d83978..2ed14c264 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,7 +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. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are shown raw. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **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. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | 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)" | diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md index 62b4ede04..b45b1b1fd 100644 --- a/changelog.d/1481-rio-transit.md +++ b/changelog.d/1481-rio-transit.md @@ -1,4 +1,6 @@ - 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. A feed that publishes speed in km/h declares - `speedUnit: 'kmh'` and the proxy converts it to m/s (IMBrandao, #1481). + `speedUnit: 'kmh'` and the proxy converts it to m/s. 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/src/data/rioRouteNames.js b/src/data/rioRouteNames.js new file mode 100644 index 000000000..68a7da7a8 --- /dev/null +++ b/src/data/rioRouteNames.js @@ -0,0 +1,515 @@ +// src/data/rioRouteNames.js +/** + * Rio de Janeiro route ids to the line a rider would say. + * + * SMTR's realtime feeds publish internal route keys: the bus feed sends + * `O0868AAA0A` for line 868 and `O0731AAN0A` for SN731, and the BRT feed + * sends `20000501110` for line 50. The keys look decodable but are not: + * `O0014AAA0A` is LECD135, not 14, and nineteen of the plain-looking keys in + * the published table break the pattern. The join is the only correct route. + * + * Generated verbatim (`route_id` to `route_short_name`, 494 routes) from + * `routes.txt` in SMTR's static GTFS, https://dados.mobilidade.rio/gtfs/schedule + * (CC BY 4.0, the same publisher and terms as the realtime feeds; file dated + * 2026-06-10, feed valid 2024-01-01 to 2026-12-31). A 2026-11-04 snapshot of + * both realtime feeds, 282 bus and 21 BRT routes in service, found every one + * of its route ids here. An id missing from this table falls through + * unchanged, so a route added later degrades to its raw key rather than + * disappearing. + */ +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 e871a2e89..d7993a954 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, for both SMTR feeds: one + * published `routes.txt` covers the buses and the BRT (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. @@ -752,10 +765,11 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ 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. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys: `O0313AAA0A` carried service 313 in the same day’s JSON feed. They are shown raw until a published table is joined.', + 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. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', }), defaultMode: 'bus', speedUnit: 'kmh', + routeLabel: rioRouteLabel, }), Object.freeze({ id: 'smtr-rio-brt', @@ -774,10 +788,11 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ 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 km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000461130`) and are shown raw.', + 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 km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', }), defaultMode: 'bus', speedUnit: 'kmh', + routeLabel: rioRouteLabel, }), ]); diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 51903b7db..8333207d5 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(); @@ -504,6 +505,36 @@ test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São P ); }); +test('Rio route keys read as the line on the front of the bus', () => { + // SMTR publishes internal keys. Plain keys carry the number (`O0868AAA0A` + // is 868), variants carry a prefix (`O0731AAN0A` is SN731), and some plain + // keys are traps: `O0014AAA0A` is LECD135, so the table is joined, not parsed. + 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'); + // A route added after the table was built keeps its raw key, never a blank + // or a name borrowed from the prototype chain. + 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, live, and declare the km/h speeds they publish', () => { for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { const feed = getTransitFeed(id); From a67c7969fdc52449dfeeef59b495772ab563b7a6 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:48:11 +0400 Subject: [PATCH 09/11] fix(transit): read Rio speeds as the m/s SMTR publishes Both SMTR feeds now publish Position.speed in m/s. Compared with how far vehicles moved between reports on 2026-11-05, the published values ran 3.1x (buses) and 3.6x (BRT) below the observed speed, so the km/h conversion divided real speeds by 3.6. Drop speedUnit and the conversion. --- DATA_SOURCES.md | 2 +- changelog.d/1481-rio-transit.md | 7 +++---- docs/CURRENT-STATE.md | 3 +-- src/data/transitFeeds.js | 11 ++--------- src/data/transitFeeds.test.mjs | 3 +-- src/data/transitProxy.js | 21 +-------------------- src/data/transitProxy.test.mjs | 25 +++++-------------------- 7 files changed, 14 insertions(+), 58 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 2ed14c264..eb20f793e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,7 +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. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **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. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | 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)" | diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md index b45b1b1fd..ac245a9c8 100644 --- a/changelog.d/1481-rio-transit.md +++ b/changelog.d/1481-rio-transit.md @@ -1,6 +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. A feed that publishes speed in km/h declares - `speedUnit: 'kmh'` and the proxy converts it to m/s. 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). + 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 81e71655c..170605951 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4649,8 +4649,7 @@ 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. Rio's feeds publish speed in km/h, which -the proxy converts to m/s before any card reads it. Mode silhouettes +Burlington, Barrie and Kingston. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index d7993a954..aa953c00b 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -398,12 +398,7 @@ function rioRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, - * speedUnit?: 'kmh', * }>>} - * - * `speedUnit: 'kmh'` marks a feed that publishes `Position.speed` in km/h - * instead of the m/s the spec requires; the proxy converts it before anything - * downstream reads `speedMps`. */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -765,10 +760,9 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ 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. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', + 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. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. 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: on 2026-11-05 they matched how far vehicles moved between reports, though a 2026-10-29 snapshot read as km/h. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', }), defaultMode: 'bus', - speedUnit: 'kmh', routeLabel: rioRouteLabel, }), Object.freeze({ @@ -788,10 +782,9 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ 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 km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', + 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 here too. Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', }), defaultMode: 'bus', - speedUnit: 'kmh', routeLabel: rioRouteLabel, }), ]); diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 8333207d5..dbae35c24 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -535,14 +535,13 @@ test('the Rio route table is a clean join of published keys to names', () => { } }); -test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { +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(feed.speedUnit, 'kmh', `${id} publishes km/h`); assert.equal(transitModeFor(feed, null), 'bus'); } const sources = readFileSync( diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 2923c9c32..458240e9d 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -224,25 +224,6 @@ export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) { }); } -/** - * Bring a feed's speeds to the m/s the spec requires. A feed registered with - * `speedUnit: 'kmh'` publishes km/h in `Position.speed`, and read as m/s a - * 44 km/h bus would be reported at 158 km/h. Converting here, once, keeps every - * consumer of `speedMps` honest without each one knowing about the feed. - * - * @param {object} feed Registry entry. - * @param {object[]} vehicles Normalized vehicle records. - * @returns {object[]} - */ -export function normalizeFeedSpeeds(feed, vehicles) { - if (feed?.speedUnit !== 'kmh') return vehicles; - return vehicles.map((vehicle) => - Number.isFinite(vehicle.speedMps) - ? { ...vehicle, speedMps: vehicle.speedMps / 3.6 } - : vehicle, - ); -} - /** * Decode upstream bytes into the JSON snapshot the browser consumes. * @@ -276,7 +257,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - normalizeFeedSpeeds(feed, decoded.vehicles), + decoded.vehicles, decoded.timestamp, Math.floor(now / 1000), ), diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index 445e658f2..07911a939 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -285,29 +285,14 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { assert.equal(snapshot.feedTimestamp, 1_700_000_000); }); -test('a feed that publishes km/h has its speeds converted to m/s, and only that feed', () => { - // Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus - // would be reported at 130 km/h. +test('Rio speeds pass through as the m/s they are published in', () => { + // SMTR publishes m/s, as the spec requires: checked against how far + // vehicles moved between reports on 2026-11-05. const bytes = encodeFeed({ - entities: [ - { id: 'moving', lat: -22.9, lon: -43.2, speed: 36 }, - { id: 'still', lat: -22.9, lon: -43.2, speed: 0 }, - { id: 'silent', lat: -22.9, lon: -43.2 }, - ], + entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }], }); const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1); - const speeds = Object.fromEntries( - rio.vehicles.map((vehicle) => [vehicle.id, vehicle.speedMps]), - ); - assert.ok(Math.abs(speeds.moving - 10) < 1e-6, '36 km/h is 10 m/s'); - assert.equal(speeds.still, 0); - assert.equal(speeds.silent, null, 'an absent speed stays absent'); - const mbta = buildTransitSnapshot(getTransitFeed('mbta'), bytes, 1); - assert.equal( - mbta.vehicles.find((vehicle) => vehicle.id === 'moving').speedMps, - 36, - 'a spec-conforming feed is left alone', - ); + assert.equal(rio.vehicles[0].speedMps, 10); }); test('conditional-request validators and gzip ride along with feed identification', () => { From 70f405cf1b448e1543b4df527f15a35d556e55f2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:48:11 +0400 Subject: [PATCH 10/11] chore(transit): register rioRouteNames.js in the package and format scopes --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 8 ++++++++ 2 files changed, 9 insertions(+) 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 d008ceaaa..a70878bd1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2954,6 +2954,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", @@ -3704,6 +3705,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", @@ -4465,6 +4467,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", @@ -4481,6 +4484,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" @@ -4514,6 +4518,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", @@ -4572,6 +4577,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", @@ -4629,6 +4635,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", @@ -4873,6 +4880,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", From b5cef8daf4c3518340cd8d76fc1e318308a1b818 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:29:21 +0400 Subject: [PATCH 11/11] Tighten the Rio comments, notes and credits Name each Rio feed in its credit instead of repeating the licence, drop the deprecation history from the bus note, list Rio among the feeds with a route-name table, and shorten the route-table header and test comments. --- DATA_SOURCES.md | 4 ++-- src/data/rioRouteNames.js | 20 +++++--------------- src/data/transitFeeds.js | 13 +++++++------ src/data/transitFeeds.test.mjs | 7 ++----- src/data/transitProxy.test.mjs | 3 +-- 5 files changed, 17 insertions(+), 30 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index eb20f793e..ed0895b0a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,7 +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. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **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)" | @@ -186,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 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 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/src/data/rioRouteNames.js b/src/data/rioRouteNames.js index 68a7da7a8..1341f873a 100644 --- a/src/data/rioRouteNames.js +++ b/src/data/rioRouteNames.js @@ -1,21 +1,11 @@ -// src/data/rioRouteNames.js /** * Rio de Janeiro route ids to the line a rider would say. * - * SMTR's realtime feeds publish internal route keys: the bus feed sends - * `O0868AAA0A` for line 868 and `O0731AAN0A` for SN731, and the BRT feed - * sends `20000501110` for line 50. The keys look decodable but are not: - * `O0014AAA0A` is LECD135, not 14, and nineteen of the plain-looking keys in - * the published table break the pattern. The join is the only correct route. - * - * Generated verbatim (`route_id` to `route_short_name`, 494 routes) from - * `routes.txt` in SMTR's static GTFS, https://dados.mobilidade.rio/gtfs/schedule - * (CC BY 4.0, the same publisher and terms as the realtime feeds; file dated - * 2026-06-10, feed valid 2024-01-01 to 2026-12-31). A 2026-11-04 snapshot of - * both realtime feeds, 282 bus and 21 BRT routes in service, found every one - * of its route ids here. An id missing from this table falls through - * unchanged, so a route added later degrades to its raw key rather than - * disappearing. + * 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', diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index aa953c00b..48ca450a1 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -378,8 +378,8 @@ function burlingtonRouteLabel(routeId) { } /** - * Rio route key to the line a rider would say, for both SMTR feeds: one - * published `routes.txt` covers the buses and the BRT (see rioRouteNames.js). + * 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} */ @@ -755,12 +755,12 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ license: 'CC BY 4.0', licenseUrl: 'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f', - attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + 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. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. 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: on 2026-11-05 they matched how far vehicles moved between reports, though a 2026-10-29 snapshot read as km/h. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', + 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, @@ -777,12 +777,13 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ license: 'CC BY 4.0', licenseUrl: 'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4', - attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + 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 here too. Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', + 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 dbae35c24..d6dc93eb6 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -506,9 +506,7 @@ test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São P }); test('Rio route keys read as the line on the front of the bus', () => { - // SMTR publishes internal keys. Plain keys carry the number (`O0868AAA0A` - // is 868), variants carry a prefix (`O0731AAN0A` is SN731), and some plain - // keys are traps: `O0014AAA0A` is LECD135, so the table is joined, not parsed. + // 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'); @@ -518,8 +516,7 @@ test('Rio route keys read as the line on the front of the bus', () => { const brt = getTransitFeed('smtr-rio-brt'); assert.equal(transitRouteLabel(brt, '20000501110'), '50'); assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01'); - // A route added after the table was built keeps its raw key, never a blank - // or a name borrowed from the prototype chain. + // 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); diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index 07911a939..f03ea2e30 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -286,8 +286,7 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { }); test('Rio speeds pass through as the m/s they are published in', () => { - // SMTR publishes m/s, as the spec requires: checked against how far - // vehicles moved between reports on 2026-11-05. + // Checked against vehicle movement on 2026-11-05. const bytes = encodeFeed({ entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }], });