From 830c08080383a8a89e9b91e692fec92f2c57ceb2 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 11 Nov 2026 14:11:10 +0100 Subject: [PATCH 1/4] feat(rail): add a Rail layer with keyless live trains Live trains from 30 keyless operator feeds: GPS positions from 29 and Irish Rail's station reports, shown as the operators publish them. 28 work out of the box; Renfe long and medium distance and KTMB appear once their pack is built, because their vehicles name no route. Packs are opt-in (scripts/build-rail-paths.mjs): the operator's own route lines, stop names, next stops and trip-update delays for live trains. Nothing downloads when the app starts, no train is placed from a timetable, and no request carries a deployer's key. --- .env.example | 8 + .gitignore | 2 + DATA_SOURCES.md | 34 +- README.md | 1 + SECURITY.md | 1 + changelog.d/0-rail-layer.md | 24 + docs/CURRENT-STATE.md | 276 +- package.json | 5 + scripts/build-rail-paths.mjs | 2356 ++++++++++++++++++ scripts/format-scope.json | 39 + scripts/package-boundaries.json | 99 + server/providers/local.js | 2 + server/providers/rail.js | 285 +++ src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 4 +- src/app/layers/rail.js | 50 + src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 17 +- src/data/dataCredits.js | 18 + src/data/dataCredits.test.mjs | 9 + src/data/irishRail.js | 257 ++ src/data/irishRail.test.mjs | 193 ++ src/data/layerState.js | 13 + src/data/layerState.test.mjs | 12 +- src/data/layerStateTokenLedger.test.mjs | 30 +- src/data/layerStateTokenReservations.json | 1 + src/data/railFeeds.js | 1632 ++++++++++++ src/data/railFeeds.test.mjs | 474 ++++ src/data/railFeedsAsia.test.mjs | 63 + src/data/railFeedsDocs.test.mjs | 97 + src/data/railFeedsFrance.test.mjs | 86 + src/data/railFeedsNational.test.mjs | 103 + src/data/railFeedsUs.test.mjs | 46 + src/data/railFeedsUsNoTerms.test.mjs | 62 + src/data/railFeedsUsUrban.test.mjs | 57 + src/data/railIreland.test.mjs | 134 + src/data/railNoPositions.js | 1092 ++++++++ src/data/railNoPositions.test.mjs | 297 +++ src/data/railPaths.js | 288 +++ src/data/railPaths.test.mjs | 163 ++ src/data/railProxy.js | 518 ++++ src/data/railProxy.test.mjs | 586 +++++ src/data/railSchedule.js | 741 ++++++ src/data/railSchedule.test.mjs | 444 ++++ src/data/railScheduleIndex.js | 124 + src/data/railSiriVm.js | 188 ++ src/data/railSiriVm.test.mjs | 182 ++ src/data/railTimeZones.js | 174 ++ src/data/railTripUpdates.js | 278 +++ src/data/railTripUpdates.test.mjs | 262 ++ src/data/spriteOrder.js | 1 + src/i18n/locales/ar.json | 15 + src/i18n/locales/de.json | 15 + src/i18n/locales/es.json | 15 + src/i18n/locales/fr.json | 15 + src/i18n/locales/he.json | 15 + src/i18n/locales/it.json | 15 + src/i18n/locales/ja.json | 15 + src/i18n/locales/pt.json | 15 + src/i18n/locales/ru.json | 15 + src/i18n/locales/sv.json | 15 + src/i18n/locales/uk.json | 15 + src/i18n/locales/zh-CN.json | 15 + src/i18n/locales/zh-Hant.json | 15 + src/layers/rail/controls.js | 202 ++ src/layers/rail/dedupe.js | 394 +++ src/layers/rail/dedupe.test.mjs | 364 +++ src/layers/rail/follow.js | 230 ++ src/layers/rail/follow.test.mjs | 454 ++++ src/layers/rail/groundHeight.test.mjs | 499 ++++ src/layers/rail/index.js | 196 ++ src/layers/rail/ingestion.js | 527 ++++ src/layers/rail/layerStats.test.mjs | 101 + src/layers/rail/lifecycle.js | 170 ++ src/layers/rail/liveOnly.test.mjs | 386 +++ src/layers/rail/pathBinding.js | 243 ++ src/layers/rail/pathBinding.test.mjs | 203 ++ src/layers/rail/policy.js | 577 +++++ src/layers/rail/policy.test.mjs | 521 ++++ src/layers/rail/rendering.js | 724 ++++++ src/layers/rail/routeDisplay.js | 788 ++++++ src/layers/rail/routeLines.js | 335 +++ src/layers/rail/routeLines.test.mjs | 331 +++ src/layers/rail/schedules.js | 518 ++++ src/layers/rail/schedules.test.mjs | 593 +++++ src/layers/rail/selection.js | 169 ++ src/layers/rail/source.js | 68 + src/layers/rail/source.test.mjs | 205 ++ src/layers/rail/stopLabels.js | 275 ++ src/layers/rail/stopLabels.test.mjs | 275 ++ src/layers/rail/viewport.js | 224 ++ src/navigationPolicy.js | 1 + src/share/viewCodec.js | 7 + src/sources/railService.js | 968 +++++++ src/standalone/layerSources.js | 2 + src/testSupport/railLayerHarness.mjs | 409 +++ src/tooling/buildRailPaths.test.mjs | 682 +++++ src/tooling/buildRailPathsCli.test.mjs | 305 +++ src/tooling/buildRailPathsLocal.test.mjs | 182 ++ src/tooling/buildRailPathsRoutes.test.mjs | 143 ++ src/tooling/buildRailPathsTripEnds.test.mjs | 162 ++ src/tooling/buildRailPathsUnlocated.test.mjs | 199 ++ src/tooling/buildRailPathsWrite.test.mjs | 318 +++ src/tooling/railCatalogPacks.test.mjs | 235 ++ src/tooling/railService.test.mjs | 877 +++++++ src/tooling/railServiceFaults.test.mjs | 214 ++ src/tooling/railServicePacks.test.mjs | 337 +++ src/tooling/transitQa.test.mjs | 11 +- src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 1 + src/ui/layerPanel.test.mjs | 4 +- src/voice/layerManifest.js | 1 + src/worldFocus.js | 3 + src/worldFocus.test.mjs | 5 + 114 files changed, 26639 insertions(+), 26 deletions(-) create mode 100644 changelog.d/0-rail-layer.md create mode 100644 scripts/build-rail-paths.mjs create mode 100644 server/providers/rail.js create mode 100644 src/app/layers/rail.js create mode 100644 src/data/irishRail.js create mode 100644 src/data/irishRail.test.mjs create mode 100644 src/data/railFeeds.js create mode 100644 src/data/railFeeds.test.mjs create mode 100644 src/data/railFeedsAsia.test.mjs create mode 100644 src/data/railFeedsDocs.test.mjs create mode 100644 src/data/railFeedsFrance.test.mjs create mode 100644 src/data/railFeedsNational.test.mjs create mode 100644 src/data/railFeedsUs.test.mjs create mode 100644 src/data/railFeedsUsNoTerms.test.mjs create mode 100644 src/data/railFeedsUsUrban.test.mjs create mode 100644 src/data/railIreland.test.mjs create mode 100644 src/data/railNoPositions.js create mode 100644 src/data/railNoPositions.test.mjs create mode 100644 src/data/railPaths.js create mode 100644 src/data/railPaths.test.mjs create mode 100644 src/data/railProxy.js create mode 100644 src/data/railProxy.test.mjs create mode 100644 src/data/railSchedule.js create mode 100644 src/data/railSchedule.test.mjs create mode 100644 src/data/railScheduleIndex.js create mode 100644 src/data/railSiriVm.js create mode 100644 src/data/railSiriVm.test.mjs create mode 100644 src/data/railTimeZones.js create mode 100644 src/data/railTripUpdates.js create mode 100644 src/data/railTripUpdates.test.mjs create mode 100644 src/layers/rail/controls.js create mode 100644 src/layers/rail/dedupe.js create mode 100644 src/layers/rail/dedupe.test.mjs create mode 100644 src/layers/rail/follow.js create mode 100644 src/layers/rail/follow.test.mjs create mode 100644 src/layers/rail/groundHeight.test.mjs create mode 100644 src/layers/rail/index.js create mode 100644 src/layers/rail/ingestion.js create mode 100644 src/layers/rail/layerStats.test.mjs create mode 100644 src/layers/rail/lifecycle.js create mode 100644 src/layers/rail/liveOnly.test.mjs create mode 100644 src/layers/rail/pathBinding.js create mode 100644 src/layers/rail/pathBinding.test.mjs create mode 100644 src/layers/rail/policy.js create mode 100644 src/layers/rail/policy.test.mjs create mode 100644 src/layers/rail/rendering.js create mode 100644 src/layers/rail/routeDisplay.js create mode 100644 src/layers/rail/routeLines.js create mode 100644 src/layers/rail/routeLines.test.mjs create mode 100644 src/layers/rail/schedules.js create mode 100644 src/layers/rail/schedules.test.mjs create mode 100644 src/layers/rail/selection.js create mode 100644 src/layers/rail/source.js create mode 100644 src/layers/rail/source.test.mjs create mode 100644 src/layers/rail/stopLabels.js create mode 100644 src/layers/rail/stopLabels.test.mjs create mode 100644 src/layers/rail/viewport.js create mode 100644 src/sources/railService.js create mode 100644 src/testSupport/railLayerHarness.mjs create mode 100644 src/tooling/buildRailPaths.test.mjs create mode 100644 src/tooling/buildRailPathsCli.test.mjs create mode 100644 src/tooling/buildRailPathsLocal.test.mjs create mode 100644 src/tooling/buildRailPathsRoutes.test.mjs create mode 100644 src/tooling/buildRailPathsTripEnds.test.mjs create mode 100644 src/tooling/buildRailPathsUnlocated.test.mjs create mode 100644 src/tooling/buildRailPathsWrite.test.mjs create mode 100644 src/tooling/railCatalogPacks.test.mjs create mode 100644 src/tooling/railService.test.mjs create mode 100644 src/tooling/railServiceFaults.test.mjs create mode 100644 src/tooling/railServicePacks.test.mjs diff --git a/.env.example b/.env.example index 573c5fab4..18b7c759e 100644 --- a/.env.example +++ b/.env.example @@ -352,3 +352,11 @@ GEMINI_CHAT_MODEL=gemini-3.5-flash-lite # the free plan). After that Local says the daily limit is reached. # Raise it on a paid plan. # WORLDNEWSAPI_DAILY_READS=30 + +# Rail layer: every feed is keyless, so nothing needs setting. Packs are an +# opt-in extra for live trains (the operator's lines, stop names, next stops): +# scripts/build-rail-paths.mjs (--feed ... or --all) writes path and +# schedule packs, and the server reads them from this directory +# (default .cache/rail-paths). The build reads it from its own environment, +# not from this file: export it, or pass the same folder as --out. +# RAIL_PATHS_DIR= diff --git a/.gitignore b/.gitignore index 11e081e7d..2d63faed0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ qa-shots/ output/ .gev-logs/ .gev-cache/ +# Local build output, e.g. rail path packs (scripts/build-rail-paths.mjs). +.cache/ # Browser QA gate evidence (screenshots, console and network dumps). qa-artifacts/ # Playwright MCP session logs: browser consoles can carry tokens in URLs. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 9b8d0d6b6..1398e52f4 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -110,6 +110,36 @@ How to read this: | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **Metro-North** (api-endpoint.mta.info, rrgtfsfeeds.s3.amazonaws.com) | Rail layer trains, New York, NY: Metro-North Railroad (MTA). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api-endpoint.mta.info) add delays at its stops. | [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions): "Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server." This proxy is that server: browsers never contact MTA. The feed stamps every vehicle with the header time, so a fix is aged from the snapshot, not from the train. | "Trains (MTA): [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions)" | +| **LIRR** (api-endpoint.mta.info, rrgtfsfeeds.s3.amazonaws.com) | Rail layer trains, New York, NY: Long Island Rail Road (MTA). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api-endpoint.mta.info) add delays at its stops. | [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions): "Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server." The feed was seen frozen for two days on 2026-11-06; the layer ages trains by their own report time, so a frozen feed empties instead of showing old trains as live. | "Trains (MTA): [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions)" | +| **MBTA rail** (cdn.mbta.com) | Rail layer trains, Boston, MA: Massachusetts Bay Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (cdn.mbta.com) add delays at its stops. | [MassDOT Developers License Agreement](https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf): "MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data." The same feed the Transit layer polls; this layer keeps only Commuter Rail, rapid transit and light rail. | "Trains (MBTA / MassDOT): [MassDOT Developers License Agreement](https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf)" | +| **SEPTA Regional Rail** (www3.septa.org, github.com) | Rail layer trains, Philadelphia, PA: Southeastern Pennsylvania Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (www3.septa.org) add delays at its stops. | [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/): "non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data". The agreement names the static data; SEPTA publishes the realtime feeds on the same developer page with no separate terms. Credit is text only: SEPTA trademarks may not be used. | "Trains (SEPTA): [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/)" | +| **SEPTA trolleys** (www3.septa.org, github.com) | Rail layer trains, Philadelphia, PA: Southeastern Pennsylvania Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (www3.septa.org) add delays at its stops. | [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/): "non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data". The bus feed, filtered to the trolley routes and the Norristown High Speed Line. The Market-Frankford and Broad Street lines are in no SEPTA feed. Routes T1–T5, G1, D1 and D2 (trams) and M1, the Norristown High Speed Line (metro). | "Trains (SEPTA): [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/)" | +| **MARC** (mdotmta-gtfs-rt.s3.amazonaws.com, feeds.mta.maryland.gov) | Rail layer trains, Baltimore–Washington: Maryland Transit Administration. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (mdotmta-gtfs-rt.s3.amazonaws.com) add delays at its stops. | [MDOT MTA developer feeds (no endorsement implied)](https://www.mta.maryland.gov/developer-resources): "MTA does not guarantee the accuracy of the data nor does it warrant or endorse any application that uses this data." Published for open fetch with this disclaimer and no licence; the site terms cover only the website and apps. Shown with attribution and the no-endorsement wording, and switched off if MDOT MTA publishes restrictive terms. | "Trains (Maryland Transit Administration): [MDOT MTA developer feeds (no endorsement implied)](https://www.mta.maryland.gov/developer-resources)" | +| **Tri-Rail** (gtfsr.tri-rail.com, gtfs.tri-rail.com) | Rail layer trains, South Florida: South Florida Regional Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsr.tri-rail.com) add delays at its stops. | [No licence stated (open feed pages)](https://gtfsr.tri-rail.com/): "Download Realtime Feeds Below:". Used without a published licence (project owner decision, 2026-11-10). gtfsr.tri-rail.com (the page quoted above, read 2026-11-10) links the GTFS-RT trip updates, alerts and position updates with no terms, and the static GTFS (gtfs.tri-rail.com/gtfs.zip, publisher ETA Transit Systems) states none either; no SFRTA data terms were found. The realtime vehicles carry the train number as their trip id and no route. | "Trains (South Florida Regional Transportation Authority (Tri-Rail)): [No licence stated (open feed pages)](https://gtfsr.tri-rail.com/)" | +| **RTD** (open-data.rtd-denver.com, www.rtd-denver.com) | Rail layer trains, Denver, CO: Regional Transportation District. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (open-data.rtd-denver.com) add delays at its stops. | [RTD GTFS-Realtime License Agreement](https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement): "non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data". RTD marks may not be used with the data, so the credit is text only. The published `www` URL redirects off its own origin, which the proxy refuses, so the final host is the one registered. | "Trains (RTD): [RTD GTFS-Realtime License Agreement](https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement)" | +| **CapMetro MetroRail** (data.texas.gov) | Rail layer trains, Austin, TX: Capital Metropolitan Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [CapMetro Open Data Terms](https://www.capmetro.org/metrolabs/): "non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data". The bus feed the Transit layer polls; MetroRail is route 550. | "Trains (CapMetro): [CapMetro Open Data Terms](https://www.capmetro.org/metrolabs/)" | +| **METRO light rail** (svc.metrotransit.org) | Rail layer trains, Minneapolis–St Paul, MN: Metro Transit (Metropolitan Council). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [No licence published](https://svc.metrotransit.org/). Publishes no licence or terms: the data feeds page linked above (read 2026-11-10) lists the GTFS and GTFS-realtime files and shows only "© 2020 Metro Transit", so there is nothing to quote. Used without a published licence, on the same basis as the Transit layer's Metro Transit feed, which polls the same file (DATA_SOURCES.md). Blue Line 901 and Green Line 902 only; Northstar ended on 2026-01-04 and route 888 is now a bus. | "Trains (Metro Transit / Metropolitan Council): [No licence published](https://svc.metrotransit.org/)" | +| **LYNX** (gtfsrealtime.ridetransit.org) | Rail layer trains, Charlotte, NC: Charlotte Area Transit System. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrealtime.ridetransit.org) add delays at its stops. | [No licence published](https://www.charlottenc.gov/cats). Publishes no terms; used without a published licence (project owner decision, 2026-11-10). The feed host (gtfsrealtime.ridetransit.org) serves the files with no page and no terms, so there is nothing to quote (2026-11-10: 126 vehicles, 13 of them trains; 205 trip updates, 11 on the two lines; every realtime trip id is a static trip id). No CATS page with terms for them was found: charlottenc.gov answered 403 to automated requests, and the CATS Terms and Conditions found there by search govern the CATS mobile app. Routes 501 LYNX Blue Line (light rail) and 510 CityLYNX Gold Line (streetcar), both route_type 0: transit, tram. | "Trains (Charlotte Area Transit System): [No licence published](https://www.charlottenc.gov/cats)" | +| **GRT ION** (webapps.regionofwaterloo.ca) | Rail layer trains, Waterloo Region, ON: Grand River Transit (Region of Waterloo). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [Region of Waterloo Open Data Licence v2.0](https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/): "You are free to: Copy, publish, distribute and transmit the Information ... Use the Information commercially". The registered URL is the `/2` light-rail feed; the suffix-less URL serves a file frozen since February 2026 and is not used. | "Trains (Contains information licensed under the Open Data Licence – Region of Waterloo): [Region of Waterloo Open Data Licence v2.0](https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/)" | +| **Queensland Rail Citytrain** (gtfsrt.api.translink.com.au) | Rail layer trains, South East Queensland, Australia: Translink (Queensland Government). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.api.translink.com.au) add delays at its stops. | [CC BY 4.0](https://translink.com.au/about-translink/open-data): "Our data is licensed under a Creative Commons Attribution 4.0 International License." The terms add: "You must not represent that the State in any way endorses your Application." | "Trains (Translink / Queensland Government): [CC BY 4.0](https://translink.com.au/about-translink/open-data)" | +| **G:link** (gtfsrt.api.translink.com.au) | Rail layer trains, Gold Coast, Australia: Translink (Queensland Government). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.api.translink.com.au) add delays at its stops. | [CC BY 4.0](https://translink.com.au/about-translink/open-data): "Our data is licensed under a Creative Commons Attribution 4.0 International License." | "Trains (Translink / Queensland Government): [CC BY 4.0](https://translink.com.au/about-translink/open-data)" | +| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Rail layer trains, Adelaide, Australia: Adelaide Metro (Rail Commissioner). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfs.adelaidemetro.com.au) add delays at its stops. | [CC BY 4.0](https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au): "Creative Commons Attribution 4.0". Licence as catalogued on data.sa.gov.au; the operator developer page sits behind a bot check. The feed is labelled beta. Its gateway answers base64 text unless the request sends `Accept: application/x-google-protobuf`, so the proxy sends that header for this feed. | "Trains (Adelaide Metro / Government of South Australia): [CC BY 4.0](https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au)" | +| **Irish Rail** (api.irishrail.ie, www.transportforireland.ie) | Rail layer trains, Ireland: Iarnród Éireann / Irish Rail. Placed at the station or timing point last reported, not by GPS. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, the delay each train's report states moves its stops. | [No licence published (positions); CC BY 4.0 (NTA timetable)](https://developer.nationaltransport.ie/usagepolicy). The train positions publish no licence or terms: the Irish Rail realtime API page (api.irishrail.ie/realtime/, read 2026-11-10) documents the calls and says only that "Irish Rail provides this information as is" (quoted under `limits`), so there is nothing to quote. They are used without a published licence, credited "Iarnród Éireann / Irish Rail". The NTA's CC BY 4.0 covers only the NTA's GTFS data (the timetable), not these positions. Positions are the station or timing point the signalling system last reported, not GPS. | "Trains (Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is): [No licence published (positions); CC BY 4.0 (NTA timetable)](https://developer.nationaltransport.ie/usagepolicy)" | +| **VR (Finland)** (rata.digitraffic.fi) | Rail layer trains, Finland: VR, via Fintraffic Digitraffic. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (rata.digitraffic.fi) add delays at its stops. | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/): "Fintraffic's open data is licensed under the Creative Commons 4.0 By license. It gives the right to distribute, remix, tweak, and build upon our data, even commercially, as long as you credit the source for the original creation." The Digitraffic-User header names the application, never a person. | "Trains (Source: Fintraffic / digitraffic.fi, license CC 4.0 BY): [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/)" | +| **Renfe long and medium distance** (gtfsrt.renfe.com, ssl.renfe.com) | Rail layer trains, Spain: Renfe (AVE, Avlo, Alvia, Euromed, Intercity, Media Distancia). Live GPS positions. Its vehicles name no route, so it is listed only once its path and schedule packs are built from its static GTFS; trip updates (gtfsrt.renfe.com) then add delays at its stops. | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): "CC-BY-4.0". Licence field of the data.renfe.com datasets (posicion-vehiculos-av-ld-md and the static GTFS). "Esta información se actualiza cada 15 segundos". Iryo and OUIGO España are not in Renfe’s feeds. | "Trains (Renfe): [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)" | +| **Renfe Cercanías** (gtfsrt.renfe.com, ssl.renfe.com) | Rail layer trains, Spain: Renfe Cercanías and Rodalies. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.renfe.com) add delays at its stops. | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): "CC-BY-4.0". Licence field of the data.renfe.com Cercanías datasets. Every vehicle in this feed is a Cercanías or Rodalies train. | "Trains (Renfe): [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)" | +| **Vy** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Vy (Norway), via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **Go-Ahead** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Go-Ahead Norge, via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api.entur.io) add delays at its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **Flytoget** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Flytoget, via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api.entur.io) add delays at its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **SJ Nord** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: SJ Nord, via Entur. Live GPS positions, read from SIRI VM. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, the delay each train's report states moves its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". SJ Nord trains are missing from Entur’s GTFS-RT vehicle positions (0 vehicles on 2026-11-06); they are read from SIRI VM instead (13 trains on 2026-11-09). Its journey refs are DatedServiceJourneys, not the GTFS trip ids, so a train is matched to its run by line and position. | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **KTMB** (api.data.gov.my) | Rail layer trains, Peninsular Malaysia: Keretapi Tanah Melayu (ETS, Intercity, KTM Komuter, Shuttle Tebrau). Live GPS positions. Its vehicles name no route, so it is listed only once its path and schedule packs are built from its static GTFS. | [CC BY 4.0](https://developer.data.gov.my/realtime-api/gtfs-realtime): "All GTFS Realtime feeds are licensed under CC BY 4.0". developer.data.gov.my, read 2026-11-09; its GTFS Static page says the same of "All GTFS Static feeds". The static GTFS has no shapes. | "Trains (KTMB, via data.gov.my (Government of Malaysia)): [CC BY 4.0](https://developer.data.gov.my/realtime-api/gtfs-realtime)" | +| **Seattle Streetcar** (s3.amazonaws.com, metro.kingcounty.gov) | Rail layer trains, Seattle, WA: City of Seattle (South Lake Union and First Hill lines), via King County Metro. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (s3.amazonaws.com) add delays at its stops. | [King County Transit Data Terms of Use](https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps): "King County grants you a limited, revocable license to use, reproduce, and redistribute the Data in accordance with these Terms." Transit Data Terms of Use on the page linked above (undated; read 2026-11-10), which links the GTFS and GTFS-RT used here and covers "schedule, geographic, real-time, or any other data posted by King County". King County also permits users "to copy, distribute, store (electronically or otherwise), create derivative works based on and perform and display (publicly or otherwise) such Data, and make such Data available to end users", and asks for the legend the credit carries; no King County service marks beyond it. Commercial use is not mentioned. The feed bundles King County Metro, Sound Transit and City of Seattle routes; only the City of Seattle streetcars (agency 23) are kept, `100340` South Lake Union and `102638` First Hill (transit, tram). Link light rail is not in this GTFS. | "Trains (Transit scheduling, geographic, and real-time data provided by permission of King County): [King County Transit Data Terms of Use](https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps)" | +| **Connector streetcar** (tmgtfsprd.sorttrpcloud.com, www.go-metro.com) | Rail layer trains, Cincinnati, OH: Southwest Ohio Regional Transit Authority (SORTA/Metro). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (tmgtfsprd.sorttrpcloud.com) add delays at its stops. | [SORTA/Metro developer data Terms](https://www.go-metro.com/about/developer-data/): "The Southwest Ohio Regional Transit Authority (SORTA/Metro) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute SORTA/Metro’s transit route schedules, pricing, and other associated data (“Data”) subject to the following Terms:". The developer data page linked above (read 2026-11-10), which links the GTFS and these GTFS-RT files. SORTA/Metro trademarks "may not be used in association with Data unless approved in writing by SORTA/Metro", so the credit is text only; "Any modification, alteration, combination or arrangement of the Data is solely at your own risk." Commercial use is not mentioned. Seen 2026-11-10: 2 streetcars among 147 vehicles, 6 streetcar trips among 265 updates, trip ids those of the static file. The file of 2026-10-01 ends on 2026-12-05. One rail route, `100` Streetcar - OTR - Banks (route_type 0): transit, tram. | "Trains (Southwest Ohio Regional Transit Authority (SORTA/Metro)): [SORTA/Metro developer data Terms](https://www.go-metro.com/about/developer-data/)" | +| **Manyōsen** (gtfs-rt-files.buscatch.jp, api.gtfs-data.jp) | Rail layer trains, Toyama, Japan: 万葉線 Manyōsen (Takaoka–Kōshinokata tram). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfs-rt-files.buscatch.jp) add delays at its stops. | [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen): ""feed_license": "CC0 1.0"". GTFS Data Repository record of the feed "万葉線", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. Tram: transit. | "Trains (万葉線株式会社, via the GTFS Data Repository (gtfs-data.jp)): [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen)" | +| **Toyama tram** (gtfs-rt-files.buscatch.jp) | Rail layer trains, Toyama, Japan: 富山地方鉄道 Toyama Chihō Railway city tram (city lines, Toyamakō Line, loop line). Live GPS positions. No static GTFS is registered, so there are no path or schedule packs, and its trip updates are not used. | [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha): ""feed_license": "CC0 1.0"". GTFS Data Repository record of the feed "富山地方鉄道市内電車", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. The tram-only feed (the company's buses are a separate feed). Tram: transit. | "Trains (富山地方鉄道株式会社, via the GTFS Data Repository (gtfs-data.jp)): [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha)" | +| **Bordeaux tram** (bdx.mecatran.com) | Rail layer trains, Bordeaux, France: TBM (Bordeaux Métropole): trams A to F. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (bdx.mecatran.com) add delays at its stops. | [Licence Ouverte / Open Licence 1.0](https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite): "fr-lo". Licence field of the transport.data.gouv.fr dataset "Réseau urbain et scolaire TBM" (publisher Bordeaux Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates; Licence Ouverte 1.0 (Etalab) allows reuse, commercial included, with attribution. Tram: transit, tram. | "Trains (Bordeaux Métropole (TBM)): [Licence Ouverte / Open Licence 1.0](https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite)" | +| **Montpellier tram** (gtfsproxy.e-tam.fr) | Rail layer trains, Montpellier, France: TaM (Montpellier Méditerranée Métropole): trams 1 to 5. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsproxy.e-tam.fr) add delays at its stops. | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/): "odc-odbl". Licence field of the transport.data.gouv.fr dataset "Réseau urbain TaM" (publisher Montpellier Méditerranée Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates. ODbL is share-alike, with the access point's special conditions (doc.transport.data.gouv.fr "Licence ODbL", read 2026-11-10): the share-alike clause covers information "de même nature, de même granularité, de mêmes conditions temporelles et de même emprise géographique", and "Les bases de données ainsi repartagées doivent être publiées sur le Point d'accès national transport.data.gouv.fr, dans le format d'origine." The path and schedule packs built from this GTFS are such a database once shared: offered under ODbL with the attribution kept. Tram: transit, tram. | "Trains (TaM / Montpellier Méditerranée Métropole): [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/)" | | **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy | | **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana | | **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio | @@ -237,6 +267,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **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 fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Rail (GTFS-Realtime trains).** Each feed in `src/data/railFeeds.js` records its licence, its credit and the passage of its terms the decision rests on, and a feed is switched on only when those terms were read and cover this use, or, for those that publish no licence at all, on the basis its entry records. Such a basis is never a licence, and the credit does not present it as one: its label says there is no licence ("No licence published", "No licence stated") or names the publication the use rests on. Five enabled feeds publish no licence for their train positions. MARC is used on what its publication says (open publication with a no-endorsement disclaimer), and METRO light rail on the same basis as the Transit layer's Metro Transit feed. Tri-Rail and Charlotte's LYNX are used without a published licence by project owner decision of 2026-11-10. The fifth is Irish Rail, whose train positions publish no licence while its NTA timetable is CC BY 4.0. The registry lists 30 feeds, all switched on, and every one is keyless: its realtime feed and its static timetable are published for anyone to fetch, so no feed needs a key, an account or a registration, and the server holds no rail credential. No rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`). `/api/rail/feeds` lists the offered feeds, `/api/rail/trains/` returns one feed's train snapshot, `/api/rail/updates/` its trip updates, and `/api/rail/paths/` and `/api/rail/schedule/` its path and schedule packs, when built. The proxy requests only registered feeds and reuses the Transit proxy's request policy (a short snapshot cache with single-flight refresh, serve-stale, admission limits, the failure cooldown ladder, redirects followed hop by hop only on the feed's own https origin, and an 8 MiB body cap unless a feed sets its own, at most 64 MiB). Many operators publish trains in the same feed as their buses, so each feed has a classifier: the proxy keeps only vehicles it recognises as trains, tags each with a category (`transit`, `local`, `regional`, `national` or `international`) and a mode (`rail`, `metro` or `tram`), and drops everything else server-side, counted in the snapshot as `dropped`; the browser never receives a bus. Every train shown is live, where its operator last reported it: 29 feeds publish live GPS positions (SJ Nord's through SIRI VM), and Irish Rail reports the station or timing point a train last passed. No train is placed or moved from a timetable; a feed whose live positions fail shows as unavailable, and its trains age out as any quiet train does. 22 feeds register trip updates, polled while one of their trains is selected and that feed's schedule pack is loaded. 28 feeds work out of the box. Packs are an opt-in extra for live trains: nothing is downloaded when the app starts, and `node scripts/build-rail-paths.mjs --feed ` (repeatable) or `--all` downloads a feed's static GTFS (29 of the 30 have one) and writes a path pack (the operator's track shapes) and a schedule pack (the stop times for a window of days) to `.cache/rail-paths/` (or to `RAIL_PATHS_DIR` when set, the directory the server then serves). With them, a live train is drawn on its operator's route line and its card names its stops, its next stop and, from the trip updates, their delays. Renfe long and medium distance and KTMB, whose vehicles name no route, are classified from their pack's routes, so `/api/rail/feeds` lists them only once that pack is built. Lines are the operator's own shapes as published: nothing is routed or redrawn from other map data, and no line is drawn where an operator publishes no shape. **Share-alike sources bind the deployer.** A pack built from a ShareAlike or ODbL source is a derived database of it, so a deployer who serves it publicly makes it subject to the same licence and must offer it under that licence with the attribution kept. That covers the Montpellier packs (ODbL). For the ODbL datasets of France's national access point, its special conditions also require that "Les bases de données ainsi repartagées doivent être publiées sur le Point d'accès national transport.data.gouv.fr, dans le format d'origine" ([Licence ODbL](https://doc.transport.data.gouv.fr/le-point-d-acces-national/cadre-juridique/conditions-dutilisation-des-donnees/licence-odbl), read 2026-11-10): a deployer who serves the Montpellier packs publicly takes on republishing that derived data there, as GTFS. The build downloads each static GTFS itself, over https and with no credential. A feed's `staticRouteTypes` lists the route types it keeps when its classifier accepts every route of a mode-specific feed. A frequency-based trip becomes one run per departure on its template's stops and run times. Packs are build output, not part of the repository, and cover a fixed window of days, so they are rebuilt before it ends, and more often where an operator republishes more often. A build in which any feed fails exits with code 2, the other feeds still built. A stop the GTFS gives no coordinates, which some feeds write as (0, 0), is left out of the run, so no line is ever drawn to it and no stop is placed by guesswork. Where several loaded feeds carry the same train, it is drawn once. Copies are matched by train number within 100 km, or, for a short line name (S1, RB 1, U6), on the same line in the same category within 15 km. A train number is three or more digits, or a shorter number after a long-distance product (EC, ECE, EN, ICE, NJ, RJ, RJX, TGV; not IC, where a short number names a line); a trip id that is an operator prefix and a number, or a headsign that is a bare number, is read too. The copy drawn is scored by its position source (live GPS, then station reports), then by its line (on its operator's shape, then without one), and stays drawn until another scores strictly higher. No train is ever placed in Ukraine, a safety rule: the build leaves out every run with a stop there, a stop whose id names Ukraine or whose position is inside its recognised borders (either is enough: an id naming another country does not keep a stop inside them), and the proxy drops every live position inside those borders (`src/data/railNoPositions.js`). Trains use the Transit layer's delayed playback between reports and move along their line in the path pack where there is one, else in straight lines. No train position is archived to disk, there is no proxy history, and nothing is collected in the background. Operator credits appear when that feed's trains are shown and do not imply endorsement. - **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. @@ -268,6 +299,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | | **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map | | **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation | +| **Ukraine outline** (Rail layer no-positions rule; 1 ring, 7,801 points) | `src/data/railNoPositions.js` | **ODbL 1.0** (OpenStreetMap, via geoBoundaries gbOpen UKR ADM0, commit 9469f09; simplified to 0.0005°) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + geoBoundaries | | **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel | | **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) | | **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" | @@ -326,7 +358,7 @@ credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. ### Bundled OpenStreetMap data -Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. +Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), the Rail layer's Ukraine outline in [`railNoPositions.js`](src/data/railNoPositions.js) (OpenStreetMap via geoBoundaries gbOpen, Runfola et al. 2020, simplified; the module's code stays MIT), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. ### NASA FIRMS acknowledgement diff --git a/README.md b/README.md index f70bd2f81..34cf2ba79 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,7 @@ Twenty-three layers and map sources. **Twenty-one have a keyless path.** Some of | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚆 **Rail** | Live trains across the US, Canada, Australia, Ireland, Finland, Spain, Norway, Malaysia, Japan and France, from 30 keyless operator feeds: live GPS positions from 29 of them and Irish Rail’s station reports, 28 live out of the box, and Renfe long and medium distance and KTMB once their pack is built. No train is placed from a timetable. Opt-in packs (`node scripts/build-rail-paths.mjs --feed ` or `--all`) add the operator’s own route lines, stop names, next stops and trip-update delays. Coloured by service category (urban, local, regional, national, international) with a filter chip and legend for each; trains are drawn once when several feeds carry them, and open a card per train. Buses in mixed feeds are dropped by the server | Operator GTFS-Realtime feeds (static GTFS for opt-in packs) | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | diff --git a/SECURITY.md b/SECURITY.md index e12a77025..bb3b1c362 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -74,6 +74,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. +- **Rail fetches registered feeds only, and none needs a credential.** `/api/rail/feeds` lists the offered feeds; `/api/rail/trains/`, `/api/rail/updates/`, `/api/rail/paths/` and `/api/rail/schedule/` resolve the id against `src/data/railFeeds.js` (`RAIL_FEED_ID_PATTERN`: lowercase letters, digits and hyphens). The browser never supplies a URL, and an id that is not registered, or a trip-updates route for a feed that registers none, does not resolve at all; any method but GET answers 405 (`Allow: GET`, never cached). Every registered feed and every static timetable is open: no rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`), and the only rail setting the server reads is `RAIL_PATHS_DIR`. Upstream requests follow the Transit proxy's policy: https only, each redirect hop validated against the feed's own https origin before it is requested (three hops at most), a request timeout, bodies capped at 8 MiB unless the registry sets a feed's own cap (never above 64 MiB), and a per-feed admission limiter and the failure cooldown ladder bound what the process asks of any operator. Responses are decoded server-side: GTFS-Realtime under entity-count and string-length ceilings, a differential feed is refused, and an empty or header-less GTFS-Realtime reply (an error page served as 200, say) is refused as unreadable, so the last good snapshot is served stale or the request fails with 502. The Irish Rail and SIRI VM XML is read by small scanners, not an XML library: a document that declares a DTD or an entity is refused, and the whole text, the number of trains and every field are capped. Only vehicles the feed's classifier recognises as trains leave the server, and no train position inside Ukraine is ever sent (`src/data/railNoPositions.js`). Snapshots and trip updates live in memory for the feed's own freshness window, with serve-stale after a failure; there is no disk cache and no history. Path and schedule packs are optional build output, read from disk (`.cache/rail-paths/` or `RAIL_PATHS_DIR`) by a reader that re-checks the id against the same pattern, so a name never leaves that directory. A pack over 24 MiB is refused before it is read and answers 503, never a partial file, and so does a pack that cannot be read: only a missing pack is 404; the rest pass through as bytes, through a 64 MiB in-memory read cache keyed by each file's modification time and size, and are served as JSON holding timetables and track shapes only, `no-cache` with an ETag from that time and size (304 when unchanged). A schedule pack of another format version or another feed, or whose window has ended, is not used, and a feed that cannot be read without it answers 503 rather than an empty list. The pack build (`scripts/build-rail-paths.mjs`) runs only when a person runs it, never on app start: it downloads over https only (a redirect to plain http is refused), caps each download in size and time, sends no credential, and refuses an `--out` folder (or a `RAIL_PATHS_DIR` it writes to) inside `src/` or `public/`. - **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. - **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached. - **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured. diff --git a/changelog.d/0-rail-layer.md b/changelog.d/0-rail-layer.md new file mode 100644 index 000000000..14eac616f --- /dev/null +++ b/changelog.d/0-rail-layer.md @@ -0,0 +1,24 @@ +- Add Rail in the Movement group, off by default: live trains from 30 keyless + operator feeds in the United States, Canada, Australia, Ireland, Finland, + Spain, Norway, Malaysia, Japan and France, from commuter and long-distance + rail to the streetcars and trams of Seattle, Cincinnati, Toyama, Bordeaux + and Montpellier. A new `/api/rail` proxy fetches registered feeds only, + needs no key and drops buses from mixed feeds. Every train shown is live: + 29 feeds publish GPS positions and Irish Rail its station reports, and 28 + of them show with no setup. No train is placed or moved from a timetable; + a feed whose live positions fail shows as unavailable. Packs are an opt-in + extra: `node scripts/build-rail-paths.mjs --feed ` (or `--all`) builds + a feed's path and schedule packs into `.cache/rail-paths/`, which add the + operator's own route lines, stop names, the next stop and trip-update + delays to its live trains; Renfe long and medium distance and KTMB, whose + vehicles name no route, appear once their pack is built. Nothing is + downloaded when the app starts, and lines are only ever the operator's own + shapes. Trains are coloured by category (urban, local, regional, national, + international), each with a chip and legend entry shared via token `02`, + glide between reports with Transit's delayed playback, are drawn once when + several feeds carry them, and open a card on click. A selected train that + is moving is followed by the camera, as an aircraft is, and any other is + flown to once; the selected train and its line stay on the map from orbit. + No train is placed in Ukraine. Feeds that publish no licence are used on + the basis each one's entry records, and their credit never presents that + as a licence (Denis Groshev, @ZloyS, #0). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5590b8c68..4c4424bf0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4911,6 +4911,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | +| Rail 🚆 | Live operator train positions (GPS, or station reports for Irish Rail) and trip updates, trains only, with opt-in route packs built from static GTFS (30 keyless feeds, all on, 28 out of the box; `src/data/railFeeds.js`) | `src/layers/rail/` via `src/app/layers/rail.js` | `/api/rail` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | @@ -5363,7 +5364,7 @@ answered yet opens nothing rather than a card hanging at sea level. The cells are re-read for up to 90 seconds, which outlasts a cold terrain round trip; reroute, CLEAR and disable cancel that. Transit is off by default, and share links use token `j`. The movement panel order -places Transit directly after Street Traffic and before Bike Share. Cameras and +places Transit directly after Street Traffic and before Rail. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude gate. Forty-nine keyless regions are available: Boston, Austin, Minneapolis–St Paul, @@ -5588,6 +5589,279 @@ refreshes. A 304 confirms contact without appending history. Missing vehicles are removed after two missed polls; reports older than ten minutes and feeds silent for five minutes are removed. Credits appear when the feed is used. +**Rail.** Rail is off by default, and share links use token `02`, with five +boolean options for its categories: `t` (urban), `l` (local), `r` +(regional), `n` (national) and `i` (international), all on by default. It sits +in the Movement panel directly after Transit and before Bike Share. The layer +lives in `src/layers/rail/` and is built by `src/app/layers/rail.js`; feeds are +registered in `src/data/railFeeds.js`. + +Every train shown is live: it is where its operator last reported it, by GPS +or, for Irish Rail, by the station or timing point the signalling last +reported. No train is placed or moved from a timetable, and a feed whose live +positions fail shows as unavailable while its trains age out as any quiet +train does. Below a 3,000 km altitude gate (with 150 km hysteresis), the layer +polls every 15 seconds each listed feed whose coverage circle contains the +centre of the view; a feed already polled stays active 40 km past its edge. A +feed's trip updates are polled every 30 seconds while one of its trains is +selected and its schedule pack is loaded. The registry lists 30 feeds, all +switched on and all keyless: every realtime feed and every static timetable +is published for anyone to fetch, so no feed needs a key, an account or a +registration, and no rail request carries a credential. + +**Opt-in packs.** Live feeds work out of the box, and nothing is downloaded +when the app starts. A path pack and a schedule pack, built only when someone +runs `scripts/build-rail-paths.mjs` for a feed (Packs below), are an extra for +its live trains: the operator's own route lines, stop names, the next stop +and, from the trip updates, the delays at each stop. Renfe long and medium +distance and KTMB, whose vehicles name no route, are classified from the +pack's routes (`railFeedNeedsSchedulePack`), so `/api/rail/feeds` lists them +only once `.schedule.json` is in the pack directory (`publicRailCatalog`). +The server checks that the file exists, never reading it for this, and keeps +each answer for 60 seconds; the catalog itself is sent with `max-age=300`. So +a host that has built nothing shows 28 feeds, in 22 regions, and the other two +appear as their packs are built. The browser reads the catalog once per layer +instance; until it answers, or while it fails (asked again after 30 s, +doubling to 10 minutes), only the feeds that need no pack are polled. A feed +the catalog lists without live positions is never polled. + +Five enabled feeds publish no licence for their train positions and are used +on the basis their entries record, which is never a licence and never +credited as one: MARC on what its publication says, and METRO light rail on +the basis the Transit layer uses for the same file. Tri-Rail and Charlotte's +LYNX are used without a published licence by project owner decision of +2026-11-10. The fifth is Irish Rail, whose train positions publish no licence +while its NTA timetable is CC BY 4.0. The +Montpellier tram's GTFS is ODbL, so the packs built from it are ODbL too: a +deployer who serves them publicly makes them subject to the same licence, +offered under it with the attribution kept, and France's national access +point adds that a re-shared derived database must be published on +transport.data.gouv.fr in its original format. + +By position source, 29 feeds publish live GPS. 13 are in the US: Metro-North, +LIRR, MBTA, SEPTA Regional Rail and trolleys, MARC, Tri-Rail, RTD, CapMetro, +METRO, Charlotte's LYNX, the Seattle Streetcar and Cincinnati's Connector. Three are in Australia: Citytrain, G:link and Adelaide Metro. The +remaining 13 are GRT ION in Canada, VR in Finland, Renfe long and medium +distance and Cercanías in Spain, the four Entur operators in Norway (SJ Nord +through SIRI VM), KTMB in Malaysia, the Bordeaux and Montpellier trams in +France, and the Manyōsen and Toyama trams in Japan. Irish Rail publishes the +station or timing point the signalling last reported. + +22 feeds register trip updates; on a feed whose pack is built their delays +reach the selected train's stops. Irish Rail and SJ Nord publish none, but +each train's report states its delay: with the pack built, that delay moves +the train's stops and the run its report is matched to, in the browser and, +for Irish Rail's train codes, on the server, so a late train is not shown +past a stop it has not reached. `DATA_SOURCES.md` lists every feed, one row +each, with its licence, terms and attribution. + +**Proxy.** `GET /api/rail/feeds` returns the public catalog of listed feeds, +`GET /api/rail/trains/` one feed's snapshot, +`GET /api/rail/updates/` its trip updates, and +`GET /api/rail/paths/` and `GET /api/rail/schedule/` its path +and schedule packs once built (`src/sources/railService.js`, +`src/data/railProxy.js`, served in development and preview by +`server/providers/rail.js`, which reads the packs from `.cache/rail-paths/` or +`RAIL_PATHS_DIR`). The request policy is the Transit proxy's: a memory cache +with single-flight refresh (15 s, or the feed's own poll interval; 30 s for +trip updates, or the feed's own), serve-stale for up to 10 minutes after a +failure (an empty or header-less GTFS-Realtime reply is refused as unreadable +and counts as one: stale, or 502 without a snapshot), per-feed and global +admission limits, the failure cooldown ladder, redirects followed hop by hop +only on the feed's own https origin, and an 8 MiB body cap unless a feed sets +its own, at most 64 MiB. A feed whose positions or trip updates can be read +only with its schedule pack (routes taken from the pack) answers +503 "Rail schedule unavailable" (`X-Rail-Backoff: schedule`, never cached) +whenever every record it sent was dropped, rather than an empty list: the +pack is missing or refused, or names none of the trips the feed runs now (a +stale pack: Renfe's trip ids carry their date). The server refuses a schedule +pack, as it does one it cannot read, when it is of another format version or +another feed, or once the last run it holds has arrived (its window has +ended), and reads it again after five minutes. A feed that needs +its pack to be listed is served only once that pack is built: until then +`/trains` and `/updates` answer 503 "Rail schedule unavailable" +(`X-Rail-Backoff: schedule`) without contacting the operator. Without a +`hasSchedulePack` probe, the server asks `schedulePackVersion` or +`readSchedulePack` (null: not built); with neither, no such feed is listed. +Only a missing pack is 404: a pack that cannot be read answers 503 with +`Retry-After: 60`. A pack is sent `no-cache` with an ETag from its file's +modification time and size, and a request whose If-None-Match names it is +answered 304 without reading the file: a feed's path and schedule packs pair +by `builtAt`, so a browser never keeps one of them past a rebuild. The browser asks again for a path or schedule pack after +30 s, doubling to 10 minutes. LIRR and Metro-North, whose positions and trip +updates are one document, are fetched once per freshness window for both. +Each feed's classifier runs on every decoded vehicle; only trains leave the +server, each tagged with a `category` (`transit`, `local`, `regional`, +`national`, `international`) and a `mode` (`rail`, `metro`, `tram`). +Everything else, buses in a mixed feed included, is dropped and counted as +`dropped`. There is no disk cache and no history. + +**Packs.** `scripts/build-rail-paths.mjs` builds, for each feed it is given +that has a static GTFS (29 of the 30), a path pack (`.json`: the +operator's track shapes) and a schedule pack (`.schedule.json`: routes, +stops, stop patterns, run-time profiles and the service dates of a window) into `--out`, else `RAIL_PATHS_DIR` when the build's own environment sets it (the folder the server reads), else `.cache/rail-paths/`, which is gitignored. A +build names its feeds (`--feed `, repeatable) or passes `--all` for every +enabled feed (many downloads and a long run); a bare run exits 1, and +`--help` prints the usage. A build in which any feed fails exits 2 once the +others are built (1 stays for a build that cannot start: a bad argument, or +another build holding the lock). `--out` may not be `src/` or `public/` or a +folder inside them. The window is four service dates by default (yesterday, +today and the next two days), 1 to 14 with `--days`, starting at `--from`. +`--local-gtfs =` builds a feed from a zip already on disk instead of +downloading it, a copy of the GTFS the feed publishes; the zip is only read, +and the packs record its file name, not its path. The server serves a pack of +up to 24 MiB, passed through as bytes from a 64 MiB in-memory read cache keyed +by the file's modification time and size (`server/providers/rail.js`), and +answers a larger one with 503 "Rail paths too large" or "Rail schedule too +large". The build refuses to write a pack over that cap (the feed counts as +failed and its previous packs stay), and a pack whose trips fall under a fifth +of the pack it would replace, or to none, which reads as an upstream fault, +unless `--force` is given. A feed's two packs are written together: to +temporary names, then renamed into place, the path pack first, only once both +are written; with `--no-schedule` the feed's old schedule pack, whose patterns +index the old path pack, is deleted, and a feed named only through its +schedule pack (Renfe long and medium distance, KTMB) is refused and counts +as failed. A lock file in the `--out` folder +(`.build-rail-paths.lock`) makes a second build there refuse to start. A +schedule pack records the `builtAt` of the path pack its shape indices refer +to (`pathsBuiltAt`); where the two no longer match, its shape indices are not +used. Every download is open and made over https only (a redirect to plain +http is refused), capped in size and time, with the script's User-Agent and +any identifying header the operator asks for (`staticHeaders`). Shapes are +taken at face value: the operator's `shapes.txt` as published; nothing is +redrawn from other map data, and no line is drawn where the operator +publishes none. A shape with a gap of more than 5 km between two points is a +chord, not track, and is dropped; a trip without a usable shape gets none, and +its train moves between reports in straight lines. A feed's +`staticRouteTypes` lists the route types it keeps when its classifier accepts +every route of a mode-specific feed. A frequency-based trip +(`frequencies.txt`) becomes one run per departure, `@`, on +its template's stops and run times. A stop the GTFS gives no coordinates, +which some feeds write as (0, 0), is left out of the run with its times, so no +line is drawn to it; a run needs two located stops. The Toyama tram has no +static GTFS registered, so it has no packs and its trains move in straight +lines between reports. + +**Duplicates across feeds.** Where loaded feeds carry the same train, +`src/layers/rail/dedupe.js` draws it once. Copies from different feeds match +by train number within 100 km of each other. A train number is three or more +digits, or a shorter number after a long-distance product (EC, ECE, EN, ICE, +NJ, RJ, RJX, TGV; not IC, where a short number names a line); a trip id that +is an operator prefix and a number, or a headsign that is a bare number, is +read too, and numbers that differ are different trains. A short line name +(S1, RB 1, U6) names a line many trains run: copies on different lines are +different trains, and copies on one line match only in the same category +within 15 km, the nearest pairs first. The copy drawn is scored by its +position source (live GPS, then station reports), then by its line (bound to +its operator's shape in a built pack, then without one). A drawn copy stays +until another scores strictly higher, and the comparison runs once per poll, +never per frame. + +**Ukraine.** No train is placed in Ukraine, a safety rule +(`src/data/railNoPositions.js`: its recognised borders as mapped in +OpenStreetMap, via geoBoundaries, simplified to about 50 m, and stop ids that +name the country). The path build leaves out every run with a stop there: a +stop whose id names Ukraine or whose position is inside its borders, either +being enough, so an id naming another country does not keep a stop that +lies inside. The proxy drops every live position inside it, for every feed. + +**Categories and markers.** Each train is a disc in its category colour (urban +`#FF7A59`, local `#F2D04B`, regional `#7BE495`, national `#5AB8FF`, +international `#C58BFF`) with a white glyph for its mode: a train front, a tram +with a pantograph, or an "M" for metro. The layer row shows one chip per +category, which hides or shows that category, and a colour legend with live +counts for the categories present. On photorealistic tiles a train's height +is sampled from the rendered mesh at points on its own line +(about every 55 m, for trains within 8 km of a camera at most 5 km above the +ground there, and only once the tiles in view have loaded), where the draped +line is the track bed or the deck. A reading more than 25 m under or 80 m over +the floor there is a coarse tile and is dropped; a kept one is read again +after 30 s, and the new reading replaces it when the camera is no higher than +it was, or once the old one is five minutes old. Elsewhere it stands on the +shared ground floor that Transit and aircraft use, and on a floor cell not +sampled yet it keeps a neighbouring cell's floor or its last one. Only a train +with no floor yet, or a stop of the selected trip, stands on the ellipsoid, +and only until its cell is warm: the stops are read again every second until +they stand on a floor. Rail warms those floor cells on one shared budget that +refills at 1,200 cells a minute and holds at most 320, so no minute spends +more than 1,520 cells (about 24 of the 90 requests of 64 points that +`/api/terrain/heights` allows a client each minute), and only under trains +within 30 km of the camera and at the selected trip's stops, the selected +train and its stops first and then the nearest, in one batch a second, so a +view full of trains cannot use up the limit every layer shares. Its drawn +height changes no faster than 5% of the distance it moves (plus 0.5 m/s while +it stands), so a roof or overpass sampled under it is a bump, not a jump, and +a followed train rides smoothly; it snaps when a better height replaces a +fallback (a real floor, or any floor replacing the ellipsoid) and after a jump +of more than 2 km. + +**Playback, lines and cards.** Trains use the Transit layer's delayed +playback between reports, so a train glides at 1× behind real time rather +than jumping. Between reports they move along the line in the feed's path +pack where there is one, else in straight lines. A loaded feed's faint +network is drawn in full below 150 km and not at all above 200 km. A feed +out of use for ten minutes gives back its network, path pack and schedule +pack; they load again when it is next used. The selected train's line, its +operator's shape from the path pack, is drawn ahead of it and, fainter, +behind it. Clicking a train opens a card titled with its route or headsign, +with at most six lines under it: category, mode, feed and region; displayed +motion and position source ("Live GPS" or "Estimated from station reports"); +the next and previous stops with their times and delays where the schedule +pack gives them; report age (marked "feed time" when the feed stamps the +header rather than the train) and display delay; reported speed and heading; +the last reported stop status where no stops are known; and the vehicle id. +Metro trains are shown at street level, as in Transit, and the card says so. + +**Camera.** Selecting a moving train gives it the camera as selecting an +aircraft does: Cesium's tracked entity rides on the train's marker, framed +2.5 km out and 35° down along the camera's heading (`WORLD_FOCUS_FRAMING.train`), +and a drag or a zoom orbits or zooms around the train. A train counts as +moving while it glides between reports, waits at a stop with its departure +already reported, or waits for its next report after its last two moved it +at least 15 m. Any other train (parked, known from one report, or not heard +from for 90 seconds) gets one fly-to with the same framing, as a vessel does; +in the Cockpit that fly-to is refused with "Exit cockpit to fly to a train". +The follow lets go, leaving the camera where it is, when the train is +deselected (a click on anything else, Escape, its category hidden, the train +leaving its feed, the layer turned off) or another train is selected, which +takes the camera in turn. An aircraft or satellite tracked by another layer +clears the selection too; a navigation that only releases the follow camera +(a search, a fly-to) keeps the train selected. When the duplicate filter moves +the selection to another feed's copy of the train, the follow stays and +glides to the new copy over 1.2 seconds. + +**The selected train from far out.** While a train is selected its feed stays +polled at any altitude and wherever the camera looks, so the train, its line +and its stops stay on the map from orbit. While nothing else would keep that +feed loaded, it carries the selected train alone: its other trains and its +faint network go as they would with the feed. On the far side of the globe +the selected train and its stop dots are hidden. Deselecting returns the feed +to the usual rules. + +**Limits.** A train a feed stops listing for two polls is removed, as is any +report older than ten minutes; a train without its own timestamp is aged from +the snapshot's fetch time, so a frozen feed empties rather than showing old +trains as live. A feed whose operator has not answered for 90 seconds reads as +stale, and its trains are removed after five minutes. The layer holds at most +5,000 trains drawn (a copy hidden as another feed's does not count); past that +the layer row reads PARTIAL. Rail is not voice-controllable yet: it is listed in +`VOICE_OFF_LAYERS` for prompt budget, and the "trains" alias still names +Transit. Credits appear when a feed's trains are shown. + +**Upkeep.** + +- Packs cover a fixed window of service dates, so they are rebuilt before it + runs out: daily at the default four days, or weekly with `--all --days 7` + from a `--from` date that starts the coming week. The window and build time + of a pack are in its `window` and `builtAt` fields. +- After a rebuild, the server picks up the new pack within a minute: it + checks each schedule pack's file time and size once a minute and rebuilds + what it reads from a changed pack (a host with its own `readSchedulePack` + and no `schedulePackVersion` keeps it up to an hour). A feed whose + first pack was just built is listed within a minute; a browser that has + read the catalog sees it after a reload, once its five-minute cache has + passed. + `src/data/militaryAwareness.js` remains registered internally as the Contacts coordinator, but it is not a user-visible Data Layers entry. Its visible entry point is the right-side `CONTEXT` chooser's `CONTACTS` mode. diff --git a/package.json b/package.json index 3dd51ae2e..b8f5c581a 100644 --- a/package.json +++ b/package.json @@ -285,6 +285,11 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", "./sources/transit-service": "./src/sources/transitService.js", + "./server/providers/rail": { + "node": "./server/providers/rail.js" + }, + "./sources/rail-service": "./src/sources/railService.js", + "./layers/rail/source": "./src/layers/rail/source.js", "./sources/map-features": "./src/sources/overpassFeatures.js", "./server/providers/mapillary": { "node": "./server/providers/mapillary.js" diff --git a/scripts/build-rail-paths.mjs b/scripts/build-rail-paths.mjs new file mode 100644 index 000000000..140ec3995 --- /dev/null +++ b/scripts/build-rail-paths.mjs @@ -0,0 +1,2356 @@ +#!/usr/bin/env node +/** + * Build per-feed rail path packs and schedule packs from each operator's + * static GTFS. + * + * node scripts/build-rail-paths.mjs (--feed ... | --all) + * [--out ] [--work ] [--keep] [--no-schedule] [--force] + * [--days ] [--from ] + * [--local-gtfs = ...] [--help] + * + * The feeds are named with `--feed` (repeatable), or `--all` builds every + * enabled feed: many downloads and hours of work, so it is never the + * default. `--help` prints this text. Packs are opt-in: nothing is + * downloaded when the app starts, only when this script is run. They add to + * live trains what their feed leaves out (the operator's lines, stop names, + * next stops); no train is ever placed from them. + * + * For every enabled rail feed with a `staticGtfsUrl` (src/data/railFeeds.js) + * the script downloads the static GTFS, streams routes.txt, trips.txt and + * shapes.txt straight out of the zip (nothing is unpacked to disk except a + * GTFS zip nested inside the download), keeps the routes the feed's + * classifier accepts and whose route_type is rail (or, with + * `staticRouteTypes`, one of the types it lists: `keepsRouteType`), and writes + * `/.json`: + * + * { version: 1, feedId, builtAt, + * source: { url, file?, entry?, lastModified, feedVersion? }, + * shapes: [[lat, lon, lat, lon, ...]], // 1e-5 degrees, identical shapes once + * dist: [[0, m, m, ...]], // cumulative metres, 0.1 m + * shapeSources: ['operator', ...], + * trips: { tripId: shapeIndex }, + * routes: { routeId: [shapeIndex, ...] } } + * + * Shapes are taken at face value: every one is the operator's shapes.txt as + * published (`shapeSources`: `operator`); nothing is redrawn from other map + * data, and no line is invented where the operator publishes none. + * `source.file` is a `--local-gtfs` zip's name, `source.entry` the GTFS zip + * read from inside the download (`staticGtfsEntry`), `source.feedVersion` + * the GTFS `feed_info.txt` `feed_version`. + * + * Quality gate: a shape with any vertex gap over 5 km is a straight chord, not + * a track, and is dropped (and logged). Trains on dropped shapes keep the + * layer's straight-line motion. Kept shapes are simplified to within 1 m, or + * the feed's `staticSimplifyM` (Douglas-Peucker), before rounding duplicates + * away. A trip without a usable shape gets none: the layer draws no line + * for it, only its stops. + * + * Unless `--no-schedule` is given, the timetable is written next to it as + * `/.schedule.json` (read by src/data/railSchedule.js); its + * `pathsBuiltAt` is the `builtAt` of the path pack whose shape indices its + * patterns use: + * + * { version: 1, feedId, builtAt, pathsBuiltAt, source, + * timezone, // agency_timezone (IANA) + * window: { from, to }, // service dates, YYYY-MM-DD + * routes: { routeId: { type, short?, long?, color?, textColor? } }, + * stops: [{ id, name, lat, lon, parent?, platform?, tz?, cc? }], + * patterns: [{ shape, stops, seq, dist, pass? }], + * profiles: [[arr0, dep0, arr1, dep1, ...]], // s after the trip start + * services: { serviceId: ['YYYY-MM-DD', ...] }, + * trips: { tripId: { pattern, profile, startSec, route, service, + * headsign?, name?, direction? } } } + * + * A stop the GTFS gives no coordinates (none, or (0, 0)) is left out of its + * runs with its times: it is never placed by guesswork, and no line is drawn + * to it; a run needs two located stops. + * + * Writing: a feed's packs go to temporary names of this process and are + * renamed into place together, the path pack first, only once both are + * written; nothing is replaced when either fails. A pack over the largest + * the server serves (RAIL_PATH_PACK_MAX_BYTES, src/data/railProxy.js; the + * error names its size) is not written: the feed fails and its previous + * packs stay. So does a build whose trips fall + * to under a fifth of the pack it would replace (the path pack's trips on + * the operator's shapes, the schedule's trips per day of its window), or to + * none: an upstream change, not a timetable; `--force` writes it anyway. + * With `--no-schedule` the path pack's shape list changes, so the feed's + * previous schedule pack, whose patterns index the old one, is removed; a + * feed whose trains are named only through its schedule pack (Renfe long + * and medium distance, KTMB: railFeedNeedsSchedulePack) is refused instead, + * and counts as failed. + * One build runs at a time per `--out`: it holds `/.build-rail-paths.lock` + * (process id and time, refreshed while it runs), and a second one refuses + * to start; a lock whose process is gone, or older than 6 hours, is taken + * over. + * + * Calendars are resolved to concrete dates for `--days` days (default 4) + * from `--from` (default yesterday in the agency zone), plus enough earlier + * dates for a multi-day run still out; only trips that run in the window are + * kept. stop_times.txt is streamed and only rows of those trips are held. + * A frequency-based trip (frequencies.txt) becomes one trip per departure, + * `@`, its template's stop times moved to that start. + * + * `--local-gtfs =` reads that feed's static GTFS from a zip already + * on disk instead of downloading it: a copy of the GTFS the feed publishes. + * The zip is only opened for reading, never moved, changed or deleted, and + * the packs record its file name and modification time, not its path. + * + * Every download is open: no account, key or sign-in. It is made over https + * only (a redirect to plain http is refused), capped in size and time, and + * sent with this script's User-Agent and any identifying headers the + * operator asks for (`staticHeaders`). + * + * Output goes to `--out`, else to RAIL_PATHS_DIR when it is set in this + * script's environment (the directory the server reads; the script does not + * read `.env`), else to `.cache/rail-paths/` (gitignored). It is served by + * `/api/rail/paths/` and `/api/rail/schedule/`. + * + * Downloads go to `--work` (kept) or a temporary directory (removed). A feed + * that fails is reported and skipped; the others still build, and the + * script then exits with code 2 (code 1: nothing could start, such as a bad + * argument or a build already holding the lock). Memory stays + * small: CSV is parsed as a stream and only rail shapes are held. A feed is + * abandoned once resident memory passes 1.75 GiB. + */ +import { randomBytes } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; +import { pipeline } from 'node:stream/promises'; +import { Readable, Transform } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { createInflateRaw, gzipSync } from 'node:zlib'; +import { + RAIL_FEED_ID_PATTERN, + classifyTrain, + railEnabledFeeds, + railFeedNeedsSchedulePack, +} from '../src/data/railFeeds.js'; +import { RAIL_PATH_PACK_MAX_BYTES } from '../src/data/railProxy.js'; +import { decodeRing, polygonsContain } from '../src/data/adminBoundaries.js'; +import { + RAIL_PATH_PACK_VERSION, + createRailPath, + pointAtS, + projectOnPath, +} from '../src/data/railPaths.js'; +import { RAIL_SCHEDULE_PACK_VERSION } from '../src/data/railSchedule.js'; +import { + dateFromDayNumber, + dayNumberOf, + isTimeZone, + localDate, +} from '../src/data/railTimeZones.js'; +import { isNoTrainPositionStop } from '../src/data/railNoPositions.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_OUT = path.join(ROOT, '.cache', 'rail-paths'); +const USER_AGENT = + 'gods-eye-view-rail-paths/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)'; + +/** Vertex gap that marks a shape as a straight chord (m). */ +const MAX_VERTEX_GAP_M = 5000; +/** Douglas-Peucker tolerance applied to kept shapes (m). */ +const SIMPLIFY_TOLERANCE_M = 1; +const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024; +/** Largest file inflated from a zip: a national stop_times.txt runs to GB. */ +const MAX_ENTRY_BYTES = 4 * 1024 * 1024 * 1024; +const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; +const FEED_DEADLINE_MS = 15 * 60_000; +const MAX_REDIRECTS = 5; +/** Resident memory past which a feed is abandoned rather than the machine strained. */ +const MAX_RSS_BYTES = 1.75 * 1024 * 1024 * 1024; +let peakRss = 0; +const MAX_CSV_RECORD_CHARS = 1024 * 1024; +/** The lock a build holds in its `--out` folder (acquireDirLock). */ +export const BUILD_LOCK = '.build-rail-paths.lock'; +/** + * Share of the trips of the pack it replaces under which a build is taken + * for an upstream fault and not written (without `--force`). + */ +export const MIN_TRIP_SHARE = 0.2; +/** Exit code of a build in which one or more feeds failed. */ +export const FEED_FAILED_EXIT_CODE = 2; + +/** + * The exit code of a finished build: 0 when every feed built, otherwise + * FEED_FAILED_EXIT_CODE, even when the other feeds built (a scheduled run + * must notice one stale feed). + */ +export function buildExitCode(failed) { + return failed > 0 ? FEED_FAILED_EXIT_CODE : 0; +} + +/** + * GTFS route types that run on rails: tram, subway, rail, cable tram, + * funicular (7, and extended 1400), monorail, and the extended railway + * (100s), urban rail (400s) and tram (900s) families. + */ +export function isRailRouteType(value) { + const type = Number.parseInt(String(value ?? '').trim(), 10); + if (!Number.isInteger(type)) return false; + if ([0, 1, 2, 5, 7, 12, 1400].includes(type)) return true; + return ( + (type >= 100 && type < 200) || + (type >= 400 && type < 500) || + (type >= 900 && type < 1000) + ); +} + +/** + * Whether a feed keeps the routes.txt rows of this route_type. Without + * `staticRouteTypes` that is every rail type; with it, only the types it + * lists. The list may name a non-rail type for an operator that codes its + * trains as buses (route_type 3); that feed's classifier (or route rename) + * then keeps only the train routes. + */ +export function keepsRouteType(feed, value) { + if (!feed?.staticRouteTypes) return isRailRouteType(value); + const type = Number.parseInt(String(value ?? '').trim(), 10); + return Number.isInteger(type) && feed.staticRouteTypes.includes(type); +} + +// ---------------------------------------------------------------- arguments + +/** + * A registered feed's static GTFS read from a zip on disk + * (`--local-gtfs id=path`) instead of its `staticGtfsUrl`. + * @returns {{id: string, file: string}} The path made absolute. + */ +export function parseLocalGtfs(text) { + const at = String(text ?? '').indexOf('='); + const id = at > 0 ? text.slice(0, at) : ''; + const file = at > 0 ? text.slice(at + 1).trim() : ''; + if (!RAIL_FEED_ID_PATTERN.test(id) || !file) + throw new Error(`--local-gtfs needs =, got ${text}`); + return { id, file: path.resolve(file) }; +} + +/** + * Stand in for a download with a zip already on disk. Only its name and + * modification time reach the packs; it is opened read-only later. + * @returns {Promise<{file: string, lastModified: string, localName: string}>} + */ +export async function localStatic(file) { + const info = await stat(file); + if (!info.isFile() || info.size === 0) + throw new Error(`--local-gtfs ${path.basename(file)} is not a file`); + return { + file, + lastModified: info.mtime.toUTCString(), + localName: path.basename(file), + }; +} + +/** + * Whether `target` is `dir` or inside it. A name that merely starts with two + * dots (`src/..cache`) is inside; only a `..` path segment leaves. + */ +export function isInside(dir, target) { + const relative = path.relative(dir, target); + return ( + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +/** + * The command line. `{help: true}` for `--help`; otherwise the feeds must be + * named (`--feed`) or `--all` given, so a bare run never starts a build of + * every feed. `--out` defaults to RAIL_PATHS_DIR when the environment sets + * it (the directory the server serves), else to DEFAULT_OUT. + * @param {string[]} argv + * @param {Record} [env] + */ +export function parseBuildArgs(argv, env = process.env) { + const args = { + out: env?.RAIL_PATHS_DIR ? path.resolve(env.RAIL_PATHS_DIR) : DEFAULT_OUT, + work: null, + feeds: [], + all: false, + keep: false, + schedule: true, + force: false, + days: DEFAULT_SCHEDULE_DAYS, + from: null, + localGtfs: new Map(), + }; + if (argv.includes('--help') || argv.includes('-h')) return { help: true }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const value = () => { + const next = argv[++i]; + if (!next) throw new Error(`${flag} needs a value`); + return next; + }; + if (flag === '--out') args.out = path.resolve(value()); + else if (flag === '--work') args.work = path.resolve(value()); + else if (flag === '--feed') args.feeds.push(value()); + else if (flag === '--all') args.all = true; + else if (flag === '--force') args.force = true; + else if (flag === '--keep') args.keep = true; + else if (flag === '--no-schedule') args.schedule = false; + else if (flag === '--days') { + args.days = Number(value()); + if (!Number.isInteger(args.days) || args.days < 1 || args.days > 14) + throw new Error('--days must be 1 to 14'); + } else if (flag === '--from') { + args.from = value(); + if (!Number.isFinite(dayNumberOf(args.from))) + throw new Error('--from must be YYYY-MM-DD'); + } else if (flag === '--local-gtfs') { + const { id, file } = parseLocalGtfs(value()); + args.localGtfs.set(id, file); + } else throw new Error(`Unknown argument ${flag}`); + } + for (const forbidden of ['src', 'public']) + if (isInside(path.join(ROOT, forbidden), args.out)) + throw new Error(`--out must not be inside ${forbidden}/`); + if (args.all && args.feeds.length) + throw new Error('--all builds every feed: give it or --feed, not both'); + if (!args.all && !args.feeds.length) + throw new Error( + 'Name the feeds to build (--feed ...), or pass --all to build every enabled feed (many downloads, hours); --help for usage', + ); + return args; +} + +/** + * Why a feed is not built with these arguments, or null. `--no-schedule` + * removes a feed's schedule pack, and a feed whose trains are named only + * through it (railFeedNeedsSchedulePack) would leave the catalog with it. + * @param {object} feed + * @param {{schedule: boolean}} args + * @returns {string|null} + */ +export function buildRefusal(feed, args) { + if (args.schedule || !railFeedNeedsSchedulePack(feed)) return null; + return `--no-schedule: ${feed.id} names its trains only through its schedule pack, which --no-schedule would remove; build it without --no-schedule`; +} + +/** + * The `source` a feed's packs record: the URL the GTFS was downloaded from + * (the last redirect's target, `finalUrl`), else the registered one; a + * `--local-gtfs` zip's file name; the entry read inside the download. + * @param {object} feed + * @param {{finalUrl?: string, localName?: string, + * lastModified?: string|null}} got downloadStatic or localStatic's answer. + * @returns {object} + */ +export function packSource(feed, got) { + return { + url: got.finalUrl ?? feed.staticGtfsUrl, + ...(got.localName ? { file: got.localName } : {}), + ...(feed.staticGtfsEntry ? { entry: feed.staticGtfsEntry } : {}), + lastModified: got.lastModified ?? null, + }; +} + +// ---------------------------------------------------------------- download + +/** + * Download over https only, following each redirect hop by hand so a hop to + * plain http is refused, with a byte cap and a time limit. Nothing secret is + * sent: the User-Agent, and the identifying headers an operator asks for. + * @param {string} url + * @param {string} dest + * @param {Record|null} [extraHeaders] A feed's + * `staticHeaders`. + * @param {{timeoutMs?: number, maxBytes?: number}} [limits] The time limit + * for the whole download and the byte cap (defaults: 5 minutes, 512 MiB). + * @returns {Promise<{finalUrl: string, lastModified: string|null, bytes: number}>} + */ +export async function downloadStatic( + url, + dest, + extraHeaders = null, + { timeoutMs = DOWNLOAD_TIMEOUT_MS, maxBytes = MAX_DOWNLOAD_BYTES } = {}, +) { + let current = url; + const signal = AbortSignal.timeout(timeoutMs); + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const target = new URL(current); + if (target.protocol !== 'https:') + throw new Error(`refusing non-https URL ${current}`); + const response = await fetch(target.href, { + redirect: 'manual', + signal, + headers: { + 'User-Agent': USER_AGENT, + Accept: '*/*', + // Identify-yourself headers an operator asks for (Digitraffic, + // Entur). + ...(extraHeaders || {}), + }, + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel(); + const location = response.headers.get('location'); + if (!location) throw new Error('redirect without a location'); + current = new URL(location, current).href; + continue; + } + if (!response.ok || !response.body) + throw new Error(`HTTP ${response.status} from ${current}`); + const declared = Number(response.headers.get('content-length')); + if (declared > maxBytes) throw new Error(`download is ${declared} bytes`); + let bytes = 0; + await pipeline( + Readable.fromWeb(response.body), + new Transform({ + transform(chunk, _encoding, done) { + bytes += chunk.length; + if (bytes > maxBytes) + done(new Error('download exceeds the size cap')); + else done(null, chunk); + }, + }), + createWriteStream(dest), + ); + return { + finalUrl: current, + lastModified: response.headers.get('last-modified'), + bytes, + }; + } + throw new Error('too many redirects'); +} + +// ---------------------------------------------------------------- zip + +/** + * Read a zip's central directory. Entries are streamed on demand; nothing is + * extracted. Stored and deflated entries, zip64 sizes; encrypted entries are + * refused. + * @param {string} file + */ +export async function openZip(file) { + const handle = await open(file, 'r'); + try { + const { size } = await handle.stat(); + const tailSize = Math.min(size, 65_557); + const tail = Buffer.alloc(tailSize); + await handle.read(tail, 0, tailSize, size - tailSize); + let eocd = -1; + for (let i = tailSize - 22; i >= 0; i--) { + if (tail.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error('not a zip file'); + let count = tail.readUInt16LE(eocd + 10); + let cdSize = tail.readUInt32LE(eocd + 12); + let cdOffset = tail.readUInt32LE(eocd + 16); + if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) { + const locator = eocd - 20; + if (locator < 0 || tail.readUInt32LE(locator) !== 0x07064b50) + throw new Error('zip64 locator missing'); + const at = Number(tail.readBigUInt64LE(locator + 8)); + const record = Buffer.alloc(56); + await handle.read(record, 0, 56, at); + if (record.readUInt32LE(0) !== 0x06064b50) + throw new Error('zip64 record missing'); + count = Number(record.readBigUInt64LE(32)); + cdSize = Number(record.readBigUInt64LE(40)); + cdOffset = Number(record.readBigUInt64LE(48)); + } + if (cdOffset + cdSize > size) throw new Error('zip directory out of range'); + const cd = Buffer.alloc(cdSize); + await handle.read(cd, 0, cdSize, cdOffset); + const entries = new Map(); + let p = 0; + for (let k = 0; k < count; k++) { + if (cd.readUInt32LE(p) !== 0x02014b50) + throw new Error('bad zip directory entry'); + const flags = cd.readUInt16LE(p + 8); + const method = cd.readUInt16LE(p + 10); + let compressed = cd.readUInt32LE(p + 20); + let uncompressed = cd.readUInt32LE(p + 24); + const nameLength = cd.readUInt16LE(p + 28); + const extraLength = cd.readUInt16LE(p + 30); + const commentLength = cd.readUInt16LE(p + 32); + let offset = cd.readUInt32LE(p + 42); + const name = cd.toString('utf8', p + 46, p + 46 + nameLength); + let e = p + 46 + nameLength; + const extraEnd = e + extraLength; + while (e + 4 <= extraEnd) { + const id = cd.readUInt16LE(e); + const length = cd.readUInt16LE(e + 2); + if (id === 0x0001) { + let q = e + 4; + if (uncompressed === 0xffffffff) { + uncompressed = Number(cd.readBigUInt64LE(q)); + q += 8; + } + if (compressed === 0xffffffff) { + compressed = Number(cd.readBigUInt64LE(q)); + q += 8; + } + if (offset === 0xffffffff) offset = Number(cd.readBigUInt64LE(q)); + } + e += 4 + length; + } + p = extraEnd + commentLength; + if (name.endsWith('/')) continue; + entries.set(name, { + name, + method, + encrypted: (flags & 1) === 1, + compressed, + uncompressed, + offset, + }); + } + return { file, size, entries, handle }; + } catch (error) { + await handle.close(); + throw error; + } +} + +/** The entry named `base`, at the root or in a single top-level folder. */ +function findEntry(zip, base) { + const exact = zip.entries.get(base); + if (exact) return exact; + for (const entry of zip.entries.values()) { + if ( + entry.name.split('/').pop() === base && + !entry.name.startsWith('__MACOSX') + ) + return entry; + } + return null; +} + +/** + * A readable stream of one entry's uncompressed bytes, refused or ended + * with an error past `maxBytes` (declared or inflated). + * @param {object} zip From openZip. + * @param {object} entry + * @param {{maxBytes?: number}} [options] Default MAX_ENTRY_BYTES; a caller + * that holds the whole entry in memory gives a smaller cap. + */ +export async function entryStream( + zip, + entry, + { maxBytes = MAX_ENTRY_BYTES } = {}, +) { + if (entry.encrypted) throw new Error(`${entry.name} is encrypted`); + if (entry.method !== 0 && entry.method !== 8) + throw new Error(`${entry.name} uses compression method ${entry.method}`); + if (entry.uncompressed > maxBytes) + throw new Error(`${entry.name} is ${entry.uncompressed} bytes`); + const header = Buffer.alloc(30); + await zip.handle.read(header, 0, 30, entry.offset); + if (header.readUInt32LE(0) !== 0x04034b50) + throw new Error(`bad local header for ${entry.name}`); + const start = + entry.offset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28); + if (start + entry.compressed > zip.size) + throw new Error(`${entry.name} runs past the end of the zip`); + const raw = createReadStream(zip.file, { + start, + end: Math.max(start, start + entry.compressed - 1), + }); + if (entry.compressed === 0) { + raw.destroy(); + return Readable.from([]); + } + let produced = 0; + const cap = new Transform({ + transform(chunk, _encoding, done) { + produced += chunk.length; + if (produced > maxBytes) + done(new Error(`${entry.name} inflates past the cap`)); + else done(null, chunk); + }, + }); + if (entry.method === 0) return raw.pipe(cap); + const inflate = createInflateRaw(); + raw.on('error', (error) => inflate.destroy(error)); + inflate.on('error', (error) => cap.destroy(error)); + return raw.pipe(inflate).pipe(cap); +} + +// ---------------------------------------------------------------- csv + +function countQuotes(text) { + let n = 0; + for (let i = text.indexOf('"'); i >= 0; i = text.indexOf('"', i + 1)) n++; + return n; +} + +/** One CSV record (RFC 4180 quoting), fields trimmed. */ +export function parseRecord(line) { + if (line.endsWith('\r')) line = line.slice(0, -1); + if (!line.includes('"')) return line.split(',').map((f) => f.trim()); + const fields = []; + let field = ''; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quoted) { + if (c === '"') { + if (line[i + 1] === '"') { + field += '"'; + i++; + } else quoted = false; + } else field += c; + } else if (c === '"') quoted = true; + else if (c === ',') { + fields.push(field.trim()); + field = ''; + } else field += c; + } + fields.push(field.trim()); + return fields; +} + +/** + * Stream a CSV entry as objects keyed by the (trimmed) header, in batches. + * Records spanning lines inside quotes are joined; only one chunk and one + * partial record are held at a time. + * @param {import('node:stream').Readable} stream + * @param {(row: Record) => void} onRow + */ +export async function readCsv(stream, onRow) { + const decoder = new StringDecoder('utf8'); + let carry = ''; + let header = null; + let first = true; + let rows = 0; + const emit = (line) => { + if (!line || line === '\r') return; + const fields = parseRecord(line); + if (!header) { + header = fields; + return; + } + const row = {}; + for (let i = 0; i < header.length; i++) row[header[i]] = fields[i] ?? ''; + onRow(row); + rows++; + }; + const consume = (text, final) => { + if (first) { + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + first = false; + } + const lines = text.split('\n'); + carry = final ? '' : lines.pop(); + let pending = null; + for (const line of lines) { + const record = pending === null ? line : `${pending}\n${line}`; + if (countQuotes(record) % 2 === 1) { + pending = record; + continue; + } + pending = null; + emit(record); + } + if (pending !== null) { + if (final) emit(pending); + else carry = `${pending}\n${carry}`; + } + if (carry.length > MAX_CSV_RECORD_CHARS) + throw new Error('CSV record too long'); + }; + for await (const chunk of stream) { + consume(carry + decoder.write(chunk), false); + checkMemory(); + } + consume(carry + decoder.end(), true); + return rows; +} + +/** + * Abandon the work in hand when resident memory is over the build's cap, + * 1.75 GiB. + * @throws when over the cap. + */ +export function checkMemory() { + const rss = process.memoryUsage.rss(); + if (rss > peakRss) peakRss = rss; + if (rss > MAX_RSS_BYTES) + throw new Error( + `resident memory ${Math.round(rss / 2 ** 20)} MiB over cap`, + ); +} + +// ---------------------------------------------------------------- geometry + +/** + * Whether a stop has coordinates: both finite and not both zero. Some feeds + * write (0, 0) for a stop they cannot place (a foreign station their + * international trains serve), and that is read as no coordinates at all. + */ +export function hasStopCoordinates(lat, lon) { + return ( + Number.isFinite(lat) && Number.isFinite(lon) && !(lat === 0 && lon === 0) + ); +} + +function haversineM(lat1, lon1, lat2, lon2) { + const r = 6_371_008.8; + const toRad = Math.PI / 180; + const dLat = (lat2 - lat1) * toRad; + const dLon = (lon2 - lon1) * toRad; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * r * Math.asin(Math.min(1, Math.sqrt(a))); +} + +const round5 = (value) => Math.round(value * 1e5) / 1e5; +const round1 = (value) => Math.round(value * 10) / 10; + +/** + * Turn raw shape points into a rounded flat polyline with cumulative + * distances, or a reason it was dropped: a vertex gap over + * MAX_VERTEX_GAP_M marks a chord. + * @param {number[]} triples `[sequence, lat, lon, ...]` + * @param {{toleranceM?: number}} [options] Simplification tolerance (m). + */ +export function finishShape( + triples, + { toleranceM = SIMPLIFY_TOLERANCE_M } = {}, +) { + const order = []; + for (let i = 0; i < triples.length; i += 3) order.push(i); + order.sort((a, b) => triples[a] - triples[b]); + const flat = []; + for (const i of order) { + const lat = round5(triples[i + 1]); + const lon = round5(triples[i + 2]); + if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90) + continue; + const n = flat.length; + if (n && flat[n - 2] === lat && flat[n - 1] === lon) continue; + flat.push(lat, lon); + } + if (flat.length < 4) return { dropped: 'fewer than two points' }; + let maxGap = 0; + let gaps = 0; + for (let i = 2; i < flat.length; i += 2) { + const gap = haversineM(flat[i - 2], flat[i - 1], flat[i], flat[i + 1]); + if (gap > maxGap) maxGap = gap; + if (gap > MAX_VERTEX_GAP_M) gaps++; + } + // The gate reads the operator's own vertices; simplifying first would + // manufacture long gaps on straight track. + if (maxGap > MAX_VERTEX_GAP_M) + return { dropped: `vertex gap ${Math.round(maxGap)} m`, maxGap, gaps }; + const line = simplifyFlat(flat, toleranceM); + const dist = [0]; + let total = 0; + for (let i = 2; i < line.length; i += 2) { + total += haversineM(line[i - 2], line[i - 1], line[i], line[i + 1]); + dist.push(round1(total)); + } + return { flat: line, dist, maxGap, rawPoints: flat.length / 2, gaps }; +} + +/** + * Douglas-Peucker on a flat lat/lon polyline in a local metric frame. Every + * dropped vertex lies within `toleranceM` of the kept line, far inside what + * a train marker shows; dense operator shapes shrink several-fold. + * @param {number[]} flat + * @param {number} toleranceM + * @returns {number[]} + */ +export function simplifyFlat(flat, toleranceM) { + const n = flat.length / 2; + if (n <= 2) return flat.slice(); + let sumLat = 0; + for (let i = 0; i < n; i++) sumLat += flat[2 * i]; + const kx = 111_320 * Math.cos(((sumLat / n) * Math.PI) / 180); + const ky = 111_320; + const x = new Float64Array(n); + const y = new Float64Array(n); + for (let i = 0; i < n; i++) { + x[i] = (flat[2 * i + 1] - flat[1]) * kx; + y[i] = (flat[2 * i] - flat[0]) * ky; + } + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const stack = [0, n - 1]; + const tolerance2 = toleranceM * toleranceM; + while (stack.length) { + const last = stack.pop(); + const first = stack.pop(); + const dx = x[last] - x[first]; + const dy = y[last] - y[first]; + const len2 = dx * dx + dy * dy; + let worst = -1; + let worstD2 = tolerance2; + for (let i = first + 1; i < last; i++) { + let t = + len2 > 0 ? ((x[i] - x[first]) * dx + (y[i] - y[first]) * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + const ex = x[first] + dx * t - x[i]; + const ey = y[first] + dy * t - y[i]; + const d2 = ex * ex + ey * ey; + if (d2 > worstD2) { + worstD2 = d2; + worst = i; + } + } + if (worst < 0) continue; + keep[worst] = 1; + stack.push(first, worst, worst, last); + } + const out = []; + for (let i = 0; i < n; i++) + if (keep[i]) out.push(flat[2 * i], flat[2 * i + 1]); + return out; +} + +// ---------------------------------------------------------------- per feed + +/** + * Spain's provinces (ISO 3166-2) grouped into the autonomous communities the + * category rule uses as states. Elsewhere the Natural Earth unit is the state. + */ +const STATE_GROUPS = Object.freeze({ + ES: Object.fromEntries( + Object.entries({ + AN: ['AL', 'CA', 'CO', 'GR', 'H', 'J', 'MA', 'SE'], + AR: ['HU', 'TE', 'Z'], + AS: ['O'], + IB: ['PM'], + CN: ['GC', 'TF'], + CB: ['S'], + CL: ['AV', 'BU', 'LE', 'P', 'SA', 'SG', 'SO', 'VA', 'ZA'], + CM: ['AB', 'CR', 'CU', 'GU', 'TO'], + CT: ['B', 'GI', 'L', 'T'], + VC: ['A', 'CS', 'V'], + EX: ['BA', 'CC'], + GA: ['C', 'LU', 'OR', 'PO'], + MD: ['M'], + MC: ['MU'], + NC: ['NA'], + PV: ['VI', 'BI', 'SS'], + RI: ['LO'], + CE: ['CE'], + ML: ['ML'], + }).flatMap(([community, provinces]) => + provinces.map((province) => ['ES-' + province, 'ES-' + community]), + ), + ), +}); +/** Nearest unit within this distance claims a point no polygon contains. */ +const STATE_SNAP_KM = 25; + +let statesPromise = null; +/** Natural Earth admin-1 units with decoded polygons and boxes, loaded once. */ +async function loadStates() { + statesPromise ??= (async () => { + const file = path.join( + ROOT, + 'src/data/local_data/natural_earth/states_provinces.json', + ); + const { meta, features } = JSON.parse( + await readFile(file, { encoding: 'utf8' }), + ); + return features + .filter((unit) => unit.iso2 && Array.isArray(unit.polygons)) + .map((unit) => { + const polygons = unit.polygons.map((poly) => + poly.map((ring) => decodeRing(ring, unit.d ?? meta.decimals)), + ); + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const [outer] of polygons) + for (const [lon, lat] of outer) { + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + return { unit, polygons, box: { west, south, east, north } }; + }); + })(); + return statesPromise; +} + +/** + * The state a point lies in, for the category rules: `{country, state}` + * (ISO codes; Spain's provinces grouped into communities), or null. A point + * just outside every simplified polygon (a coastal station) goes to the + * nearest unit within STATE_SNAP_KM. + */ +export async function createStateLookup() { + const states = await loadStates(); + const result = (unit) => ({ + country: unit.iso2, + state: STATE_GROUPS[unit.iso2]?.[unit.iso] ?? unit.iso ?? unit.name, + }); + return (lat, lon) => { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + for (const { unit, polygons, box } of states) { + if ( + lat < box.south || + lat > box.north || + lon < box.west || + lon > box.east + ) + continue; + if (polygonsContain(polygons, lat, lon)) return result(unit); + } + let best = null; + const margin = STATE_SNAP_KM / 111; + for (const { unit, polygons, box } of states) { + if ( + lat < box.south - margin || + lat > box.north + margin || + lon < box.west - margin * 2 || + lon > box.east + margin * 2 + ) + continue; + for (const [outer] of polygons) + for (const [vlon, vlat] of outer) { + const km = haversineM(lat, lon, vlat, vlon) / 1000; + if (km <= STATE_SNAP_KM && (!best || km < best.km)) + best = { km, unit }; + } + } + return best ? result(best.unit) : null; + }; +} + +/** + * Where each rail trip begins and ends, for a feed whose category rule needs + * it (`staticTripEnds: true`): the first and last stop by stop_sequence, each + * as `{id, name, lat, lon, country, state}` (`createStateLookup`; null + * country and state for a point no unit claims), and every country the trip + * stops in. Only trips on rail routes are held, so memory follows the + * feed's trains, not its buses. + * @returns {Promise>} + */ +export async function readTripEnds(feed, zip) { + const routes = new Set(); + await csvEntry(zip, 'routes.txt', (row) => { + if (row.route_id && keepsRouteType(feed, row.route_type)) + routes.add(row.route_id); + }); + const runs = new Map(); + await csvEntry(zip, 'trips.txt', (row) => { + if (row.trip_id && routes.has(row.route_id)) runs.set(row.trip_id, null); + }); + const stops = new Map(); + await csvEntry(zip, 'stops.txt', (row) => { + const lat = Number(row.stop_lat); + const lon = Number(row.stop_lon); + if (row.stop_id && hasStopCoordinates(lat, lon)) + stops.set(row.stop_id, { + id: row.stop_id, + name: row.stop_name || '', + lat, + lon, + }); + }); + const stateAt = await createStateLookup(); + const placed = (id) => { + const stop = stops.get(id); + if (stop && !('country' in stop)) { + const at = stateAt(stop.lat, stop.lon); + stop.country = at?.country ?? null; + stop.state = at?.state ?? null; + } + return stop ?? null; + }; + await csvEntry(zip, 'stop_times.txt', (row) => { + if (!runs.has(row.trip_id)) return; + const stop = placed(row.stop_id); + const seq = Number(row.stop_sequence); + if (!stop || !Number.isFinite(seq)) return; + let run = runs.get(row.trip_id); + if (!run) + runs.set( + row.trip_id, + (run = { first: seq, last: seq, from: stop, to: stop, countries: '' }), + ); + if (seq < run.first) [run.first, run.from] = [seq, stop]; + if (seq > run.last) [run.last, run.to] = [seq, stop]; + // ISO codes joined by `|`: small enough for a national feed's year of + // trips (over a million). + if (stop.country && !run.countries.includes(stop.country)) + run.countries += `${run.countries ? '|' : ''}${stop.country}`; + }); + for (const [tripId, run] of runs) { + if (!run) runs.delete(tripId); + else + runs.set(tripId, { + from: run.from, + to: run.to, + countries: run.countries ? run.countries.split('|').sort() : [], + }); + } + return runs; +} + +/** + * How a feed's route ids are written into its packs. Most feeds keep the + * operator's ids. A feed whose classifier needs more than the id gives a + * `staticRouteRename(route, {stop, stateAt, agencyName})` (from the + * routes.txt row; `agencyName(agency_id)` reads agency.txt) or a + * `staticTripRename(trip, routeId, {ends})` (per trips.txt row; `ends` is the + * trip's `readTripEnds` entry, or null, when the feed sets `staticTripEnds`) + * that returns an id carrying it (see railFeeds.js `scopedRoute`); the + * realtime proxy reads the same ids back from the schedule pack. + * @returns {Promise<{route: (row: object) => string|null, + * trip: (row: object, routeId: string) => string|null}>} + */ +export async function createRouteNames(feed, zip) { + const tripEnds = + feed.staticTripEnds === true && typeof feed.staticTripRename === 'function' + ? await readTripEnds(feed, zip) + : null; + let helpers = null; + if (typeof feed.staticRouteRename === 'function') { + const stops = new Map(); + await csvEntry( + zip, + 'stops.txt', + (row) => { + const lat = Number(row.stop_lat); + const lon = Number(row.stop_lon); + if (row.stop_id && hasStopCoordinates(lat, lon)) + stops.set(row.stop_id.trim(), { lat, lon }); + }, + { required: false }, + ); + const stateAt = await createStateLookup(); + // agency_id → agency_name, for a feed that sorts its routes by operator. + const agencies = new Map(); + await csvEntry( + zip, + 'agency.txt', + (row) => { + agencies.set(String(row.agency_id ?? '').trim(), row.agency_name ?? ''); + }, + { required: false }, + ); + helpers = { + stop: (id) => stops.get(id) ?? null, + stateAt, + agencyName: (id) => agencies.get(String(id ?? '').trim()) ?? null, + }; + } + return { + /** The pack id of a routes.txt row this feed keeps, else null. */ + route(row) { + if (!row.route_id || !keepsRouteType(feed, row.route_type)) return null; + const id = helpers + ? (feed.staticRouteRename(row, helpers) ?? row.route_id) + : row.route_id; + // A per-trip rename decides at the trip. + if (typeof feed.staticTripRename === 'function') return id; + return classifyTrain(feed, id) ? id : null; + }, + /** The pack id of a trips.txt row on a kept route, else null. */ + trip(row, routeId) { + if (typeof feed.staticTripRename !== 'function') return routeId; + const id = + (tripEnds + ? feed.staticTripRename(row, routeId, { + ends: tripEnds.get(row.trip_id) ?? null, + }) + : feed.staticTripRename(row, routeId)) ?? routeId; + return classifyTrain(feed, id, { tripId: row.trip_id }) ? id : null; + }, + }; +} + +async function csvEntry(zip, base, onRow, { required = true } = {}) { + const entry = findEntry(zip, base); + if (!entry) { + if (required) throw new Error(`${base} missing`); + return 0; + } + return readCsv(await entryStream(zip, entry), onRow); +} + +/** + * `feed_info.txt` `feed_version`, or null: the packs record which version + * of the operator's timetable they were built from. + */ +export async function readFeedVersion(zip) { + let version = null; + await csvEntry( + zip, + 'feed_info.txt', + (row) => { + const value = String(row.feed_version ?? '').trim(); + if (version === null && value && value.length <= 64) version = value; + }, + { required: false }, + ); + return version; +} + +/** + * Build one feed's pack from an open GTFS zip: the operator's shapes as + * published, through the quality gate and simplification. + */ +export async function buildPack(feed, zip, source, names = null) { + names ??= await createRouteNames(feed, zip); + const deadline = Date.now() + FEED_DEADLINE_MS; + const overdue = () => { + if (Date.now() > deadline) + throw new Error('feed build ran past its deadline'); + }; + /** routes.txt id → pack id, for the routes this feed keeps. */ + const railRoutes = new Map(); + let routeRows = 0; + await csvEntry(zip, 'routes.txt', (row) => { + routeRows++; + const id = names.route(row); + if (id) railRoutes.set(row.route_id, id); + }); + overdue(); + const tripShape = new Map(); + const routeShapes = new Map(); + let railTrips = 0; + let shapelessTrips = 0; + await csvEntry(zip, 'trips.txt', (row) => { + const base = railRoutes.get(row.route_id); + const routeId = base ? names.trip(row, base) : null; + if (!routeId) return; + railTrips++; + if (!row.shape_id) { + shapelessTrips++; + return; + } + if (row.trip_id) tripShape.set(row.trip_id, row.shape_id); + let shapes = routeShapes.get(routeId); + if (!shapes) routeShapes.set(routeId, (shapes = new Set())); + shapes.add(row.shape_id); + }); + overdue(); + const wanted = new Map(); + for (const shapes of routeShapes.values()) + for (const id of shapes) if (!wanted.has(id)) wanted.set(id, []); + if (wanted.size) + await csvEntry(zip, 'shapes.txt', (row) => { + const points = wanted.get(row.shape_id); + if (!points) return; + points.push( + Number(row.shape_pt_sequence), + Number(row.shape_pt_lat), + Number(row.shape_pt_lon), + ); + }); + overdue(); + + let sparseShapes = 0; + const shapes = []; + const dist = []; + const shapeSources = []; + const indexByKey = new Map(); + const indexByShapeId = new Map(); + const dropped = []; + let rawPoints = 0; + for (const [shapeId, triples] of wanted) { + // A feed whose operator shapes are dense enough to break the pack cap + // (Vy: 1.2 million points at 1 m) sets a coarser `staticSimplifyM`. + const result = finishShape(triples, { + toleranceM: feed.staticSimplifyM ?? SIMPLIFY_TOLERANCE_M, + }); + if (result.maxGap > MAX_VERTEX_GAP_M && !result.dropped) sparseShapes++; + wanted.set(shapeId, null); + if (result.dropped) { + dropped.push({ shapeId, reason: result.dropped }); + continue; + } + const key = result.flat.join(','); + if (!indexByKey.has(key)) rawPoints += result.rawPoints; + let index = indexByKey.get(key); + if (index === undefined) { + index = shapes.length; + shapes.push(result.flat); + dist.push(result.dist); + shapeSources.push('operator'); + indexByKey.set(key, index); + } + indexByShapeId.set(shapeId, index); + } + const trips = Object.create(null); + let tripsMapped = 0; + for (const [tripId, shapeId] of tripShape) { + const index = indexByShapeId.get(shapeId); + if (index === undefined) continue; + trips[tripId] = index; + tripsMapped++; + } + const routes = Object.create(null); + for (const [routeId, ids] of routeShapes) { + const list = [ + ...new Set([...ids].map((id) => indexByShapeId.get(id))), + ].filter((index) => index !== undefined); + if (list.length) routes[routeId] = list.sort((a, b) => a - b); + } + const pack = { + version: RAIL_PATH_PACK_VERSION, + feedId: feed.id, + builtAt: new Date().toISOString(), + source, + shapes, + dist, + shapeSources, + trips, + routes, + }; + + return { + pack, + stats: { + routeRows, + railRoutes: railRoutes.size, + routesWithShapes: Object.keys(routes).length, + railTrips, + shapelessTrips, + tripsMapped, + shapeIds: indexByShapeId.size + dropped.length, + shapesKept: shapes.length, + shapesDropped: dropped.length, + sparseShapes, + points: shapes.reduce((sum, flat) => sum + flat.length / 2, 0), + rawPoints, + dropped, + }, + }; +} + +// ---------------------------------------------------------------- schedule + +/** Service dates in a pack by default: yesterday, today and two days on. */ +const DEFAULT_SCHEDULE_DAYS = 4; +/** Longest run (days) a pack looks back for: a multi-day train still out. */ +const MAX_LOOKBACK_DAYS = 7; +/** A stop farther than this from its trip's shape unties the pattern from it (m). */ +const MAX_STOP_OFFSET_M = 1000; +/** A stop's match on a shape may be this much worse than its best (m). */ +const STOP_MATCH_SLACK_M = 50; +/** Arc stride of the earliest-match search along a shape (m). */ +const STOP_SEARCH_STEP_M = 2000; +const WEEKDAYS = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +]; + +/** GTFS 'H:MM:SS' (hours may pass 24) to seconds, or NaN when blank. */ +export function parseGtfsTime(text) { + const match = /^\s*(\d{1,3}):([0-5]\d):([0-5]\d)\s*$/.exec(text ?? ''); + return match + ? Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + : NaN; +} + +/** Most runs one frequencies.txt row may expand to (a minute apart all day). */ +const MAX_FREQUENCY_RUNS = 1440; + +/** + * The departures of a frequency-based trip (frequencies.txt): from each + * row's start_time every headway_secs while before its end_time, the GTFS + * reading for both `exact_times` values. Rows are flat `[start, end, + * headway, ...]` in seconds; a malformed row adds nothing, and one row adds + * at most MAX_FREQUENCY_RUNS runs. Sorted, without duplicates. + * @param {number[]} windows + * @returns {number[]} + */ +export function frequencyDepartures(windows) { + const out = new Set(); + for (let i = 0; i + 2 < windows.length; i += 3) { + const [start, end, headway] = windows.slice(i, i + 3); + if (!Number.isFinite(start) || !Number.isFinite(end)) continue; + if (!Number.isInteger(headway) || headway <= 0) continue; + for ( + let at = start, n = 0; + at < end && n < MAX_FREQUENCY_RUNS; + at += headway, n++ + ) + out.add(at); + } + return [...out].sort((a, b) => a - b); +} + +/** Seconds after midnight as GTFS 'HH:MM:SS' (hours may pass 24). */ +function gtfsTimeText(sec) { + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(Math.floor(sec / 3600))}:${pad(Math.floor(sec / 60) % 60)}:${pad(sec % 60)}`; +} + +/** + * Resolve calendar.txt and calendar_dates.txt rows to the concrete service + * dates between two UTC-day numbers (inclusive). + * @param {object[]} calendar + * @param {object[]} exceptions + * @param {number} fromDay + * @param {number} toDay + * @returns {Map} service_id → sorted day numbers. + */ +export function resolveServiceDays(calendar, exceptions, fromDay, toDay) { + const days = new Map(); + const setFor = (id) => { + let set = days.get(id); + if (!set) days.set(id, (set = new Set())); + return set; + }; + for (const row of calendar) { + const start = dayNumberOf(row.start_date); + const end = dayNumberOf(row.end_date); + if (!row.service_id || !Number.isFinite(start) || !Number.isFinite(end)) + continue; + const set = setFor(row.service_id); + const last = Math.min(end, toDay); + // Day 0 (1970-01-01) was a Thursday. + for (let day = Math.max(start, fromDay); day <= last; day++) + if (row[WEEKDAYS[(((day + 4) % 7) + 7) % 7]] === '1') set.add(day); + } + for (const row of exceptions) { + const day = dayNumberOf(row.date); + if ( + !row.service_id || + !Number.isFinite(day) || + day < fromDay || + day > toDay + ) + continue; + const set = setFor(row.service_id); + if (row.exception_type === '1') set.add(day); + else if (row.exception_type === '2') set.delete(day); + } + const out = new Map(); + for (const [id, set] of days) + if (set.size) + out.set( + id, + [...set].sort((a, b) => a - b), + ); + return out; +} + +/** + * Arc length of each stop along a path, in stop order and never decreasing. + * Each stop takes the earliest point at or after the previous stop whose + * distance is within STOP_MATCH_SLACK_M of its best forward match, so a loop + * that passes a station twice binds the station to the pass in order. + * @param {object} path From createRailPath. + * @param {Array<{lat: number, lon: number}>} stops + * @returns {{dist: number[], maxOffsetM: number}|null} + */ +export function projectStops(path, stops) { + const dist = []; + let prev = 0; + let maxOffsetM = 0; + for (const { lat, lon } of stops) { + const rest = Math.max(0, path.length - prev); + const whole = projectOnPath(path, lat, lon, prev + rest / 2, rest / 2 + 1); + if (!whole) return null; + let pick = { s: whole.s, offsetM: whole.offsetM }; + const accept = whole.offsetM + STOP_MATCH_SLACK_M; + for ( + let a = prev; + a < whole.s - STOP_SEARCH_STEP_M; + a += STOP_SEARCH_STEP_M + ) { + const hit = projectOnPath( + path, + lat, + lon, + a + STOP_SEARCH_STEP_M / 2, + STOP_SEARCH_STEP_M / 2, + ); + if (hit && hit.offsetM <= accept && hit.s + 1 >= prev) { + pick = { s: hit.s, offsetM: hit.offsetM }; + break; + } + } + const s = Math.max(prev, pick.s); + dist.push(round1(s)); + prev = s; + // The path's flat projection stretches east-west distance away from its + // middle latitude (2% at Newark on a Roanoke to New York line), so the + // offset is the great-circle distance to the matched point. + const foot = pointAtS(path, pick.s); + const offsetM = foot + ? haversineM(lat, lon, foot.lat, foot.lon) + : pick.offsetM; + if (offsetM > maxOffsetM) maxOffsetM = offsetM; + } + return { dist, maxOffsetM }; +} + +/** Cumulative great-circle metres along a list of points. */ +function straightDist(points) { + const dist = [0]; + for (let k = 1; k < points.length; k++) { + const a = points[k - 1]; + const b = points[k]; + dist.push(round1(dist[k - 1] + haversineM(a.lat, a.lon, b.lat, b.lon))); + } + return dist; +} + +/** + * Fill blank stop times: a stop with one of arrival/departure takes it for + * both; a stop with neither (GTFS allows that between timepoints) is placed + * by distance between the timed stops around it. + * @returns {boolean} false when the first or last stop has no time. + */ +export function fillTimes(arr, dep, dist) { + const n = arr.length; + for (let k = 0; k < n; k++) { + if (!Number.isFinite(arr[k])) arr[k] = dep[k]; + if (!Number.isFinite(dep[k])) dep[k] = arr[k]; + } + if (!Number.isFinite(dep[0]) || !Number.isFinite(arr[n - 1])) return false; + let last = 0; + for (let k = 1; k < n; k++) { + if (!Number.isFinite(arr[k])) continue; + const span = dist[k] - dist[last]; + for (let j = last + 1; j < k; j++) { + const f = + span > 0 ? (dist[j] - dist[last]) / span : (j - last) / (k - last); + arr[j] = dep[j] = Math.round(dep[last] + (arr[k] - dep[last]) * f); + } + last = k; + } + return true; +} + +/** + * Build one feed's schedule pack from an open GTFS zip and the path pack just + * built from it. A stop with no coordinates (hasStopCoordinates) is left out + * of its runs, so no line is drawn to it and nothing is guessed; a run needs + * two located stops. A pattern whose trips have no usable operator shape + * takes `shape: null`, and the layer draws no line for it. + * @param {object} feed + * @param {object} zip + * @param {object} source + * @param {object} pathPack Read only. + * @param {{from?: string|null, days?: number, now?: number, + * names?: object}} [options] + */ +export async function buildSchedule(feed, zip, source, pathPack, options = {}) { + const names = options.names ?? (await createRouteNames(feed, zip)); + const deadline = Date.now() + FEED_DEADLINE_MS; + const overdue = () => { + if (Date.now() > deadline) + throw new Error('schedule build ran past its deadline'); + }; + let timezone = null; + await csvEntry(zip, 'agency.txt', (row) => { + if (!timezone && row.agency_timezone) timezone = row.agency_timezone; + }); + if (!isTimeZone(timezone)) + throw new Error(`agency_timezone ${timezone} is not an IANA zone`); + const days = Math.max(1, Math.min(14, options.days ?? DEFAULT_SCHEDULE_DAYS)); + const fromDay = options.from + ? dayNumberOf(options.from) + : dayNumberOf(localDate(options.now ?? Date.now(), timezone)) - 1; + if (!Number.isFinite(fromDay)) throw new Error(`bad --from ${options.from}`); + const toDay = fromDay + days - 1; + + const calendar = []; + const exceptions = []; + await csvEntry(zip, 'calendar.txt', (row) => calendar.push(row), { + required: false, + }); + // Only dates inside the window can change it; a national feed lists a + // year of exceptions (hundreds of MB), too many rows to hold. + await csvEntry( + zip, + 'calendar_dates.txt', + (row) => { + const day = dayNumberOf(row.date); + if (day >= fromDay - MAX_LOOKBACK_DAYS && day <= toDay) + exceptions.push(row); + }, + { required: false }, + ); + const serviceDays = resolveServiceDays( + calendar, + exceptions, + fromDay - MAX_LOOKBACK_DAYS, + toDay, + ); + calendar.length = 0; + exceptions.length = 0; + overdue(); + + const routes = Object.create(null); + /** routes.txt id → pack id, for the routes this feed keeps. */ + const routeIds = new Map(); + await csvEntry(zip, 'routes.txt', (row) => { + const id = names.route(row); + if (!id) return; + routeIds.set(row.route_id, id); + const info = { type: Number.parseInt(row.route_type, 10) }; + if (row.route_short_name) info.short = row.route_short_name; + if (row.route_long_name) info.long = row.route_long_name; + if (/^[0-9a-f]{6}$/i.test(row.route_color ?? '')) + info.color = row.route_color.toUpperCase(); + if (/^[0-9a-f]{6}$/i.test(row.route_text_color ?? '')) + info.textColor = row.route_text_color.toUpperCase(); + routes[id] = info; + }); + const trips = new Map(); + let railTrips = 0; + await csvEntry(zip, 'trips.txt', (row) => { + const base = routeIds.get(row.route_id); + if (!base || !row.trip_id) return; + const routeId = names.trip(row, base); + if (!routeId) return; + // A per-trip rename makes a new pack route with its line's details. + if (!Object.hasOwn(routes, routeId)) routes[routeId] = { ...routes[base] }; + railTrips++; + if (!serviceDays.has(row.service_id)) return; + trips.set(row.trip_id, { + route: routeId, + service: row.service_id, + headsign: row.trip_headsign || '', + name: row.trip_short_name || '', + direction: + row.direction_id === '0' ? 0 : row.direction_id === '1' ? 1 : null, + rows: [], + }); + }); + // Frequency-based runs repeat one template trip (Santiago's Tren Nos): + // the template's stop times are kept and expanded below into one run per + // departure, `@`, all sharing its pattern and run times. + /** trip_id → flat [start, end, headway, ...] seconds. */ + const frequencyWindows = new Map(); + await csvEntry( + zip, + 'frequencies.txt', + (row) => { + if (!trips.has(row.trip_id)) return; + let windows = frequencyWindows.get(row.trip_id); + if (!windows) frequencyWindows.set(row.trip_id, (windows = [])); + windows.push( + parseGtfsTime(row.start_time), + parseGtfsTime(row.end_time), + Number(row.headway_secs), + ); + }, + { required: false }, + ); + const frequencyTrips = frequencyWindows.size; + overdue(); + + const allStops = new Map(); + await csvEntry(zip, 'stops.txt', (row) => { + if (!row.stop_id) return; + allStops.set(row.stop_id, { + name: row.stop_name || '', + lat: row.stop_lat ? Number(row.stop_lat) : NaN, + lon: row.stop_lon ? Number(row.stop_lon) : NaN, + parent: row.parent_station || '', + tz: row.stop_timezone || '', + platform: row.platform_code || '', + }); + }); + overdue(); + + // The heavy file is streamed; only rows of kept trips are held, as flat + // tuples [sequence, arrival, departure, stop id, pass-through]. + let stopTimeRows = 0; + await csvEntry(zip, 'stop_times.txt', (row) => { + stopTimeRows++; + const trip = trips.get(row.trip_id); + if (!trip) return; + trip.rows.push( + Number(row.stop_sequence), + parseGtfsTime(row.arrival_time), + parseGtfsTime(row.departure_time), + row.stop_id, + row.pickup_type === '1' && row.drop_off_type === '1' ? 1 : 0, + ); + }); + overdue(); + + const paths = new Map(); + const pathAt = (index) => { + if (!paths.has(index)) + paths.set( + index, + createRailPath(pathPack.shapes[index], pathPack.dist[index]), + ); + return paths.get(index); + }; + const patterns = []; + const patternByKey = new Map(); + const profiles = []; + const profileByKey = new Map(); + const outTrips = Object.create(null); + const stats = { + railTrips, + tripsInWindow: trips.size, + frequencyTrips, + /** Runs the kept frequency-based trips expanded to. */ + frequencyRuns: 0, + stopTimeRows, + tripsKept: 0, + tripsUntimed: 0, + tripsOutOfWindow: 0, + tripsUnknownStop: 0, + tripsNoPositions: 0, + /** Runs that kept their located stops when some had no coordinates. */ + tripsStopsLeftOut: 0, + stopsLeftOut: 0, + patternsOffShape: 0, + patternsShapeless: 0, + maxStopOffsetM: 0, + }; + let lookbackDays = 0; + + /** Stops projected onto an operator shape, or null when they do not fit. */ + const projections = new Map(); + const projectOnto = (shape, stopIds) => { + const key = `${shape}|${stopIds.join('\u0001')}`; + if (!projections.has(key)) { + const path = pathAt(shape); + const projected = path + ? projectStops( + path, + stopIds.map((id) => allStops.get(id)), + ) + : null; + projections.set( + key, + projected && projected.maxOffsetM <= MAX_STOP_OFFSET_M + ? projected + : null, + ); + } + return projections.get(key); + }; + + const patternFor = (shapeIndex, stopIds, seqs, pass) => { + const key = `${shapeIndex ?? ''}|${stopIds.join('\u0001')}|${seqs.join(',')}|${pass.join('')}`; + const known = patternByKey.get(key); + if (known !== undefined) return known; + const coords = stopIds.map((id) => allStops.get(id)); + let shape = shapeIndex; + let dist = null; + if (shape !== null) { + const projected = projectOnto(shape, stopIds); + if (projected) { + dist = projected.dist; + stats.maxStopOffsetM = Math.max( + stats.maxStopOffsetM, + Math.round(projected.maxOffsetM), + ); + } else { + stats.patternsOffShape++; + shape = null; + } + } + if (shape === null) { + stats.patternsShapeless++; + dist = straightDist(coords); + } + const pattern = { shape, stops: stopIds, seq: seqs, dist }; + if (pass.includes(1)) pattern.pass = pass; + const index = patterns.length; + patterns.push(pattern); + patternByKey.set(key, index); + return index; + }; + + /** Stops without coordinates, left out of their runs below. */ + const leftOut = new Set(); + + // The trips that run in the window, their stops in order. + const prepared = []; + for (const [tripId, trip] of trips) { + const rows = trip.rows; + trip.rows = null; + if (rows.length / 5 < 2) { + stats.tripsUntimed++; + continue; + } + const all = Array.from({ length: rows.length / 5 }, (_, k) => k).sort( + (a, b) => rows[5 * a] - rows[5 * b], + ); + const located = (k) => { + const stop = allStops.get(rows[5 * k + 3]); + return hasStopCoordinates(stop?.lat, stop?.lon); + }; + // A stop with no coordinates is left out of the run, its times with it, + // so no line is drawn to it; a run needs two located stops. + const order = all.filter(located); + if (order.length < all.length) + for (const k of all) if (!located(k)) leftOut.add(rows[5 * k + 3]); + if (order.length < 2) { + stats.tripsUnknownStop++; + continue; + } + if (order.length < all.length) stats.tripsStopsLeftOut++; + const count = order.length; + const stopIds = order.map((k) => rows[5 * k + 3]); + // A trip that stops in a no-position country (Ukraine, + // src/data/railNoPositions.js) is left out of the pack. A stop left out + // above still counts, by its id. + if ( + all.some((k) => { + const id = rows[5 * k + 3]; + const stop = allStops.get(id); + return isNoTrainPositionStop({ id, lat: stop?.lat, lon: stop?.lon }); + }) + ) { + stats.tripsNoPositions++; + continue; + } + const seqs = order.map((k) => rows[5 * k]); + const pass = order.map((k) => rows[5 * k + 4]); + const arr = order.map((k) => rows[5 * k + 1]); + const dep = order.map((k) => rows[5 * k + 2]); + const first = Number.isFinite(dep[0]) ? dep[0] : arr[0]; + const last = Number.isFinite(arr[count - 1]) + ? arr[count - 1] + : dep[count - 1]; + if (!Number.isFinite(first) || !Number.isFinite(last)) { + stats.tripsUntimed++; + continue; + } + // A frequency-based template runs from each departure, its stop times + // moved by the departure's distance from its own first one. + const departures = frequencyWindows.has(tripId) + ? frequencyDepartures(frequencyWindows.get(tripId)) + : null; + if (departures && !departures.length) { + stats.tripsUntimed++; + continue; + } + // A run that left before the window opened still counts while it is out. + const reach = Math.floor( + (departures ? last - first + departures.at(-1) : last) / 86_400, + ); + const runDays = serviceDays + .get(trip.service) + .filter((day) => day <= toDay && day >= fromDay - reach); + if (!runDays.length) { + stats.tripsOutOfWindow++; + continue; + } + const shapeIndex = Object.hasOwn(pathPack.trips, tripId) + ? pathPack.trips[tripId] + : null; + prepared.push({ + tripId, + trip, + count, + stopIds, + seqs, + pass, + arr, + dep, + reach, + shapeIndex, + first, + departures, + }); + } + trips.clear(); + frequencyWindows.clear(); + stats.stopsLeftOut = leftOut.size; + overdue(); + + for (const { + tripId, + trip, + count, + stopIds, + seqs, + pass, + arr, + dep, + reach, + shapeIndex, + first, + departures, + } of prepared) { + const patternIndex = patternFor(shapeIndex, stopIds, seqs, pass); + fillTimes(arr, dep, patterns[patternIndex].dist); + const startSec = Math.min(arr[0], dep[0]); + const offsets = []; + for (let k = 0; k < count; k++) + offsets.push(arr[k] - startSec, Math.max(arr[k], dep[k]) - startSec); + lookbackDays = Math.max(lookbackDays, Math.min(reach, MAX_LOOKBACK_DAYS)); + const profileKey = offsets.join(','); + let profile = profileByKey.get(profileKey); + if (profile === undefined) { + profile = profiles.length; + profiles.push(offsets); + profileByKey.set(profileKey, profile); + } + const out = { + pattern: patternIndex, + profile, + startSec, + route: trip.route, + service: trip.service, + }; + if (trip.headsign) out.headsign = trip.headsign; + if (trip.name) out.name = trip.name; + if (trip.direction !== null) out.direction = trip.direction; + if (!departures) { + outTrips[tripId] = out; + stats.tripsKept++; + continue; + } + for (const departure of departures) { + const run = `${tripId}@${gtfsTimeText(departure)}`; + if (Object.hasOwn(outTrips, run)) continue; + outTrips[run] = { ...out, startSec: startSec + departure - first }; + stats.tripsKept++; + stats.frequencyRuns++; + } + } + prepared.length = 0; + overdue(); + + // Stops: those the kept trips serve, then their parent stations. + const stopIndex = new Map(); + const stopList = []; + const addStop = (id) => { + if (stopIndex.has(id)) return stopIndex.get(id); + const stop = allStops.get(id); + const index = stopList.length; + stopIndex.set(id, index); + const entry = { + id, + name: stop.name, + lat: round5(stop.lat), + lon: round5(stop.lon), + }; + stopList.push(entry); + if (stop.platform) entry.platform = stop.platform; + // A child without its own zone takes its parent's (GTFS stop_timezone). + const zone = stop.tz || allStops.get(stop.parent)?.tz || ''; + if (zone && zone !== timezone && isTimeZone(zone)) entry.tz = zone; + if ( + stop.parent && + stop.parent !== id && + hasStopCoordinates( + allStops.get(stop.parent)?.lat, + allStops.get(stop.parent)?.lon, + ) + ) + entry.parent = addStop(stop.parent); + return index; + }; + for (const pattern of patterns) for (const id of pattern.stops) addStop(id); + for (const pattern of patterns) + pattern.stops = pattern.stops.map((id) => stopIndex.get(id)); + allStops.clear(); + + const services = Object.create(null); + for (const trip of Object.values(outTrips)) { + if (services[trip.service]) continue; + services[trip.service] = serviceDays + .get(trip.service) + .filter((day) => day >= fromDay - lookbackDays && day <= toDay) + .map(dateFromDayNumber); + } + const usedRoutes = new Set(Object.values(outTrips).map((t) => t.route)); + for (const id of Object.keys(routes)) + if (!usedRoutes.has(id)) delete routes[id]; + + const schedule = { + version: RAIL_SCHEDULE_PACK_VERSION, + feedId: feed.id, + builtAt: new Date().toISOString(), + pathsBuiltAt: pathPack.builtAt, + source, + timezone, + window: { from: dateFromDayNumber(fromDay), to: dateFromDayNumber(toDay) }, + routes, + stops: stopList, + patterns: patterns, + profiles, + services, + trips: outTrips, + }; + return { + schedule, + stats: { + ...stats, + stops: stopList.length, + patterns: patterns.length, + profiles: profiles.length, + services: Object.keys(services).length, + lookbackDays, + }, + }; +} + +// ---------------------------------------------------------------- main + +/** + * A script's usage for `--help`: the doc comment at the head of the file, + * where its flags are written down, without the comment markers. + * @param {string} moduleUrl The script's `import.meta.url`. + * @returns {Promise} + */ +export async function headerHelp(moduleUrl) { + const text = await readFile(fileURLToPath(moduleUrl), 'utf8'); + const block = /\/\*\*([\s\S]*?)\*\//.exec(text)?.[1] ?? ''; + return block + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n') + .trim(); +} + +async function prepareWorkDir(dir) { + if (!dir) return mkdtemp(path.join(os.tmpdir(), 'gev-rail-paths-')); + await mkdir(dir, { recursive: true }); + if ((await readdir(dir)).length) + throw new Error(`--work ${dir} must be a new or empty directory`); + return dir; +} + +const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`; + +/** A pack already on disk, parsed, or null (missing or unreadable). */ +async function readPackIfAny(file) { + try { + return JSON.parse(await readFile(file, 'utf8')); + } catch { + return null; + } +} + +/** + * Trips of a path pack bound to the shapes buildPack makes, the operator's. + * A shape of any other source (a pack from an older build) is not counted. + */ +export function operatorShapeTrips(pack) { + const sources = Array.isArray(pack?.shapeSources) ? pack.shapeSources : []; + let count = 0; + for (const index of Object.values(pack?.trips ?? {})) + if ((sources[index] ?? 'operator') === 'operator') count++; + return count; +} + +/** A schedule pack's trips per service day of its window. */ +export function scheduleTripsPerDay(schedule) { + const trips = Object.keys(schedule?.trips ?? {}).length; + const from = dayNumberOf(schedule?.window?.from ?? ''); + const to = dayNumberOf(schedule?.window?.to ?? ''); + const days = + Number.isFinite(from) && Number.isFinite(to) && to >= from + ? to - from + 1 + : 1; + return trips / days; +} + +/** + * Why a new pack's trips look like an upstream fault rather than a + * timetable, or null: under MIN_TRIP_SHARE of the pack it replaces (none at + * all included). + */ +export function tripFloorProblem(label, now, before, share = MIN_TRIP_SHARE) { + if (!(before > 0) || now >= before * share) return null; + const round = (n) => Math.round(n * 10) / 10; + return `${label} ${round(now)} against ${round(before)} in the pack it would replace`; +} + +/** How many points a path pack holds, and what thins them. */ +function shrinkHint(pack) { + const points = pack.shapes.reduce((sum, flat) => sum + flat.length / 2, 0); + return `its shapes hold ${points} points (simplified to the feed's staticSimplifyM, default ${SIMPLIFY_TOLERANCE_M} m: set it coarser)`; +} + +/** + * Write one feed's path pack and, unless null, its schedule pack, as a pair. + * + * Both texts are made and checked before anything is written: a pack over + * `maxBytes` (what the server serves), or trips under MIN_TRIP_SHARE of the + * packs being replaced (operatorShapeTrips; scheduleTripsPerDay) unless + * `force`, throws and leaves the previous packs as they were. Both are then + * written to temporary names of this process; only when both are written is + * the path pack renamed into place, then the schedule. Without a schedule, + * the previous one is removed: its patterns index the replaced path pack's + * shapes. Should the second step fail, the previous schedule is removed for + * the same reason, and the error says so. + * @param {string} out + * @param {string} feedId + * @param {{pack: object, schedule?: object|null, force?: boolean, + * maxBytes?: number}} packs + * @returns {Promise<{pathText: string, scheduleText: string|null, + * removedSchedule: boolean}>} + */ +export async function writeFeedPacks( + out, + feedId, + { pack, schedule = null, force = false, maxBytes = RAIL_PATH_PACK_MAX_BYTES }, +) { + const pathFile = path.join(out, `${feedId}.json`); + const scheduleFile = path.join(out, `${feedId}.schedule.json`); + const pathText = JSON.stringify(pack); + const scheduleText = schedule ? JSON.stringify(schedule) : null; + const pathBytes = Buffer.byteLength(pathText); + if (pathBytes > maxBytes) + throw new Error( + `paths are ${kib(pathBytes)}, over the ${kib(maxBytes)} the server serves, so the previous packs are kept; ${shrinkHint(pack)}`, + ); + if (scheduleText !== null && Buffer.byteLength(scheduleText) > maxBytes) + throw new Error( + `the schedule is ${kib(Buffer.byteLength(scheduleText))}, over the ${kib(maxBytes)} the server serves, so the previous packs are kept; narrow it with --days`, + ); + if (!force) { + const problems = [ + tripFloorProblem( + 'trips on operator shapes', + operatorShapeTrips(pack), + operatorShapeTrips(await readPackIfAny(pathFile)), + ), + ]; + if (schedule) + problems.push( + tripFloorProblem( + 'schedule trips per day', + scheduleTripsPerDay(schedule), + scheduleTripsPerDay(await readPackIfAny(scheduleFile)), + ), + ); + const found = problems.filter(Boolean); + if (found.length) + throw new Error( + `${found.join('; ')}: an upstream change, not a timetable, so the previous packs are kept (--force writes these)`, + ); + } + const pathTemp = tempPathFor(pathFile); + const scheduleTemp = scheduleText !== null ? tempPathFor(scheduleFile) : null; + const clean = () => + Promise.all( + [pathTemp, scheduleTemp] + .filter(Boolean) + .map((file) => rm(file, { force: true }).catch(() => {})), + ); + try { + await writeFile(pathTemp, pathText); + if (scheduleTemp) await writeFile(scheduleTemp, scheduleText); + await rename(pathTemp, pathFile); + } catch (error) { + await clean(); + throw error; + } + // The path pack is in place: the schedule beside it must match it or go. + let removedSchedule = false; + try { + if (scheduleTemp) await rename(scheduleTemp, scheduleFile); + else { + removedSchedule = await stat(scheduleFile).then( + () => true, + () => false, + ); + await rm(scheduleFile, { force: true }); + } + } catch (error) { + await clean(); + await rm(scheduleFile, { force: true }).catch(() => {}); + throw new Error( + `the path pack was written but the schedule beside it could not be ${scheduleTemp ? 'replaced' : 'removed'} (${error?.message || error}); it indexes the previous path pack's shapes, so it is removed where it can be`, + ); + } + return { pathText, scheduleText, removedSchedule }; +} + +// ---------------------------------------------------------------- lock + +/** + * A temporary name next to `target`, of this process and this call alone, + * so two writers of the same file never write into one temporary file. + */ +export function tempPathFor(target) { + return `${target}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`; +} + +/** A lock not refreshed for this long was left by a run that died (ms). */ +export const BUILD_LOCK_STALE_MS = 6 * 3_600_000; +/** How often a held lock's time is refreshed (ms). */ +const BUILD_LOCK_REFRESH_MS = 10 * 60_000; +/** A lock file younger than this is held even while it reads as empty (ms). */ +const BUILD_LOCK_WRITE_GRACE_MS = 60_000; + +/** Whether a process id is running on this machine. */ +function processAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + if (pid === process.pid) return true; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: it runs, under another user. + return error?.code === 'EPERM'; + } +} + +/** + * Take the lock file `name` in `dir` for a build, or refuse when another + * build holds it. The file records the holder's process id and the time it + * was taken or last refreshed (every 10 minutes while held). A lock whose + * process is not running, or that was not refreshed for `staleMs`, is taken + * over. Locks are advisory: they keep two builds from writing into the same + * folder at once, nothing more. + * @param {string} dir Created when missing. + * @param {string} name + * @param {{what?: string, staleMs?: number, now?: () => number, + * alive?: (pid: number) => boolean}} [options] + * @returns {Promise<{file: string, release: () => Promise}>} + */ +export async function acquireDirLock( + dir, + name, + { + what = 'build', + staleMs = BUILD_LOCK_STALE_MS, + now = Date.now, + alive = processAlive, + } = {}, +) { + await mkdir(dir, { recursive: true }); + const file = path.join(dir, name); + const body = () => + JSON.stringify({ pid: process.pid, time: new Date(now()).toISOString() }); + const ours = async () => { + try { + return JSON.parse(await readFile(file, 'utf8'))?.pid === process.pid; + } catch { + return false; + } + }; + for (let attempt = 0; attempt < 3; attempt++) { + try { + await writeFile(file, body(), { flag: 'wx' }); + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + let held = null; + let modified = NaN; + try { + modified = (await stat(file)).mtimeMs; + held = JSON.parse(await readFile(file, 'utf8')); + } catch { + held = null; + } + const since = Date.parse(held?.time ?? ''); + const fresh = held + ? alive(held.pid) && Number.isFinite(since) && now() - since <= staleMs + : // Unreadable: another build may be writing it this moment. + Number.isFinite(modified) && + now() - modified <= BUILD_LOCK_WRITE_GRACE_MS; + if (fresh) + throw new Error( + `another ${what} holds ${file} (pid ${held?.pid ?? '?'}, ${held?.time ?? 'just now'}); ` + + `wait for it to finish, or delete the file if that process is gone`, + ); + await rm(file, { force: true }); + continue; + } + const timer = setInterval(() => { + ours() + .then((yes) => (yes ? writeFile(file, body()) : null)) + .catch(() => {}); + }, BUILD_LOCK_REFRESH_MS); + timer.unref?.(); + return { + file, + async release() { + clearInterval(timer); + if (await ours()) await rm(file, { force: true }); + }, + }; + } + throw new Error(`could not take ${file}`); +} + +// ---------------------------------------------------------------- run + +async function main() { + const args = parseBuildArgs(process.argv.slice(2)); + if (args.help) { + console.log(await headerHelp(import.meta.url)); + return; + } + // One build at a time per output folder. + const lock = await acquireDirLock(args.out, BUILD_LOCK, { + what: 'rail path build', + }); + try { + await buildFeeds(args); + } finally { + await lock.release(); + } +} + +/** Build the feeds the command line names, holding the --out lock. */ +async function buildFeeds(args) { + const wanted = (feed) => args.all || args.feeds.includes(feed.id); + const enabled = railEnabledFeeds(); + const feeds = enabled.filter((feed) => feed.staticGtfsUrl && wanted(feed)); + for (const id of args.feeds) + if (!feeds.some((feed) => feed.id === id)) + throw new Error(`No enabled feed ${id} with a staticGtfsUrl`); + for (const id of args.localGtfs.keys()) + if (!feeds.some((feed) => feed.id === id)) + throw new Error( + `--local-gtfs ${id}: no enabled feed ${id} in this build`, + ); + const work = await prepareWorkDir(args.work); + await mkdir(args.out, { recursive: true }); + console.log(`[rail-paths] ${feeds.length} feeds → ${args.out}`); + console.log(`[rail-paths] downloads in ${work}`); + const downloads = new Map(); + const failures = []; + try { + for (const [n, feed] of feeds.entries()) { + const started = Date.now(); + peakRss = process.memoryUsage.rss(); + let zip = null; + let inner = null; + const local = args.localGtfs.get(feed.id) ?? null; + try { + const refusal = buildRefusal(feed, args); + if (refusal) throw new Error(refusal); + let fetched = local + ? localStatic(local) + : downloads.get(feed.staticGtfsUrl); + if (!fetched) { + const file = path.join(work, `download-${downloads.size + 1}.zip`); + fetched = downloadStatic( + feed.staticGtfsUrl, + file, + feed.staticHeaders || null, + ).then((info) => ({ ...info, file })); + downloads.set(feed.staticGtfsUrl, fetched); + } + const got = await fetched; + zip = await openZip(got.file); + if (feed.staticGtfsEntry) { + const entry = zip.entries.get(feed.staticGtfsEntry); + if (!entry) + throw new Error(`${feed.staticGtfsEntry} not in download`); + const nested = path.join( + work, + `${feed.id}-${path.basename(entry.name)}`, + ); + await pipeline( + await entryStream(zip, entry), + createWriteStream(nested), + ); + inner = zip; + zip = await openZip(nested); + } + const source = packSource(feed, got); + const feedVersion = await readFeedVersion(zip); + if (feedVersion) source.feedVersion = feedVersion; + const names = await createRouteNames(feed, zip); + const { pack, stats } = await buildPack(feed, zip, source, names); + // Both packs are written together, once both are built. + const timetable = args.schedule + ? await buildSchedule(feed, zip, source, pack, { + from: args.from, + days: args.days, + names, + }) + : null; + const written = await writeFeedPacks(args.out, feed.id, { + pack, + schedule: timetable?.schedule ?? null, + force: args.force, + }); + const text = written.pathText; + const gz = gzipSync(text).length; + console.log( + `[rail-paths] ${n + 1}/${feeds.length} ${feed.id}: ` + + `rail routes ${stats.railRoutes} (of ${stats.routeRows}), ` + + `with shapes ${stats.routesWithShapes}; ` + + `shapes kept ${stats.shapesKept} (from ${stats.shapeIds} ids), dropped ${stats.shapesDropped}, sparse kept ${stats.sparseShapes}; ` + + `points ${stats.points} (from ${stats.rawPoints}); ` + + `trips mapped ${stats.tripsMapped}/${stats.railTrips} (no shape_id ${stats.shapelessTrips}); ` + + `size ${kib(Buffer.byteLength(text))}, gzip ${kib(gz)}; ` + + `${((Date.now() - started) / 1000).toFixed(1)} s, rss ${Math.round(process.memoryUsage.rss() / 2 ** 20)} MiB, peak ${Math.round(peakRss / 2 ** 20)} MiB`, + ); + for (const { shapeId, reason } of stats.dropped) + console.log(`[rail-paths] dropped shape ${shapeId}: ${reason}`); + if (written.removedSchedule) + console.log( + `[rail-paths] ${feed.id}: removed the previous schedule pack (--no-schedule): its patterns index the shapes of the path pack just replaced`, + ); + if (timetable) { + const body = written.scheduleText; + const bytes = Buffer.byteLength(body); + const t = timetable.stats; + const w = timetable.schedule.window; + console.log( + `[rail-paths] schedule ${feed.id} (${timetable.schedule.timezone}, ${w.from}..${w.to}, lookback ${t.lookbackDays} d): ` + + `trips ${t.tripsKept} (rail ${t.railTrips}, in calendar ${t.tripsInWindow}, ` + + `out of window ${t.tripsOutOfWindow}, untimed ${t.tripsUntimed}, unknown stop ${t.tripsUnknownStop}, no positions ${t.tripsNoPositions}, frequency ${t.frequencyTrips} → ${t.frequencyRuns} runs); ` + + `unlocated stops left out ${t.stopsLeftOut} (from ${t.tripsStopsLeftOut} trips); ` + + `stops ${t.stops}; patterns ${t.patterns} (off shape ${t.patternsOffShape}, shapeless ${t.patternsShapeless}); ` + + `profiles ${t.profiles}; services ${t.services}; ` + + `max stop offset ${t.maxStopOffsetM} m; stop_times rows ${t.stopTimeRows}; ` + + `size ${kib(bytes)}, gzip ${kib(gzipSync(body).length)}; ` + + `rss ${Math.round(process.memoryUsage.rss() / 2 ** 20)} MiB`, + ); + } + } catch (error) { + failures.push(feed.id); + console.error( + `[rail-paths] ${n + 1}/${feeds.length} ${feed.id} skipped: ${error?.message || error}`, + ); + } finally { + await zip?.handle.close().catch(() => {}); + await inner?.handle.close().catch(() => {}); + } + } + } finally { + if (!args.work && !args.keep) + await rm(work, { recursive: true, force: true }); + } + console.log( + `[rail-paths] done: ${feeds.length - failures.length} built` + + (failures.length ? `, failed: ${failures.join(', ')}` : ''), + ); + const code = buildExitCode(failures.length); + if (code) process.exitCode = code; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + main().catch((error) => { + console.error(`[rail-paths] ${error?.message || error}`); + process.exitCode = 1; + }); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7d3962cce..b49781778 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -847,6 +847,45 @@ "scripts/qa-transit-recovery.mjs", "scripts/format-runtime.json", "src/tooling/transitService.test.mjs", + "src/tooling/railService.test.mjs", + "src/data/railFeeds.test.mjs", + "src/data/railProxy.test.mjs", + "src/layers/rail/policy.test.mjs", + "src/layers/rail/source.test.mjs", + "src/layers/rail/pathBinding.test.mjs", + "src/layers/rail/routeLines.test.mjs", + "src/layers/rail/stopLabels.test.mjs", + "src/layers/rail/schedules.test.mjs", + "src/data/railPaths.test.mjs", + "src/data/railSchedule.test.mjs", + "src/data/railTripUpdates.test.mjs", + "src/data/irishRail.test.mjs", + "src/tooling/buildRailPaths.test.mjs", + "src/tooling/buildRailPathsRoutes.test.mjs", + "scripts/build-rail-paths.mjs", + "src/tooling/buildRailPathsUnlocated.test.mjs", + "src/layers/rail/follow.test.mjs", + "src/layers/rail/groundHeight.test.mjs", + "src/layers/rail/liveOnly.test.mjs", + "src/testSupport/railLayerHarness.mjs", + "src/layers/rail/dedupe.test.mjs", + "src/data/railIreland.test.mjs", + "src/data/railNoPositions.test.mjs", + "src/data/railFeedsNational.test.mjs", + "src/data/railSiriVm.test.mjs", + "src/tooling/buildRailPathsTripEnds.test.mjs", + "src/data/railFeedsUs.test.mjs", + "src/data/railFeedsAsia.test.mjs", + "src/data/railFeedsFrance.test.mjs", + "src/data/railFeedsUsNoTerms.test.mjs", + "src/data/railFeedsUsUrban.test.mjs", + "src/tooling/buildRailPathsLocal.test.mjs", + "src/tooling/buildRailPathsWrite.test.mjs", + "src/tooling/railServiceFaults.test.mjs", + "src/tooling/railCatalogPacks.test.mjs", + "src/tooling/railServicePacks.test.mjs", + "src/tooling/buildRailPathsCli.test.mjs", + "src/data/railFeedsDocs.test.mjs", "src/sources/overpassFeatures.test.mjs", "src/data/cctvHlsStream.test.mjs", "src/layers/cctv/videoPlayback.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6335d7fa7..8da3b848d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3249,6 +3249,27 @@ "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", + "src/app/layers/rail.js", + "src/data/railFeeds.js", + "src/layers/rail/index.js", + "src/layers/rail/source.js", + "src/layers/rail/policy.js", + "src/layers/rail/ingestion.js", + "src/layers/rail/dedupe.js", + "src/layers/rail/rendering.js", + "src/layers/rail/selection.js", + "src/layers/rail/follow.js", + "src/layers/rail/viewport.js", + "src/layers/rail/controls.js", + "src/layers/rail/lifecycle.js", + "src/layers/rail/pathBinding.js", + "src/layers/rail/routeDisplay.js", + "src/layers/rail/routeLines.js", + "src/layers/rail/schedules.js", + "src/layers/rail/stopLabels.js", + "src/data/railPaths.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", "src/data/transit.js", "src/data/transitFeeds.js", "src/data/rioRouteNames.js", @@ -4072,6 +4093,27 @@ "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", + "src/app/layers/rail.js", + "src/data/railFeeds.js", + "src/layers/rail/index.js", + "src/layers/rail/source.js", + "src/layers/rail/policy.js", + "src/layers/rail/ingestion.js", + "src/layers/rail/dedupe.js", + "src/layers/rail/rendering.js", + "src/layers/rail/selection.js", + "src/layers/rail/follow.js", + "src/layers/rail/viewport.js", + "src/layers/rail/controls.js", + "src/layers/rail/lifecycle.js", + "src/layers/rail/pathBinding.js", + "src/layers/rail/routeDisplay.js", + "src/layers/rail/routeLines.js", + "src/layers/rail/schedules.js", + "src/layers/rail/stopLabels.js", + "src/data/railPaths.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", "src/data/transitFeeds.js", "src/data/rioRouteNames.js", "src/data/transitIcons.js", @@ -5090,6 +5132,63 @@ ], "external": [] }, + "rail-provider": { + "runtime": "node", + "exports": ["./server/providers/rail"], + "modules": [ + "server/providers/rail.js", + "src/data/railFeeds.js", + "src/data/railProxy.js", + "src/data/irishRail.js", + "src/data/railSiriVm.js", + "src/data/railTripUpdates.js", + "src/data/railScheduleIndex.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", + "src/data/railNoPositions.js", + "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", + "src/geoDistance.js", + "src/data/transitProxy.js", + "src/data/gtfsRealtime.js", + "src/sources/httpBody.js", + "src/i18n/marker.js", + "src/sources/transitHistory.js", + "src/sources/railService.js", + "src/sources/rateLimit.js" + ], + "external": ["pbf"] + }, + "rail-source-factory": { + "exports": ["./layers/rail/source"], + "modules": ["src/layers/rail/source.js"], + "external": [] + }, + "rail-service": { + "exports": ["./sources/rail-service"], + "modules": [ + "src/data/railFeeds.js", + "src/data/railProxy.js", + "src/data/irishRail.js", + "src/data/railSiriVm.js", + "src/data/railTripUpdates.js", + "src/data/railScheduleIndex.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", + "src/data/railNoPositions.js", + "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", + "src/geoDistance.js", + "src/data/transitProxy.js", + "src/data/gtfsRealtime.js", + "src/sources/httpBody.js", + "src/i18n/marker.js", + "src/sources/transitHistory.js", + "src/sources/railService.js", + "src/sources/rateLimit.js" + ], + "external": ["pbf"] + }, "transit-service": { "exports": ["./sources/transit-service"], "modules": [ diff --git a/server/providers/local.js b/server/providers/local.js index f343e549e..f7c5b36ec 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -16,6 +16,7 @@ import { radioBrowserProxy } from './radio.js'; import { gbfsProxy } from './gbfs.js'; import { localReceiversProxy } from './local-receivers.js'; import { transitProxy } from './transit.js'; +import { railProxy } from './rail.js'; import { adsbLolProxy } from './aircraft/adsb-lol.js'; import { aisLiveProxy } from './vessels/ais-live.js'; import { trackBackfillProxies } from './aircraft/tracks.js'; @@ -71,6 +72,7 @@ function localProviderPlugins({ gbfsProxy(), localReceiversProxy(), transitProxy(), + railProxy(), adsbLolProxy(), aisLiveProxy(), trackBackfillProxies(), diff --git a/server/providers/rail.js b/server/providers/rail.js new file mode 100644 index 000000000..c026331ca --- /dev/null +++ b/server/providers/rail.js @@ -0,0 +1,285 @@ +import { open, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; +import { createRailService } from '../../src/sources/railService.js'; +import { RAIL_FEED_ID_PATTERN } from '../../src/data/railFeeds.js'; + +/** + * Where development and preview look for path and schedule packs when + * RAIL_PATHS_DIR is unset: the default output of scripts/build-rail-paths.mjs. + */ +export const DEFAULT_RAIL_PATHS_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../.cache/rail-paths', +); + +/** + * Pack bytes the development server keeps in memory, across path and + * schedule packs: a few of the largest national packs. + */ +export const RAIL_PACK_CACHE_MAX_BYTES = 64 * 1024 * 1024; + +/** + * A bounded cache of pack files' bytes, by path, least recently read first + * out. An entry is used only while the file's modification time and size + * are the ones it was read with, so a rebuilt pack is read again; a file + * larger than the whole byte budget is never kept. Reads of the same file at + * the same time share one read. + * @param {number} [maxBytes] + * @returns {{load: (file: string, info: {mtimeMs: number, size: number}, + * read: () => Promise) => Promise, bytes: () => number}} + */ +export function createRailPackCache(maxBytes = RAIL_PACK_CACHE_MAX_BYTES) { + /** @type {Map} */ + const entries = new Map(); + /** @type {Map>} */ + const pending = new Map(); + let total = 0; + const drop = (file) => { + const entry = entries.get(file); + if (!entry) return; + entries.delete(file); + total -= entry.bytes.byteLength; + }; + return { + async load(file, info, read) { + const entry = entries.get(file); + if (entry && entry.mtimeMs === info.mtimeMs && entry.size === info.size) { + // Most recently read last: the first entry is the next one out. + entries.delete(file); + entries.set(file, entry); + return entry.bytes; + } + const version = `${file}|${info.mtimeMs}|${info.size}`; + const reading = pending.get(version); + if (reading) return reading; + const promise = (async () => { + const bytes = await read(); + drop(file); + if (bytes.byteLength <= maxBytes) { + entries.set(file, { mtimeMs: info.mtimeMs, size: info.size, bytes }); + total += bytes.byteLength; + for (const [oldest] of entries) { + if (total <= maxBytes) break; + drop(oldest); + } + } + return bytes; + })().finally(() => pending.delete(version)); + pending.set(version, promise); + return promise; + }, + bytes: () => total, + }; +} + +/** + * A pack reader for the rail service that reads `/`. + * Ids are re-checked against the registry's id pattern, so a name can never + * leave the directory; a missing file is "no pack", and a file over the cap is + * refused without being read (an error with `code: 'PACK_TOO_LARGE'`). The + * pack is returned as the file's bytes, never decoded to text; with a + * `cache` (createRailPackCache), an unchanged file is read once. + * @param {string} dir + * @param {string} suffix `.json` (path packs) or `.schedule.json`. + * @param {ReturnType|null} [cache] + * @returns {(feedId: string, options?: {maxBytes?: number}) => + * Promise} + */ +function createFsPackReader(dir, suffix, cache = null) { + const root = path.resolve(dir); + return async (feedId, { maxBytes = Infinity } = {}) => { + if (typeof feedId !== 'string' || !RAIL_FEED_ID_PATTERN.test(feedId)) + return null; + const file = path.join(root, `${feedId}${suffix}`); + let handle; + try { + handle = await open(file, 'r'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + try { + const info = await handle.stat(); + if (!info.isFile()) return null; + if (info.size > maxBytes) { + const error = new Error(`pack is ${info.size} bytes`); + error.code = 'PACK_TOO_LARGE'; + throw error; + } + return cache + ? await cache.load(file, info, () => handle.readFile()) + : await handle.readFile(); + } finally { + await handle.close(); + } + }; +} + +/** + * A `readPathPack` that reads `/.json`. + * @param {string} dir + * @param {{cache?: ReturnType|null}} [options] + */ +export function createFsPathPackReader(dir, { cache = null } = {}) { + return createFsPackReader(dir, '.json', cache); +} + +/** + * A `readSchedulePack` that reads `/.schedule.json`. + * @param {string} dir + * @param {{cache?: ReturnType|null}} [options] + */ +export function createFsSchedulePackReader(dir, { cache = null } = {}) { + return createFsPackReader(dir, '.schedule.json', cache); +} + +/** + * A `hasSchedulePack` for the rail service: whether + * `/.schedule.json` is a file. Only the directory entry is + * read, never the pack; a missing file is false, any other error is thrown + * (the service then lists the feed, so the read shows the failure). + * @param {string} dir + * @returns {(feedId: string) => Promise} + */ +export function createFsSchedulePackProbe(dir) { + const root = path.resolve(dir); + return async (feedId) => { + if (typeof feedId !== 'string' || !RAIL_FEED_ID_PATTERN.test(feedId)) + return false; + try { + return (await stat(path.join(root, `${feedId}.schedule.json`))).isFile(); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + }; +} + +/** + * A pack version for the rail service: the modification time and size of + * `/`, or null when there is no such file. Only the + * directory entry is read; a rebuilt pack has a new version. + * @param {string} dir + * @param {string} suffix + * @returns {(feedId: string) => Promise} + */ +function createFsPackVersion(dir, suffix) { + const root = path.resolve(dir); + return async (feedId) => { + if (typeof feedId !== 'string' || !RAIL_FEED_ID_PATTERN.test(feedId)) + return null; + try { + const info = await stat(path.join(root, `${feedId}${suffix}`)); + return info.isFile() ? `${info.mtimeMs}:${info.size}` : null; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + }; +} + +/** + * A `schedulePackVersion` for the rail service: the modification time and + * size of `/.schedule.json`, or null when there is no such + * file. + * @param {string} dir + * @returns {(feedId: string) => Promise} + */ +export function createFsSchedulePackVersion(dir) { + return createFsPackVersion(dir, '.schedule.json'); +} + +/** + * A `pathPackVersion` for the rail service: the same for + * `/.json`. + * @param {string} dir + * @returns {(feedId: string) => Promise} + */ +export function createFsPathPackVersion(dir) { + return createFsPackVersion(dir, '.json'); +} + +/** + * Write a service Response to a Node response, streaming its body: a pack's + * bytes go to the socket as they are, with no text decode and no copy. + * @param {Response} response + * @param {import('node:http').ServerResponse} res + * @returns {Promise} + */ +export async function sendRailResponse(response, res) { + res.writeHead(response.status, Object.fromEntries(response.headers)); + if (!response.body) { + res.end(); + return; + } + await pipeline(Readable.fromWeb(response.body), res); +} + +/** Connect the reusable rail request service to development and preview. */ +export function railProxy(options = {}) { + const pathsDir = + options.pathsDir || process.env.RAIL_PATHS_DIR || DEFAULT_RAIL_PATHS_DIR; + // One byte budget for both kinds of pack. + const cache = createRailPackCache(); + const service = createRailService({ + ...options, + readPathPack: + options.readPathPack || createFsPathPackReader(pathsDir, { cache }), + readSchedulePack: + options.readSchedulePack || + createFsSchedulePackReader(pathsDir, { cache }), + // Probe the same directory only when its fs reader is the one in use. + hasSchedulePack: + options.hasSchedulePack || + (options.readSchedulePack ? null : createFsSchedulePackProbe(pathsDir)), + schedulePackVersion: + options.schedulePackVersion || + (options.readSchedulePack ? null : createFsSchedulePackVersion(pathsDir)), + pathPackVersion: + options.pathPackVersion || + (options.readPathPack ? null : createFsPathPackVersion(pathsDir)), + }); + // A throw here would escape into the HTTP server's event loop and end the + // whole dev server (Node exits on an unhandled rejection or a throwing + // 'close' listener), so failures stay inside the rail routes. + function closeService() { + try { + service.close(); + } catch (error) { + console.warn('[rail] provider close failed', error); + } + } + function install(server) { + server.middlewares.use('/api/rail', async (req, res) => { + try { + const response = await service.handle({ + url: `http://localhost/api/rail${req.url || '/'}`, + method: req.method, + // Read for If-None-Match on a pack. + headers: req.headers, + }); + await sendRailResponse(response, res); + } catch (error) { + // An answer already under way (a viewer that left mid-pack) has + // nothing to add: the stream is closed and that is all. + if (res.headersSent) { + res.destroy(); + return; + } + console.warn('[rail] request failed', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Rail provider error' })); + } + }); + server.httpServer?.once('close', closeService); + } + return { + name: 'rail-proxy', + closeBundle: closeService, + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 42b0d8008..772316300 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -18,6 +18,7 @@ import { createApplicationDirections } from './layers/directions.js'; import { createApplicationRecentImagery } from './layers/recentImagery.js'; import { createApplicationOsmPlaces } from './layers/osmPlaces.js'; import { createApplicationTransit } from './layers/transit.js'; +import { createApplicationRail } from './layers/rail.js'; import { createApplicationInstallations } from './layers/militaryInstallations.js'; import { createApplicationSatellites } from './layers/satellites.js'; import { createApplicationLaunches } from './layers/rocketLaunches.js'; @@ -191,6 +192,7 @@ export function createApplicationCatalog({ ...createOsmOverlayLayers(), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), + createApplicationRail({ surface, source: sources.rail }), createApplicationFirms({ surface, id: 'local-firms', diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 8c0f889db..dadf2c541 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 40); + assert.equal(first.layers.length, 41); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'osm-places'), @@ -162,7 +162,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 40); + assert.equal(catalog.layers.length, 41); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/rail.js b/src/app/layers/rail.js new file mode 100644 index 000000000..9b5a3bb0e --- /dev/null +++ b/src/app/layers/rail.js @@ -0,0 +1,50 @@ +import { createRailLayer } from '../../layers/rail/index.js'; +import * as render from '../../renderGovernor.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { applyTrackedCameraFrame } from '../../data/trackedCamera.js'; +import { WORLD_FOCUS_FRAMING, requestWorldFocus } from '../../worldFocus.js'; +import { + railFeedCredit, + registerDynamicCredit, +} from '../../data/dataCredits.js'; + +/** + * Construct the Rail layer on the application's scene owners and its shared + * ground floor, the same floor Transit and the aircraft stand on. + * @param {{surface: object, source?: object}} options + * @returns {object} + */ +export function createApplicationRail({ surface, source }) { + if (!surface?.groundFloor) + throw new TypeError('Rail needs the application surface services'); + const { groundFloor } = surface; + return createRailLayer({ + source, + services: { + render, + sprites, + picking, + overlays, + credits: { registerDynamicCredit, railFeedCredit }, + ground: { + GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M, + cachedGroundFloor: groundFloor.cachedGroundFloor, + warmGroundFloor: groundFloor.warmGroundFloor, + neighborFloorM: groundFloor.neighborFloorM, + coarseFloorCoord: groundFloor.coarseFloorCoord, + }, + // A selected train is followed as a tracked aircraft is, or flown to + // once as a vessel is (src/layers/rail/follow.js). + camera: { + framing: WORLD_FOCUS_FRAMING.train, + requestWorldFocus, + applyTrackedCameraFrame, + isCockpitActive: () => + globalThis.document?.body?.classList?.contains('cockpit-mode') === + true, + }, + }, + }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 42b90c05b..fef0136c1 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -55,6 +55,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), + rail: contract(['rail'], ['requestSnapshot', 'getFeeds']), }); /** An omitted provider is unavailable, never an authoritative empty dataset. */ diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index fc93c40e0..466572d84 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 40); + assert.equal(actual.layers.length, 41); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, @@ -247,3 +247,18 @@ test('absent military acquisition does not poll, suppress flights, or erase supp assert.equal(registry.isMilitaryLayerActive(), false); assert.equal(fetches, 0); }); + +test('a rail source must carry the feed catalog that keeps unbuilt packs out', () => { + // Without getFeeds the layer could never learn which schedule packs are + // built, so the live feeds that read their routes from one would never run. + assert.throws( + () => + resolveCatalogSources({ rail: { requestSnapshot: async () => null } }), + /Invalid catalog source: rail/, + ); + const source = { + requestSnapshot: async () => null, + getFeeds: async () => [], + }; + assert.equal(resolveCatalogSources({ rail: source }).sources.rail, source); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 62e3b2aee..a452fa0cf 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -503,6 +503,10 @@ export const DATA_CREDITS = [ key: 'gtfs-rt', html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)', }, + { + key: 'rail-gtfs-rt', + html: 'Trains: operator realtime feeds (each operator is credited below when its trains are shown)', + }, { key: 'radio-browser', html: @@ -759,6 +763,20 @@ export function transitFeedCredit(feed, snapshot = null) { }; } +/** + * Per-feed rail credit, registered the first time that feed's trains render + * (see `src/data/railFeeds.js` for the licence of each). + * @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed + * @returns {{ key: string, html: string }} + */ +export function railFeedCredit(feed) { + const transit = transitFeedCredit(feed); + return { + key: `rail-${feed.id}`, + html: transit.html.replace(/^Transit \(/, 'Trains ('), + }; +} + /** Registered when the Bhote Koshi event reconstruction activates. */ export const BHOTE_KOSHI_CREDIT = { key: 'bhote-koshi-2026', diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index e3129f378..8e7179338 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -244,3 +244,12 @@ test('Warsaw and Poznań credits carry the data date their licences ask for', () assert.equal(mbta.key, 'transit-mbta'); assert.doesNotMatch(mbta.html, /data of/); }); + +test('the always-on rail credit names live feeds only, never timetables', () => { + // Every train shown is where its operator reported it; none is placed + // from a timetable. + const rail = DATA_CREDITS.find((entry) => entry.key === 'rail-gtfs-rt'); + assert.ok(rail); + assert.match(rail.html, /realtime feeds/); + assert.doesNotMatch(rail.html, /timetable|placed from/i); +}); diff --git a/src/data/irishRail.js b/src/data/irishRail.js new file mode 100644 index 000000000..a76e96536 --- /dev/null +++ b/src/data/irishRail.js @@ -0,0 +1,257 @@ +/** + * @module irishRail + * @description Adapter for the Irish Rail realtime API's running-trains list + * (`getCurrentTrainsXML`), turning it into the train records the GTFS-RT path + * produces. + * + * The document is a flat list of `` elements, each with + * seven text children (TrainStatus, TrainLatitude, TrainLongitude, TrainCode, + * TrainDate, PublicMessage, Direction). That schema is all this reads, so it + * is read with a small scanner instead of an XML library: no DTDs, no entity + * definitions, no attributes, no nesting beyond those two levels. A document + * that declares a DTD or entities is refused, and every size is capped (whole + * text, train count, field length). + * + * Positions are NOT GPS: the operator places each train at the station or + * timing point its signalling system last reported. The record says so with + * `positionSource: 'event'` (added by the rail proxy). The feed carries no + * time for that report, so a record's time is the fetch time, labelled + * `timestampSource: 'fetch'`, which the layer ages conservatively. + * + * Pure: no Node built-ins, safe for node:test and any server runtime. + */ + +import { isPlausibleVehiclePosition } from './gtfsRealtime.js'; + +/** Longest document read (characters). A full day's list is about 35 KB. */ +export const IRISH_RAIL_MAX_TEXT_CHARS = 1024 * 1024; +/** Most trains read from one document; about 80 run at once. */ +export const IRISH_RAIL_MAX_TRAINS = 2000; +/** Longest accepted field (characters); the public message is the longest. */ +export const IRISH_RAIL_MAX_FIELD_CHARS = 512; + +const FIELDS = Object.freeze([ + 'TrainStatus', + 'TrainLatitude', + 'TrainLongitude', + 'TrainCode', + 'TrainDate', + 'PublicMessage', + 'Direction', +]); + +/** Error for a document this adapter will not read. */ +export class IrishRailFormatError extends Error { + constructor(message) { + super(message); + this.name = 'IrishRailFormatError'; + } +} + +const NAMED_ENTITIES = Object.freeze({ + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}); + +/** + * Decode the five predefined XML entities and numeric character references. + * An unknown entity is left as written (no DTD can define one here). + * @param {string} text + * @returns {string} + */ +export function decodeXmlText(text) { + return String(text).replace( + /&(#x[0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-zA-Z]{2,4});/g, + (whole, body) => { + if (body[0] === '#') { + const code = + body[1] === 'x' || body[1] === 'X' + ? Number.parseInt(body.slice(2), 16) + : Number.parseInt(body.slice(1), 10); + return Number.isInteger(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : whole; + } + return Object.hasOwn(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole; + }, + ); +} + +/** + * The text of `...` inside one train element, `''` for an empty + * or self-closing element, null when absent or over the field cap. + */ +function readField(block, name) { + const open = `<${name}>`; + const start = block.indexOf(open); + if (start < 0) { + return block.includes(`<${name}/>`) || block.includes(`<${name} />`) + ? '' + : null; + } + const from = start + open.length; + const end = block.indexOf(``, from); + if (end < 0 || end - from > IRISH_RAIL_MAX_FIELD_CHARS * 6) return null; + const value = decodeXmlText(block.slice(from, end)).trim(); + return value.length <= IRISH_RAIL_MAX_FIELD_CHARS ? value : null; +} + +/** + * Split the running-trains document into raw train rows. + * @param {string} text + * @returns {{rows: Array>, truncated: boolean}} + */ +export function parseIrishRailTrainsXml(text) { + if (typeof text !== 'string') throw new IrishRailFormatError('not text'); + if (text.length > IRISH_RAIL_MAX_TEXT_CHARS) + throw new IrishRailFormatError(`document is ${text.length} characters`); + if (/'; + const CLOSE = ''; + let at = text.indexOf(OPEN); + while (at >= 0) { + const end = text.indexOf(CLOSE, at + OPEN.length); + if (end < 0) break; + if (rows.length >= IRISH_RAIL_MAX_TRAINS) { + truncated = true; + break; + } + const block = text.slice(at + OPEN.length, end); + const row = {}; + for (const name of FIELDS) row[name] = readField(block, name); + rows.push(row); + at = text.indexOf(OPEN, end + CLOSE.length); + } + return { rows, truncated }; +} + +/** + * Read the three lines of a public message. The operator separates lines + * with a literal backslash-n; a real newline is accepted too. + * + * A118\n14:50 - Dublin Connolly to Belfast (0 mins late)\nDeparted Dundalk next stop Newry + * E244\nHowth to Bray\nExpected Departure 16:35 + * A311\n15:05 - Tralee to Mallow(-2 mins late)\nTERMINATED Mallow at 16:34 + * + * @param {string|null} message + * @returns {{origin: string|null, destination: string|null, + * scheduledDeparture: string|null, delaySec: number|null, + * event: 'departed'|'arrived'|'expected'|'terminated'|null, + * lastStop: string|null, nextStop: string|null}} + */ +export function parsePublicMessage(message) { + const result = { + origin: null, + destination: null, + scheduledDeparture: null, + delaySec: null, + event: null, + lastStop: null, + nextStop: null, + }; + const lines = String(message ?? '') + .split(/\\n|\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const route = + /^(?:(\d{1,2}:\d{2})\s*-\s*)?(.+?)\s+to\s+(.+?)(?:\s*\((-?\d{1,4})\s*mins?\s+late\))?$/i.exec( + lines[1] || '', + ); + if (route) { + result.scheduledDeparture = route[1] || null; + result.origin = route[2].trim(); + result.destination = route[3].trim(); + if (route[4] !== undefined) result.delaySec = Number(route[4]) * 60; + } + const event = lines[2] || ''; + let match; + if ( + (match = /^(Departed|Arrived)\s+(.+?)\s+next stop\s+(.+)$/i.exec(event)) + ) { + result.event = + match[1].toLowerCase() === 'departed' ? 'departed' : 'arrived'; + result.lastStop = match[2].trim(); + result.nextStop = match[3].trim(); + } else if (/^Expected Departure\b/i.test(event)) { + result.event = 'expected'; + } else if ( + (match = /^TERMINATED\s+(.+?)\s+at\s+\d{1,2}:\d{2}/i.exec(event)) + ) { + result.event = 'terminated'; + result.lastStop = match[1].trim(); + } + return result; +} + +/** + * Turn the running-trains document into unclassified train records in the + * shape the GTFS-RT path produces (src/data/gtfsRealtime.js), plus the + * delay the public message states (`delaySec`). The message's last and next + * stop and the row's direction only decide `status`; nothing reads them + * further, so they do not leave the server. + * + * Status `T` (terminated) trains are dropped: they are out of service, parked + * at the end of their run. Status `N` trains are due to start within ten + * minutes and stand at their origin (`STOPPED_AT`). A running train is + * `STOPPED_AT` after "Arrived" and `IN_TRANSIT_TO` its next stop after + * "Departed". + * + * @param {string} text The XML document. + * @param {number} fetchedAtS Fetch time (epoch seconds), the only time the + * feed lets a record carry. + * @returns {{entityCount: number, truncated: boolean, vehicles: object[]}} + */ +export function decodeIrishRailTrains(text, fetchedAtS) { + const { rows, truncated } = parseIrishRailTrainsXml(text); + const seen = new Set(); + const vehicles = []; + for (const row of rows) { + const code = row.TrainCode; + if (!code || !/^[A-Z0-9]{1,8}$/.test(code) || seen.has(code)) continue; + const status = row.TrainStatus; + if (status !== 'R' && status !== 'N') continue; + const lat = Number.parseFloat(row.TrainLatitude ?? ''); + const lon = Number.parseFloat(row.TrainLongitude ?? ''); + if (!isPlausibleVehiclePosition(lat, lon)) continue; + seen.add(code); + const message = parsePublicMessage(row.PublicMessage); + const routeId = + message.origin && message.destination + ? `${message.origin} - ${message.destination}` + : null; + vehicles.push({ + id: code, + lat: Number(lat.toFixed(6)), + lon: Number(lon.toFixed(6)), + bearing: null, + speedMps: null, + timestamp: fetchedAtS, + timestampSource: 'fetch', + routeId, + tripId: code, + directionId: null, + label: routeId + ? `${code} ${message.origin} to ${message.destination}` + : code, + stopId: null, + status: + // i18n-ignore: GTFS-Realtime status codes, not UI text + status === 'N' || message.event === 'arrived' + ? 'STOPPED_AT' + : message.event === 'departed' + ? 'IN_TRANSIT_TO' + : null, + occupancy: null, + delaySec: message.delaySec, + }); + } + return { entityCount: rows.length, truncated, vehicles }; +} diff --git a/src/data/irishRail.test.mjs b/src/data/irishRail.test.mjs new file mode 100644 index 000000000..d6c3665e9 --- /dev/null +++ b/src/data/irishRail.test.mjs @@ -0,0 +1,193 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + IRISH_RAIL_MAX_TEXT_CHARS, + IRISH_RAIL_MAX_TRAINS, + IrishRailFormatError, + decodeIrishRailTrains, + decodeXmlText, + parseIrishRailTrainsXml, + parsePublicMessage, +} from './irishRail.js'; + +/** One `` element, written by hand for these tests. */ +function train({ + status = 'R', + lat = '53.3531', + lon = '-6.24591', + code = 'A118', + date = '06 Nov 2026', + message = 'A118\\n14:50 - Dublin Connolly to Belfast (3 mins late)\\nDeparted Dundalk next stop Newry', + direction = 'Northbound', +} = {}) { + return ` + ${status} + ${lat} + ${lon} + ${code} + ${date} + ${message} + ${direction} + `; +} + +function document(...trains) { + return ` + +${trains.join('\n')} +`; +} + +const FETCHED_S = 1_794_000_000; + +test('a running-trains document becomes GTFS-shaped train records', () => { + const { vehicles, entityCount, truncated } = decodeIrishRailTrains( + document( + train(), + train({ + code: 'E244', + status: 'N', + message: 'E244\\nHowth to Bray\\nExpected Departure 16:35', + direction: 'Southbound', + }), + train({ + code: 'D825', + message: + 'D825\\n16:40 - Dun Laoghaire to Dundalk (0 mins late)\\nArrived Dun Laoghaire next stop Blackrock', + }), + ), + FETCHED_S, + ); + assert.equal(entityCount, 3); + assert.equal(truncated, false); + assert.equal(vehicles.length, 3); + const [enterprise, dart, commuter] = vehicles; + assert.deepEqual(enterprise, { + id: 'A118', + lat: 53.3531, + lon: -6.24591, + bearing: null, + speedMps: null, + timestamp: FETCHED_S, + timestampSource: 'fetch', + routeId: 'Dublin Connolly - Belfast', + tripId: 'A118', + directionId: null, + label: 'A118 Dublin Connolly to Belfast', + stopId: null, + status: 'IN_TRANSIT_TO', + occupancy: null, + delaySec: 180, + }); + // The message's stops and the row's direction decide the status only. + for (const field of ['serviceState', 'lastStop', 'nextStop', 'direction']) + assert.equal(field in dart, false, field); + assert.equal(dart.status, 'STOPPED_AT'); + assert.equal(dart.routeId, 'Howth - Bray'); + assert.equal(dart.delaySec, null); + assert.equal(commuter.status, 'STOPPED_AT'); + assert.equal(commuter.delaySec, 0); +}); + +test('terminated, unplaced, malformed and repeated trains are dropped', () => { + const { vehicles, entityCount } = decodeIrishRailTrains( + document( + train({ + code: 'A311', + status: 'T', + message: + 'A311\\n15:05 - Tralee to Mallow(-2 mins late)\\nTERMINATED Mallow at 16:34', + }), + train({ code: 'A1', lat: '0', lon: '0' }), + train({ code: 'A2', lat: 'north', lon: '-6.2' }), + train({ code: 'A3', status: 'X' }), + train({ code: 'bad code!' }), + train({ code: 'A4' }), + train({ code: 'A4', lat: '53.0' }), + ), + FETCHED_S, + ); + assert.equal(entityCount, 7); + assert.deepEqual( + vehicles.map((vehicle) => [vehicle.id, vehicle.lat]), + [['A4', 53.3531]], + ); +}); + +test('public messages: the three line shapes, with and without spacing', () => { + assert.deepEqual( + parsePublicMessage( + 'A311\\n15:05 - Tralee to Mallow(-2 mins late)\\nTERMINATED Mallow at 16:34', + ), + { + origin: 'Tralee', + destination: 'Mallow', + scheduledDeparture: '15:05', + delaySec: -120, + event: 'terminated', + lastStop: 'Mallow', + nextStop: null, + }, + ); + const real = parsePublicMessage( + 'D319\n16:27 - Docklands to M3 Parkway (1 mins late)\nArrived Pelletstown next stop Ashtown', + ); + assert.equal(real.destination, 'M3 Parkway'); + assert.equal(real.event, 'arrived'); + assert.equal(real.nextStop, 'Ashtown'); + assert.equal(parsePublicMessage('').origin, null); + assert.equal(parsePublicMessage(null).event, null); + assert.equal(parsePublicMessage('X1\\nno route here\\n?').origin, null); +}); + +test('entities are decoded and DTDs refused', () => { + assert.equal( + decodeXmlText('Tom & Jerry <> "' AB &bogus;'), + 'Tom & Jerry <> "\' AB &bogus;', + ); + assert.equal(decodeXmlText('� �'), '� �'); + const { rows } = parseIrishRailTrainsXml( + document(train({ direction: 'To Dun Laoghaire & Bray' })), + ); + assert.equal(rows[0].Direction, 'To Dun Laoghaire & Bray'); + assert.throws( + () => + parseIrishRailTrainsXml( + `]>${document(train())}`, + ), + IrishRailFormatError, + ); + assert.throws( + () => parseIrishRailTrainsXml('error'), + IrishRailFormatError, + ); + assert.throws(() => parseIrishRailTrainsXml(42), IrishRailFormatError); +}); + +test('sizes are capped: document, train count and field length', () => { + assert.throws( + () => + parseIrishRailTrainsXml( + document(train()) + ' '.repeat(IRISH_RAIL_MAX_TEXT_CHARS), + ), + /characters/, + ); + const many = Array.from({ length: IRISH_RAIL_MAX_TRAINS + 3 }, (_, i) => + train({ code: `E${i}` }), + ); + const capped = parseIrishRailTrainsXml(document(...many)); + assert.equal(capped.rows.length, IRISH_RAIL_MAX_TRAINS); + assert.equal(capped.truncated, true); + const { rows } = parseIrishRailTrainsXml( + document(train({ message: 'm'.repeat(600) }), ''), + ); + assert.equal(rows.length, 1, 'an unclosed element is not a train'); + assert.equal(rows[0].PublicMessage, null, 'an over-long field is refused'); + const empty = parseIrishRailTrainsXml( + document( + 'E1', + ), + ); + assert.equal(empty.rows[0].Direction, ''); + assert.equal(empty.rows[0].TrainStatus, null); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index ede1a60a4..4fef4ba3b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -313,6 +313,13 @@ const OPTION_GROUPS = Object.freeze({ high: 'h', }), ]), + rail: Object.freeze([ + booleanOption('transit', 't', true), + booleanOption('local', 'l', true), + booleanOption('regional', 'r', true), + booleanOption('national', 'n', true), + booleanOption('international', 'i', true), + ]), 'wildlife-sightings': Object.freeze([ booleanOption('confirmed', 'c', false), enumOption( @@ -700,6 +707,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ disposition: 'enabled+options', optionOwner: 'radio', }), + Object.freeze({ + id: 'rail', + token: '02', + disposition: 'enabled+options', + optionOwner: 'rail', + }), Object.freeze({ id: 'recent-imagery', token: '1', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 7614e80d0..116a77c88 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -203,8 +203,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 38); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 38); + assert.equal(REGISTERED_LAYER_IDS.length, 39); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 39); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -243,12 +243,14 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - // News holds 00 and Shipwrecks 01, the first pairs: the next token is 02. - assert.equal(nextLayerStateToken(), '02'); - // The allocation rules below read the ledger before either took a pair. + // News holds 00, Shipwrecks 01 and Rail 02, the first pairs: the next token + // is 03. + assert.equal(nextLayerStateToken(), '03'); + // The allocation rules below read the ledger before any of them took a pair. const beforeNews = { ...LAYER_STATE_TOKEN_RESERVATIONS }; delete beforeNews.news; delete beforeNews.shipwrecks; + delete beforeNews.rail; assert.equal(nextLayerStateToken(beforeNews), '00'); assert.equal( nextLayerStateToken({ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 067904a35..a17be2142 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -97,10 +97,11 @@ function withPublishedBase( } } -// The ledger before Wildlife Sightings took the last digit and News and -// Shipwrecks the first pairs, for the allocation rules written against it. +// The ledger before Wildlife Sightings took the last digit and News, +// Shipwrecks and Rail the first pairs, for the allocation rules written +// against it. const beforeLastDigitRows = reservationRows.filter( - ([id]) => !['wildlife-sightings', 'news', 'shipwrecks'].includes(id), + ([id]) => !['wildlife-sightings', 'news', 'shipwrecks', 'rail'].includes(id), ); function writeCodecFixture(cwd, rows, entries) { @@ -112,6 +113,7 @@ function writeCodecFixture(cwd, rows, entries) { readFileSync(new URL(`./${name}`, import.meta.url)), ); let source = readFileSync(CODEC_SOURCE_PATH, 'utf8'); + // Layers published after the fixture's ledger are not in its registry. if (!rows.some(([id]) => id === 'wildlife-sightings')) source = source.replace( / Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/, @@ -127,6 +129,11 @@ function writeCodecFixture(cwd, rows, entries) { / Object\.freeze\(\{\s*id: 'shipwrecks',[\s\S]*?\}\),\n/, '', ); + if (!rows.some(([id]) => id === 'rail')) + source = source.replace( + / Object\.freeze\(\{\s*id: 'rail',[\s\S]*?\}\),\n/, + '', + ); const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS'; assert.equal(source.split(registryEnd).length, 2); const additions = entries @@ -150,7 +157,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 38); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 39); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -165,6 +172,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'wildlife-sightings': '9', news: '00', shipwrecks: '01', + rail: '02', }, ); assert.throws( @@ -190,9 +198,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '02']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '03']]) .future, - '02', + '03', ); assert.throws( () => @@ -423,19 +431,19 @@ test('an isolated valid two-character fixture round-trips an l field beyond the test('checker reads a complete future base ledger and rejects retired-token reuse', () => { withPublishedBase( { - rows: [...reservationRows, ['retired-layer', '02']], + rows: [...reservationRows, ['retired-layer', '03']], source: - 'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "02" });\n', + 'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "03" });\n', }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 39); - assert.equal(published['retired-layer'], '02'); + assert.equal(Object.keys(published).length, 40); + assert.equal(published['retired-layer'], '03'); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '02', + newcomer: '03', }), /changed or removed/, ); diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index f320ca0f7..965a0693d 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -20,6 +20,7 @@ ["military-installations", "i"], ["news", "00"], ["radio", "r"], + ["rail", "02"], ["recent-imagery", "1"], ["rocket-launches", "x"], ["s2-ghostmaps", "5"], diff --git a/src/data/railFeeds.js b/src/data/railFeeds.js new file mode 100644 index 000000000..f1c1eec1d --- /dev/null +++ b/src/data/railFeeds.js @@ -0,0 +1,1632 @@ +import { greatCircleKm } from '../geoDistance.js'; + +/** + * @module railFeeds + * @description Registry of the live train feeds the Rail layer shows (GPS + * positions, or Irish Rail's station reports, with trip updates where the + * operator publishes them), with the licence or other basis each is used on, + * and the rules that sort each feed's vehicles into service categories. + * + * Every entry is a URL the SERVER fetches: the browser only ever asks + * `/api/rail/trains/` for a registered id (SECURITY.md: proxies never fetch + * client-supplied URLs). Adding a feed means adding a row here, a + * DATA_SOURCES.md row with its licence, and a credit in dataCredits.js. + * + * `defaultEnabled` is true when the operator's published terms were read and + * cover this use, or, where the operator publishes no licence or terms at + * all, on the basis the entry records: a project owner decision to use the + * data without a published licence, which is never presented as a licence + * (DATA_SOURCES.md, "Rail"). A feed also needs real coordinates: no train is + * placed or moved from a timetable. `terms` records the passage the decision + * rests on; an empty `quote` means there is no published text to quote, and + * its `note` says why. A registered feed that is switched off is never polled + * and never offered to the browser. + * + * Every feed is keyless: its realtime feed and its static timetable are + * published for anyone to fetch. Values are shown as the source gives them: + * positions where the operator reports them, and, once a host has built a + * feed's optional pack (scripts/build-rail-paths.mjs), route lines from the + * operator's own shapes. + * + * Many operators publish trains in the same feed as their buses. A feed's + * `classify` decides, per vehicle, whether it is a train at all and which + * category it belongs to; anything it returns null for is dropped before the + * snapshot leaves the server, so the browser never sees a bus. + * + * Pure data + pure helpers: imported by the browser layer, the Vite proxy, and + * node:test. No Cesium, no Node built-ins. + */ + +/** + * Service categories, narrowest first. `transit` is rail inside the urban + * fabric (metro, light rail, tram); `local` is suburban and commuter rail and + * short branch lines; `regional` stays inside one state or province; + * `national` crosses one; `international` crosses a national border. + */ +export const RAIL_CATEGORIES = Object.freeze([ + 'transit', + 'local', + 'regional', + 'national', + 'international', +]); + +/** + * What runs on the track, for the icon: `rail` (main-line trains), `metro` + * (grade-separated urban rail), `tram` (light rail and streetcars). + */ +export const RAIL_MODES = Object.freeze(['rail', 'metro', 'tram']); + +/** + * Where a feed's positions come from, which the layer states to the user: + * `gps` (the train reports where it is) or `event` (the train was last seen + * at a station or timing point by the signalling system, not tracked between + * them). + */ +export const RAIL_POSITION_SOURCES = Object.freeze(['gps', 'event']); + +/** One classified train: the category the layer filters on and the icon mode. */ +function train(category, mode) { + return Object.freeze({ category, mode }); +} +const LOCAL_RAIL = train('local', 'rail'); +const REGIONAL_RAIL = train('regional', 'rail'); +const NATIONAL_RAIL = train('national', 'rail'); +const INTERNATIONAL_RAIL = train('international', 'rail'); +const TRANSIT_METRO = train('transit', 'metro'); +const TRANSIT_TRAM = train('transit', 'tram'); + +/** Every vehicle in a rail-only feed is the same kind of train. */ +function always(kind) { + return () => kind; +} + +/** + * Irish Rail province of each terminus an InterCity train can start or end + * at. Northern Ireland stations are `NI`: a train with one end there and the + * other in the Republic (the Enterprise) is international. + */ +const IRISH_RAIL_PROVINCE = new Map( + [ + [ + 'Leinster', + [ + 'Dublin Heuston', + 'Dublin Connolly', + 'Dublin Pearse', + 'Grand Canal Dock', + 'Drogheda', + 'Dundalk', + 'Maynooth', + 'Kildare', + 'Newbridge', + 'Hazelhatch', + 'Portlaoise', + 'Portarlington', + 'Ballybrophy', + 'Athy', + 'Carlow', + 'Kilkenny', + 'Wexford', + 'Rosslare Europort', + 'Rosslare Strand', + 'Gorey', + 'Arklow', + 'Wicklow', + 'Greystones', + 'Bray', + 'Athlone', + 'Mullingar', + 'Longford', + 'Tullamore', + ], + ], + [ + 'Munster', + [ + 'Cork', + 'Cobh', + 'Midleton', + 'Mallow', + 'Tralee', + 'Killarney', + 'Limerick', + 'Limerick Junction', + 'Ennis', + 'Waterford', + 'Thurles', + 'Nenagh', + 'Roscrea', + 'Clonmel', + 'Charleville', + ], + ], + [ + 'Connacht', + [ + 'Galway', + 'Athenry', + 'Westport', + 'Ballina', + 'Manulla Junction', + 'Sligo', + 'Carrick on Shannon', + 'Roscommon', + 'Claremorris', + ], + ], + [ + 'NI', + [ + 'Belfast', + 'Belfast Grand Central', + 'Belfast Lanyon Place', + 'Lisburn', + 'Portadown', + 'Newry', + ], + ], + ].flatMap(([province, stations]) => + stations.map((station) => [station.toLowerCase(), province]), + ), +); + +/** + * Irish Rail trains, from the train code and the route the adapter builds + * from the public message (` - `). Code prefixes, as + * published by the Irish Rail realtime API: `E` DART and `D`/`P` Commuter + * (both local), `A`/`B` InterCity. An InterCity train is national when its + * two ends are in different provinces, regional when both are in the same + * province (Mallow–Tralee, Limerick–Limerick Junction), and international + * when one end is in Northern Ireland (the Enterprise, Dublin–Belfast). An + * InterCity train whose ends are not both known stays national, the + * operator's own grouping. Any other prefix is an Irish Rail passenger train + * of an unknown kind and is shown as local. + */ +function irishRailTrain(routeId, context) { + // A train matched to its pack run: the pack route id carries the code + // letter and the scope (irishRailTripRename). + const scoped = scopedRoute(routeId); + if (scoped) return irishRailPackTrain(scoped); + const code = String(context?.tripId ?? ''); + if (!/^[A-Z]\d{1,4}$/.test(code)) return null; + const prefix = code[0]; + if (prefix !== 'A' && prefix !== 'B') return LOCAL_RAIL; + const [from, to] = String(routeId ?? '') + .split(' - ') + .map((name) => IRISH_RAIL_PROVINCE.get(name.trim().toLowerCase())); + if ((from === 'NI') !== (to === 'NI')) return INTERNATIONAL_RAIL; + if (from && to && from === to) return REGIONAL_RAIL; + return NATIONAL_RAIL; +} + +/** Route ids mapped to a kind; any other route (a bus) is dropped. */ +function byRoute(table) { + const routes = new Map(Object.entries(table)); + return (routeId) => routes.get(String(routeId ?? '')) ?? null; +} + +/** + * MBTA route ids are mode-typed: commuter rail is `CR-*`, heavy rapid transit + * is Red/Orange/Blue, the Green Line branches and the Mattapan trolley are + * light rail. Buses, ferries and shuttles are dropped. + */ +function mbtaTrain(routeId) { + const id = String(routeId ?? ''); + if (/^CR-/.test(id)) return LOCAL_RAIL; + if (/^(Red|Orange|Blue)$/.test(id)) return TRANSIT_METRO; + if (/^(Green-[BCDE]|Mattapan)$/.test(id)) return TRANSIT_TRAM; + return null; +} + +/** + * RTD Denver: commuter rail is the A Line plus the 113 and 117 corridors + * (`113B`, `113G`, `117N`); light rail is the 101, 103 and 107 corridors with a + * line letter (`101E`, `103W`, `107R`). Everything else in the feed is a bus. + */ +function rtdTrain(routeId) { + const id = String(routeId ?? ''); + if (id === 'A' || /^11[37][A-Z]$/.test(id)) return LOCAL_RAIL; + if (/^10[137][A-Z]$/.test(id)) return TRANSIT_TRAM; + return null; +} + +/** + * TransLink SEQ route ids end in the GTFS route_type family: `-5` is + * Queensland Rail Citytrain (`BRCA-5`). The rail-only URL is used, so the + * suffix is a guard rather than the only filter. + */ +function translinkRailTrain(routeId) { + return /-5$/.test(String(routeId ?? '')) ? LOCAL_RAIL : null; +} + +/** + * Finland (Digitraffic GTFS-RT, the same ids as its passenger GTFS): route + * ids are `____`, station codes + * as the operator writes them (2026-11-06). Types: `HL` commuter (local); + * `H`/`HDM` the operator's regional trains (regional); `IC`, `S` + * (Pendolino), `P` and `PYO` (night train) long-distance (national). Any + * train with an end at `HPA` (Haaparanta, in Sweden) is international. + * Unknown types are dropped. + */ +const FINLAND_ABROAD = new Set(['HPA']); +function finlandTrain(routeId) { + const match = /^([^_]+)_([^_]+)_[^_]+_([A-Z]+)_\d+$/.exec( + String(routeId ?? ''), + ); + if (!match) return null; + const [, from, to, type] = match; + let kind = null; + if (type === 'HL') kind = LOCAL_RAIL; + else if (type === 'H' || type === 'HDM') kind = REGIONAL_RAIL; + else if (['IC', 'S', 'P', 'PYO'].includes(type)) kind = NATIONAL_RAIL; + if (!kind) return null; + return FINLAND_ABROAD.has(from) || FINLAND_ABROAD.has(to) + ? INTERNATIONAL_RAIL + : kind; +} + +/** + * Pack route ids that carry what the classifier reads. The path build + * (scripts/build-rail-paths.mjs, `staticRouteRename` / `staticTripRename`) + * writes `~~`, scope being where the + * run's two ends lie: `S` in one state, `X` in two states of one country, + * `B` across a national border. The proxy reads the same ids back from the + * schedule pack for realtime trips that carry no route (`routesFrom`). + */ +function scopedRoute(routeId) { + const match = /^([^~]+)~([SXB])~/.exec(String(routeId ?? '')); + return match ? { product: match[1], scope: match[2] } : null; +} + +/** + * Renfe Alta Velocidad / Larga y Media Distancia. Product is the static + * GTFS `route_short_name`; the scope is computed from the two stations the + * route id names (`...`), Spain's + * states being its 17 autonomous communities (and Ceuta and Melilla): + * - international: `AVE INT`, `TRENCELTA` (Vigo–Porto), or any end abroad. + * - national: `AVE`, `AVLO`, `ALVIA`, `EUROMED` (it crosses communities), + * `Intercity`. + * - Media Distancia and the other regional products (`MD`, `REGIONAL`, + * `REG.EXP.`, `PROXIMDAD`, `AVANT`, `AVANT EXP`): regional when both ends + * are in one community, national when they are not. + */ +const RENFE_NATIONAL = new Set([ + 'AVE', + 'AVLO', + 'ALVIA', + 'EUROMED', + 'Intercity', +]); +const RENFE_COMMUNITY_RULE = new Set([ + 'MD', + 'REGIONAL', + 'REG.EXP.', + 'PROXIMDAD', + 'AVANT', + 'AVANT EXP', +]); +const RENFE_INTERNATIONAL = new Set(['AVE INT', 'TRENCELTA']); +function renfeLongDistanceTrain(routeId) { + const route = scopedRoute(routeId); + if (!route) return null; + if (RENFE_INTERNATIONAL.has(route.product) || route.scope === 'B') + return INTERNATIONAL_RAIL; + if (RENFE_NATIONAL.has(route.product)) return NATIONAL_RAIL; + if (RENFE_COMMUNITY_RULE.has(route.product)) + return route.scope === 'S' ? REGIONAL_RAIL : NATIONAL_RAIL; + return null; +} + +/** + * Renfe route-id rewrite for the packs: product from `route_short_name`, + * scope from the community (`stateAt`) of the two stations the id names. + * A station the GTFS does not place leaves the route unscoped (`X`). + */ +function renfeRouteRename(route, { stop, stateAt }) { + const product = String(route.route_short_name ?? '').trim(); + const id = String(route.route_id ?? ''); + if (!product || !/^\d{10}/.test(id)) return null; + const ends = [id.slice(0, 5), id.slice(5, 10)].map((code) => { + const at = stop(code); + return at ? stateAt(at.lat, at.lon) : null; + }); + let scope = 'X'; + if (ends.every(Boolean)) { + if (ends.some((end) => end.country !== 'ES')) scope = 'B'; + else if (ends[0].state === ends[1].state) scope = 'S'; + } + return `${product}~${scope}~${id}`; +} + +/** + * Norway (Entur), by line, with the counties (fylker, as of 2024) at each + * end of the line, read from each codespace's GTFS `route_long_name` + * (2026-11-06): + * - local: the `L` lines (L1, L2, L2x, L4 Bergen–Arna, L5 Jærbanen) and the + * Flytoget airport expresses (FLY1, FLY2). + * - international, to Sweden: F1 Oslo–Karlstad, RE20/RX20 Oslo–Göteborg, + * R71 Meråkerbanen (Trondheim–Storlien). + * - national, crossing a county line: the long-distance F4 Bergensbanen, + * F5 Sørlandsbanen, F6 Dovrebanen, F7 Nordlandsbanen; and the regional + * lines whose ends are in different counties (R12 Buskerud–Akershus, R13, + * R14, R21, R22, R23, R31, RE10, RE11/RX11, RE30, R40 Vestland–Buskerud, + * R60 Rørosbanen, R65 Raumabanen). + * - regional, inside one county: R55 (Telemark), R50 Sørtoget lokal + * (Agder), R70 Trønderbanen (Trøndelag), R75 Saltenpendelen (Nordland), + * and R45 Flåmsbana (Vestland, a tourist line). + * Vy runs replacement buses under the train line ids; their journeys are + * `ServiceJourney:BUS-*` and are dropped. + */ +const NORWAY_TOURIST_REGIONAL = Object.freeze({ + ...REGIONAL_RAIL, + tourist: true, +}); +const NORWAY_LINES = new Map( + [ + [LOCAL_RAIL, ['VYG:Line:L1', 'VYG:Line:L2', 'VYG:Line:L2x', 'VYG:Line:L4']], + [LOCAL_RAIL, ['GOA:Line:59', 'FLT:Line:FLY1', 'FLT:Line:FLY2']], + [ + INTERNATIONAL_RAIL, + ['VYG:Line:F1', 'VYG:Line:RE20', 'VYG:Line:RX20', 'SJN:Line:72'], + ], + [ + NATIONAL_RAIL, + [ + 'VYG:Line:F4', + 'GOA:Line:50', + 'SJN:Line:21', + 'SJN:Line:71', + 'VYG:Line:R12', + 'VYG:Line:R13', + 'VYG:Line:R13x', + 'VYG:Line:R14', + 'VYG:Line:R21', + 'VYG:Line:R22', + 'VYG:Line:R23', + 'VYG:Line:R23x', + 'VYG:Line:R31', + 'VYG:Line:RE10', + 'VYG:Line:RE11', + 'VYG:Line:RX11', + 'VYG:Line:RE30', + 'VYG:Line:R40', + 'SJN:Line:25', + 'SJN:Line:22', + ], + ], + [ + REGIONAL_RAIL, + ['VYG:Line:R55', 'GOA:Line:53', 'SJN:Line:26', 'SJN:Line:79'], + ], + [NORWAY_TOURIST_REGIONAL, ['VYG:Line:R45']], + ].flatMap(([kind, ids]) => ids.map((id) => [id, kind])), +); + +function norwayTrain(routeId, context) { + if (/:ServiceJourney:BUS-/.test(String(context?.tripId ?? ''))) return null; + return NORWAY_LINES.get(String(routeId ?? '')) ?? null; +} + +const CC_BY_4_URL = 'https://creativecommons.org/licenses/by/4.0/'; + +/** + * Entur (Norway). `ET-Client-Name` is mandatory (`-`); + * unidentified clients may be blocked. The feeds refresh every 15 s. + */ +const ENTUR_HEADERS = Object.freeze({ 'ET-Client-Name': 'godseyeview-rail' }); +const ENTUR_LIMITS = Object.freeze({ + quote: 'This dataset is updated every 15 seconds.', + note: 'No realtime rate limit is published; each codespace is polled at most every 15 s, filtered with ?datasource= so only that operator is fetched.', +}); +const NLOD_URL = 'https://data.norge.no/nlod/en/2.0'; +const ENTUR_ATTRIBUTION = + 'Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS'; +const ENTUR_TERMS = Object.freeze({ + quote: + "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements", +}); + +const TFI_GTFS_URL = + 'https://www.transportforireland.ie/transitData/Data/GTFS_Realtime.zip'; + +// ------------------------------------------- United States +// Helpers for the United States entries (Tri-Rail). They sit before the +// registry, which uses them. + +/** + * Tri-Rail (SFRTA). The static GTFS (gtfs.tri-rail.com, read 2026-11-10) + * has two train routes, `1` Mainline (Mangonia Park–Miami Airport) and `2` + * Downtown Miami Link (to MiamiCentral): commuter rail through Palm Beach, + * Broward and Miami-Dade counties, the Miami metropolitan area, so local. + * The realtime vehicles carry no route, only the trip id, which is the train + * number (`P617`; Downtown Miami Link trips are `P601X`) and the static + * trip's id. Route `4` is the Fort Lauderdale airport shuttle bus + * (route_type 3), which never reaches the packs. + */ +function triRailTrain(routeId, context) { + const route = String(routeId ?? ''); + if (route === '1' || route === '2') return LOCAL_RAIL; + if (route) return null; + return /^P\d{3}X?$/.test(String(context?.tripId ?? '')) ? LOCAL_RAIL : null; +} + +// ------------------------------------------ United States +// Helpers for the feeds that publish no terms and are used without a +// published licence by project owner decision of 2026-11-10 (Charlotte +// LYNX). They sit before the registry, which uses them. + +/** + * The basis each of those feeds records, before its own facts. It records a + * decision to use the data, not a licence: none was granted. + */ +const OWNER_AUTHORIZED_NO_TERMS = + 'Publishes no terms; used without a published licence (project owner decision, 2026-11-10).'; + +/** + * Registry of feeds. Order is presentation order in the stats/credit text. + * `loadRadiusKm` is the distance from `center` inside which the feed is polled. + * + * `format` picks the decoder: `gtfs-rt` (VehiclePositions protobuf), + * `irish-rail-xml` (src/data/irishRail.js) or `siri-vm` (SIRI Vehicle + * Monitoring, src/data/railSiriVm.js); `url` is the positions feed. + * `positions` says where positions come from (RAIL_POSITION_SOURCES, default + * `gps`). `headers` are sent with every realtime request, `maxBodyBytes` + * sets the feed's own response cap in place of the proxy's 8 MiB (never above + * 64 MiB: railMaxBodyBytes; the feeds that set one publish small documents + * and set a lower cap), and `headerClock: 'untrusted'` marks a feed whose + * header time is wrong and whose vehicles carry none: every train is then + * stamped with the fetch time. + * `tripUpdatesUrl` is the operator's GTFS-RT TripUpdates feed, served as + * `/api/rail/updates/`. + * + * `pollIntervalMs` and `tripUpdatesIntervalMs` are how long a snapshot is + * served from cache before the operator is asked again: never faster than + * the operator asks. `limits` records the published figures the settings + * rest on. + * + * The `static*` fields are read only by the opt-in pack build + * (scripts/build-rail-paths.mjs), never by the proxy, and never sent to the + * browser. `staticGtfsUrl` is the operator's static GTFS; when the GTFS is a + * zip inside that zip, `staticGtfsEntry` names it, and `staticHeaders` are + * the headers its download needs. `staticRouteTypes` narrows the static + * routes kept when the realtime classifier accepts every route of a + * mode-specific feed. `staticSimplifyM` coarsens shape simplification for a + * feed whose shapes would not fit the pack cap at the default 1 m. + * `staticRouteRename(route, helpers)` and `staticTripRename(trip, routeId, + * {ends})` rewrite pack route ids into ids the classifier reads (with + * `staticTripEnds`, each trip's first and last stop are passed as `ends`). + * + * `routesFrom: 'schedule'` marks a feed whose realtime vehicles carry a trip + * id but no route: the route is read from the feed's pack, and a GTFS-RT feed + * of this kind is offered only once that pack is built + * (railFeedNeedsSchedulePack). `positionsByTrainNumber` (Irish Rail) matches + * each reported train to its pack run by train code instead of trip id. + * @type {ReadonlyArray, headerClock?: 'untrusted', + * pollIntervalMs?: number, tripUpdatesUrl?: string, + * tripUpdatesIntervalMs?: number, maxBodyBytes?: number, + * limits?: {quote: string, note?: string}, + * routesFrom?: 'schedule', positionsByTrainNumber?: boolean, + * staticGtfsUrl?: string, staticGtfsEntry?: string, + * staticHeaders?: Record, + * staticRouteTypes?: ReadonlyArray, + * staticTripEnds?: boolean, staticSimplifyM?: number, + * staticRouteRename?: (route: object, helpers: object) => string|null, + * staticTripRename?: (trip: object, routeId: string, + * context?: {ends: object|null}) => string|null, + * license: string, licenseUrl: string, attribution: string, + * defaultEnabled: boolean, terms: {quote: string, note?: string}, + * classify: (routeId: string|null, context?: {tripId?: string|null}) + * => ({category: string, mode: string, tourist?: boolean}|null), + * }>>} + */ +export const RAIL_FEED_REGISTRY = Object.freeze([ + Object.freeze({ + id: 'mta-metro-north', + name: 'Metro-North', + operator: 'Metro-North Railroad (MTA)', + region: 'New York, NY', + center: Object.freeze({ lat: 41.2, lon: -73.6 }), + loadRadiusKm: 180, + format: 'gtfs-rt', + url: 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/mnr%2Fgtfs-mnr', + // One combined feed: the same file carries trip updates. + tripUpdatesUrl: + 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/mnr%2Fgtfs-mnr', + staticGtfsUrl: 'https://rrgtfsfeeds.s3.amazonaws.com/gtfsmnr.zip', + license: 'MTA Data Feed Terms and Conditions', + licenseUrl: 'https://www.mta.info/developers/terms-and-conditions', + attribution: 'MTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server.', + note: 'This proxy is that server: browsers never contact MTA. The feed stamps every vehicle with the header time, so a fix is aged from the snapshot, not from the train.', + }), + classify: always(LOCAL_RAIL), + }), + Object.freeze({ + id: 'mta-lirr', + name: 'LIRR', + operator: 'Long Island Rail Road (MTA)', + region: 'New York, NY', + center: Object.freeze({ lat: 40.75, lon: -73.3 }), + loadRadiusKm: 160, + format: 'gtfs-rt', + url: 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/lirr%2Fgtfs-lirr', + tripUpdatesUrl: + 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/lirr%2Fgtfs-lirr', + staticGtfsUrl: 'https://rrgtfsfeeds.s3.amazonaws.com/gtfslirr.zip', + license: 'MTA Data Feed Terms and Conditions', + licenseUrl: 'https://www.mta.info/developers/terms-and-conditions', + attribution: 'MTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server.', + note: 'The feed was seen frozen for two days on 2026-11-06; the layer ages trains by their own report time, so a frozen feed empties instead of showing old trains as live.', + }), + classify: always(LOCAL_RAIL), + }), + Object.freeze({ + id: 'mbta-rail', + name: 'MBTA', + operator: 'Massachusetts Bay Transportation Authority', + region: 'Boston, MA', + center: Object.freeze({ lat: 42.3601, lon: -71.0589 }), + loadRadiusKm: 110, + format: 'gtfs-rt', + url: 'https://cdn.mbta.com/realtime/VehiclePositions.pb', + // All modes, about 1 MB: the rail filter keeps a fraction of it. + tripUpdatesUrl: 'https://cdn.mbta.com/realtime/TripUpdates.pb', + staticGtfsUrl: 'https://cdn.mbta.com/MBTA_GTFS.zip', + license: 'MassDOT Developers License Agreement', + licenseUrl: + 'https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf', + attribution: 'MBTA / MassDOT', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data.', + note: 'The same feed the Transit layer polls; this layer keeps only Commuter Rail, rapid transit and light rail.', + }), + classify: mbtaTrain, + }), + Object.freeze({ + id: 'septa-regional-rail', + name: 'SEPTA Regional Rail', + operator: 'Southeastern Pennsylvania Transportation Authority', + region: 'Philadelphia, PA', + center: Object.freeze({ lat: 40.05, lon: -75.25 }), + loadRadiusKm: 110, + format: 'gtfs-rt', + url: 'https://www3.septa.org/gtfsrt/septarail-pa-us/Vehicle/rtVehiclePosition.pb', + tripUpdatesUrl: + 'https://www3.septa.org/gtfsrt/septarail-pa-us/Trip/rtTripUpdates.pb', + staticGtfsUrl: + 'https://github.com/septadev/GTFS/releases/latest/download/gtfs_public.zip', + staticGtfsEntry: 'google_rail.zip', + license: 'SEPTA Trip Planning Data Agreement', + licenseUrl: 'https://www3.septa.org/developer/', + attribution: 'SEPTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data', + note: 'The agreement names the static data; SEPTA publishes the realtime feeds on the same developer page with no separate terms. Credit is text only: SEPTA trademarks may not be used.', + }), + classify: always(LOCAL_RAIL), + }), + Object.freeze({ + id: 'septa-trolley', + name: 'SEPTA trolleys', + operator: 'Southeastern Pennsylvania Transportation Authority', + region: 'Philadelphia, PA', + center: Object.freeze({ lat: 39.98, lon: -75.2 }), + loadRadiusKm: 60, + format: 'gtfs-rt', + url: 'https://www3.septa.org/gtfsrt/septa-pa-us/Vehicle/rtVehiclePosition.pb', + // The bus feed's trip updates (about 1 MB), filtered like its vehicles. + tripUpdatesUrl: + 'https://www3.septa.org/gtfsrt/septa-pa-us/Trip/rtTripUpdates.pb', + staticGtfsUrl: + 'https://github.com/septadev/GTFS/releases/latest/download/gtfs_public.zip', + staticGtfsEntry: 'google_bus.zip', + license: 'SEPTA Trip Planning Data Agreement', + licenseUrl: 'https://www3.septa.org/developer/', + attribution: 'SEPTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data', + note: 'The bus feed, filtered to the trolley routes and the Norristown High Speed Line. The Market-Frankford and Broad Street lines are in no SEPTA feed.', + }), + classify: byRoute({ + T1: TRANSIT_TRAM, + T2: TRANSIT_TRAM, + T3: TRANSIT_TRAM, + T4: TRANSIT_TRAM, + T5: TRANSIT_TRAM, + G1: TRANSIT_TRAM, + D1: TRANSIT_TRAM, + D2: TRANSIT_TRAM, + M1: TRANSIT_METRO, + }), + }), + Object.freeze({ + id: 'marc', + name: 'MARC', + operator: 'Maryland Transit Administration', + region: 'Baltimore–Washington', + center: Object.freeze({ lat: 39.1, lon: -77.0 }), + loadRadiusKm: 140, + format: 'gtfs-rt', + url: 'https://mdotmta-gtfs-rt.s3.amazonaws.com/MARC+RT/marc-vp.pb', + tripUpdatesUrl: + 'https://mdotmta-gtfs-rt.s3.amazonaws.com/MARC+RT/marc-tu.pb', + staticGtfsUrl: 'https://feeds.mta.maryland.gov/gtfs/marc', + license: 'MDOT MTA developer feeds (no endorsement implied)', + licenseUrl: 'https://www.mta.maryland.gov/developer-resources', + attribution: 'Maryland Transit Administration', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'MTA does not guarantee the accuracy of the data nor does it warrant or endorse any application that uses this data.', + note: 'Published for open fetch with this disclaimer and no licence; the site terms cover only the website and apps. Shown with attribution and the no-endorsement wording, and switched off if MDOT MTA publishes restrictive terms.', + }), + classify: always(LOCAL_RAIL), + }), + // ------------------------------------------- United States + // Added 2026-11-10; licences read at the source that day unless the note + // says otherwise. Helpers are above the registry. + Object.freeze({ + id: 'tri-rail', + name: 'Tri-Rail', + operator: 'South Florida Regional Transportation Authority', + region: 'South Florida', + center: Object.freeze({ lat: 26.27, lon: -80.15 }), + loadRadiusKm: 90, + format: 'gtfs-rt', + url: 'https://gtfsr.tri-rail.com/download.aspx?file=position_updates.pb', + tripUpdatesUrl: + 'https://gtfsr.tri-rail.com/download.aspx?file=trip_updates.pb', + // Vehicles report every 20 to 40 s (seen 2026-11-10); the positions are + // asked every 30 s, the trip updates every minute. + pollIntervalMs: 30_000, + tripUpdatesIntervalMs: 60_000, + staticGtfsUrl: 'https://gtfs.tri-rail.com/gtfs.zip', + license: 'No licence stated (open feed pages)', + licenseUrl: 'https://gtfsr.tri-rail.com/', + attribution: 'South Florida Regional Transportation Authority (Tri-Rail)', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'Download Realtime Feeds Below:', + note: 'Used without a published licence (project owner decision, 2026-11-10). gtfsr.tri-rail.com (the page quoted above, read 2026-11-10) links the GTFS-RT trip updates, alerts and position updates with no terms, and the static GTFS (gtfs.tri-rail.com/gtfs.zip, publisher ETA Transit Systems) states none either; no SFRTA data terms were found. The realtime vehicles carry the train number as their trip id and no route.', + }), + classify: triRailTrain, + }), + Object.freeze({ + id: 'rtd-denver-rail', + name: 'RTD', + operator: 'Regional Transportation District', + region: 'Denver, CO', + center: Object.freeze({ lat: 39.74, lon: -104.99 }), + loadRadiusKm: 70, + format: 'gtfs-rt', + // The www URL redirects twice and lands on this host; the proxy refuses a + // redirect that leaves the registered origin, so the final URL is the one + // registered. + url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb', + tripUpdatesUrl: + 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/TripUpdate.pb', + staticGtfsUrl: 'https://www.rtd-denver.com/files/gtfs/google_transit.zip', + license: 'RTD GTFS-Realtime License Agreement', + licenseUrl: + 'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement', + attribution: 'RTD', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data', + note: 'RTD marks may not be used with the data, so the credit is text only.', + }), + classify: rtdTrain, + }), + Object.freeze({ + id: 'capmetro-rail', + name: 'CapMetro MetroRail', + operator: 'Capital Metropolitan Transportation Authority', + region: 'Austin, TX', + center: Object.freeze({ lat: 30.4, lon: -97.75 }), + loadRadiusKm: 70, + format: 'gtfs-rt', + url: 'https://data.texas.gov/download/eiei-9rpf/application%2Foctet-stream', + staticGtfsUrl: + 'https://data.texas.gov/download/r4v4-vz24/application%2Fzip', + license: 'CapMetro Open Data Terms', + licenseUrl: 'https://www.capmetro.org/metrolabs/', + attribution: 'CapMetro', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data', + note: 'The bus feed the Transit layer polls; MetroRail is route 550.', + }), + classify: byRoute({ 550: LOCAL_RAIL }), + }), + Object.freeze({ + id: 'metrotransit-rail', + name: 'METRO light rail', + operator: 'Metro Transit (Metropolitan Council)', + region: 'Minneapolis–St Paul, MN', + center: Object.freeze({ lat: 44.95, lon: -93.2 }), + loadRadiusKm: 50, + format: 'gtfs-rt', + url: 'https://svc.metrotransit.org/mtgtfs/vehiclepositions.pb', + staticGtfsUrl: 'https://svc.metrotransit.org/mtgtfs/gtfs.zip', + license: 'No licence published', + licenseUrl: 'https://svc.metrotransit.org/', + attribution: 'Metro Transit / Metropolitan Council', + defaultEnabled: true, + terms: Object.freeze({ + quote: '', + note: 'Publishes no licence or terms: the data feeds page linked above (read 2026-11-10) lists the GTFS and GTFS-realtime files and shows only "© 2020 Metro Transit", so there is nothing to quote. Used without a published licence, on the same basis as the Transit layer\'s Metro Transit feed, which polls the same file (DATA_SOURCES.md). Blue Line 901 and Green Line 902 only; Northstar ended on 2026-01-04 and route 888 is now a bus.', + }), + classify: byRoute({ 901: TRANSIT_TRAM, 902: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'charlotte-lynx', + name: 'LYNX', + operator: 'Charlotte Area Transit System', + region: 'Charlotte, NC', + center: Object.freeze({ lat: 35.22, lon: -80.84 }), + loadRadiusKm: 40, + format: 'gtfs-rt', + url: 'https://gtfsrealtime.ridetransit.org/GTFSRealTime/Vehicle/VehiclePositions.pb', + // The URLs the Transitland and Mobility Database catalogues list for + // CATS. The trip updates carry every bus too; only the two lines' are + // kept. + tripUpdatesUrl: + 'https://gtfsrealtime.ridetransit.org/GTFSRealTime/TripUpdate/Tripupdates.pb', + staticGtfsUrl: + 'https://gtfsrealtime.ridetransit.org/GTFSStatic/api/GTFSDownload/GTFS.zip', + license: 'No licence published', + licenseUrl: 'https://www.charlottenc.gov/cats', + attribution: 'Charlotte Area Transit System', + defaultEnabled: true, + terms: Object.freeze({ + quote: '', + note: `${OWNER_AUTHORIZED_NO_TERMS} The feed host (gtfsrealtime.ridetransit.org) serves the files with no page and no terms, so there is nothing to quote (2026-11-10: 126 vehicles, 13 of them trains; 205 trip updates, 11 on the two lines; every realtime trip id is a static trip id). No CATS page with terms for them was found: charlottenc.gov answered 403 to automated requests, and the CATS Terms and Conditions found there by search govern the CATS mobile app. Routes 501 LYNX Blue Line (light rail) and 510 CityLYNX Gold Line (streetcar), both route_type 0: transit, tram.`, + }), + classify: byRoute({ 501: TRANSIT_TRAM, 510: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'grt-ion', + name: 'ION', + operator: 'Grand River Transit (Region of Waterloo)', + region: 'Waterloo Region, ON', + center: Object.freeze({ lat: 43.45, lon: -80.5 }), + loadRadiusKm: 40, + format: 'gtfs-rt', + // The suffix-less URL catalogued in Transitland serves a file frozen in + // February 2026; `/2` is the light-rail feed the status page publishes. + url: 'https://webapps.regionofwaterloo.ca/api/grt-routes/api/vehiclepositions/2', + staticGtfsUrl: + 'https://webapps.regionofwaterloo.ca/api/grt-routes/api/staticfeeds/2', + license: 'Region of Waterloo Open Data Licence v2.0', + licenseUrl: + 'https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/', + attribution: + 'Contains information licensed under the Open Data Licence – Region of Waterloo', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'You are free to: Copy, publish, distribute and transmit the Information ... Use the Information commercially', + }), + classify: byRoute({ 301: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'translink-seq-rail', + name: 'Queensland Rail Citytrain', + operator: 'Translink (Queensland Government)', + region: 'South East Queensland, Australia', + center: Object.freeze({ lat: -27.47, lon: 153.02 }), + loadRadiusKm: 160, + format: 'gtfs-rt', + url: 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/VehiclePositions/Rail', + tripUpdatesUrl: + 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/TripUpdates/Rail', + staticGtfsUrl: 'https://gtfsrt.api.translink.com.au/GTFS/SEQ_GTFS.zip', + license: 'CC BY 4.0', + licenseUrl: 'https://translink.com.au/about-translink/open-data', + attribution: 'Translink / Queensland Government', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Our data is licensed under a Creative Commons Attribution 4.0 International License.', + note: 'The terms add: "You must not represent that the State in any way endorses your Application."', + }), + classify: translinkRailTrain, + }), + Object.freeze({ + id: 'translink-seq-tram', + name: 'G:link', + operator: 'Translink (Queensland Government)', + region: 'Gold Coast, Australia', + center: Object.freeze({ lat: -27.98, lon: 153.4 }), + loadRadiusKm: 50, + format: 'gtfs-rt', + url: 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/VehiclePositions/Tram', + tripUpdatesUrl: + 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/TripUpdates/Tram', + staticGtfsUrl: 'https://gtfsrt.api.translink.com.au/GTFS/SEQ_GTFS.zip', + // The static GTFS is all of SEQ; only its tram routes are G:link. + staticRouteTypes: Object.freeze([0]), + license: 'CC BY 4.0', + licenseUrl: 'https://translink.com.au/about-translink/open-data', + attribution: 'Translink / Queensland Government', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Our data is licensed under a Creative Commons Attribution 4.0 International License.', + }), + classify: always(TRANSIT_TRAM), + }), + Object.freeze({ + id: 'adelaide-metro', + name: 'Adelaide Metro', + operator: 'Adelaide Metro (Rail Commissioner)', + region: 'Adelaide, Australia', + center: Object.freeze({ lat: -34.93, lon: 138.6 }), + loadRadiusKm: 70, + format: 'gtfs-rt', + url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions', + tripUpdatesUrl: + 'https://gtfs.adelaidemetro.com.au/v1/realtime/trip_updates', + // "stop predictions are refreshed every 60 seconds" (Swagger). + tripUpdatesIntervalMs: 60_000, + // The gateway negotiates on Accept: anything but its own protobuf type + // (including the proxy's default `application/x-protobuf`) is answered + // with base64 text labelled application/json, which no decoder reads. + headers: Object.freeze({ Accept: 'application/x-google-protobuf' }), + staticGtfsUrl: + 'https://gtfs.adelaidemetro.com.au/v1/static/latest/google_transit.zip', + license: 'CC BY 4.0', + licenseUrl: + 'https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au', + attribution: 'Adelaide Metro / Government of South Australia', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'Creative Commons Attribution 4.0', + note: 'Licence as catalogued on data.sa.gov.au; the operator developer page sits behind a bot check. The feed is labelled beta.', + }), + classify: byRoute({ + BEL: LOCAL_RAIL, + FLNDRS: LOCAL_RAIL, + GAW: LOCAL_RAIL, + GAWC: LOCAL_RAIL, + GRNG: LOCAL_RAIL, + NOAR: LOCAL_RAIL, + OSBORN: LOCAL_RAIL, + OUTHA: LOCAL_RAIL, + PTDOCK: LOCAL_RAIL, + SALIS: LOCAL_RAIL, + SEAFRD: LOCAL_RAIL, + GLNELG: TRANSIT_TRAM, + BTANIC: TRANSIT_TRAM, + FESTVL: TRANSIT_TRAM, + }), + }), + Object.freeze({ + id: 'irish-rail', + name: 'Irish Rail', + operator: 'Iarnród Éireann / Irish Rail', + region: 'Ireland', + center: Object.freeze({ lat: 53.3, lon: -7.7 }), + loadRadiusKm: 260, + format: 'irish-rail-xml', + positions: 'event', + // The API documentation shows http; the same path answers over https, + // which is the only scheme this proxy fetches. + url: 'https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML', + headers: Object.freeze({ Accept: 'text/xml, application/xml;q=0.9' }), + pollIntervalMs: 30_000, + maxBodyBytes: 1024 * 1024, + limits: Object.freeze({ + quote: + 'Irish Rail provides this information as is and do not offer any support', + note: 'No rate limit is published. Polled every 30 s; one file is about 35 KB.', + }), + // The TFI static GTFS. Its trip_short_name is the train code the XML + // names: each reported train takes the run that code is on now, so it + // is drawn on its line with its stops. + positionsByTrainNumber: true, + routesFrom: 'schedule', + staticGtfsUrl: TFI_GTFS_URL, + staticRouteTypes: Object.freeze([2]), + staticTripEnds: true, + staticTripRename: irishRailTripRename, + license: 'No licence published (positions); CC BY 4.0 (NTA timetable)', + // The NTA's policy page: it names the CC BY 4.0 licence of the NTA's + // GTFS data (the timetable) and is the NTA link its section 7 asks + // public use to carry. The positions' API page publishes no licence. + licenseUrl: 'https://developer.nationaltransport.ie/usagepolicy', + attribution: + 'Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is', + defaultEnabled: true, + terms: Object.freeze({ + quote: '', + note: 'The train positions publish no licence or terms: the Irish Rail realtime API page (api.irishrail.ie/realtime/, read 2026-11-10) documents the calls and says only that "Irish Rail provides this information as is" (quoted under `limits`), so there is nothing to quote. They are used without a published licence, credited "Iarnród Éireann / Irish Rail". The NTA\'s CC BY 4.0 covers only the NTA\'s GTFS data (the timetable), not these positions. Positions are the station or timing point the signalling system last reported, not GPS.', + }), + classify: irishRailTrain, + }), + Object.freeze({ + id: 'fintraffic-vr', + name: 'VR (Finland)', + operator: 'VR, via Fintraffic Digitraffic', + region: 'Finland', + center: Object.freeze({ lat: 63.5, lon: 25.5 }), + loadRadiusKm: 600, + format: 'gtfs-rt', + // Digitraffic's GTFS-RT locations: passenger trains only, with the + // passenger GTFS's route and trip ids (the route id names the train + // type). The REST pair (train-locations + live-trains) would need the + // 6.5 MB live-trains list on every poll to learn each train's type. + url: 'https://rata.digitraffic.fi/api/v1/trains/gtfs-rt-locations', + tripUpdatesUrl: 'https://rata.digitraffic.fi/api/v1/trains/gtfs-rt-updates', + headers: Object.freeze({ 'Digitraffic-User': 'GodsEyeView/RailLayer' }), + staticHeaders: Object.freeze({ + 'Digitraffic-User': 'GodsEyeView/RailLayer', + }), + limits: Object.freeze({ + quote: 'Real time feed is updated every 10 seconds', + note: 'Requests without a Digitraffic-User header are limited to 60 a minute per IP; compression is mandatory (the proxy asks for gzip). Positions every 15 s, trip updates every 30 s.', + }), + staticGtfsUrl: + 'https://rata.digitraffic.fi/api/v1/trains/gtfs-passenger-stops.zip', + license: 'CC BY 4.0', + licenseUrl: 'https://www.digitraffic.fi/en/terms-of-service/', + attribution: 'Source: Fintraffic / digitraffic.fi, license CC 4.0 BY', + defaultEnabled: true, + terms: Object.freeze({ + quote: + "Fintraffic's open data is licensed under the Creative Commons 4.0 By license. It gives the right to distribute, remix, tweak, and build upon our data, even commercially, as long as you credit the source for the original creation.", + note: 'The Digitraffic-User header names the application, never a person.', + }), + classify: finlandTrain, + }), + Object.freeze({ + id: 'renfe-larga-distancia', + name: 'Renfe long and medium distance', + operator: 'Renfe (AVE, Avlo, Alvia, Euromed, Intercity, Media Distancia)', + region: 'Spain', + center: Object.freeze({ lat: 40.2, lon: -3.7 }), + loadRadiusKm: 700, + format: 'gtfs-rt', + url: 'https://gtfsrt.renfe.com/vehicle_positions_LD.pb', + tripUpdatesUrl: 'https://gtfsrt.renfe.com/trip_updates_LD.pb', + // The header clock runs an hour behind (local time written as UTC) and + // vehicles carry no time: every train is stamped with the fetch time. + headerClock: 'untrusted', + // Vehicles carry a trip id but no route: the route (with its product) + // is read from the schedule pack. + routesFrom: 'schedule', + staticGtfsUrl: + 'https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip', + staticRouteRename: renfeRouteRename, + license: 'CC BY 4.0', + licenseUrl: CC_BY_4_URL, + attribution: 'Renfe', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'CC-BY-4.0', + note: 'Licence field of the data.renfe.com datasets (posicion-vehiculos-av-ld-md and the static GTFS). "Esta información se actualiza cada 15 segundos". Iryo and OUIGO España are not in Renfe’s feeds.', + }), + classify: renfeLongDistanceTrain, + }), + Object.freeze({ + id: 'renfe-cercanias', + name: 'Renfe Cercanías', + operator: 'Renfe Cercanías and Rodalies', + region: 'Spain', + center: Object.freeze({ lat: 40.2, lon: -3.7 }), + loadRadiusKm: 700, + format: 'gtfs-rt', + url: 'https://gtfsrt.renfe.com/vehicle_positions.pb', + tripUpdatesUrl: 'https://gtfsrt.renfe.com/trip_updates.pb', + staticGtfsUrl: + 'https://ssl.renfe.com/ftransit/Fichero_CER_FOMENTO/fomento_transit.zip', + license: 'CC BY 4.0', + licenseUrl: CC_BY_4_URL, + attribution: 'Renfe', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'CC-BY-4.0', + note: 'Licence field of the data.renfe.com Cercanías datasets. Every vehicle in this feed is a Cercanías or Rodalies train.', + }), + classify: always(LOCAL_RAIL), + }), + ...[ + ['VYG', 'entur-vy', 'Vy', 'Vy (Norway)', 61.0, 9.5, 450], + ['GOA', 'entur-go-ahead', 'Go-Ahead', 'Go-Ahead Norge', 58.8, 7.5, 280], + ['FLT', 'entur-flytoget', 'Flytoget', 'Flytoget', 59.9, 10.9, 80], + ].map(([codespace, id, name, operator, lat, lon, radius]) => + Object.freeze({ + id, + name, + operator: `${operator}, via Entur`, + region: 'Norway', + center: Object.freeze({ lat, lon }), + loadRadiusKm: radius, + format: 'gtfs-rt', + url: `https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions?datasource=${codespace}`, + // Vy's shapes are dense enough (1.2 million points at 1 m) to break + // the pack cap (16 MiB when measured, 24 MiB now); 5 m keeps them on + // the track at map scale. + ...(codespace === 'VYG' ? { staticSimplifyM: 5 } : {}), + ...(codespace === 'VYG' + ? {} + : { + tripUpdatesUrl: `https://api.entur.io/realtime/v1/gtfs-rt/trip-updates?datasource=${codespace}`, + }), + headers: ENTUR_HEADERS, + staticHeaders: ENTUR_HEADERS, + limits: ENTUR_LIMITS, + staticGtfsUrl: `https://storage.googleapis.com/marduk-production/outbound/gtfs/rb_${codespace.toLowerCase()}-aggregated-gtfs.zip`, + license: 'NLOD 2.0', + licenseUrl: NLOD_URL, + attribution: ENTUR_ATTRIBUTION, + defaultEnabled: true, + terms: ENTUR_TERMS, + classify: norwayTrain, + }), + ), + Object.freeze({ + id: 'entur-sj-nord', + name: 'SJ Nord', + operator: 'SJ Nord, via Entur', + region: 'Norway', + center: Object.freeze({ lat: 65.0, lon: 12.5 }), + loadRadiusKm: 600, + // SJ Nord is missing from Entur's GTFS-RT positions; its trains report + // in SIRI VM (SIRI Lite, about 7 KB for the codespace), read by + // src/data/railSiriVm.js. The static GTFS still gives the stops. + format: 'siri-vm', + url: 'https://api.entur.io/realtime/v1/rest/vm?datasetId=SJN', + headers: Object.freeze({ ...ENTUR_HEADERS, Accept: 'application/xml' }), + maxBodyBytes: 2 * 1024 * 1024, + limits: ENTUR_LIMITS, + staticHeaders: ENTUR_HEADERS, + staticSimplifyM: 5, + staticGtfsUrl: + 'https://storage.googleapis.com/marduk-production/outbound/gtfs/rb_sjn-aggregated-gtfs.zip', + license: 'NLOD 2.0', + licenseUrl: NLOD_URL, + attribution: ENTUR_ATTRIBUTION, + defaultEnabled: true, + terms: Object.freeze({ + ...ENTUR_TERMS, + note: 'SJ Nord trains are missing from Entur’s GTFS-RT vehicle positions (0 vehicles on 2026-11-06); they are read from SIRI VM instead (13 trains on 2026-11-09). Its journey refs are DatedServiceJourneys, not the GTFS trip ids, so a train is matched to its run by line and position.', + }), + classify: norwayTrain, + }), + // National sources. Licences were read at the source on 2026-11-09; their + // classifier helpers follow the registry. + Object.freeze({ + id: 'ktmb', + name: 'KTMB', + operator: + 'Keretapi Tanah Melayu (ETS, Intercity, KTM Komuter, Shuttle Tebrau)', + region: 'Peninsular Malaysia', + center: Object.freeze({ lat: 4.0, lon: 101.8 }), + loadRadiusKm: 450, + format: 'gtfs-rt', + // The documented URL (no trailing slash) answers 301 to this one. + url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/ktmb/', + pollIntervalMs: 30_000, + limits: Object.freeze({ + quote: 'GTFS Realtime 4 requests per minute', + note: 'developer.data.gov.my/rate-limit (a 429 past it). "All Vehicle Position feeds are updated every 30 seconds": polled every 30 s, 2 a minute. Trip updates are "in our pipeline for 2026".', + }), + // Vehicles carry a trip id but no route: the scoped route is read from + // the schedule pack. + routesFrom: 'schedule', + staticGtfsUrl: 'https://api.data.gov.my/gtfs-static/ktmb', + staticTripEnds: true, + staticTripRename: ktmbTripRename, + license: 'CC BY 4.0', + licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime', + attribution: 'KTMB, via data.gov.my (Government of Malaysia)', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0', + note: 'developer.data.gov.my, read 2026-11-09; its GTFS Static page says the same of "All GTFS Static feeds". The static GTFS has no shapes.', + }), + classify: ktmbTrain, + }), + // ------------------------------------------ United States + // The BTS National Transit Map's rail agencies (snapshot of 2025-09-02) + // that no feed covered yet: each agency's current files and terms, read + // on 2026-11-10 unless the note says otherwise. Helpers are above the + // registry. + Object.freeze({ + id: 'seattle-streetcar', + name: 'Seattle Streetcar', + operator: + 'City of Seattle (South Lake Union and First Hill lines), via King County Metro', + region: 'Seattle, WA', + center: Object.freeze({ lat: 47.61, lon: -122.33 }), + loadRadiusKm: 10, + format: 'gtfs-rt', + // King County Metro's whole-fleet files (about 500 vehicles in 50 KB; the + // trip updates about 1.1 MB): only the two streetcar routes are kept. + url: 'https://s3.amazonaws.com/kcm-alerts-realtime-prod/vehiclepositions.pb', + tripUpdatesUrl: + 'https://s3.amazonaws.com/kcm-alerts-realtime-prod/tripupdates.pb', + staticGtfsUrl: 'https://metro.kingcounty.gov/GTFS/google_transit.zip', + license: 'King County Transit Data Terms of Use', + licenseUrl: + 'https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps', + attribution: + 'Transit scheduling, geographic, and real-time data provided by permission of King County', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'King County grants you a limited, revocable license to use, reproduce, and redistribute the Data in accordance with these Terms.', + note: 'Transit Data Terms of Use on the page linked above (undated; read 2026-11-10), which links the GTFS and GTFS-RT used here and covers "schedule, geographic, real-time, or any other data posted by King County". King County also permits users "to copy, distribute, store (electronically or otherwise), create derivative works based on and perform and display (publicly or otherwise) such Data, and make such Data available to end users", and asks for the legend the credit carries; no King County service marks beyond it. Commercial use is not mentioned. The feed bundles King County Metro, Sound Transit and City of Seattle routes; only the City of Seattle streetcars (agency 23) are kept, `100340` South Lake Union and `102638` First Hill (transit, tram). Link light rail is not in this GTFS.', + }), + classify: byRoute({ 100340: TRANSIT_TRAM, 102638: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'cincinnati-connector', + name: 'Connector streetcar', + operator: 'Southwest Ohio Regional Transit Authority (SORTA/Metro)', + region: 'Cincinnati, OH', + center: Object.freeze({ lat: 39.11, lon: -84.51 }), + loadRadiusKm: 8, + format: 'gtfs-rt', + // Metro's whole-fleet files (about 150 vehicles in 12 KB; trip updates + // about 300 KB): only the streetcar is kept. + url: 'https://tmgtfsprd.sorttrpcloud.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb', + tripUpdatesUrl: + 'https://tmgtfsprd.sorttrpcloud.com/TMGTFSRealTimeWebService/tripupdate/tripupdates.pb', + // The developer page's "Metro GTFS data" link, replaced in place. + staticGtfsUrl: + 'https://www.go-metro.com/wp-content/uploads/2024/12/google_transit-1.zip', + license: 'SORTA/Metro developer data Terms', + licenseUrl: 'https://www.go-metro.com/about/developer-data/', + attribution: 'Southwest Ohio Regional Transit Authority (SORTA/Metro)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The Southwest Ohio Regional Transit Authority (SORTA/Metro) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute SORTA/Metro’s transit route schedules, pricing, and other associated data (“Data”) subject to the following Terms:', + note: 'The developer data page linked above (read 2026-11-10), which links the GTFS and these GTFS-RT files. SORTA/Metro trademarks "may not be used in association with Data unless approved in writing by SORTA/Metro", so the credit is text only; "Any modification, alteration, combination or arrangement of the Data is solely at your own risk." Commercial use is not mentioned. Seen 2026-11-10: 2 streetcars among 147 vehicles, 6 streetcar trips among 265 updates, trip ids those of the static file. The file of 2026-10-01 ends on 2026-12-05. One rail route, `100` Streetcar - OTR - Banks (route_type 0): transit, tram.', + }), + classify: byRoute({ 100: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'jp-manyosen', + name: 'Manyōsen', + operator: '万葉線 Manyōsen (Takaoka–Kōshinokata tram)', + region: 'Toyama, Japan', + center: Object.freeze({ lat: 36.77, lon: 137.06 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + url: 'https://gtfs-rt-files.buscatch.jp/toyama/manyo_line/VehiclePositions.pb', + pollIntervalMs: 30_000, + tripUpdatesUrl: + 'https://gtfs-rt-files.buscatch.jp/toyama/manyo_line/TripUpdates.pb', + staticGtfsUrl: + 'https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen/files/feed.zip?rid=current', + license: 'CC0 1.0', + licenseUrl: + 'https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen', + attribution: '万葉線株式会社, via the GTFS Data Repository (gtfs-data.jp)', + defaultEnabled: true, + terms: Object.freeze({ + quote: '"feed_license": "CC0 1.0"', + note: 'GTFS Data Repository record of the feed "万葉線", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. Tram: transit.', + }), + classify: always(TRANSIT_TRAM), + }), + Object.freeze({ + id: 'jp-toyama-tram', + name: 'Toyama tram', + operator: + '富山地方鉄道 Toyama Chihō Railway city tram (city lines, Toyamakō Line, loop line)', + region: 'Toyama, Japan', + center: Object.freeze({ lat: 36.72, lon: 137.21 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + url: 'https://gtfs-rt-files.buscatch.jp/toyama/chitetsu_tram/VehiclePositions.pb', + pollIntervalMs: 30_000, + // No staticGtfsUrl: the tram's static GTFS codes every route as a bus + // (route_type 3), so the path build would keep none of it. Its + // TripUpdates are not used either: there are no stops to show delays at. + license: 'CC0 1.0', + licenseUrl: + 'https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha', + attribution: + '富山地方鉄道株式会社, via the GTFS Data Repository (gtfs-data.jp)', + defaultEnabled: true, + terms: Object.freeze({ + quote: '"feed_license": "CC0 1.0"', + note: 'GTFS Data Repository record of the feed "富山地方鉄道市内電車", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. The tram-only feed (the company\'s buses are a separate feed). Tram: transit.', + }), + classify: always(TRANSIT_TRAM), + }), + Object.freeze({ + id: 'tbm-bordeaux', + name: 'Bordeaux tram', + operator: 'TBM (Bordeaux Métropole): trams A to F', + region: 'Bordeaux, France', + center: Object.freeze({ lat: 44.85, lon: -0.58 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + // Every TBM vehicle (about 80 KB); the tram routes are kept. The + // `apiKey` in these links is a fixed public value the publisher prints + // on the dataset page for everyone: the same for every user, not a + // credential of the deployer's, and nothing is signed up for to get it. + url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + tripUpdatesUrl: + 'https://bdx.mecatran.com/utw/ws/gtfsfeed/realtime/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + tripUpdatesIntervalMs: 60_000, + staticGtfsUrl: + 'https://bdx.mecatran.com/utw/ws/gtfsfeed/static/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + license: 'Licence Ouverte / Open Licence 1.0', + licenseUrl: + 'https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite', + attribution: 'Bordeaux Métropole (TBM)', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'fr-lo', + note: 'Licence field of the transport.data.gouv.fr dataset "Réseau urbain et scolaire TBM" (publisher Bordeaux Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates; Licence Ouverte 1.0 (Etalab) allows reuse, commercial included, with attribution. Tram: transit, tram.', + }), + classify: byRoute({ + 59: TRANSIT_TRAM, // A + 60: TRANSIT_TRAM, // B + 61: TRANSIT_TRAM, // C + 62: TRANSIT_TRAM, // D + 163: TRANSIT_TRAM, // E + 164: TRANSIT_TRAM, // F + }), + }), + Object.freeze({ + id: 'tam-montpellier', + name: 'Montpellier tram', + operator: 'TaM (Montpellier Méditerranée Métropole): trams 1 to 5', + region: 'Montpellier, France', + center: Object.freeze({ lat: 43.61, lon: 3.88 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + // Every TaM vehicle (about 15 KB); the tram routes are kept. + url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb', + tripUpdatesUrl: 'https://gtfsproxy.e-tam.fr/COMMON/TripUpdate.pb', + tripUpdatesIntervalMs: 60_000, + staticGtfsUrl: 'https://gtfsproxy.e-tam.fr/COMMON/GTFS.zip', + license: 'ODbL 1.0', + licenseUrl: 'https://opendatacommons.org/licenses/odbl/1-0/', + attribution: 'TaM / Montpellier Méditerranée Métropole', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'odc-odbl', + note: `Licence field of the transport.data.gouv.fr dataset "Réseau urbain TaM" (publisher Montpellier Méditerranée Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates. ${panOdblNote()} Tram: transit, tram.`, + }), + classify: byRoute( + Object.fromEntries( + ['1', '2', '3', '4', '5'].map((line) => [line, TRANSIT_TRAM]), + ), + ), + }), +]); + +/** + * What ODbL means for a transport.data.gouv.fr dataset: the access point + * applies its "Conditions Particulières d'Utilisation" to every ODbL + * dataset it lists (doc.transport.data.gouv.fr, "Licence ODbL", read + * 2026-11-10). + */ +function panOdblNote() { + return 'ODbL is share-alike, with the access point\'s special conditions (doc.transport.data.gouv.fr "Licence ODbL", read 2026-11-10): the share-alike clause covers information "de même nature, de même granularité, de mêmes conditions temporelles et de même emprise géographique", and "Les bases de données ainsi repartagées doivent être publiées sur le Point d\'accès national transport.data.gouv.fr, dans le format d\'origine." The path and schedule packs built from this GTFS are such a database once shared: offered under ODbL with the attribution kept.'; +} + +/** + * Where a run goes, from its two ends as the path build reads them + * (`staticTripEnds`, scripts/build-rail-paths.mjs `readTripEnds`): `B` when + * its ends are in two countries or (unless `endsOnly`) it stops in two, `S` + * when both ends are in one state, `X` otherwise, also when an end could not + * be placed. `fix(end)` corrects an end the simplified state polygons + * misplace, which happens to stations on a border. + */ +function tripScope(ends, { fix = null, endsOnly = false } = {}) { + const place = (end) => (end && fix ? fix(end) : end); + const from = place(ends?.from); + const to = place(ends?.to); + if (!from?.country || !to?.country) return 'X'; + if (from.country !== to.country) return 'B'; + if (!endsOnly && (ends.countries?.length ?? 0) > 1) return 'B'; + return from.state && from.state === to.state ? 'S' : 'X'; +} + +/** + * `~~` (see `scopedRoute`) from a pack route id + * that is `~`, or a bare route id that is its own + * product. + */ +function withScope(routeId, scope) { + const id = String(routeId ?? ''); + const at = id.indexOf('~'); + return at > 0 + ? `${id.slice(0, at)}~${scope}~${id.slice(at + 1)}` + : `${id}~${scope}~${id}`; +} + +/** Regional inside one state, national across states, international abroad. */ +function kindForScope(scope) { + if (scope === 'B') return INTERNATIONAL_RAIL; + return scope === 'S' ? REGIONAL_RAIL : NATIONAL_RAIL; +} + +/** + * KTMB (data.gov.my GTFS of 2026-11-09). Route ids name the service: + * - local: KTM Komuter, the Klang Valley lines (`KA15_KD19` Tanjung + * Malim–Pelabuhan Klang, `KC05_KB18` Batu Caves–Pulau Sebang), Komuter + * Utara (`100_47300` Butterworth–Padang Besar, `100_9000` Butterworth– + * Ipoh) and the Shuttle Selatan (`SS`, coded as a tram in the GTFS). + * - international: Shuttle Tebrau (`ST`, JB Sentral–Woodlands, Singapore). + * - ETS, Ekspres Rakyat Timuran (`ERT`) and the Intercity shuttle (`SH`) + * by where each run ends, the states being Malaysia's 13 states and its + * federal territories: regional inside one (Tumpat–Gua Musang), national + * across them, international to Hat Yai (Thailand) or Woodlands. + * Padang Besar station lies inside the simplified Thai polygon; it is in + * Perlis, so the scope reads the run's ends only, with that one corrected. + */ +const KTMB_KOMUTER = /^(KA15_KD19|KC05_KB18|SS|\d+_\d+)$/; +const KTMB_INTERCITY = new Set(['ETS', 'ERT', 'SH']); +function ktmbPlace(end) { + return /^padang besar$/i.test(String(end.name ?? '').trim()) + ? { ...end, country: 'MY', state: 'MY-09' } + : end; +} +function ktmbTripRename(trip, routeId, { ends = null } = {}) { + return withScope( + routeId, + tripScope(ends, { fix: ktmbPlace, endsOnly: true }), + ); +} +function ktmbTrain(routeId) { + const route = scopedRoute(routeId); + if (!route) return null; + if (route.product === 'ST') return INTERNATIONAL_RAIL; + if (KTMB_KOMUTER.test(route.product)) return LOCAL_RAIL; + return KTMB_INTERCITY.has(route.product) ? kindForScope(route.scope) : null; +} + +/** + * Irish Rail in the TFI static GTFS (`GTFS_Realtime.zip`, the one the NTA + * GTFS-R feed matches): `trip_short_name` is the train code the realtime + * XML names (`E206` DART, `P600` and `D` Commuter, `A101` InterCity). The + * pack route id is `~~`, the scope by the + * provinces of the run's two ends (IRISH_RAIL_PROVINCE, parenthesised + * station names such as `Cork (Kent)` read as `Cork`): `B` when one end is + * in Northern Ireland or abroad, `S` in one province, `X` otherwise or + * when an end is not a listed terminus. A trip with no train code is left + * out. + */ +function irishRailProvince(end) { + const name = String(end?.name ?? '') + .replace(/\s*\([^)]*\)\s*$/, '') + .trim() + .toLowerCase(); + return IRISH_RAIL_PROVINCE.get(name) ?? null; +} +function irishRailTripRename(trip, routeId, { ends = null } = {}) { + const letter = /^([A-Z])\d{1,4}$/.exec( + String(trip.trip_short_name ?? '').trim(), + )?.[1]; + if (!letter) return null; + const from = irishRailProvince(ends?.from); + const to = irishRailProvince(ends?.to); + let scope = 'X'; + if ( + (from && to && (from === 'NI') !== (to === 'NI')) || + (ends?.from?.country && + ends?.to?.country && + ends.from.country !== ends.to.country) + ) + scope = 'B'; + else if (from && to && from === to) scope = 'S'; + return `${letter}~${scope}~${routeId}`; +} +/** The same rule as the realtime codes, read from a pack route id. */ +function irishRailPackTrain(route) { + if (route.product !== 'A' && route.product !== 'B') return LOCAL_RAIL; + if (route.scope === 'B') return INTERNATIONAL_RAIL; + return route.scope === 'S' ? REGIONAL_RAIL : NATIONAL_RAIL; +} + +/** + * The feeds this build polls: every registered feed that is switched on. A + * registered feed that is switched off is inert everywhere: it resolves to no + * route and is never offered to the browser. + */ +export const RAIL_ENABLED_FEEDS = Object.freeze( + RAIL_FEED_REGISTRY.filter((feed) => feed.defaultEnabled === true), +); + +const ANY_FEED_BY_ID = new Map( + RAIL_FEED_REGISTRY.map((feed) => [feed.id, feed]), +); + +/** Feed ids are path segments: lowercase letters, digits, hyphens only. */ +export const RAIL_FEED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/; + +/** + * Whether a feed serves trip updates: it registers a `tripUpdatesUrl`. + * @param {object} feed Registry entry. + * @returns {boolean} + */ +export function railFeedHasTripUpdates(feed) { + return typeof feed?.tripUpdatesUrl === 'string'; +} + +/** + * Whether a registered feed may be polled: switched on. + * @param {object} feed + * @returns {boolean} + */ +export function isRailFeedPollable(feed) { + return feed?.defaultEnabled === true; +} + +/** + * Every pollable feed. + * @returns {object[]} + */ +export function railEnabledFeeds() { + return [...RAIL_ENABLED_FEEDS]; +} + +/** + * Look up a POLLABLE feed by id. Unknown ids, malformed ids and feeds that + * are registered but switched off return null: this is the only door from a + * request path to an upstream URL. + * @param {string} id + * @returns {object|null} + */ +export function getRailFeed(id) { + if (typeof id !== 'string' || !RAIL_FEED_ID_PATTERN.test(id)) return null; + const feed = ANY_FEED_BY_ID.get(id); + return feed && isRailFeedPollable(feed) ? feed : null; +} + +/** + * Look up any registered feed, enabled or not. For credits and documentation + * only, never for resolving a request into an upstream fetch. + * @param {string} id + * @returns {object|null} + */ +export function getRegisteredRailFeed(id) { + if (typeof id !== 'string' || !RAIL_FEED_ID_PATTERN.test(id)) return null; + return ANY_FEED_BY_ID.get(id) || null; +} + +/** + * Where a feed's positions come from (RAIL_POSITION_SOURCES). + * @param {object} feed + * @returns {'gps'|'event'} + */ +export function railFeedPositions(feed) { + return RAIL_POSITION_SOURCES.includes(feed?.positions) + ? feed.positions + : 'gps'; +} + +/** + * Whether a feed shows trains only once its pack has been built + * (scripts/build-rail-paths.mjs): a GTFS-Realtime feed whose vehicles carry + * no route (`routesFrom: 'schedule'`), so nothing classifies them without + * the pack. Irish Rail's XML names each train by its code, which classifies + * it without one. + * @param {object} feed + * @returns {boolean} + */ +export function railFeedNeedsSchedulePack(feed) { + return feed?.routesFrom === 'schedule' && feed?.format === 'gtfs-rt'; +} + +/** + * Enabled feeds whose coverage circle contains the point, nearest first. + * @param {number} lat + * @param {number} lon + * @param {number} [slackKm=0] Extra radius so a feed at the edge of coverage + * does not flap on small camera moves. + * @param {ReadonlyArray<{center: {lat: number, lon: number}, + * loadRadiusKm: number}>} [feeds=RAIL_ENABLED_FEEDS] The feeds to choose + * from; the browser passes the server's catalog, which leaves out a feed + * whose vehicles name no route until its pack is built. + * @returns {object[]} + */ +export function railFeedsInRange( + lat, + lon, + slackKm = 0, + feeds = RAIL_ENABLED_FEEDS, +) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return []; + return (Array.isArray(feeds) ? feeds : []) + .filter( + (feed) => + Number.isFinite(feed?.center?.lat) && + Number.isFinite(feed?.center?.lon) && + Number.isFinite(feed?.loadRadiusKm), + ) + .map((feed) => ({ + feed, + km: greatCircleKm({ lat, lon }, feed.center), + })) + .filter(({ feed, km }) => km <= feed.loadRadiusKm + Math.max(0, slackKm)) + .sort((a, b) => a.km - b.km) + .map(({ feed }) => feed); +} + +/** + * The category and mode of one vehicle, or null when it is not a train the + * layer shows. A classifier that returns anything outside the known category + * and mode lists counts as null, so a registry typo drops vehicles instead of + * inventing a category. + * @param {object} feed Registry entry. + * @param {string|null} routeId GTFS route_id from the vehicle's trip. + * @param {{tripId?: string|null}} [context] More of the record, for feeds + * whose route id alone does not say what the train is (Irish Rail). + * @returns {{category: string, mode: string}|null} + */ +export function classifyTrain(feed, routeId, context = null) { + const kind = + typeof feed?.classify === 'function' + ? feed.classify(routeId, context || {}) + : null; + if (!kind) return null; + if (!RAIL_CATEGORIES.includes(kind.category)) return null; + if (!RAIL_MODES.includes(kind.mode)) return null; + return kind; +} + +/** + * Public catalog shape served by `/api/rail/feeds`: everything the browser + * needs to gate polling, label positions and credit the source, and nothing + * it could misuse. With `schedulePackBuilt`, a feed that needs its pack + * (`railFeedNeedsSchedulePack`) appears only once the host has that pack: + * packs are opt-in build output, so a feed nobody has built is absent rather + * than shown as unavailable. + * @param {{schedulePackBuilt?: ((feedId: string) => boolean)|null}} + * [options] + * @returns {object[]} + */ +export function publicRailCatalog({ schedulePackBuilt = null } = {}) { + const listed = (feed) => + typeof schedulePackBuilt !== 'function' || + !railFeedNeedsSchedulePack(feed) || + schedulePackBuilt(feed.id) === true; + return railEnabledFeeds() + .filter(listed) + .map((feed) => ({ + id: feed.id, + name: feed.name, + operator: feed.operator, + region: feed.region, + center: { ...feed.center }, + loadRadiusKm: feed.loadRadiusKm, + positions: railFeedPositions(feed), + tripUpdates: railFeedHasTripUpdates(feed), + license: feed.license, + licenseUrl: feed.licenseUrl, + attribution: feed.attribution, + })); +} diff --git a/src/data/railFeeds.test.mjs b/src/data/railFeeds.test.mjs new file mode 100644 index 000000000..5304680a9 --- /dev/null +++ b/src/data/railFeeds.test.mjs @@ -0,0 +1,474 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_CATEGORIES, + RAIL_ENABLED_FEEDS, + RAIL_FEED_ID_PATTERN, + RAIL_FEED_REGISTRY, + RAIL_MODES, + RAIL_POSITION_SOURCES, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + isRailFeedPollable, + publicRailCatalog, + railEnabledFeeds, + railFeedPositions, + railFeedsInRange, +} from './railFeeds.js'; + +const isHttps = (url) => new URL(url).protocol === 'https:'; + +test('every registered feed is complete, https and uniquely named', () => { + const ids = new Set(); + for (const feed of RAIL_FEED_REGISTRY) { + assert.match(feed.id, RAIL_FEED_ID_PATTERN, feed.id); + assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`); + ids.add(feed.id); + const positions = railFeedPositions(feed); + assert.ok(RAIL_POSITION_SOURCES.includes(positions), feed.id); + assert.ok(isHttps(feed.url), feed.id); + for (const url of [feed.tripUpdatesUrl, feed.staticGtfsUrl]) + if (url !== undefined) assert.ok(isHttps(url), `${feed.id} ${url}`); + assert.ok( + ['gtfs-rt', 'irish-rail-xml', 'siri-vm'].includes(feed.format), + feed.id, + ); + if (feed.format === 'irish-rail-xml') + assert.equal(positions, 'event', `${feed.id} positions are events`); + for (const key of ['name', 'operator', 'region', 'license', 'attribution']) + assert.ok(feed[key], `${feed.id} has ${key}`); + assert.ok(URL.canParse(feed.licenseUrl), `${feed.id} licence url`); + assert.equal(typeof feed.defaultEnabled, 'boolean', feed.id); + assert.equal(typeof feed.terms?.quote, 'string', feed.id); + assert.equal(typeof feed.classify, 'function', feed.id); + assert.ok( + Number.isFinite(feed.center.lat) && Number.isFinite(feed.center.lon), + ); + assert.ok(feed.loadRadiusKm > 0 && feed.loadRadiusKm <= 1000, feed.id); + for (const ms of [feed.pollIntervalMs, feed.tripUpdatesIntervalMs]) + if (ms !== undefined) assert.ok(ms >= 5_000 && ms <= 300_000, feed.id); + } +}); + +// The fields the registry documents (railFeeds.js, RAIL_FEED_REGISTRY): +// an entry carries nothing else, so no feed asks for a key, a sign-in or a +// timetable to place its trains. +const REGISTRY_FIELDS = new Set([ + 'id', + 'name', + 'operator', + 'region', + 'center', + 'loadRadiusKm', + 'format', + 'url', + 'positions', + 'headers', + 'headerClock', + 'pollIntervalMs', + 'tripUpdatesUrl', + 'tripUpdatesIntervalMs', + 'maxBodyBytes', + 'limits', + 'routesFrom', + 'positionsByTrainNumber', + 'staticGtfsUrl', + 'staticGtfsEntry', + 'staticHeaders', + 'staticRouteTypes', + 'staticTripEnds', + 'staticSimplifyM', + 'staticRouteRename', + 'staticTripRename', + 'license', + 'licenseUrl', + 'attribution', + 'defaultEnabled', + 'terms', + 'classify', +]); + +test('every feed reports live positions: GPS or signalling events', () => { + for (const feed of RAIL_FEED_REGISTRY) { + assert.notEqual(feed.positions, 'schedule', feed.id); + assert.ok( + ['gps', 'event'].includes(railFeedPositions(feed)), + `${feed.id} positions`, + ); + assert.equal(typeof feed.url, 'string', `${feed.id} has a live feed`); + } + for (const entry of publicRailCatalog()) + assert.ok(['gps', 'event'].includes(entry.positions), entry.id); +}); + +/** Query parameter names that carry a key, a token or a signature. */ +const ACCESS_PARAM = + /^(api[-_]?key|appid|app[-_]?key|key|token|access[-_]?token|auth|sig|signature|subscription[-_]?key)$/i; + +/** `name=value` of each access parameter in a URL (values decoded). */ +function accessParams(url) { + if (!url) return []; + return [...new URL(url).searchParams] + .filter(([name]) => ACCESS_PARAM.test(name)) + .map(([name, value]) => `${name}=${value}`); +} + +// The only access parameter allowed, a fixed public value the publisher +// prints in the published link for everyone, the same for every user and not +// a credential of the deployer's: Bordeaux Métropole's TBM `apiKey` (on the +// dataset page). +const PUBLISHED_ACCESS_VALUES = new Set([ + 'tbm-bordeaux apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', +]); + +test('an access parameter in a feed URL is found, whatever its spelling', () => { + for (const url of [ + 'https://x.test/a?apiKey=1', + 'https://x.test/a?api_key=1', + 'https://x.test/a?APIKEY=1', + 'https://x.test/a?f=pb&key=1', + 'https://x.test/a?token=1', + 'https://x.test/a?access_token=1', + 'https://x.test/a?sr=b&sig=abc%3D', + 'https://x.test/a?signature=1', + 'https://x.test/a?subscription-key=1', + ]) + assert.equal(accessParams(url).length, 1, url); + assert.deepEqual(accessParams('https://x.test/a?format=pb&route=key'), []); + assert.deepEqual(accessParams(null), []); + // Each published value is still in use, so the allow-list cannot go stale. + const used = new Set(); + for (const feed of RAIL_FEED_REGISTRY) + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + for (const param of accessParams(url)) used.add(`${feed.id} ${param}`); + assert.deepEqual([...used].sort(), [...PUBLISHED_ACCESS_VALUES].sort()); +}); + +test('every feed is on, credited with its licence and terms, and fetched as published', () => { + const ids = new Set(); + for (const feed of RAIL_FEED_REGISTRY) { + assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`); + ids.add(feed.id); + assert.equal(feed.defaultEnabled, true, `${feed.id} is on`); + for (const field of Object.keys(feed)) + assert.ok(REGISTRY_FIELDS.has(field), `${feed.id}: ${field}`); + assert.ok(typeof feed.license === 'string' && feed.license, feed.id); + assert.ok(isHttps(feed.licenseUrl), `${feed.id} licence url`); + assert.ok( + typeof feed.attribution === 'string' && feed.attribution, + feed.id, + ); + assert.equal(typeof feed.terms?.quote, 'string', `${feed.id} terms`); + assert.ok(feed.terms.quote || feed.terms.note, `${feed.id} terms basis`); + // No URL carries an access parameter, but for the fixed public values + // their publishers print for everyone (PUBLISHED_ACCESS_VALUES). + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + for (const param of accessParams(url)) + assert.ok( + PUBLISHED_ACCESS_VALUES.has(`${feed.id} ${param}`), + `${feed.id} ${url}`, + ); + } + assert.equal(ids.size, RAIL_FEED_REGISTRY.length); + assert.deepEqual( + RAIL_ENABLED_FEEDS.map((feed) => feed.id), + RAIL_FEED_REGISTRY.map((feed) => feed.id), + ); +}); + +test('a feed is switched on only when its terms were read and quoted', () => { + for (const feed of RAIL_ENABLED_FEEDS) { + assert.ok( + feed.terms.quote || feed.terms.note, + `${feed.id} is enabled without a recorded basis`, + ); + } + // Tri-Rail states no licence; it is used without one by project owner + // decision, and the terms say so. + const triRail = getRailFeed('tri-rail'); + assert.ok(triRail, 'tri-rail is pollable'); + assert.ok( + triRail.terms.note.startsWith( + 'Used without a published licence (project owner decision, 2026-11-10)', + ), + ); + assert.match(triRail.license, /^No licence stated/); +}); + +test('only enabled, well-formed ids resolve to a pollable feed', () => { + assert.equal(getRailFeed('mta-metro-north')?.id, 'mta-metro-north'); + for (const id of ['', 'Nope', '../x', 'a', null, 42, 'unknown-feed']) + assert.equal(getRailFeed(id), null, String(id)); + const mbta = getRegisteredRailFeed('mbta-rail'); + assert.equal(isRailFeedPollable(mbta), true); + assert.equal(isRailFeedPollable({ ...mbta, defaultEnabled: false }), false); + assert.deepEqual( + railEnabledFeeds().map((entry) => entry.id), + RAIL_ENABLED_FEEDS.map((entry) => entry.id), + ); +}); + +test('classifiers keep trains and drop buses in mixed feeds', () => { + const kind = (id, route) => classifyTrain(getRegisteredRailFeed(id), route); + assert.deepEqual(kind('mbta-rail', 'CR-Worcester'), { + category: 'local', + mode: 'rail', + }); + assert.equal(kind('mbta-rail', 'Red').mode, 'metro'); + assert.equal(kind('mbta-rail', 'Green-E').mode, 'tram'); + assert.equal(kind('mbta-rail', 'Mattapan').category, 'transit'); + assert.equal(kind('mbta-rail', '1'), null); + assert.equal(kind('mbta-rail', 'Boat-F1'), null); + assert.equal(kind('mbta-rail', 'Shuttle-Generic'), null); + assert.equal(kind('mbta-rail', null), null); + + assert.equal(kind('rtd-denver-rail', 'A').category, 'local'); + assert.equal(kind('rtd-denver-rail', '113G').category, 'local'); + assert.equal(kind('rtd-denver-rail', '117N').category, 'local'); + assert.equal(kind('rtd-denver-rail', '101E').category, 'transit'); + assert.equal(kind('rtd-denver-rail', '107R').category, 'transit'); + assert.equal(kind('rtd-denver-rail', '15'), null); + assert.equal(kind('rtd-denver-rail', '15L'), null); + + assert.equal(kind('capmetro-rail', '550').category, 'local'); + assert.equal(kind('capmetro-rail', '801'), null); + // Northstar's old route id is a bus now and must not come back as a train. + assert.equal(kind('metrotransit-rail', '888'), null); + assert.equal(kind('metrotransit-rail', '902').mode, 'tram'); + assert.equal(kind('septa-trolley', 'M1').mode, 'metro'); + assert.equal(kind('septa-trolley', '17'), null); + assert.equal(kind('translink-seq-rail', 'BRCA-5').category, 'local'); + assert.equal(kind('translink-seq-rail', '61-3'), null); + assert.equal(kind('adelaide-metro', 'SEAFRD').category, 'local'); + assert.equal(kind('adelaide-metro', 'GLNELG').mode, 'tram'); + assert.equal(kind('adelaide-metro', 'J1A'), null); + assert.equal(kind('mta-metro-north', null).category, 'local'); +}); + +test('Irish Rail: DART and Commuter local, InterCity by province, Enterprise international', () => { + const kind = (code, route) => + classifyTrain(getRegisteredRailFeed('irish-rail'), route, { + tripId: code, + })?.category ?? null; + assert.equal(kind('E244', 'Howth - Bray'), 'local'); + assert.equal(kind('D822', 'Grand Canal Dock - Drogheda'), 'local'); + assert.equal(kind('P571', 'Midleton - Cork'), 'local'); + assert.equal(kind('A216', 'Dublin Heuston - Cork'), 'national'); + assert.equal(kind('A720', 'Dublin Heuston - Galway'), 'national'); + assert.equal(kind('B701', 'Galway - Dublin Heuston'), 'national'); + assert.equal(kind('A308', 'Mallow - Tralee'), 'regional', 'both Munster'); + assert.equal(kind('A606', 'Dublin Connolly - Rosslare Europort'), 'regional'); + assert.equal(kind('A118', 'Dublin Connolly - Belfast'), 'international'); + assert.equal(kind('A131', 'Belfast - Dublin Connolly'), 'international'); + assert.equal(kind('A999', 'Nowhere - Elsewhere'), 'national'); + assert.equal(kind('A999', null), 'national'); + assert.equal(kind('not a code', 'Howth - Bray'), null); + assert.equal(kind(null, 'Howth - Bray'), null); +}); + +test('a classifier answer outside the known lists is treated as not a train', () => { + const feed = (answer) => ({ classify: () => answer }); + assert.equal( + classifyTrain(feed({ category: 'express', mode: 'rail' })), + null, + ); + assert.equal(classifyTrain(feed({ category: 'local', mode: 'bus' })), null); + assert.equal(classifyTrain(feed(null)), null); + assert.equal(classifyTrain({}), null); + assert.equal(classifyTrain(null), null); + for (const feed of RAIL_FEED_REGISTRY) { + for (const route of [ + 'CR-Lowell', + 'A', + '550', + '901', + 'T1', + 'BRCA-5', + 'GAW', + null, + ]) { + const kind = feed.classify(route); + if (!kind) continue; + assert.ok(RAIL_CATEGORIES.includes(kind.category), `${feed.id} ${route}`); + assert.ok(RAIL_MODES.includes(kind.mode), `${feed.id} ${route}`); + } + } +}); + +test('coverage selection returns enabled feeds near the point, nearest first', () => { + const nyc = railFeedsInRange(40.75, -73.99).map((feed) => feed.id); + assert.ok(nyc.includes('mta-metro-north') && nyc.includes('mta-lirr')); + assert.deepEqual(railFeedsInRange(0, -150), []); + assert.deepEqual(railFeedsInRange(Number.NaN, 0), []); + const brisbane = railFeedsInRange(-27.47, 153.02); + assert.equal(brisbane[0].id, 'translink-seq-rail'); + // The browser passes the server's catalog, which lists a feed that reads + // its routes from a schedule pack only once that pack is built. + const madrid = (built) => + railFeedsInRange( + 40.42, + -3.7, + 0, + publicRailCatalog({ schedulePackBuilt: () => built }), + ).map((feed) => feed.id); + assert.ok( + !madrid(false).includes('renfe-larga-distancia'), + 'no pack: not offered', + ); + assert.ok(madrid(false).includes('renfe-cercanias'), 'needs no pack'); + assert.ok(madrid(true).includes('renfe-larga-distancia')); + assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, null), []); + assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, [{ id: 'x' }]), []); +}); + +test('the public catalog exposes coverage and credit but never upstream URLs', () => { + const catalog = publicRailCatalog(); + assert.equal(catalog.length, RAIL_ENABLED_FEEDS.length); + for (const entry of catalog) { + assert.equal(entry.url, undefined); + assert.equal(entry.tripUpdatesUrl, undefined); + assert.equal(entry.headers, undefined); + assert.equal(entry.classify, undefined); + assert.ok(entry.attribution && entry.licenseUrl); + assert.ok(RAIL_POSITION_SOURCES.includes(entry.positions)); + assert.equal(typeof entry.tripUpdates, 'boolean'); + } + const byId = new Map(catalog.map((entry) => [entry.id, entry])); + assert.equal(byId.get('irish-rail').positions, 'event'); + assert.equal(byId.get('irish-rail').tripUpdates, false); + assert.equal(byId.get('mbta-rail').positions, 'gps'); + assert.equal(byId.get('mbta-rail').tripUpdates, true); + // No upstream URL reaches the browser, by any field but the licence link + // (an access point's page can be both the licence and the download). + const text = JSON.stringify( + catalog.map((entry) => ({ ...entry, licenseUrl: null })), + ); + for (const feed of RAIL_FEED_REGISTRY) + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + if (url) assert.ok(!text.includes(url), `${feed.id} ${url}`); +}); + +test('Finland: commuter local, H/HDM regional, long distance national, Haaparanta international', () => { + const kind = (route) => + classifyTrain(getRegisteredRailFeed('fintraffic-vr'), route)?.category ?? + null; + assert.equal(kind('EPO_HKI_E_HL_10'), 'local'); + assert.equal(kind('SK_ÄHT_480_HDM_10'), 'regional'); + assert.equal(kind('TPE_PRI_1_H_10'), 'regional'); + for (const route of [ + 'HKI_ROI_23_IC_10', + 'HKI_OL_45_S_10', + 'HKI_KOK_101_P_10', + 'HKI_KLI_265_PYO_10', + ]) + assert.equal(kind(route), 'national', route); + assert.equal(kind('OL_HPA_407_IC_10'), 'international'); + assert.equal(kind('HPA_OL_408_IC_10'), 'international'); + for (const route of ['HKI_TKU_1_T_10', 'IC 23', null]) + assert.equal(kind(route), null, String(route)); +}); + +test('Renfe: products and the autonomous-community rule', () => { + const feed = getRegisteredRailFeed('renfe-larga-distancia'); + const kind = (route) => classifyTrain(feed, route)?.category ?? null; + for (const product of ['AVE', 'AVLO', 'ALVIA', 'EUROMED', 'Intercity']) + for (const scope of ['S', 'X']) + assert.equal(kind(`${product}~${scope}~1`), 'national', product); + for (const product of ['MD', 'REGIONAL', 'REG.EXP.', 'PROXIMDAD', 'AVANT']) { + assert.equal(kind(`${product}~S~1`), 'regional', product); + assert.equal(kind(`${product}~X~1`), 'national', product); + } + assert.equal(kind('AVE INT~X~1'), 'international'); + assert.equal(kind('TRENCELTA~X~1'), 'international'); + assert.equal(kind('MD~B~1'), 'international', 'an end abroad'); + assert.equal(kind('BUS~S~1'), null); + assert.equal(kind('1700037606GL023'), null, 'an unscoped operator id'); + assert.equal(kind(null), null); + // The rename the path build applies to routes.txt rows. + const places = { + 17000: { lat: 40.47, lon: -3.68 }, + 37606: { lat: 38.89, lon: -6.98 }, + 18000: { lat: 40.41, lon: -3.7 }, + 87089: { lat: 43.3, lon: 5.38 }, + }; + const helpers = { + stop: (id) => places[id] ?? null, + stateAt: (lat) => + lat > 43 + ? { country: 'FR', state: 'FR-13' } + : lat < 39.5 + ? { country: 'ES', state: 'ES-EX' } + : { country: 'ES', state: 'ES-MD' }, + }; + const rename = (id, product) => + feed.staticRouteRename( + { route_id: id, route_short_name: product }, + helpers, + ); + assert.equal(rename('1700037606GL023', 'MD'), 'MD~X~1700037606GL023'); + assert.equal( + rename('1700018000GL001', 'REGIONAL'), + 'REGIONAL~S~1700018000GL001', + ); + assert.equal(rename('1700087089AV016', 'AVE'), 'AVE~B~1700087089AV016'); + assert.equal( + rename('1700099999GL001', 'MD'), + 'MD~X~1700099999GL001', + 'unplaced', + ); + assert.equal(rename('short', 'MD'), null); + assert.equal( + classifyTrain(getRegisteredRailFeed('renfe-cercanias'), null)?.category, + 'local', + ); +}); + +test('Norway: by line and county, replacement buses dropped, Flåmsbana a tourist line', () => { + const kind = (id, route, tripId = 'VYG:ServiceJourney:536_906310-R') => + classifyTrain(getRegisteredRailFeed(id), route, { tripId }); + assert.equal(kind('entur-vy', 'VYG:Line:F4')?.category, 'national'); + assert.equal(kind('entur-vy', 'VYG:Line:RE11')?.category, 'national'); + assert.equal(kind('entur-vy', 'VYG:Line:L1')?.category, 'local'); + assert.equal(kind('entur-vy', 'VYG:Line:RE20')?.category, 'international'); + assert.equal(kind('entur-vy', 'VYG:Line:F1')?.category, 'international'); + assert.equal(kind('entur-vy', 'VYG:Line:R55')?.category, 'regional'); + const flam = kind('entur-vy', 'VYG:Line:R45'); + assert.equal(flam.category, 'regional'); + assert.equal(flam.tourist, true); + assert.equal(kind('entur-go-ahead', 'GOA:Line:50')?.category, 'national'); + assert.equal(kind('entur-go-ahead', 'GOA:Line:59')?.category, 'local'); + assert.equal(kind('entur-flytoget', 'FLT:Line:FLY1')?.category, 'local'); + assert.equal(kind('entur-sj-nord', 'SJN:Line:21')?.category, 'national'); + assert.equal(kind('entur-sj-nord', 'SJN:Line:72')?.category, 'international'); + assert.equal(kind('entur-sj-nord', 'SJN:Line:26')?.category, 'regional'); + assert.equal( + kind('entur-vy', 'VYG:Line:RE11', 'VYG:ServiceJourney:BUS-836_761763-R'), + null, + 'a replacement bus on a train line', + ); + assert.equal(kind('entur-vy', 'VYG:Line:X99'), null); + assert.match(getRegisteredRailFeed('entur-sj-nord').terms.note, /missing/); + // Its positions come from SIRI VM, the timetable still gives its stops. + assert.equal( + railFeedPositions(getRegisteredRailFeed('entur-sj-nord')), + 'gps', + ); + assert.equal(getRegisteredRailFeed('entur-sj-nord').format, 'siri-vm'); +}); + +test('feeds that read routes from their schedule pack can build one', () => { + for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.routesFrom)) { + assert.equal(feed.routesFrom, 'schedule', feed.id); + assert.ok(isHttps(feed.staticGtfsUrl), feed.id); + } + for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.headerClock)) + assert.equal(feed.headerClock, 'untrusted', feed.id); + // Identify-yourself headers never carry a person. + for (const feed of RAIL_FEED_REGISTRY) + for (const value of Object.values({ + ...(feed.headers || {}), + ...(feed.staticHeaders || {}), + })) + assert.ok(!/@/.test(value), feed.id); +}); diff --git a/src/data/railFeedsAsia.test.mjs b/src/data/railFeedsAsia.test.mjs new file mode 100644 index 000000000..319f4c66c --- /dev/null +++ b/src/data/railFeedsAsia.test.mjs @@ -0,0 +1,63 @@ +// Asian feeds: Japan's live tram feeds (Manyōsen and Toyama), registered, +// switched on with their licence basis recorded, and sorted into +// categories. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedPositions, + railFeedsInRange, +} from './railFeeds.js'; + +const LIVE = ['jp-manyosen', 'jp-toyama-tram']; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId) => classifyTrain(feed(id), routeId); + +const TRAM = { category: 'transit', mode: 'tram' }; + +test('the Japanese feeds are on, with their licence basis recorded', () => { + for (const id of LIVE) { + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(RAIL_ENABLED_FEEDS.includes(feed(id)), id); + assert.ok(feed(id).license, id); + assert.match(feed(id).licenseUrl, /^https:\/\//, id); + assert.ok(feed(id).attribution, id); + assert.ok(feed(id).terms.quote, `${id} quotes its terms`); + assert.match(feed(id).terms.note, /read 2026-11-10/, id); + assert.doesNotMatch(feed(id).terms.note, /@/, id); + assert.equal(railFeedPositions(feed(id)), 'gps', id); + assert.match(feed(id).url, /^https:\/\/gtfs-rt-files\.buscatch\.jp\//); + } + assert.equal( + feed('jp-toyama-tram').staticGtfsUrl, + undefined, + 'its static GTFS codes the tram as buses', + ); +}); + +test('Japan: the Manyōsen and Toyama trams are transit', () => { + assert.deepEqual(kind('jp-manyosen', '1'), TRAM); + assert.deepEqual( + kind('jp-toyama-tram', '富山港線(南富山)(3004-1)'), + TRAM, + 'a route id seen live', + ); +}); + +test('each Japanese tram feed is polled around its own city only', () => { + const near = (lat, lon) => railFeedsInRange(lat, lon).map((f) => f.id); + // Takaoka for the Manyōsen, Toyama station for the city tram. + assert.ok(near(36.75, 137.02).includes('jp-manyosen')); + assert.ok(near(36.7, 137.21).includes('jp-toyama-tram')); + // Tokyo and Nagoya are hundreds of kilometres off. + for (const [lat, lon] of [ + [35.68, 139.77], + [35.17, 136.88], + ]) + for (const id of LIVE) assert.ok(!near(lat, lon).includes(id), id); + for (const id of LIVE) assert.ok(feed(id).loadRadiusKm <= 25, id); +}); diff --git a/src/data/railFeedsDocs.test.mjs b/src/data/railFeedsDocs.test.mjs new file mode 100644 index 000000000..5a79ec088 --- /dev/null +++ b/src/data/railFeedsDocs.test.mjs @@ -0,0 +1,97 @@ +// The rail counts the documents state, against the registry: how many feeds +// there are, how many publish GPS or station reports, how many work out of +// the box, register trip updates or have a static GTFS, and one +// DATA_SOURCES.md row per feed, none for a feed the registry does not hold. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + RAIL_ENABLED_FEEDS, + RAIL_FEED_REGISTRY, + railFeedNeedsSchedulePack, + railFeedPositions, +} from './railFeeds.js'; + +const read = (file) => + readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8'); + +const counts = { + registry: RAIL_FEED_REGISTRY.length, + gps: RAIL_FEED_REGISTRY.filter((feed) => railFeedPositions(feed) === 'gps') + .length, + stationReports: RAIL_FEED_REGISTRY.filter( + (feed) => railFeedPositions(feed) === 'event', + ).length, + outOfTheBox: RAIL_ENABLED_FEEDS.filter( + (feed) => !railFeedNeedsSchedulePack(feed), + ).length, + tripUpdates: RAIL_FEED_REGISTRY.filter( + (feed) => typeof feed.tripUpdatesUrl === 'string', + ).length, + staticGtfs: RAIL_FEED_REGISTRY.filter( + (feed) => typeof feed.staticGtfsUrl === 'string', + ).length, +}; + +test('the registry holds the feeds the documents count', () => { + assert.deepEqual(counts, { + registry: 30, + gps: 29, + stationReports: 1, + outOfTheBox: 28, + tripUpdates: 22, + staticGtfs: 29, + }); + assert.equal(RAIL_ENABLED_FEEDS.length, counts.registry, 'all switched on'); + assert.deepEqual( + RAIL_FEED_REGISTRY.filter( + (feed) => railFeedPositions(feed) === 'event', + ).map((feed) => feed.id), + ['irish-rail'], + 'the station reports are Irish Rail’s', + ); +}); + +test('DATA_SOURCES.md states the rail counts and has one row per feed', () => { + const sources = read('DATA_SOURCES.md'); + const { registry, gps, outOfTheBox, tripUpdates, staticGtfs } = counts; + for (const phrase of [ + `The registry lists ${registry} feeds, all switched on`, + `${gps} feeds publish live GPS positions (SJ Nord's through SIRI VM), and Irish Rail reports the station or timing point a train last passed`, + `${tripUpdates} feeds register trip updates`, + `${outOfTheBox} feeds work out of the box`, + `(${staticGtfs} of the ${registry} have one)`, + ]) + assert.ok(sources.includes(phrase), phrase); + const rows = sources + .split('\n') + .filter((line) => /^\| \*\*.+\| Rail layer trains, /.test(line)); + assert.equal(rows.length, registry, 'one row per feed'); + for (const feed of RAIL_FEED_REGISTRY) + assert.ok( + rows.some((row) => row.includes(`| Rail layer trains, ${feed.region}:`)), + `${feed.id} has a row`, + ); +}); + +test('CURRENT-STATE.md states the rail counts', () => { + const state = read('docs/CURRENT-STATE.md'); + const { registry, gps, outOfTheBox, tripUpdates, staticGtfs } = counts; + for (const phrase of [ + `(${registry} keyless feeds, all on, ${outOfTheBox} out of the box;`, + `The registry lists ${registry} feeds, all`, + `a host that has built nothing shows ${outOfTheBox} feeds`, + `By position source, ${gps} feeds publish live GPS.`, + 'Irish Rail publishes the\nstation or timing point the signalling last reported.', + `${tripUpdates} feeds register trip updates;`, + `that has a static GTFS (${staticGtfs} of the ${registry})`, + ]) + assert.ok(state.includes(phrase), phrase); +}); + +test('README.md states the rail counts', () => { + const readme = read('README.md'); + const { registry, gps, outOfTheBox } = counts; + const phrase = `from ${registry} keyless operator feeds: live GPS positions from ${gps} of them and Irish Rail’s station reports, ${outOfTheBox} live out of the box`; + assert.ok(readme.includes(phrase), phrase); +}); diff --git a/src/data/railFeedsFrance.test.mjs b/src/data/railFeedsFrance.test.mjs new file mode 100644 index 000000000..af0a0442c --- /dev/null +++ b/src/data/railFeedsFrance.test.mjs @@ -0,0 +1,86 @@ +// France (2026-11-10): the Bordeaux and Montpellier tram networks, from +// transport.data.gouv.fr. Registered, switched on with the dataset's licence +// field quoted, polled for live positions, and sorted into categories; no +// registry URL is on a download path its host's robots.txt disallows. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_FEED_REGISTRY, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; + +const kind = (id, routeId) => { + const train = classifyTrain(getRegisteredRailFeed(id), routeId); + return train ? `${train.category}/${train.mode}` : null; +}; + +const FR_FEEDS = ['tbm-bordeaux', 'tam-montpellier']; + +// ------------------------------------------------------------ registry + +test('the French feeds are on and quote the dataset licence field', () => { + for (const id of FR_FEEDS) { + const feed = getRegisteredRailFeed(id); + assert.ok(feed, `${id} is registered`); + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.equal(feed.defaultEnabled, true, id); + assert.match(feed.staticGtfsUrl, /^https:\/\//, id); + assert.ok(['lov2', 'odc-odbl', 'fr-lo'].includes(feed.terms.quote), id); + assert.match(feed.terms.note, /transport\.data\.gouv\.fr dataset "/, id); + assert.match(feed.terms.note, /2026-11-10/, id); + } +}); + +test('the ODbL dataset carries the share-alike note and the access point conditions', () => { + const feed = getRegisteredRailFeed('tam-montpellier'); + assert.equal(feed.license, 'ODbL 1.0'); + assert.match(feed.terms.note, /share-alike/); + assert.match(feed.terms.note, /dans le format d'origine/); + assert.equal( + getRegisteredRailFeed('tbm-bordeaux').license, + 'Licence Ouverte / Open Licence 1.0', + ); +}); + +test('positions: Bordeaux and Montpellier trams are GPS, with trip updates', () => { + for (const id of FR_FEEDS) { + assert.equal(railFeedPositions(getRegisteredRailFeed(id)), 'gps', id); + assert.ok(railFeedHasTripUpdates(getRegisteredRailFeed(id)), id); + } +}); + +// ------------------------------------------------------------ categories + +test('tram lines are transit; buses are dropped', () => { + const cases = [ + ['tbm-bordeaux', '164', 'transit/tram'], + ['tbm-bordeaux', '901', null], + ['tam-montpellier', '5', 'transit/tram'], + ['tam-montpellier', 'A', null], + ]; + for (const [id, route, expected] of cases) + assert.equal(kind(id, route), expected, `${id} ${route}`); +}); + +// ------------------------------------------------------------ download + +// Hosts whose robots.txt disallows the download paths French publishers use. +const DISALLOWED = [ + /^https:\/\/eu\.ftp\.opendatasoft\.com\//, + /^https:\/\/thello\.axelor\.com\//, + /^https:\/\/static\.data\.gouv\.fr\/resources\//, + /^https:\/\/www\.data\.gouv\.fr\/(fr\/)?(api\/1\/)?datasets\/r\//, + /\/explore\/dataset\/.*\/download/, +]; + +test('no registry URL is on a download path its robots.txt disallows', () => { + for (const feed of RAIL_FEED_REGISTRY) + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + if (url) + for (const pattern of DISALLOWED) + assert.ok(!pattern.test(url), `${feed.id} ${url}`); +}); diff --git a/src/data/railFeedsNational.test.mjs b/src/data/railFeedsNational.test.mjs new file mode 100644 index 000000000..b3f3e6430 --- /dev/null +++ b/src/data/railFeedsNational.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, +} from './railFeeds.js'; +import { + railAdmissionGlobalMax, + railNaturalRequestsPerMinute, +} from './railProxy.js'; + +/** One end of a run, as the path build's `readTripEnds` writes it. */ +const at = (name, country, state) => ({ name, country, state }); +const ends = ( + from, + to, + countries = [...new Set([from.country, to.country])], +) => ({ from, to, countries }); + +/** + * The category a timetable trip gets: the feed's route rename (when it has + * one), its trip rename with the run's ends, then its classifier on the pack + * route id, as the build and the browser apply them. + */ +function tripKind(feedId, route, run = null, trip = { trip_id: 't1' }) { + const feed = getRegisteredRailFeed(feedId); + const base = + (typeof feed.staticRouteRename === 'function' + ? feed.staticRouteRename(route, { stop: () => null, stateAt: () => null }) + : null) ?? route.route_id; + const id = + typeof feed.staticTripRename === 'function' + ? feed.staticTripRename(trip, base, { ends: run }) + : base; + return classifyTrain(feed, id, { tripId: trip.trip_id }); +} +const category = (...args) => tripKind(...args)?.category ?? null; + +test('the national feeds are on and quote their terms', () => { + for (const id of ['ktmb', 'entur-sj-nord']) { + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(getRegisteredRailFeed(id).terms.quote, `${id} quotes its terms`); + } +}); + +test('the admission cap grows with the KTMB and SJ Nord feeds', () => { + const before = RAIL_ENABLED_FEEDS.filter( + (feed) => !['ktmb', 'entur-sj-nord'].includes(feed.id), + ); + assert.ok( + railNaturalRequestsPerMinute() > railNaturalRequestsPerMinute(before), + ); + assert.ok(railAdmissionGlobalMax() >= railAdmissionGlobalMax(before)); +}); + +test('KTMB: Komuter local, Shuttle Tebrau international, ETS and Intercity by state', () => { + const route = (id) => ({ route_id: id, route_type: '2' }); + const kl = at('KL SENTRAL', 'MY', 'MY-14'); + const jb = at('JB SENTRAL', 'MY', 'MY-01'); + const tumpat = at('TUMPAT', 'MY', 'MY-03'); + const guaMusang = at('GUA MUSANG', 'MY', 'MY-03'); + // The simplified polygons put Padang Besar station in Thailand. + const padangBesar = at('PADANG BESAR', 'TH', 'TH-90'); + const butterworth = at('BUTTERWORTH', 'MY', 'MY-07'); + assert.equal(category('ktmb', route('KA15_KD19'), ends(kl, kl)), 'local'); + assert.equal(category('ktmb', route('KC05_KB18')), 'local'); + assert.equal( + category('ktmb', route('100_47300'), ends(butterworth, padangBesar)), + 'local', + 'Komuter Utara', + ); + assert.equal(category('ktmb', { route_id: 'SS', route_type: '0' }), 'local'); + assert.equal(category('ktmb', route('ST')), 'international'); + assert.equal(category('ktmb', route('ETS'), ends(kl, jb)), 'national'); + assert.equal( + category('ktmb', route('ETS'), ends(kl, padangBesar)), + 'national', + 'Padang Besar is in Perlis', + ); + assert.equal( + category('ktmb', route('ETS'), ends(kl, at('HAT YAI', 'TH', 'TH-90'))), + 'international', + ); + assert.equal( + category( + 'ktmb', + route('ERT'), + ends(at('MERAPOH', 'MY', 'MY-06'), at('WOODLANDS CIQ', 'SG', 'SG-03')), + ), + 'international', + ); + assert.equal( + category('ktmb', route('SH'), ends(tumpat, guaMusang)), + 'regional', + ); + assert.equal(category('ktmb', route('SH')), 'national', 'ends unknown'); + assert.equal(category('ktmb', route('BUS1'), ends(kl, jb)), null); + // The realtime vehicles carry no route: it comes from the schedule pack. + assert.equal(getRegisteredRailFeed('ktmb').routesFrom, 'schedule'); + assert.equal(getRegisteredRailFeed('ktmb').pollIntervalMs, 30_000); +}); diff --git a/src/data/railFeedsUs.test.mjs b/src/data/railFeedsUs.test.mjs new file mode 100644 index 000000000..cc85a2a9f --- /dev/null +++ b/src/data/railFeedsUs.test.mjs @@ -0,0 +1,46 @@ +// Tri-Rail, South Florida's commuter line: switched on with its basis, +// polled for live positions, and sorted into categories by route or by +// train number. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId, context) => + classifyTrain(feed(id), routeId, context); + +test('Tri-Rail is on, with its basis', () => { + const tri = feed('tri-rail'); + assert.ok(getRailFeed('tri-rail'), 'pollable'); + assert.ok(RAIL_ENABLED_FEEDS.includes(tri)); + assert.ok(tri.terms.quote, 'quotes its terms'); + assert.ok(tri.terms.note, 'explains its basis'); + // Used by project owner decision, which it says, naming no person. + assert.match(tri.terms.note, /\(project owner decision, 2026-11-10\)/); + assert.doesNotMatch(tri.terms.note, /@/); +}); + +test('Tri-Rail: live positions with trip updates, trains by route or train number', () => { + const tri = feed('tri-rail'); + assert.equal(railFeedPositions(tri), 'gps'); + assert.ok(railFeedHasTripUpdates(tri)); + assert.equal(tri.staticGtfsUrl, 'https://gtfs.tri-rail.com/gtfs.zip'); + const local = { category: 'local', mode: 'rail' }; + // Pack routes: Mainline and Downtown Miami Link. + assert.deepEqual(kind('tri-rail', '1'), local); + assert.deepEqual(kind('tri-rail', '2'), local); + // The airport shuttle bus, should a route id ever reach the classifier. + assert.equal(kind('tri-rail', '4'), null); + // Realtime vehicles carry no route, only the train number. + assert.deepEqual(kind('tri-rail', null, { tripId: 'P617' }), local); + assert.deepEqual(kind('tri-rail', '', { tripId: 'P601X' }), local); + assert.equal(kind('tri-rail', null, { tripId: 'FLA Loop_T01' }), null); + assert.equal(kind('tri-rail', null, {}), null); +}); diff --git a/src/data/railFeedsUsNoTerms.test.mjs b/src/data/railFeedsUsNoTerms.test.mjs new file mode 100644 index 000000000..6dc88ee75 --- /dev/null +++ b/src/data/railFeedsUsNoTerms.test.mjs @@ -0,0 +1,62 @@ +// A United States feed whose publisher states no terms (Charlotte LYNX): +// switched on with the reason recorded, and sorted into categories. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; +import { keepsRouteType } from '../../scripts/build-rail-paths.mjs'; + +// The feeds that publish no terms, used without a published licence by project +// owner decision of 2026-11-10. +const ON = ['charlotte-lynx']; +const BASIS = + 'Publishes no terms; used without a published licence (project owner decision, 2026-11-10).'; + +const TRAM = { category: 'transit', mode: 'tram' }; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId, context) => + classifyTrain(feed(id), routeId, context); + +test('the no-terms feed is on and says why', () => { + for (const id of ON) { + const entry = feed(id); + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(RAIL_ENABLED_FEEDS.includes(entry), id); + assert.equal(entry.license, 'No licence published', id); + assert.ok(entry.terms.note.startsWith(BASIS), id); + // Nothing personal in the recorded basis. + assert.doesNotMatch(entry.terms.note, /@/, id); + } +}); + +test('Charlotte LYNX: live light rail and streetcar with trip updates, buses dropped', () => { + const lynx = feed('charlotte-lynx'); + assert.equal(railFeedPositions(lynx), 'gps'); + assert.ok(railFeedHasTripUpdates(lynx)); + assert.match( + lynx.staticGtfsUrl, + /^https:\/\/gtfsrealtime\.ridetransit\.org\//, + ); + // Routes seen live on 2026-11-10: 501 Blue Line, 510 Gold Line, buses. + assert.deepEqual(kind('charlotte-lynx', '501'), TRAM); + assert.deepEqual(kind('charlotte-lynx', '510'), TRAM); + for (const bus of ['9', '22', '5', null]) + assert.equal(kind('charlotte-lynx', bus), null, String(bus)); +}); + +test('a listed staticRouteTypes is the whole list, so trains coded as buses build', () => { + assert.equal(keepsRouteType({}, '3'), false, 'a bus by default'); + assert.equal(keepsRouteType({}, ' 2 '), true); + assert.equal(keepsRouteType({ staticRouteTypes: [3] }, '3'), true); + assert.equal(keepsRouteType({ staticRouteTypes: [3] }, '2'), false); + assert.equal(keepsRouteType({ staticRouteTypes: [0, 2] }, '0'), true); + assert.equal(keepsRouteType({ staticRouteTypes: [0, 2] }, '1'), false); + assert.equal(keepsRouteType({ staticRouteTypes: [3] }, ''), false); +}); diff --git a/src/data/railFeedsUsUrban.test.mjs b/src/data/railFeedsUsUrban.test.mjs new file mode 100644 index 000000000..9678477d2 --- /dev/null +++ b/src/data/railFeedsUsUrban.test.mjs @@ -0,0 +1,57 @@ +// United States streetcar feeds (Seattle, Cincinnati): switched on with +// their basis, polled for live positions, and sorted into categories with +// the buses dropped. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; + +// Licensed and switched on. +const ON = ['seattle-streetcar', 'cincinnati-connector']; + +const TRAM = { category: 'transit', mode: 'tram' }; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId, context) => + classifyTrain(feed(id), routeId, context); + +test('the US streetcar feeds: licensed feeds on, each with its basis', () => { + for (const id of ON) { + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(RAIL_ENABLED_FEEDS.includes(feed(id)), id); + assert.ok(feed(id).terms.note, `${id} explains its basis`); + assert.ok(feed(id).staticGtfsUrl, `${id} has a timetable`); + assert.ok(feed(id).terms.quote, `${id} quotes its terms`); + // Nothing personal in the recorded basis. + assert.doesNotMatch(feed(id).terms.note, /@/, id); + } +}); + +test('the US streetcar feeds poll positions and trip updates', () => { + for (const id of ON) { + assert.equal(railFeedPositions(feed(id)), 'gps', id); + assert.ok(railFeedHasTripUpdates(feed(id)), id); + } +}); + +test('Seattle Streetcar: the two City of Seattle routes of the King County feed', () => { + assert.deepEqual(kind('seattle-streetcar', '100340'), TRAM); + assert.deepEqual(kind('seattle-streetcar', '102638'), TRAM); + assert.equal(kind('seattle-streetcar', '100001'), null, 'a Metro bus'); + assert.equal(kind('seattle-streetcar', null), null); + assert.equal( + feed('seattle-streetcar').attribution, + 'Transit scheduling, geographic, and real-time data provided by permission of King County', + ); +}); + +test('Cincinnati Connector: the streetcar route, buses dropped', () => { + assert.deepEqual(kind('cincinnati-connector', '100'), TRAM); + assert.equal(kind('cincinnati-connector', '78'), null); +}); diff --git a/src/data/railIreland.test.mjs b/src/data/railIreland.test.mjs new file mode 100644 index 000000000..cebda5819 --- /dev/null +++ b/src/data/railIreland.test.mjs @@ -0,0 +1,134 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + publicRailCatalog, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; +import { + buildRailSnapshot, + railMaxBodyBytes, + railUpstreamHeaders, +} from './railProxy.js'; + +const irish = getRegisteredRailFeed('irish-rail'); + +test('Irish Rail keeps its XML positions and gains a timetable', () => { + assert.equal(irish.format, 'irish-rail-xml'); + assert.equal(irish.positions, 'event'); + assert.ok(getRailFeed('irish-rail')); + assert.match(irish.staticGtfsUrl, /\/transitData\/Data\/GTFS_Realtime\.zip$/); + assert.deepEqual(irish.staticRouteTypes, [2]); + assert.equal(irish.positionsByTrainNumber, true); + assert.equal(irish.routesFrom, 'schedule'); + // The XML's own headers and cap. + assert.match(railUpstreamHeaders(irish).Accept, /xml/); + assert.equal(railMaxBodyBytes(irish), 1024 * 1024); + // The positions publish no licence; the NTA timetable is CC BY 4.0. + assert.equal( + irish.license, + 'No licence published (positions); CC BY 4.0 (NTA timetable)', + ); + assert.equal( + irish.attribution, + 'Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is', + ); +}); + +test('Irish Rail is placed by its XML events and serves no trip updates', () => { + assert.equal(railFeedPositions(irish), 'event'); + assert.equal(irish.tripUpdatesUrl, undefined); + assert.equal(railFeedHasTripUpdates(irish), false); + // Listed without a timetable pack: the XML's train codes classify alone. + const entry = publicRailCatalog({ schedulePackBuilt: () => false }).find( + (feed) => feed.id === 'irish-rail', + ); + assert.equal(entry.positions, 'event'); + assert.equal(entry.tripUpdates, false); +}); + +test('Irish Rail pack routes: the code letter and the provinces of the ends', () => { + const rename = (code, from, to, countries = ['IE']) => + irish.staticTripRename({ trip_short_name: code }, 'R-1', { + ends: { + from: { name: from, country: countries[0] }, + to: { name: to, country: countries.at(-1) }, + countries, + }, + }); + assert.equal( + rename('A100', 'Dublin Connolly', 'Belfast', ['IE', 'GB']), + 'A~B~R-1', + ); + assert.equal(rename('A203', 'Cork (Kent)', 'Dublin Heuston'), 'A~X~R-1'); + assert.equal(rename('A301', 'Mallow', 'Tralee (Casement)'), 'A~S~R-1'); + assert.equal(rename('P501', 'Mallow', 'Cobh'), 'P~S~R-1'); + assert.equal(rename('E206', 'Bray (Daly)', 'Howth'), 'E~X~R-1'); + assert.equal(rename('195.MF-BH.93-GRN-y11', 'a', 'b'), null, 'no train code'); + const kind = (route) => classifyTrain(irish, route, { tripId: '6024_1' }); + assert.equal( + kind('A~B~DUB-BFT-O').category, + 'international', + 'the Enterprise', + ); + assert.equal(kind('A~X~DUB-CORK-O').category, 'national'); + assert.equal(kind('A~S~DUB-TRALEE-O').category, 'regional'); + assert.equal(kind('E~X~BRAY-HOWTH-I').category, 'local', 'DART'); + assert.equal(kind('P~S~MAL-COBH-O').category, 'local', 'commuter'); + assert.equal(kind('D~S~MAL-COBH-O').category, 'local'); + // The XML's own records still classify by their code and ends. + assert.equal( + classifyTrain(irish, 'Dublin Connolly - Belfast', { tripId: 'A100' }) + .category, + 'international', + ); + assert.equal( + classifyTrain(irish, 'Bray - Howth', { tripId: 'E206' }).category, + 'local', + ); +}); + +test('an Irish Rail train code takes the timetable run it is on now', () => { + const xml = ` + + + R53.1442 + -6.06085A610 + 09 Nov 2026 + A610\\n18:37 - Dublin Connolly to Rosslare Europort (3 mins late)\\nDeparted Greystones next stop Kilcoole + Southbound + + + R53.35 + -6.25E999 + 09 Nov 2026 + E999\\n19:00 - Bray to Howth (0 mins late)\\nDeparted Connolly next stop Clontarf Road + Northbound + +`; + const pack = { '6024_8435': 'A~X~DUB-EUROPORT-I' }; + const snapshot = buildRailSnapshot( + irish, + new TextEncoder().encode(xml), + 1_794_251_747_000, + { + routeFor: (tripId) => pack[tripId] ?? null, + tripFor: (code) => (code === 'A610' ? '6024_8435' : null), + }, + ); + const byCode = Object.fromEntries( + snapshot.trains.map((train) => [train.trainNumber ?? train.tripId, train]), + ); + assert.equal(byCode.A610.tripId, '6024_8435'); + assert.equal(byCode.A610.routeId, 'A~X~DUB-EUROPORT-I'); + assert.equal(byCode.A610.category, 'national'); + assert.equal(byCode.A610.positionSource, 'event'); + assert.equal(byCode.A610.id, 'A610', 'the vehicle keeps its code'); + // Not in the timetable: its own code and ends, as before. + assert.equal(byCode.E999.tripId, 'E999'); + assert.equal(byCode.E999.trainNumber, undefined); + assert.equal(byCode.E999.category, 'local'); +}); diff --git a/src/data/railNoPositions.js b/src/data/railNoPositions.js new file mode 100644 index 000000000..b645a7d0a --- /dev/null +++ b/src/data/railNoPositions.js @@ -0,0 +1,1092 @@ +/** + * @module railNoPositions + * @description Where the Rail layer places no train at all: Ukraine, a + * safety rule. The proxy (src/data/railProxy.js) drops every live position + * inside it before the snapshot leaves the server, counted with the other + * dropped vehicles; the path build (scripts/build-rail-paths.mjs) leaves out + * every timetable trip that stops there, so no schedule pack holds one and + * no train can take its line or stops. + * + * A stop is placed by the country its id starts with where the id carries + * one (IFOPT-style `ua:22005:1:1`, parent stations `Pua:22005`), else by the + * outline. A position is tested against a bounding box first and against + * the outline only inside it, so the check costs nothing elsewhere. + * + * The outline is Ukraine's recognised borders, Crimea included, as mapped in + * OpenStreetMap: geoBoundaries gbOpen UKR ADM0 (OpenStreetMap via Wambacher's + * OSM Boundaries), file geoBoundaries-UKR-ADM0.geojson at geoBoundaries commit + * 9469f09 (SHA-256 a24186ba62303d653efdbb2d91eddbac263619899d85a5802ac845e0a93e480a). + * It runs out to the territorial sea, where no train runs. Outer ring only, + * Douglas-Peucker at 0.0005° (longitude scaled by the cosine of latitude), + * four decimals, delta-encoded lon/lat pairs, stored open. Natural Earth's + * 10m outline is too coarse here: it puts Romanian, Polish and Slovak + * stations on the border river or line inside Ukraine (Bocicoi, Medyka, + * Čierna nad Tisou) and leaves Ukrainian halts out (Uhniv, Korchiv). + * + * Licence: the outline is a derivative of OpenStreetMap and is offered under + * the Open Database License 1.0 (https://opendatacommons.org/licenses/odbl/1-0/), + * © OpenStreetMap contributors (https://www.openstreetmap.org/copyright); + * geoBoundaries (Runfola et al. 2020, https://www.geoboundaries.org). The + * code in this module is under the repository's licence. + * + * Pure data and helpers: imported by the proxy, the path build and node:test. + */ + +/** ISO 3166-1 alpha-2 codes of the countries where no train is placed. */ +export const NO_TRAIN_POSITION_COUNTRIES = Object.freeze(['UA']); + +const DECIMALS = 4; +/** Outer rings, `[lon, lat]` deltas at DECIMALS. */ +const RINGS = Object.freeze([ + Object.freeze([ + 331962, 523736, -5, 53, -70, 2, 0, -60, -188, -3, -1, -41, 30, -21, -32, + -258, -134, -24, -120, 59, -72, 16, -30, -9, -16, -18, -33, -14, 6, -34, + -124, -96, -62, -34, -36, 13, -107, -68, -161, -122, -80, -5, 7, -28, -64, + -18, -54, 35, -10, 40, 25, 52, 36, -23, 75, 18, -29, 18, 20, 50, -15, 23, + 20, 2, -18, 23, -23, -5, -74, 20, 10, 15, -34, 2, -53, -49, 43, -64, -143, + -48, 2, -64, -210, -72, -54, -67, -120, -44, -94, -7, 30, -69, -3, -45, -36, + -1, -4, -47, -79, 1, -54, 15, -208, -3, -166, -90, -347, -142, -35, -40, + -38, -2, -97, 2, -57, -9, -85, 137, 62, 96, -9, 50, -278, 7, -236, 42, -159, + -194, -179, 65, -204, -39, -2, -23, -63, 2, -95, -39, -10, 66, -52, 47, + -306, -105, -92, 23, -162, -107, -5, 70, -105, 8, -45, 106, -230, 124, -106, + 43, -137, 36, -158, 106, -102, 36, -39, 39, -138, -11, -17, -11, -88, -19, + -64, -4, -58, 99, -214, 168, -131, -7, -164, 18, -188, -7, -22, -61, -98, + -68, -176, 99, -55, -4, -67, 29, -261, -48, -31, 8, -19, 22, -215, 110, -62, + 20, -68, -43, -67, 38, -108, -2, -216, -100, -33, -111, 117, -156, 34, -165, + 33, -91, 36, -19, 78, 52, 132, -1, -120, -85, 53, -115, 18, -9, -8, -17, 13, + -26, 18, -2, 27, -24, 55, -3, -17, -7, 11, -13, 18, 1, -10, -15, 8, -7, -61, + 6, -19, -17, -97, -44, -164, -1, -60, -7, -5, -13, -142, 57, -25, -21, 32, + -11, -31, -20, -20, 6, -11, -6, -167, -155, 2, -88, 61, -91, -2, -27, 100, + -49, 37, -28, 26, -126, -22, -7, -21, 4, -7, -19, -21, 4, 5, -12, -18, -40, + -37, 8, 21, -22, 23, 1, -8, -13, -31, 0, -2, -11, -22, -10, 7, -10, -16, -5, + 16, -15, -21, -10, -10, 13, -4, -33, -20, 0, 11, -16, -29, 0, 10, -27, -20, + -5, 15, -12, 10, 8, 18, -6, -28, -21, -4, -38, -25, -16, 24, -16, 60, 61, + 329, -7, 32, -76, -5, -12, -78, -66, -252, 5, -23, -21, -28, -58, -95, 37, + -23, -143, -15, -9, -108, -7, -54, 9, -6, -14, -19, 3, -10, -10, -10, 19, + -16, -10, -7, -20, 20, -2, -14, -12, 15, -12, -3, -21, 18, 3, -7, -14, 19, + -9, -13, -10, -23, -2, 9, -9, -25, 1, -17, -9, 6, -19, -13, -35, -41, 2, -3, + -19, -57, -20, -11, 6, -7, -22, -22, 3, 4, -8, -18, 5, -35, -10, -14, 7, + -17, -19, -23, -1, -3, -15, -23, -14, -28, 5, 17, -10, -24, -12, -11, 5, -9, + -9, -11, 8, -8, -9, -23, 8, -17, -4, 4, -18, -66, -2, -31, 8, -12, -8, -35, + 0, -2, -19, -67, -3, -24, 12, -44, -5, -1, -15, -15, 2, -11, -8, 16, -15, + -12, -30, -41, 16, -92, -36, -13, -10, 13, -10, -24, -17, -45, -4, -14, -18, + 5, -29, 15, -11, -32, -26, -1, 24, -11, -11, -53, -5, -15, 39, -59, 11, -18, + -7, -42, 4, -43, -15, -43, 21, -24, -6, -61, 3, -39, -15, -27, 12, -38, -27, + -48, 3, 11, -18, -18, -7, -1, -14, 11, -2, 9, -26, -47, -20, 18, -12, -13, + -11, 21, 3, 7, -8, -37, -30, -57, -24, -34, 2, 17, 12, -20, 5, -40, -52, 40, + -56, -83, 24, -240, -6, -52, -17, -2, -99, -289, 18, -15, -7, -182, 45, -30, + 1, -180, 95, -70, 7, -35, -12, -441, 37, -116, 1, -17, -7, 9, -11, -23, -16, + -21, -4, 1, -7, -17, 4, -1, -12, -43, -11, -1, 12, -24, -2, -3, 8, -18, -5, + 1, 9, -28, -4, -12, 16, -30, -17, -23, 3, 12, -11, -46, -17, -44, -3, -22, + -14, -57, 15, -9, -23, -23, -2, -13, 11, 18, 42, 109, -13, 7, 40, -67, 24, + -45, 3, 15, 14, -6, 6, 29, 10, -3, 7, 21, 6, 0, 9, 41, -1, 32, 21, 24, -3, + 2, -17, 99, -12, 14, 41, 135, 75, 32, 62, -71, 72, -26, -3, -10, 10, -16, + -15, -11, 11, -69, -22, 0, 24, -46, 23, -53, 55, -121, 51, -15, 15, -90, 16, + -66, 42, -83, 25, -164, 26, -91, 25, -49, -3, -69, 10, -94, -9, -49, -28, + -92, -29, -31, -27, -67, -29, -115, -3, -95, 37, -36, -1, -109, 101, -55, + -1, -47, 8, -56, -10, -156, -77, -117, -14, -151, -47, -76, -13, -28, 67, + -67, 45, -140, -2, -147, 49, -134, 19, -105, 32, -67, -27, -267, 6, -109, + -9, -80, -21, -70, 35, -67, -13, -111, 31, -190, 26, -166, -1, -111, 40, + -81, 1, -65, 10, -37, -1, -46, -26, -104, -31, -118, -22, -17, -13, -74, 10, + -49, 25, -106, 10, -17, 8, -42, 71, -42, 23, -16, 77, -20, 17, -62, -4, -63, + 10, -20, 14, -35, -12, -43, 22, -32, -25, -54, 0, -10, -14, -70, 24, -108, + 7, -14, -21, -70, -17, -54, -44, -31, -7, -56, 7, 100, -88, -2, -30, -41, + -26, -48, 1, -91, -49, -102, -28, -209, -33, -98, -21, -30, -16, -25, -41, + -7, -48, -40, -61, -95, -34, 102, -40, -238, -313, -445, -94, -201, 69, + -143, 20, -10, 61, -39, 22, -19, 40, 39, 77, -81, 20, -154, 23, -96, -3, + -10, 7, -2, -9, -24, 0, -3, 11, -26, -5, -30, 9, -7, -6, -106, 25, -43, 23, + -6, 18, -21, 14, 17, 20, -18, 3, -15, -7, -39, 17, 15, 18, -34, 15, -26, 48, + 0, 34, -50, 45, -39, 2, -74, -28, -23, -26, -69, -3, -102, -45, -94, -29, + -93, 10, -242, 0, -45, -22, -298, -36, -223, -43, -114, -6, -61, -15, -5, + -14, -134, 9, -146, 31, -5, 25, 21, 61, -66, 7, -19, -10, 6, -19, -80, 15, + 32, -85, -4, -58, -24, -14, -37, -4, -65, 23, -31, 1, -30, -14, -12, -29, + 24, -57, 24, -25, 81, -31, 34, -49, -12, -46, -43, -51, -79, -30, -17, -24, + 2, -26, 41, -61, 67, -37, 10, -15, -28, -39, -37, -27, 13, -40, -9, -27, + -44, -3, -99, 30, -32, -11, -42, -45, -77, -10, -57, -43, -74, -14, -20, + -11, -24, -41, 21, -37, 139, -19, 19, -11, 9, -29, -10, -21, -33, -16, -54, + -7, -127, 21, -32, -12, -16, -40, 64, -61, 8, -21, -12, -35, -70, -20, -108, + -4, -87, 52, -45, -1, -50, -35, -30, -55, -23, -15, -39, -3, -46, 10, -39, + -9, 1, -77, -14, -10, -35, -9, -75, 36, -46, 2, -59, -47, -69, -29, -1, -26, + 68, -99, 103, -100, 16, -29, -11, -35, -184, -119, -31, -81, -29, -15, -62, + -4, -372, 68, -65, -6, -24, -10, -37, -49, -146, -109, -76, -132, -128, -52, + -43, -42, -35, -56, -15, -53, -2, -100, -86, -179, -56, -16, -177, -20, -41, + -19, -21, -21, -6, -27, 107, -123, 11, -33, -17, -86, -55, -50, -29, -11, + -173, 5, -54, -4, -34, -14, -23, -15, -20, -55, -33, -33, -119, -32, -34, + -26, -3, -53, 21, -16, 45, -12, 184, 58, 88, -9, 31, -12, 15, -24, -7, -77, + -62, -64, -189, -145, -4, -32, 27, -119, -11, -31, -83, -46, -99, -16, -260, + 38, -61, 1, -97, -32, -49, -36, -12, -20, -1, -28, 22, -34, 156, -157, 7, + -44, -23, -80, -32, -54, 0, -26, 69, -92, 4, -29, -11, -21, -25, -22, -43, + -14, -144, 15, -97, -10, -24, -21, 2, -42, 19, -23, 95, -54, 8, -44, -15, + -17, -78, -13, -131, 9, -50, -28, -5, -37, 37, -38, 15, -45, -38, -45, -64, + -8, -114, 19, -55, -15, -20, -17, -3, -38, 41, -37, 70, -23, 152, -29, 184, + -72, 26, -21, 13, -43, -33, -23, -169, 21, -34, -11, -36, -32, 3, -51, 15, + -33, 122, -96, 18, -25, 16, -70, 19, -17, 57, -5, 145, 103, 58, 16, 54, -8, + 79, -89, 5, -21, -15, -28, -157, -162, -15, -31, -2, -54, 18, -30, 130, -82, + 55, -67, 1, -105, 19, -51, 26, -28, 60, -38, 19, 26, 122, -33, 58, -7, 35, + -25, 6, -43, -9, -38, -99, -36, -16, 12, -51, -3, -31, 7, -110, -30, -86, + -40, -29, -22, -7, -32, 33, -55, 37, -112, 49, -50, 45, -12, 44, 1, 30, 13, + 65, 54, 33, 6, 53, -6, 32, -16, 14, -29, -7, -34, 54, -29, 44, -121, 237, + -314, -39, -54, -109, -31, -43, -29, -19, -38, 3, -21, 31, -26, 110, -45, + 69, -67, 6, -26, -50, -71, -33, -21, -198, 4, -64, -11, -23, -13, -17, -33, + -57, -52, -104, -48, -84, -62, 21, -26, 73, -10, 74, 9, 35, -12, 3, -19, + -45, -25, -58, 18, -40, -10, -1, -16, 32, -19, 94, 1, 33, 16, 27, 33, 26, 2, + 15, -13, -42, -32, 7, -9, -25, -25, 24, -57, -9, -43, -45, -45, -64, -34, + -145, 4, -157, -65, -23, -18, -28, -63, 3, -27, -47, -57, -632, 219, -366, + -42, -90, -1, -32, 16, -10, 46, 58, 34, 63, 66, -1, 15, -70, 52, 0, 24, 26, + 33, 6, 27, -190, 20, -119, -24, -77, -5, -264, 71, -61, 88, -50, 20, -363, + 270, -78, 12, -182, 88, 385, 192, -95, 337, -43, 101, -124, 16, -50, 26, + -25, 28, -18, 1, -11, 18, -37, 13, -32, 58, -81, 37, -19, 37, -38, 23, -67, + 70, -43, 15, -69, 63, -37, -1, -100, 41, -272, 175, -68, 22, -274, 54, -322, + 194, -117, -35, -45, -200, -46, 5, -115, 37, -140, 77, -8, 39, -51, 13, -70, + -15, 55, -141, -65, -28, -26, -3, -65, 9, -38, 21, -364, 88, -55, 7, -59, + -11, -177, 45, -131, 9, -4, 31, -18, 3, -82, -16, -11, -9, 0, -23, 37, -34, + 4, -28, -37, -10, -25, -54, -68, -9, -41, -31, -129, -37, -60, 38, -37, 3, + -45, -9, -35, -24, -16, -85, -15, -19, -41, -14, -74, -2, -92, 31, -17, 48, + 19, 48, -50, 25, -82, 23, -43, 33, -57, 0, -36, -40, -77, -14, -23, 12, -19, + -3, -27, -7, -16, -16, -132, 0, -9, -16, 12, -35, -44, -16, -39, -2, 7, -28, + 53, -17, -21, -47, -4, -65, -103, -49, 9, -42, 32, -37, -16, -36, -48, -2, + -107, 30, -75, 11, -61, -3, -23, 55, -34, 30, -188, -61, -106, -25, -36, + 102, -191, -19, 9, -64, 24, -52, -4, -48, -111, 50, -73, 17, -84, 9, -212, + 78, -50, 3, 20, 75, -52, 50, -29, 48, -35, 66, 5, 54, -35, 57, 103, 9, 43, + 87, -18, 43, -50, 30, 0, 27, 57, 11, 11, 23, -34, 60, -25, 97, -27, 5, -80, + -23, -71, 11, -59, -5, -56, -24, -14, -44, -77, -41, -71, -18, -32, 5, -48, + -26, -102, -29, -25, -23, 5, -42, -19, -27, -50, -7, -57, 27, -75, 13, -82, + -71, -31, -2, -44, 52, -53, 21, -91, -119, -85, -16, -72, 9, -1, -70, -19, + -35, 30, -38, -7, -47, -32, -31, -107, -48, -98, -29, -61, -15, -66, 45, + -306, 158, -33, -2, -40, -81, 14, -41, -7, -76, -138, -103, -26, -87, 44, + -48, -14, -89, 12, -62, -25, -14, -35, 21, -22, -4, -32, -74, -56, -62, -39, + -84, -67, -53, -314, 71, -127, 50, -300, 62, -73, -15, -128, -45, -57, -9, + -55, -37, -75, -23, -49, -37, -87, -91, -59, -1, -41, -38, -30, -10, -118, + -4, -41, 16, -91, -60, -50, -20, -44, 85, -13, 88, 24, 41, 18, 109, -26, 74, + -55, 41, -9, 122, -78, 112, -7, 27, 19, 66, -141, 53, -61, 7, -73, -16, -62, + 2, -36, 70, -11, 62, 6, 50, -84, 39, -43, 34, -32, 62, -44, 51, 0, 78, 46, + 154, -5, 50, 114, 73, -50, 140, 30, 64, -69, 120, -2, 45, -31, 21, -39, 3, + -312, -19, -103, 51, -2, 37, -57, 32, -9, 19, 28, 19, 8, 70, -20, 89, -232, + 111, 68, 73, 16, 34, -39, 15, -77, 12, -112, 31, -40, 26, 2, 19, 55, 26, 31, + 83, 64, 37, 9, 31, -20, 12, -32, 0, -30, 26, -29, 3, -24, -3, -42, -28, -30, + 16, -61, -16, -15, 16, -88, -22, -95, 25, -89, 53, -73, -44, -162, -129, 21, + -14, -31, -39, -11, -47, -28, -10, -166, 1, -38, -22, -58, -18, -11, -43, + -52, -28, -140, -133, -48, -30, -62, -67, -17, -36, -334, -190, -192, -1, + -75, 52, -23, 99, -25, 30, -278, -42, -107, -8, -40, 1, -63, 21, -160, 3, + -135, -20, -53, -18, -17, -20, 17, -11, 0, -25, -72, -37, -54, -14, 2, -22, + -30, -24, -63, -7, -252, -89, -127, -1, -33, -12, -103, -106, -6, -50, -151, + -114, -110, -183, -9, -41, -68, -86, -123, -103, -10, -65, 99, -62, 23, -34, + 22, -109, 50, -102, -15, -20, -65, -5, -17, -19, -34, -195, -44, -55, -47, + -27, -55, -53, -30, 14, 2, 85, -67, 7, -27, 40, -72, 53, 21, 86, -23, 71, + 42, 136, -20, 18, -91, -43, -59, -15, -12, -85, -208, -103, -41, 0, -111, + 24, -169, 452, -96, 203, -33, 110, 3, 46, 19, 29, -36, 100, -36, 88, -81, + 76, 62, 29, 60, 14, 11, 14, -29, 16, -43, -4, -71, -23, -39, -22, -29, 15, + -13, 36, 144, 43, 108, 0, 17, 12, -75, 30, -41, -18, -77, -3, -63, 9, -18, + 41, -763, -9, 7, 101, -288, 0, -38, 13, -13, 27, -47, 38, -29, 8, -143, -2, + -251, 19, -73, 0, -195, -100, -262, -169, -114, -55, -171, -101, -130, -40, + -85, -4, -84, -55, -79, -17, -39, 18, -28, 36, 63, 69, 26, 62, -81, 120, + -80, 82, -131, 62, -45, 61, -412, 330, -89, 0, -152, -38, -24, 33, -24, 51, + 55, 116, 16, 105, 45, 147, 19, 42, 30, 30, -16, 47, -84, 47, -190, -67, -60, + -34, -395, -172, -116, -30, -146, -72, 10, -153, -40, -54, -84, -67, -104, + -106, -164, 29, -105, -87, -77, -170, -149, -44, -180, -38, -74, 50, -74, + -55, -186, -80, -83, -1, -47, 11, -218, -37, -255, -9, -449, -46, -17, 11, + 5, 29, 161, 99, 16, 50, 58, 27, 69, 51, 49, 75, 4, 32, -127, 7, -17, -19, + -61, -10, -93, -123, -201, -34, -40, 9, -15, 31, 51, 138, -22, 36, -10, 84, + -44, 42, -15, 36, -28, 19, -48, 10, -150, 5, -60, 45, -40, 17, -132, 12, + -186, 73, -126, -28, -214, -125, 180, -98, -68, -104, 3, -124, -15, -44, + -28, -24, 15, -22, 28, -12, -15, -24, 50, -37, 12, -39, 44, -27, 17, -26, + -9, -35, 17, -24, -11, -23, 49, -14, 49, -49, -145, -76, -8, -23, -337, -45, + -92, -88, -88, -129, -73, -86, -49, -30, -54, -51, 0, -94, -13, -55, -48, + -33, -45, -4, -171, 33, -195, 149, -123, 55, -69, 11, -57, -8, -41, 21, -95, + 92, -64, 37, -52, 17, -36, 30, 155, 30, -3, 68, -21, 23, 18, 112, 102, 55, + 119, 83, 129, 46, 55, 28, 26, 44, 78, 270, -36, 11, -34, 98, -221, 25, -61, + -10, -49, 6, -41, 27, -20, 35, -187, -6, -166, 53, -76, -57, -67, -15, -30, + -17, -68, -21, -76, 5, -334, 178, -197, 52, -127, 61, -55, -73, -67, -64, + -193, 138, -38, -9, -53, -45, -13, -33, 13, -40, -3, -46, 22, -100, 15, -22, + 55, 13, 72, -1, -9, -38, -93, -102, -40, -33, -63, -28, -45, -1, -65, 27, + -127, 32, -45, 53, 36, 48, 83, 68, -3, 26, -54, 27, -30, -12, -13, -24, -93, + -35, -61, 5, -61, 22, -26, 73, -23, 14, -309, -63, 3, -80, -345, -10, -336, + 111, -151, -97, -767, -1, 10, -39, -280, -5, 83, 76, -6, 53, -34, 65, 287, + 254, -4, 78, -227, 129, -331, -17, -40, 9, -44, 560, -7, 260, -19, 146, 12, + 108, -152, 1, -58, -7, -43, 7, -531, -18, -36, 10, -144, -19, -50, 12, -390, + -20, -454, -5, -61, -8, -218, -6, -39, -44, -108, -71, -224, -186, -61, -23, + -179, 74, -183, 53, -121, -4, -61, 21, -78, 7, -38, -18, -63, 20, -75, 37, + -103, 74, -98, 43, -91, 7, -89, -14, -94, -28, -88, -51, -48, -18, -135, 23, + -11, 19, 1, 64, -30, 13, -53, -17, -45, 9, -54, 48, -178, 54, 19, 74, -12, + 43, -19, -3, -4, 36, -49, 18, -18, 28, 51, 35, -8, 25, -264, 122, -138, -41, + -62, -2, -37, 11, -132, 110, -144, -1, -126, 7, -31, 8, -114, 0, -42, -10, + -258, 6, -52, 6, -60, 35, -103, 8, -18, -8, -35, -75, -55, -20, -201, -32, + -79, -24, -12, -13, 2, -56, -87, -77, -54, -12, -113, 12, -271, 10, -83, + -12, -224, 9, -2, 102, -25, 32, -174, 21, -72, 26, -41, 33, -50, 74, -113, + 76, -38, 17, -121, -24, -108, 0, -2, 93, -19, 52, 9, 193, -7, 68, -129, -71, + -167, -55, -51, -3, -186, 22, -178, -14, -137, -24, -61, 5, -95, 72, -39, 4, + -105, -36, -27, 42, -37, 5, -93, -2, -53, -67, -32, -17, -86, 34, -38, 74, + -170, -9, -27, 15, -27, -3, -26, -40, -209, -53, -74, -12, -110, 60, -26, 5, + -46, -22, -20, 19, -27, -18, -31, 15, -55, -26, -11, 21, 11, 51, -15, 61, + -36, 1, -106, -29, -40, 8, -80, 73, -57, 93, -113, 15, -106, 42, -82, 119, + -119, 14, -392, 10, -77, 114, -175, 44, -108, 19, -121, -1, -28, 7, -107, + -39, -30, 6, -50, -22, -50, 15, -17, -36, -56, 21, -31, -16, 11, -32, -22, + 1, -3, 14, -38, 1, -18, -10, -30, 0, -1, -34, -58, 5, 10, 15, -10, 9, -35, + -5, -21, -13, -9, -24, -69, -28, -54, -5, -129, 4, -150, 54, -32, -2, -30, + -12, -177, 22, -64, -2, -71, 38, -342, 5, -34, 27, -14, 44, -41, 52, 53, 18, + 10, 23, -57, 34, -72, -1, -50, 19, -174, 3, -78, 52, -93, -12, -26, -24, -1, + -81, 45, -82, 50, -13, 23, -36, 8, -32, -22, -18, -134, 28, -55, 1, -82, + -22, -279, -33, -83, -17, -75, -3, -302, 4, -853, 55, -30, 7, -6, 15, -187, + 32, -463, 114, -94, -142, -90, -42, -24, 23, -66, 36, -41, -1, -38, 19, -41, + 5, -35, -20, 3, -58, -157, -26, -31, 6, -8, -14, -39, 9, -29, -7, 4, 19, + -109, 7, -145, 5, -233, -6, -43, 36, -89, 13, -163, 0, -10, 8, -97, 6, -43, + 57, -19, 5, -42, 4, -91, -26, -68, 7, -68, 65, -159, 16, -7, 37, -108, 46, + -70, 49, -113, 18, -17, 36, -185, 41, -72, 50, -53, 3, -132, -19, -110, -57, + -74, -4, -59, 17, -72, 41, -222, 26, -112, -38, -151, -24, -90, -65, -140, + -34, -252, -22, -167, 22, -73, -19, -128, -41, -121, -62, -95, -20, -37, + -15, -345, -185, -257, -38, -8, -23, -154, -96, -158, -56, -284, -64, -46, + 74, -151, 183, -102, -58, -240, -42, -372, -109, -83, 172, -246, -13, -63, + -28, -17, -38, -59, -41, -35, -46, -35, -2, -26, -12, -197, -109, -173, -32, + -146, 38, -15, 16, -18, 3, -177, 97, -423, -38, -89, 69, -377, 31, -204, + -15, -305, -67, -63, -44, -141, -14, -163, -13, -196, 13, -402, -64, -903, + -5, -106, -32, -47, -4, -249, -147, -75, 6, -56, -81, 59, -65, -24, -38, + -34, -8, -93, -103, -124, -79, -128, -124, -39, -73, -15, -73, 114, -198, + 16, -109, 123, -179, -513, -349, -1478, -494, -203, -270, -242, -203, -435, + -155, -170, -77, -5, -50, -198, -108, -99, 20, -116, 43, -169, 15, -136, 74, + -153, 20, -243, 1, -213, -56, -52, 59, 31, 103, 89, 48, 236, 183, -457, 113, + -101, -3, -108, -21, -109, 37, -77, 14, -240, -52, -152, 2, 4, 41, -12, 27, + 10, 65, 21, 25, -8, 52, -30, 43, -42, 17, -121, -29, -156, -55, -61, 10, + -130, -23, -55, -27, -145, -42, -128, -24, -53, 4, -194, 42, -28, -43, -90, + -74, -63, -24, -155, 3, -89, -54, -54, -51, -67, -11, -48, -27, -89, -18, + -42, -24, -5, -40, 93, -35, 64, -52, 42, -2, 4, -29, 11, -7, -12, -17, 5, + -36, 50, -51, -3, -17, 27, -1, 40, 17, 138, -53, 63, -12, 34, -32, 35, -68, + -27, -61, -23, -7, 9, -17, -34, -15, -10, 11, -36, 1, -29, -24, -18, 2, -5, + -44, -35, -38, -47, -15, -28, -38, 1, -39, 182, -129, 19, -38, 102, -79, 49, + -52, 54, -125, -55, -72, -25, -4, -115, 23, -32, 32, -61, 19, -79, -11, -19, + 5, -45, -15, -82, -9, -22, 5, -59, 42, -10, -50, 19, -12, 61, -9, -4, -16, + -40, -12, -5, -12, 76, -27, 30, -29, 39, -2, 20, -9, 6, -35, 15, -12, 52, + -7, 30, 10, 54, 1, 19, 11, 15, 27, 43, 8, 36, -25, 8, -29, 59, -22, 17, -35, + -1, -47, 37, -14, 12, -24, -22, -11, -63, 14, -20, -11, -8, -35, -49, -19, + -14, -24, -43, -27, -29, -8, -31, 7, -17, -5, 0, -21, 42, -33, -22, -27, 24, + -13, -17, -34, 114, -22, 69, 13, 82, -42, 30, 9, 35, 29, 22, 2, 17, -12, -4, + -17, -34, -22, -12, -27, 44, -36, -20, -9, -52, 5, -15, -37, 21, -18, 34, + -3, 20, -11, 42, -74, 54, -3, 67, 21, 21, -14, -18, -17, -83, -33, 22, -23, + 72, -6, -7, -13, -52, -24, 7, -19, 41, -6, 16, -19, -58, -5, -59, -43, -34, + 11, -51, -13, -7, -10, 53, -20, -4, -17, -23, 0, -40, 17, -35, -14, 8, -24, + 23, -13, 80, -14, 12, -8, -9, -8, -35, -4, -4, -33, -41, 12, -17, -11, 26, + -24, -32, -44, 21, -10, 14, 22, 29, -1, -17, -57, 26, -13, 13, -25, -32, -9, + -52, 12, -26, -24, -75, 13, -28, -10, -2, -17, 40, -29, 1, -13, -24, -6, + -48, 23, -11, -13, 24, -15, -1, -17, -18, -2, -26, 16, -21, -4, -2, -48, + -16, -17, 34, -13, -41, -10, 5, -17, -11, -18, 23, -20, -61, -8, 2, -8, 34, + -11, 42, 1, -11, -28, -41, 8, -49, -4, 0, -28, -21, -26, -55, -24, -41, -35, + 10, -18, 36, 5, 18, -8, -48, -31, -2, -22, -16, -17, 41, -25, 5, -21, 27, + -4, 10, -10, -5, -29, -19, 10, -19, -7, 14, -40, 24, -4, 0, -21, 25, -3, 64, + -44, -9, -10, -45, -3, 3, -9, 16, -2, -4, -13, 18, -16, -42, -16, 2, -11, + 32, -15, -10, -27, 47, 17, 31, -21, 12, 2, 7, 23, 49, 29, 58, 0, -4, -17, + 23, -7, 53, 9, 18, 16, -4, -21, 34, 14, 8, -14, 35, 7, -8, -27, 18, 0, 18, + -20, 44, 2, 29, 22, -12, 22, 10, 6, 19, -27, -17, -18, 21, -39, 33, -17, 31, + 5, 22, -31, 34, -14, -9, -13, -30, -2, 1, -12, 50, 8, 9, -11, -19, -4, 0, + -7, 29, -18, 14, -23, 27, 22, 8, -18, 32, -2, -28, -13, 19, -12, -22, -28, + 24, 7, 1, -15, 28, -5, -2, -15, 41, -1, -32, -8, 27, -10, -29, -12, 20, -24, + 4, -50, -14, -44, 31, -21, 26, 5, -2, -33, -27, 14, -10, -13, 14, -21, 25, + 2, 14, -11, -47, -1, -1, -21, 63, -19, 10, -20, 51, -10, 14, -27, -11, -14, + 23, -2, 15, -29, -2, -36, -53, -2, -16, -9, 34, -45, -1, -32, 31, 2, -13, + 15, 13, 12, 37, -34, 5, 13, 38, -20, 63, 15, 13, -15, -18, -12, 39, -19, 5, + -48, 12, 3, -3, 18, 17, 6, 13, -10, -16, -21, 39, -1, 19, -24, 73, 4, 23, + -13, 50, -4, -27, -22, 29, -8, 3, -25, -13, -15, 27, -21, 121, -19, 46, -25, + 66, -2, 58, -60, -21, -38, 21, -45, 42, -17, 44, 0, 37, -20, -10, -9, -28, + 12, -12, -9, 6, -29, 21, -18, 28, 13, 3, -24, 16, -5, 3, 21, 40, -9, 24, 23, + 8, -30, 18, -12, 25, 20, 7, 28, 16, 1, 14, -18, -24, -18, 72, 1, 18, -8, + -20, -28, 9, -20, 18, -1, 54, 28, 34, -1, 27, -28, -13, -22, 5, -12, 27, + -12, 42, -1, -39, -27, 60, -25, -35, -31, 24, -16, -16, -48, 14, -21, -14, + -25, 11, -11, -82, -23, -8, 4, 8, 28, -13, 4, -50, -33, 2, 23, -13, 6, -18, + -29, 12, -15, -40, -24, 35, -17, -10, -41, 52, 0, -16, -15, -4, -24, 70, + -15, -43, -11, -11, -12, 19, -20, 38, -5, -16, -11, -34, 4, 46, -40, -53, + -33, 46, -31, 28, 24, 31, -10, 5, -49, 11, 2, 6, 14, 18, -1, -8, -45, 24, + -82, 35, -32, -6, -19, 37, 4, -3, 30, 57, 17, 14, -34, 36, 3, 23, -46, 40, + 22, 19, -26, 51, -1, 24, 44, 27, -8, 3, -20, 24, -10, -9, -25, 23, -21, -35, + -33, -2, -25, 34, -1, 7, -9, 1, -49, 30, -95, -23, -20, 55, 5, 7, -7, -18, + -25, -42, -10, -9, -13, 30, -21, 55, -7, -1, -24, 59, 19, 42, -4, -48, -28, + -33, -7, -36, -36, -31, -6, -8, -20, 53, -14, -30, -16, 91, -54, -8, -12, + -29, 1, -3, -25, 68, 9, 56, -26, -11, -18, -17, 15, -45, -6, -25, 11, -3, + -23, -36, 2, 3, -32, -36, 24, -30, 4, -9, -22, 37, -8, -34, -23, 14, -10, + 65, -6, 63, 6, 53, -25, 40, -6, 91, -82, 34, 2, 57, -42, 102, -125, 4, -108, + 33, -82, -24, -15, -1, -13, 22, -62, 42, -9, 14, -32, 20, 3, 2, 20, 15, 3, + 23, -25, 64, -15, 24, -18, 50, -90, -3, -31, 18, -26, 88, -29, 49, -56, 60, + -14, 31, -19, 41, -3, 99, -70, 11, -23, 42, -34, 78, -27, 41, -38, -14, -29, + 24, -27, 31, -8, 49, 18, 124, -13, 74, -15, 9, -18, 18, -8, 37, 21, 42, -50, + 47, -22, 41, -51, 101, -17, 30, 6, 32, -27, 6, -31, 29, 2, 31, 18, 35, -36, + 71, -1, 31, -15, 18, 5, 9, 21, 30, -5, 32, 8, 7, -6, -6, -53, -22, -33, 4, + -22, -87, -57, -25, -35, -77, -18, -5, -21, -62, -4, -20, -30, -68, -14, + -17, -16, -66, -5, -4, -23, -33, 5, -115, -10, -48, 13, 11, -20, -22, -5, + -25, 13, -24, -15, -71, 9, -28, 13, -11, -21, -27, 13, -50, -12, -33, 20, + -50, -4, -25, -23, -14, 0, -38, 30, 11, 7, 62, 3, -2, 8, -68, 12, -57, 1, + -4, -29, -17, -9, -36, 26, 18, 17, -7, 10, -32, 2, 3, -32, -25, 25, -44, 11, + -12, -7, 4, -23, -56, 6, -44, -5, -24, 24, -102, -17, -37, -21, -34, 0, -45, + -27, 2, -13, -23, -6, -20, -30, 26, -21, -4, -11, -28, 13, -7, -36, -23, 28, + -20, -11, -2, -36, -27, -19, 20, -34, -32, 20, -29, 3, 21, -34, -27, 4, -43, + -20, 4, -10, 19, 0, 31, -17, -21, -19, -16, -81, -17, -27, -60, -45, 30, + -34, 48, -14, 30, -39, -26, -36, -23, -9, 27, -33, 43, 7, -21, -24, 5, -13, + 12, -1, 10, 20, 11, 1, -2, -25, 66, -12, 1, -16, -18, -22, 14, -11, 38, -4, + -20, 27, 8, 5, 57, -13, 110, -7, 138, 29, 47, -13, 49, -38, 28, -5, 12, -77, + -32, -8, -15, 19, 12, 12, -7, 5, -40, 11, -26, -6, 21, -19, -38, -31, 14, + -6, 33, 9, 7, -7, -67, -22, 21, -10, 40, 11, 1, -26, 64, -11, -7, -10, -22, + -3, -29, 16, -25, -38, 23, -10, 22, 23, 6, -17, 26, -15, -28, 1, -41, -84, + 6, -4, 28, 14, 10, -4, -11, -50, -38, -30, 15, 1, 15, 15, 10, -5, -11, -54, + -19, -25, 27, 0, 29, -12, -6, 31, 20, 2, 46, -29, 141, -46, 18, -24, 57, 38, + 32, -12, 78, 29, 20, -22, 15, -1, 16, 33, 9, -16, 51, 5, 12, -28, -14, -5, + -6, -19, 41, -18, -38, -15, 38, -10, -18, -31, -64, -6, 29, -22, 2, -20, 14, + -7, -19, -9, 23, -12, 4, -14, -33, -39, -40, -83, 44, -29, -31, -6, -30, 17, + -4, -43, 21, -2, -28, -26, 11, -6, 46, 2, 23, 14, 28, -52, 7, 24, 19, -25, + 8, 6, -12, 17, 18, -2, 21, -29, 1, 19, 28, 1, -6, -29, 52, -27, -16, -11, 9, + -11, -6, -56, 19, 1, 24, 23, 14, -1, -28, -63, -26, -22, 34, -4, 14, -9, + -34, -10, 20, -22, -46, -11, -44, -47, 27, -9, 34, 12, 16, -5, -27, -14, 23, + -14, -5, -28, 15, -39, 1, 21, 51, -21, 46, 22, -70, -183, 6, -26, 56, -59, + 13, -111, -83, -216, 9, -159, -50, -105, -88, -265, -19, -21, -55, -195, + -321, -483, -35, -103, -287, -94, -83, -233, -567, 18, -568, -56, -41, 6, + -342, -28, -423, -13, -252, -38, -512, -130, -182, -87, -57, -40, -65, -91, + -55, -129, 1, -64, -49, -153, -475, -111, -78, -157, -194, -56, -306, -332, + -223, -90, -284, -136, -240, -131, -362, -124, -407, -337, -491, -248, -382, + -258, -281, -216, -55, -11, -100, -38, -16, -42, -130, -39, -38, 15, -9, -7, + 1, -135, -147, -74, -253, -244, -233, -70, -30, -164, -704, -599, -277, + -147, -74, -145, -190, -171, -233, -165, -319, -270, -378, -392, -249, -37, + -140, -274, 53, -64, -75, -14, -125, -67, -91, -33, -223, -189, -89, -61, + -37, -38, -11, -127, -117, -72, -117, -40, -213, -202, -43, -103, -259, + -156, -163, -11, -184, -169, -39, -193, -271, -220, -68, -39, -331, -310, + -97, -70, -118, -113, -101, -103, -65, -110, -35, -115, -115, -104, -224, + -88, 39, -62, 65, -154, 192, -109, 263, -24, 37, -67, 10, -75, 115, -433, + 126, -278, 70, -112, 117, -288, 26, -99, -36, -241, 78, -192, -69, -177, + -45, -516, -183, -60, -46, -55, -18, -93, 52, -53, 50, -6, 42, 6, 73, -28, + 50, 5, 19, -19, 39, -3, 3, -11, -33, -11, -40, -28, -64, -3, -7, -20, 15, + -23, -55, -32, -4, -33, 20, -20, -19, -16, -62, -10, -4, -23, -28, -23, 6, + -27, -21, -12, -19, -39, -80, -27, -9, -59, 22, -26, 51, -21, 16, -50, 28, + -3, 61, 36, -11, 56, 13, 9, 26, -6, 23, 10, 34, 44, -9, 24, 54, 1, 20, 13, + 29, -9, 41, 17, 29, -4, 25, -28, 37, 13, 1, -19, -66, -41, -6, -38, -21, -5, + -48, -35, -8, -42, -32, -39, 9, -27, 16, -6, 36, 7, 13, -20, 33, 3, 23, -36, + 42, 12, 50, -15, 29, 17, 38, -4, 70, 21, -12, -35, 118, 51, 45, 2, 31, -29, + 0, -14, -29, -23, -6, -17, 39, -28, -8, -15, 20, -22, -21, -22, 13, -24, 69, + -23, 13, -23, 35, 3, 28, -13, -2, -40, 57, 18, 33, -7, -5, -21, -36, -16, + -2, -11, 29, -17, 39, 29, 12, -35, 17, 3, 74, -38, 16, -15, -14, -9, -1, + -41, 22, -17, 14, -3, 36, 17, 9, -6, -10, -14, 62, -3, 35, 27, 51, 19, 30, + -36, 60, 12, -54, -63, 7, -23, 46, -12, 26, -24, 41, 6, 12, -19, 24, 7, 53, + -29, 39, 20, 11, -15, 17, 2, -4, 9, 37, 6, -14, -18, 8, -6, 51, 7, 25, -27, + -45, -42, -22, -42, -42, -10, -12, -12, 6, -11, 5, 14, 16, 2, 7, -28, -30, + -13, -52, -3, -78, -25, -14, -27, 29, -18, 15, 1, 9, -11, -26, -20, -2, 7, + -25, -7, -9, -10, 14, -8, -26, -13, 8, -17, 20, 2, -2, -23, 16, -6, -25, + -21, 14, -10, 2, -19, 14, 3, -9, 10, 27, -2, -11, -8, 15, -10, -5, -11, 8, + -5, 28, 7, -2, -36, 15, -8, -26, -29, -30, -13, 10, -14, -45, -59, 64, -14, + 6, -18, 15, 4, 24, 29, 16, -4, 3, -13, 19, 0, -56, -42, -13, -1, 5, -20, -9, + -12, 11, -14, 20, -4, 24, -51, 29, -11, 7, -13, 25, 0, 8, -21, 36, 3, 37, + -15, 2, -15, -34, -12, -12, -14, 0, -19, 20, -17, 2, -27, -28, -10, -62, 1, + -81, -30, -50, 2, -17, -12, -81, -9, -74, 12, -52, -8, -3, 11, -28, 14, 5, + 65, -19, 17, -30, 67, -38, 27, -21, 36, -53, -12, -41, -21, -47, 18, -64, 5, + -35, 60, -2, 35, -94, 20, -52, 29, -87, 70, -131, 53, -76, 17, -37, -14, + -17, -48, -51, -44, -30, -1, -33, 25, -47, 1, -86, 36, -79, -7, -32, 29, + -17, -2, -47, 18, -33, -13, -155, -18, -126, -100, -32, -8, -31, 5, -3, 10, + -52, 9, -34, 15, -21, 48, -63, 4, -30, -9, -83, 37, -7, 16, -38, 25, -11, + 29, -63, 19, -43, 66, -14, 6, -28, 89, -55, 45, -74, 9, -51, 29, -82, 4, + -52, 90, -153, 38, -51, -12, -18, -18, -75, -32, -17, -22, -27, -9, -1, -43, + -30, -27, -94, -15, -19, -165, -2, -328, -34, -50, -37, -21, -60, -18, 75, + -145, -144, -39, -103, -62, -69, -1, -84, 33, -50, -4, -55, -11, -47, -43, + -106, -13, -24, -16, 1, -71, -18, -78, -15, -12, -79, -22, -379, -412, -34, + -24, 14, -49, 49, -25, 3, -26, -67, -67, 3, -29, -15, -16, -2, -35, -27, + -11, 19, -20, -15, -169, -18, -11, -248, -63, -1, -38, -37, -26, -40, -11, + -32, -47, -22, -50, 7, -40, -28, -149, 7, -22, -38, -25, -2, -24, 58, -83, + 20, -12, 20, -137, -7, -42, 26, -66, -6, -11, -62, -24, -17, -19, -29, -44, + 10, -17, -38, -61, -91, -29, -64, -65, -76, -14, -46, -55, -1, -90, -29, + -64, 25, -13, 49, -2, 38, -60, 62, -14, 21, -18, -17, -15, 13, -83, -17, + -41, -31, -5, -63, -53, 30, -25, -81, -119, 1, -73, -61, -147, -75, -12, + -69, 3, -37, -7, -30, -7, -21, -17, -66, -10, -27, -21, -46, -4, -11, -32, + -106, -39, -17, -13, 2, -17, -70, -48, -134, -66, -37, -2, -2, -14, -96, + -50, -51, -9, -6, -79, -92, -118, -96, -55, -9, -17, -143, 8, -72, -39, -50, + -60, -51, -20, -94, 5, -9, -15, 8, -25, -14, -24, -20, -12, -17, 1, -124, + -149, -104, -202, -27, -403, -27, -120, -24, -22, 14, -30, -131, -571, -38, + -201, 25, -41, -23, -35, -8, -62, 12, -42, 45, -33, -5, -60, 17, -15, 113, + -4, 3, 47, 26, 24, 106, 30, 94, 2, 59, 11, 52, 21, 122, 74, 59, 14, 52, -1, + 28, -12, 115, -102, 102, -42, 39, -3, 181, 14, 64, -12, -8, -57, -78, -106, + -25, -19, -111, -21, -27, -12, -7, -14, 13, -16, 83, -31, 41, -34, 32, -57, + 108, -120, 268, -45, 35, -19, 199, -3, 1, -21, 20, -27, -26, -47, 30, -63, + -51, -102, -25, -12, 6, -22, 17, -15, 46, -15, -10, -23, 10, -13, -12, -8, + 27, -16, 0, -18, 22, -2, 9, -11, 47, -5, 2, -19, 82, -27, 6, -65, 24, -58, + -34, -53, 40, -77, -25, -34, 111, -79, 16, 8, 92, -67, 56, -92, 11, -46, 39, + -51, -18, -14, 157, -111, 15, 40, 83, 32, 17, 44, 43, 36, 190, 14, 2, -18, + 131, -22, -30, 44, 29, 15, 35, -22, 41, -1, 69, -66, 47, -4, 6, 8, 28, -2, + 10, -15, 56, 23, 41, -1, 36, 28, 3, 26, 12, -20, 55, 16, 39, 2, 74, 38, 19, + -1, 1, -6, 114, -36, -25, -25, 46, -15, -63, -56, 189, -16, 2, -45, -33, + -38, 6, -38, 102, -75, 2, -19, 58, -41, 13, -30, 55, 12, 73, -22, 137, -101, + 135, -22, 7, -56, -36, -61, -61, 3, -5, -31, 45, -24, -13, -20, 16, -8, 9, + -34, 47, -58, 32, -80, 33, -11, 49, 9, 28, -50, 32, -30, 28, 9, 60, -71, + -15, -13, -48, -2, -27, -14, -11, -23, 74, -46, -5, -19, -22, -9, -2, -90, + 75, -64, -75, -37, -23, -58, 48, 4, 79, -39, 65, -5, 41, 3, 61, 20, 0, -17, + 37, 4, 17, -8, 68, -59, 32, -17, 169, -8, 55, 6, 76, -32, 103, -5, 38, 15, + 81, 61, 123, 52, 71, 57, 91, 11, 49, 38, 93, -4, 47, 48, 96, -21, 79, 28, + 54, -4, 14, -13, 13, -50, 21, -21, 25, -17, 55, -10, 61, 18, 15, 18, -74, + 49, -3, 28, 20, 16, 61, -3, 77, -37, 56, -10, 34, 18, 28, 33, 49, -1, 32, + -12, 8, -19, -52, -84, 3, -30, 32, -12, 49, 13, 8, 49, 43, 56, 45, 7, 46, + -12, 37, -27, 36, -54, 2, -33, -37, -52, 8, -36, -9, -9, 25, -27, 40, -2, + -9, -43, 11, -20, 21, -17, 26, -3, -5, -11, -25, -7, 1, -30, 36, -10, 19, 7, + 24, 31, 41, 0, 23, -30, 60, -21, 15, -29, 30, -9, -16, -20, 21, -17, -30, + -20, 29, -7, -19, -24, 28, -11, -2, -17, 12, -8, -2, -13, -19, -8, 36, -6, + -36, -8, -3, -8, 85, -62, 39, 3, -16, 12, 11, 11, -7, 11, 14, 10, 12, -13, + 23, -5, 42, 6, 6, -16, -14, -170, -77, -98, -1, -17, -40, -22, -33, -7, 1, + -99, -75, -28, -72, -64, -34, -3, -30, -46, -82, -75, 23, -6, 10, -14, 29, + -4, -6, -18, 22, -15, -33, -16, -2, -20, 63, 14, 29, -9, 20, 4, 12, -18, 23, + 5, 6, -18, 43, 6, -14, -30, 15, 1, 15, 16, 15, -4, -15, -41, 16, 15, 43, -9, + -13, 20, 9, 9, 22, -5, 9, -15, 24, -3, 7, -13, -22, -15, 13, -21, -48, -17, + 17, -23, 31, 26, 13, -3, 1, -22, 18, 19, 18, 4, 13, -17, -14, -22, 21, -10, + 59, 33, 14, -13, 38, 16, 23, -1, 5, -39, -38, -16, 32, -2, -15, -29, 5, -14, + 49, -2, -17, -23, 61, 1, -5, 32, 30, -12, 21, 4, -2, 18, 25, 13, 3, -21, 23, + -9, 13, 5, -18, 9, 9, 20, 22, -6, -1, -21, 12, -6, 11, 28, 14, 4, -6, -18, + 24, -18, 12, 8, -11, 12, 26, -7, -13, 14, 24, 2, 9, 9, 19, -14, 41, 0, 75, + 46, 50, 4, 1, 15, 57, 17, -33, 19, -18, 32, 8, 27, -32, 75, 0, 56, -26, 74, + -25, 34, -65, 5, -58, 29, -27, 44, 20, 33, -4, 44, 54, 46, 172, -25, -2, + -14, 16, -10, 41, -22, 13, 8, 99, -53, 19, 15, -3, 25, 151, -16, -6, -31, + 97, -15, -1, -14, 53, -8, 6, 12, 33, -36, 10, -47, 94, -7, 44, -13, 28, -35, + 16, 12, 25, -13, 13, 8, -5, 8, 57, -3, 11, 29, 49, 56, 268, 31, 89, 44, 88, + 8, 35, -8, 54, -30, 71, 10, 16, 45, 48, 54, -68, 8, -8, 28, 32, 29, 41, 12, + 34, 20, 55, 56, 24, 43, -4, 19, -23, 22, -46, 15, -24, 33, 133, 96, -26, 39, + 1, 12, 25, 22, -56, 26, -6, 12, 145, 101, 20, 5, 7, 19, -10, 14, 12, 27, + -62, 26, -26, 24, 9, 10, 125, 52, 36, 36, 72, -18, 7, 18, 76, 36, 36, 115, + 20, 15, 16, 5, 22, -5, 11, 16, 86, 6, 11, 48, 17, 1, 34, -22, 22, 23, 51, + -12, 15, -17, -1, -34, 67, -43, -4, -31, -17, -24, 18, -30, 30, -13, -6, + -20, 67, -2, 28, -19, 98, 1, 29, -10, 178, -16, 64, -14, 62, 25, 44, -25, + 25, 27, 32, 62, 138, 1, 18, -6, 18, -143, 16, -16, 101, -24, 99, -99, -56, + -16, -33, -60, -50, -46, 112, -101, 0, -19, 68, -33, 81, -10, 77, 10, 107, + -27, 44, -47, 83, 16, 31, -12, 1, -21, -41, -12, -46, -101, 45, -29, 18, 1, + 66, -29, 55, 24, 283, -120, 0, -46, 10, -7, 58, 10, 61, -2, 65, -27, 56, -5, + 14, -9, 10, -44, 18, -23, 116, -55, 175, 19, 100, -74, 24, -1, 86, -60, 275, + -28, 127, -53, 61, 5, 31, 28, -17, 20, 4, 33, 72, 69, 39, 60, 57, 42, 20, + 28, 54, 194, 26, 57, 102, -110, 153, -21, 118, -58, 90, 17, 93, -10, 40, 33, + 45, 16, 73, -25, 133, 27, 17, -6, 4, -40, 14, -9, 52, 7, 53, -17, 72, 4, 33, + -5, 37, -22, 30, -81, 137, -41, 56, -42, 39, 4, 45, 24, 134, 10, 161, 46, + 38, 20, 23, -4, 44, -26, 42, 35, 28, 10, 107, -30, 53, 22, 66, 11, 83, 27, + 64, -67, 70, 33, 88, -3, 75, -24, 69, -50, 40, -13, 55, 6, 111, -23, 108, + -10, 37, -72, 36, -18, 42, -53, 54, -22, -17, -40, 47, -18, 14, -14, 12, + -55, -13, -39, 54, -86, 27, -18, 35, -3, 40, -43, 83, -34, 34, 8, 53, 37, + 52, 52, 49, 23, 83, -11, 113, 32, 85, 7, 99, -9, 97, 32, 87, -14, 64, 47, + 23, 37, 1, 48, 15, 20, 69, 6, 104, -8, 6, 31, 15, 12, 74, 13, 21, -4, 19, + -15, -20, -44, 15, -20, 57, 16, 112, 5, 69, 48, 33, 11, 175, -104, 76, -83, + 36, 2, 44, 20, 99, 20, 41, -9, 38, 11, 52, -2, 70, -57, 100, -31, 139, -69, + 36, -6, 14, -15, 0, -45, 16, -34, 76, -86, 4, -18, 37, -25, 178, 12, 79, + -37, 46, 2, 85, 46, 51, -3, 50, -19, 39, 53, 37, -16, 84, -18, 39, -19, 140, + 21, 218, -141, 4, -52, 107, -1, 119, 27, 146, 60, 105, 11, 78, -12, 58, 1, + 95, 36, 97, 16, 59, 58, 46, 7, 73, 51, 34, -1, 57, 31, 30, -22, 1, -17, 64, + -43, 44, 4, 64, -37, 39, 7, 93, 38, 166, 51, 67, 24, -12, 72, 26, 62, 33, + 12, 19, 32, 29, 22, 13, 19, -2, 44, 17, 14, 37, 17, 36, -1, 56, 32, 43, 12, + 47, -6, 25, 9, 41, 37, 59, 35, 77, 27, 114, -64, 168, -21, 79, 13, 149, -67, + 88, -11, 60, -31, 57, -5, 48, 8, 148, 90, 39, -10, 59, 12, 100, -14, 33, + -17, 23, -2, 35, 27, 4, 22, 39, 35, 28, 15, 66, 7, 100, -20, 17, -9, 26, + -56, 85, -41, 68, -89, 79, 17, 36, 24, 140, -4, 21, -115, 143, -174, 101, + -33, 9, -82, 60, -21, 145, -120, -66, -191, 14, -60, 188, -151, 181, -60, 9, + -113, 224, 17, 69, 24, 185, -137, 38, 58, 67, 34, 125, -12, 83, -142, 56, + 14, 146, -26, 38, 12, 192, -28, 4, -24, -50, -53, 9, -17, -21, -48, 146, + -132, 184, -222, 3, -53, 12, -12, 216, -117, -7, -43, 31, -24, -7, -24, 18, + -49, 46, -50, 3, -19, -13, -16, 8, -58, 356, 15, 256, 31, 355, 2, 67, -12, + 27, -27, 33, -5, 61, 20, 18, 36, 43, 36, 50, -9, 42, -24, 86, 6, 47, -32, + 101, -30, 18, 17, 13, 47, 103, 116, 10, 27, 84, -55, 22, 0, 117, 50, 58, -4, + 70, 40, 80, 1, 57, 37, 100, 15, 33, 23, -10, 48, 17, 76, 76, 51, 97, 82, 68, + 32, 73, 65, 44, -27, 70, 1, 54, 12, 53, 57, -5, 78, -39, 44, 109, 53, -7, + 17, 30, 19, 4, 82, 41, 32, 45, 7, 34, 30, 39, 12, 23, 35, 38, 23, -20, 67, + 49, 64, 63, 32, 101, 29, 12, 106, 23, 33, -23, 47, 1, 21, 64, 124, 28, 18, + 68, 22, 36, -7, 19, -16, 81, 14, 42, -25, 33, 2, 45, 16, 56, -19, 413, 219, + 155, -8, 141, 6, 61, 17, 1484, 164, 380, 28, 13, 9, 82, -5, 496, 30, 334, + 102, 161, 8, 17, 6, 70, -5, 24, -15, 38, 12, 57, -4, 106, -29, 127, -18, 29, + 20, 33, -20, 17, 13, 49, 3, 102, -41, 94, -5, 26, 34, 44, -6, 85, 19, 49, + -42, 365, -22, 260, 79, 174, 67, 80, 98, 114, 34, 126, 19, 108, 13, 188, + -72, 120, -20, 157, 35, 73, 128, 256, -17, 40, -28, 76, -21, 274, 49, 814, + 123, 393, -97, 175, 38, 88, 55, 40, -5, 65, -30, 65, 18, 260, 45, 148, 42, + 97, 74, 49, 56, 90, 213, 43, 219, 18, 17, 179, 89, 363, 158, 46, 44, 27, 47, + 22, 88, 47, 56, 20, 62, 4, 62, 26, 53, 107, 152, 188, 149, 116, 42, 39, 36, + 29, 53, -11, 49, -59, 96, 10, 39, 36, 20, 5, 17, 113, -18, 49, 15, 1, 29, + -26, 29, 11, 18, 47, -7, 14, -12, 6, -29, 28, -16, 23, 3, 85, 49, 35, 76, + 29, 9, 33, -3, 79, -32, -7, -23, -61, -17, -5, -11, 15, -19, 25, -8, 45, -4, + 61, 29, 53, -12, 42, 5, 152, 85, 33, 35, 22, -3, 23, -22, 47, -20, 26, -32, + 34, 2, 30, 22, 2, 14, -15, 14, -48, 9, -9, 9, 29, 16, 3, 24, 115, -7, 104, + 29, 24, 11, 11, 17, -6, 15, -33, 22, 4, 10, 109, 22, 31, 33, 64, -42, 28, + -8, 26, 0, 55, 33, 47, 3, 57, -8, 99, -30, 27, -28, 24, -7, 44, 6, 40, 20, + 26, -2, 21, -39, 14, 0, 91, 49, 59, 0, 64, 20, 1, 20, -34, 49, 27, 20, 36, + -2, 32, -21, 48, -16, 108, -20, 1, 56, -36, 52, 5, 23, 21, 18, 3, 42, 57, + 47, 52, 2, 37, 33, 29, -7, 28, -40, 56, -40, 147, 25, 64, -16, 10, 4, -28, + 67, -32, 38, 9, 11, 35, 5, 73, -50, 28, 5, 21, 46, 0, 84, -139, 146, 251, + 156, 83, 83, -8, 4, 13, 23, -19, 40, 275, 123, 1, 77, 200, 24, -51, 177, + -107, 140, 113, 45, 117, -138, 55, -38, 211, -201, 105, -123, 176, 93, 91, + -203, 165, -52, 113, -61, 188, 27, 38, 13, 90, 86, -27, 78, 77, 19, 0, 11, + -31, 9, -41, 45, -5, 56, 35, 7, -3, 110, -19, 71, -163, 73, -106, -59, -19, + -24, -26, 2, -185, 85, -98, 16, -43, 70, -74, 49, -136, 139, -307, 259, 71, + 37, 91, 27, 289, -124, 72, 54, 31, 36, 36, -11, 68, 112, 131, 9, 314, -24, + 318, -37, 217, 62, 93, -38, 89, -76, 108, -112, -32, -20, -42, -113, 371, + -202, 19, 10, -26, 73, 642, -121, 53, 54, 18, -9, -11, 134, 9, 18, 18, 7, + 109, 22, 57, -6, 33, 13, 34, -1, 187, -84, 9, 21, 7, 29, -28, 248, -46, 66, + -75, 43, -63, 30, -31, -4, -85, 31, -11, -3, -14, 26, 22, 21, 155, 20, 42, + -29, 32, -7, 141, 19, 109, -41, 19, 7, 14, 73, 39, 15, -2, 16, 156, 23, 23, + -215, 197, -166, 167, -174, 155, -47, 175, 37, 36, 24, 10, -17, 68, 19, 243, + 141, 80, -59, 74, 7, 53, 14, 226, -183, 54, 20, 89, 6, 138, 49, 227, -88, + 107, 378, 170, 134, -26, 58, 41, 50, 41, 101, 176, -26, 13, 13, -33, 17, 39, + 40, 126, -74, 17, -1, -10, 32, -22, 12, -5, 18, 35, 24, 117, -71, 82, -3, + 22, -84, -10, -82, 26, -84, 38, -65, 42, -12, 252, 32, 55, 14, 94, -30, 164, + -14, 56, 5, 152, 241, 30, 135, 82, 51, 181, 60, 121, -31, 184, 79, 17, 2, + 28, -17, -4, 28, 40, 34, 223, 98, 86, 54, 81, 25, 120, 53, 80, 10, 94, -7, + 36, -18, 27, -38, -107, -96, 13, -27, -30, -47, -34, -30, 23, -43, 38, -20, + 152, -48, 216, -90, 116, -37, 321, -19, 65, 8, 167, 65, 241, 66, 194, 37, + 61, 5, 79, -13, 168, -64, 131, -37, 112, -82, 118, -60, 64, -57, 46, -72, + 61, -23, 100, -18, 91, -6, 98, -27, 182, -75, 75, -126, -27, -42, 6, -87, + 32, -61, 60, -59, 60, -54, 102, -69, 56, -24, 60, -42, 236, -109, 149, -92, + 98, -31, 73, -6, 433, 20, 270, -4, 171, -28, 116, -39, 100, -70, 50, -50, + 79, -136, -49, -216, -75, -160, -13, -55, -4, -45, 12, -46, 19, -38, 40, + -25, 163, -43, 34, 3, 227, 99, 77, 49, 25, 26, 17, 105, 38, 35, 30, 4, 107, + -13, 195, -47, 47, -17, 44, -41, -2, -23, -34, -49, -12, -40, 2, -69, 39, + -169, 23, -32, 43, -30, 103, -45, 53, -7, 110, 14, 124, 40, 435, 187, 97, + 22, 182, 102, 57, 2, 74, -66, 37, -17, 34, 1, 61, 34, 9, 28, 127, 70, 50, 9, + 81, -24, 32, -21, 75, -108, 29, -148, -15, -100, -65, -132, -1, -22, -68, + -81, -79, -58, -322, -128, -73, -46, -17, -24, 15, -88, -22, -56, 16, -43, + 73, -40, 84, -27, 174, 10, 120, 28, 47, 22, 77, 106, 29, 43, 9, 48, 37, 65, + 116, 101, 77, 14, 195, 7, 57, -10, 123, -38, 50, -39, 21, -54, 6, -98, 36, + -91, -10, -27, -122, -73, -31, -29, -21, -63, 4, -28, 20, -28, 102, -87, 40, + -140, 81, -77, 39, -64, 39, -36, 97, -48, 177, -37, 88, -46, 94, -2, 16, 15, + -5, 20, -69, 36, -21, 46, 55, 105, -16, 88, 7, 148, 29, 36, -62, 35, 76, 29, + -10, 61, 185, 98, 32, 9, -179, 127, 41, 9, 62, -4, 127, 8, 37, 8, 1, 22, 44, + 25, 9, 30, 41, 30, 38, 15, 18, 41, 43, 16, 20, -7, 60, 11, 5, 9, 67, 29, 30, + 0, 30, 10, 8, 18, 60, 44, 11, -11, -19, -17, 13, -18, -19, -9, 2, -24, -23, + 11, -32, -15, 88, -75, 125, 67, 195, -162, 32, 25, 66, -17, 92, -38, 43, -2, + 5, 9, 138, -29, -15, -86, 346, 35, -45, -40, 27, -82, 75, -100, 143, 30, 12, + 11, 32, 9, 38, -1, 115, -34, 139, 72, 189, 29, 173, -114, 228, 15, 1, 33, + 50, 4, 67, 45, 74, -102, 196, 45, 38, -49, 38, 16, 62, -101, 80, -72, 22, + -53, -76, -191, 9, -51, -109, -2, -65, -57, 34, -15, 58, 37, 71, -45, -59, + -48, 82, -48, -49, -132, -77, -128, 165, -79, 9, -72, 35, 12, 39, 1, 46, + -20, -10, -11, 153, -77, 42, 30, 30, 7, 88, -20, 28, 6, -9, -24, -61, -6, + -48, 22, -59, -37, 31, -20, -23, -23, -73, -30, 48, -54, 433, -353, 261, + 130, 97, 61, 167, -78, 162, -101, 58, 9, 14, -41, 108, -18, 126, -101, 75, + -36, 42, 53, 154, -52, 216, 12, 59, 19, 71, 9, -22, 127, -33, 60, -27, 21, + 7, 16, -13, 12, 15, 11, 27, 2, 52, -35, 36, 2, 36, -53, 7, -32, 28, 3, -16, + 132, -40, -2, -48, 36, -24, -12, -12, 18, 57, 40, 21, -11, 10, 29, 31, 1, + 17, -10, 43, 7, 2, -12, 68, 24, 220, -17, -12, 41, -29, 18, -29, 1, 9, 53, + 504, 1, 52, -296, 30, 7, 18, -4, 73, -444, 80, -357, 126, 14, -1, 26, 20, + 17, 11, 60, 47, -20, 26, -30, -12, -20, -53, -6, -12, -25, 558, 43, 86, -8, + 11, -11, 0, -10, -35, -9, -9, -33, -30, -35, -49, -9, -19, -36, -240, -79, + -71, -48, -6, -36, -59, -12, -22, -13, -61, -80, -54, -50, -10, -28, -43, + -7, -32, -31, -89, -170, 1, -13, 208, -201, 134, -10, 144, 135, 290, -72, 3, + -16, -278, -508, -334, -126, -108, -196, 183, -215, -23, -10, 107, -128, + -33, -13, -83, -5, 1, -239, -98, -1, -4, -157, 246, 3, -108, -76, -40, -80, + -53, -12, -43, 28, 81, -163, -170, -99, 28, -44, -41, -42, -73, -19, -14, + -29, -47, -32, 27, -22, -1, -21, -37, -33, 8, -40, -10, -22, -137, -44, -43, + 36, -498, -167, 346, -479, 335, 122, 47, -48, -48, -27, 6, -61, -31, -93, 4, + -27, 78, -34, 17, -24, -22, -37, -32, -9, -33, -37, 0, -12, 39, -27, -299, + -160, 46, -65, 234, 127, 50, 17, -17, -40, 41, -36, -108, -104, 61, -102, + -49, -29, 20, -28, -2, -20, 58, -61, 513, 369, 15, -467, 75, 8, 23, 24, -2, + 41, 44, 35, 16, -1, 25, 13, 107, 74, 45, 16, 121, 6, 16, 31, 137, 28, 42, + 39, 88, 25, 201, -786, 240, 28, -3, 22, 294, 42, 73, -262, -143, -85, 147, + -446, 253, 43, 33, -127, 192, 25, -11, 52, 406, 61, -15, 151, 18, 13, -23, + 41, -6, 60, 82, 5, 21, 48, -50, 11, -11, 10, 34, 82, -20, 17, -23, 3, 13, + 59, 61, 0, 39, 26, 199, 61, 566, 58, 75, -288, 170, -842, -466, -60, 278, + -1156, -744, -74, -101, 35, -7, -4, 46, -223, 163, 34, 144, -307, 2, -65, + 195, 5, -21, 178, 643, 65, 16, -41, 152, -514, -262, -31, -67, -780, -384, + -55, 143, -176, 680, 147, 82, -358, 526, 55, 3, -10, 91, 8, -10, 36, 92, 38, + 31, -138, 98, 10, 184, -583, 997, 144, -23, 66, 373, 41, 80, -325, -85, -20, + 18, -38, 206, -274, 224, 77, -29, 67, 266, 77, 21, -30, 46, -20, 26, -27, + 72, -30, 36, -79, 73, -41, -8, -35, -158, 20, -277, -125, 75, -80, -8, -17, + 157, -139, 62, -72, 23, -36, 1, -157, 79, -18, -4, -15, 10, -3, -67, -217, + 3, -102, -21, -109, 53, -230, -53, -46, -69, -42, -11, -33, 1, -77, -29, + -72, -38, -24, -17, -33, -32, -28, -8, -23, 9, -43, 76, -97, -10, -133, 65, + -148, -23, -123, -34, -43, -74, -25, -12, -20, -56, 17, -20, -40, -25, -13, + -3, -16, 37, -117, 53, -7, 35, 15, 11, -8, -24, -29, -40, 0, -5, -41, -19, + -19, 18, -5, 57, 22, 12, -3, -33, -28, -4, -25, -24, -9, -507, -66, -60, 0, + -19, -12, -16, 10, -65, -12, 29, -45, 16, -3, 48, 49, 19, 8, 13, -5, 52, + -75, 89, -71, -6, -9, -65, -12, 5, -31, 22, -14, 154, -43, -22, -27, -61, + -20, -2, -12, 13, -14, 51, -16, 165, 2, 0, -24, -47, -57, 7, -13, 97, -27, + 86, 15, 16, -5, -7, -18, -80, -41, 21, -21, 72, 7, 74, 31, 47, 44, 15, 74, + 35, 6, 35, -23, -3, -27, -69, -71, -49, -75, 22, -6, 57, 8, 60, 38, 41, 72, + 36, 1, 72, -29, 11, 4, 7, 97, 14, 16, 19, -3, 56, -92, -9, -20, -40, -32, 2, + -19, 39, 0, 78, 28, 17, -9, -21, -23, -76, -50, -15, -21, 0, -53, 19, -32, + 23, -16, 3, -20, 35, -27, 33, -10, 160, 12, 41, -7, 20, -23, -4, -21, -17, + -14, -244, -7, -25, -7, -2, -19, 88, -63, 53, -22, 93, 17, 76, -15, 14, -18, + -44, -72, 3, -26, 109, -2, 122, 18, 188, -9, 8, -12, -14, -15, -85, -51, 18, + -77, 20, -10, 119, -4, 75, -20, 13, 10, -4, 81, 32, 19, 25, -1, 28, -15, 46, + -76, 30, 0, 21, 10, 9, 23, -11, 31, -75, 65, 7, 11, 26, 6, 26, -2, 49, -36, + 48, -88, 19, 0, 29, 36, 22, 2, 62, -23, 58, -35, 108, -83, 3, -16, -53, -17, + -3, -9, 13, -14, 31, -8, 54, 6, 63, 28, 22, 26, 11, 94, 12, 20, 53, 9, 33, + -11, 15, -15, 7, -113, 22, -21, -169, -77, -396, -142, -104, -162, -55, 79, + -211, 140, -119, 15, -362, -156, -249, 67, -90, -54, -458, 142, -36, 20, + -520, -290, 46, -194, -861, 334, 178, 103, -254, 206, -36, -22, -71, 57, 30, + 19, -135, 329, -86, -17, -185, 174, -63, 20, -5, 10, -157, -66, 155, -232, + -112, -35, 35, -51, -487, -89, -4, 59, -40, -7, -27, 103, -185, -58, 164, + -634, 10, -112, -745, -74, -28, 91, -199, -21, -142, 557, -593, 66, -91, 62, + -34, -13, -56, 94, -41, 100, 171, 143, -533, 369, -617, -520, 124, -205, + -31, -9, -196, 329, 23, 15, -274, 447, -198, -83, 63, -138, -50, -34, 39, + -48, -96, -37, -13, 6, -181, -60, 30, -24, 12, -51, 66, -97, -123, -33, 180, + -295, -211, -64, -66, 87, -326, -110, 43, -42, -56, -27, 51, -54, -270, + -178, -330, 86, -12, 19, 344, 263, 56, 33, -27, 40, -85, 44, -76, 74, 39, + 18, -20, 20, -3, 226, -100, 54, 357, 217, -58, 146, -86, -10, -50, 593, + -739, -139, 21, -292, -1009, -28, 3, -56, -246, -7, 4, -125, -58, -5, 2, + -25, -522, -17, -35, -77, 91, -47, 4, 25, 362, 9, 20, -177, -238, -21, -710, + -27, 67, -63, 42, -203, 252, -551, 202, -329, 16, 8, 1, 18, 19, 2, 63, -180, + 7, -72, 59, -29, 1, -10, -65, -9, -118, 27, -319, -532, -11, -57, 1152, + -639, -1168, -1019, 548, -443, -264, -459, -2026, -328, 32, -102, -230, -33, + 69, -226, -17, -53, -73, 0, 2, -18, 313, -950, -892, -146, 125, -380, -849, + -137, -327, 59, -62, 6, -49, -20, 0, -20, 37, -38, 64, -102, 37, -139, 3, + -45, -60, -70, -247, -32, -32, 54, -48, 50, -174, -21, -44, -14, -34, 6, + -72, 39, -33, -2, -56, -110, -41, -153, -68, -122, -139, -108, -57, -30, + -24, -30, -12, -38, 10, -58, 44, -119, 297, 48, 284, -855, -530, -87, 250, + -712, -829, -137, -119, -6, 2, 275, -1166, 339, -472, -296, -138, -143, + -323, -364, 101, -5, 387, -99, 73, -29, 129, -93, 60, -107, 2, -75, -28, + -136, 12, -127, 64, -92, 106, -114, 126, -177, 50, -97, 116, -154, 43, -55, + 62, -57, 70, -46, 208, -62, 158, -60, 608, -188, 75, -12, 129, -37, 289, + -94, 158, -65, 202, -59, 155, -73, 140, -53, 175, -29, 532, -28, 106, -14, + 268, -63, 272, -106, 94, -22, 80, -3, 161, 38, 143, 58, 55, 13, 134, 19, + 100, 1, 80, 11, 73, 21, 41, 38, -10, 105, -63, 49, -51, 16, -65, -2, -75, + 23, -26, 16, -93, 115, -24, 56, 1, 28, 26, 38, 48, 24, 55, 10, 182, -5, 103, + 23, 99, 100, 21, 36, -1, 48, -55, 35, -175, 59, -18, 21, 11, 43, 146, 74, + 94, 18, 109, -2, 64, -24, 120, -105, 110, -61, 37, -12, 43, -3, 135, 16, 68, + -13, 140, -108, 265, -179, 92, -41, 77, -23, 103, -5, 54, 17, 49, 41, 23, + 51, 2, 54, -60, 112, 4, 32, 81, 119, 87, 79, 40, 12, 116, -2, 72, 13, 77, + 34, 121, 78, 216, 80, 117, 73, 23, 7, 77, -11, 62, 5, 74, 39, 80, 27, 93, + 68, 61, 12, 157, -1, 56, 18, 107, 58, 189, 34, 277, 89, 116, 57, 83, 118, + 160, 50, 72, 7, 139, 52, 138, 100, 65, 10, 64, -2, 70, -9, 92, -39, 57, -14, + 117, -8, 60, 10, 83, 29, 45, 27, 141, 124, 59, 26, 32, 3, 47, -2, 45, -13, + 120, -79, 156, -28, 184, 33, 95, -34, 29, -2, 72, 12, 133, 44, 91, 13, 58, + -2, 41, -24, 66, -64, 216, -55, 120, -51, 65, -16, 173, -19, 472, -98, 221, + -89, 248, -125, 139, -114, 255, -237, 196, -138, 58, -66, 84, -184, 45, -71, + 12, -144, 51, -126, 27, -123, -80, -34, -71, -51, -38, -96, 25, -112, 63, + -101, 30, -90, 63, -69, 60, -33, 53, -60, 8, -26, -16, -46, 23, -47, 2615, + -220, 51, -96, 147, -205, 237, -252, 267, -169, 375, -171, 337, -118, 355, + -85, 368, -52, 375, -17, 374, 17, 369, 52, 355, 85, 337, 118, 312, 147, 282, + 174, 248, 200, 208, 220, 166, 237, 121, 251, 73, 259, 25, 264, -25, 263, + -73, 259, -121, 250, -166, 237, -208, 219, -248, 198, -282, 174, -312, 147, + -337, 116, -355, 85, -369, 51, -374, 17, -375, -17, -368, -51, -355, -85, + -205, -71, 17, 272, -11, 140, -25, 138, -48, 161, 302, 157, 286, 196, 341, + 111, 172, 72, 319, 166, 285, 194, 245, 222, 256, 117, 155, 87, 285, 194, + 136, 116, 296, 115, 164, 79, 303, 180, 137, 101, 244, 220, 106, 117, 151, + 214, 194, 119, 137, 101, 244, 219, 199, 240, 103, 167, 176, 110, 138, 100, + 244, 219, 198, 240, 82, 126, 115, 241, 344, 111, 172, 70, 164, 79, 302, 179, + 138, 100, 244, 219, 105, 117, 132, 181, 224, 103, 156, 86, 147, 94, 265, + 206, 222, 229, 175, 247, 68, 130, 99, 267, 29, 136, 16, 173, 217, 195, 105, + 117, 175, 247, 124, 261, 72, 270, 17, 186, 86, 167, 293, 49, 371, 98, 178, + 62, 275, 119, 211, -33, 197, -20, 374, -12, -37, -160, -18, -275, 36, -273, + 42, -144, -10, -204, 11, -138, 25, -136, 90, -268, 142, -258, 192, -241, + 113, -114, 258, -209, 219, -139, 220, -204, 278, -197, 153, -88, 330, -154, + 358, -121, 187, -48, 191, -39, 215, -32, 234, -90, 369, -104, 386, -69, 197, + -20, 248, -11, 260, -71, 386, -68, 197, -20, 391, -13, 226, -60, 192, -38, + 194, -30, 277, -25, 182, -68, 369, -104, 191, -39, 318, -42, 254, -99, 369, + -104, 386, -69, 197, -20, 239, -11, 219, -71, 378, -86, 194, -30, 255, -23, + 273, -108, 368, -104, 386, -69, 272, -24, 315, -106, 378, -87, 391, -50, + 199, -11, 221, 0, 321, -90, 191, -39, 391, -50, 199, -11, 245, 1, 324, -91, + 191, -39, 194, -29, 392, -32, -183, -99, -147, -94, -138, -100, -233, -209, + -196, -68, -171, -71, -320, -165, -284, -194, -164, -143, -206, -59, -349, + -134, -320, -165, -237, -160, -236, -42, -188, -45, -361, -117, -172, -71, + -319, -166, -147, -94, -265, -208, -117, -113, -199, -241, -132, -224, -133, + -127, -105, -118, -175, -251, -124, -265, -72, -275, -16, -139, 9, -280, 64, + -276, 82, -198, 29, -192, 90, -272, 142, -262, 90, -125, 215, -236, 258, + -214, 297, -186, 330, -157, 358, -123, 187, -49, 386, -69, 323, -28, 286, + -114, 182, -57, 378, -88, 391, -51, 199, -11, 398, 6, 198, 18, 195, 27, 381, + 82, 318, 103, 356, 96, 155, 55, 114, -167, 102, -121, 237, -226, 134, -104, + 297, -187, 253, -122, 200, -163, 144, -97, 315, -172, 345, -141, 181, -57, + 243, -60, 286, -135, 394, -143, 182, -58, 378, -88, 195, -30, 395, -32, 242, + 0, -98, -130, -150, -261, -56, -135, -57, -205, -80, -182, -42, -138, -45, + -280, -2, -141, 11, -142, 30, -158, -208, -85, -320, -168, -284, -198, -128, + -109, -222, -235, -93, -125, -150, -262, -56, -136, -72, -279, -16, -141, + -2, -142, 36, -283, 90, -276, 65, -134, 167, -258, 102, -122, 237, -228, + 134, -105, 144, -99, 200, -115, 97, -139, 215, -240, 258, -216, 144, -99, + 314, -174, 345, -142, 369, -108, 374, -68, 262, -191, 153, -91, 330, -159, + 176, -67, 182, -58, 346, -83, 169, -75, 358, -126, 596, -147, 385, -70, 396, + -32, 398, 6, 393, 46, 220, 43, 353, -63, 197, -21, 397, -13, 397, 26, 388, + 65, 371, 101, 257, 98, 292, -6, 199, 8, 198, 18, 387, 64, 372, 102, 178, 64, + 335, 154, 303, 185, 138, 103, 222, 204, 284, 96, 335, 154, 156, 89, 253, + 175, 197, 70, 171, 73, 164, 80, 303, 185, 265, 212, 222, 236, 175, 256, 68, + 133, 77, 203, 229, 212, 105, 121, 106, 145, 182, 76, 164, 80, 156, 89, 195, + 131, 218, 40, 188, 46, 321, 105, 240, -16, 398, 7, 197, 18, 372, 61, 342, + -119, 378, -89, 195, -30, 395, -32, 200, -2, 396, 26, 195, 28, 381, 82, 184, + 55, 349, 137, 280, 146, 265, 106, 320, 169, 147, 95, 265, 211, 222, 235, + 170, 246, 140, 70, 319, 38, 192, 37, 372, 101, 178, 63, 335, 153, 156, 88, + 284, 197, 244, 223, 126, 148, 221, -136, 161, -82, 345, -141, 182, -58, 378, + -88, 194, -31, 396, -32, 398, 7, 393, 45, 251, 51, 303, -7, 198, 8, 393, 45, + 381, 82, 184, 55, 328, 127, 345, -79, 391, -51, 199, -12, 398, 7, 197, 18, + 388, 63, 372, 101, 312, 120, 326, 7, 198, 18, 195, 27, 381, 83, 184, 55, + 349, 135, 320, 168, 284, 197, 127, 109, 103, 105, -114, 278, -303, 822, + -251, 833, -91, 412, -33, 240, 237, 224, 317, 226, 83, 83, 33, 311, 55, 258, + 605, 505, 181, 967, -132, 1700, -852, 86, -2445, 363, -2033, -238, -319, + -93, -311, -128, -268, -2, -172, -12, -170, -20, -296, -55, -283, 114, -158, + 49, -327, 77, -168, 28, -171, 19, -344, 15, -344, -16, -171, -20, -333, -63, + -162, -42, -309, -108, -287, -134, -259, -160, -227, -182, -100, -98, -172, + -210, -132, -223, -50, -116, -56, -193, -17, -9, -151, 154, -110, 94, -244, + 170, -275, 147, -146, 64, -225, 79, -42, 128, -111, 228, -154, 217, -91, + 102, -101, 98, -228, 181, -260, 159, -237, 112, -75, 187, -61, 113, -71, + 109, -173, 209, -101, 98, -228, 180, -127, 83, -203, 110, -71, 257, -111, + 228, -154, 215, -91, 102, -168, 154, -78, 196, -61, 112, -90, 134, 243, 14, + 171, 20, 168, 27, 327, 78, 157, 50, 298, 120, 141, 70, 259, 159, 227, 180, + 191, 200, 152, 215, 68, 128, 201, 142, 210, 190, 91, 102, 152, 215, 114, + 240, 139, 101, 210, 190, 172, 207, 106, 174, 242, 190, 100, 97, 170, 204, + 218, 65, 152, 57, 287, 133, 133, 76, 244, 169, 210, 189, 141, 166, 315, 91, + 152, 57, 287, 132, 259, 158, 227, 179, 124, -161, 106, -112, 244, -207, 137, + -94, 300, -168, 162, -73, 343, -123, 365, -89, 188, -31, 383, -36, 386, 0, + 192, 14, 379, 54, 184, 41, 354, 106, 330, 139, 300, 169, 137, 94, 243, 207, + 106, 112, 178, 237, 214, 82, 287, 132, 259, 157, 227, 179, 100, 97, 172, + 206, 71, 109, 73, 141, 234, 30, 235, 45, 165, -146, 119, -87, 126, -81, 274, + -144, 300, -118, 157, -49, 327, -76, 339, -46, 344, -15, 345, 16, 338, 46, + 327, 77, 309, 106, 287, 132, 133, 76, 244, 168, 210, 189, 172, 206, 71, 108, + 110, 225, 39, 115, 28, 118, 21, 212, 144, 71, 259, 157, 227, 178, 107, 104, + 355, 81, 277, 89, 76, 35, 272, -91, 327, -75, 339, -46, 172, -12, 173, -3, + 344, 16, 339, 46, 326, 77, 158, 49, 239, 93, 1825, 1141, 5208, 311, 1972, + 1198, -1083, 1385, -13, 33, 6, 46, 27, 26, 44, 98, -3, 590, -3, 43, -16, 9, + 32, 45, 38, 17, 8, 23, -9, 16, 19, 39, -25, 9, 0, 30, -16, 4, -1, 13, -21, + 9, -1, 11, -25, 4, -10, 32, 17, 19, 19, 0, 21, 17, 38, -1, 40, 15, 42, -6, + 37, 5, 60, 44, 279, 2, -2, 183, 365, 2, 71, 66, 0, 10, -25, 14, 2, 10, 42, + 22, -27, 67, -43, 12, 6, 27, -35, 6, -8, 11, 26, 30, 27, 9, 16, 26, -4, 12, + 38, 35, 4, 39, -65, 12, 0, 17, 76, 32, 14, 36, -1151, -9, 2, 217, 59, 31, + -15, 20, -30, 9, 32, 34, 47, 21, 15, 57, 74, 18, 69, 46, -6, 15, 9, 20, 28, + 26, -3, 20, -19, 13, 3, 16, -24, 20, 123, 95, 292, 2, 0, 180, 141, 1, 18, + 13, -4, 822, -65, -3, -2, 12, -69, 2, -24, 74, 25, 53, 35, 17, -20, 85, 25, + 12, 13, 20, -4, 24, -18, 12, 18, 23, -81, 3, -4, 6, -2, 353, 204, 5, 22, 17, + -12, 18, 19, 7, 33, 46, -5, 225, 399, 1, -2, 404, 1063, 5, -3, 269, 1599, 6, + 97, 89, 36, -1, -4, 160, 367, -1, -3, 232, -10, 46, 16, 8, -5, 8, 616, 1, + 75, 8, 12, -15, -6, -35, -47, -21, -9, -13, 42, -38, 36, 3, 20, -26, -4, + -13, -37, -12, -3, -19, 13, -6, 29, 10, 10, -9, 27, 5, 17, 16, 3, 29, 17, + 12, 39, -19, 27, 20, 16, -12, 175, 2, 0, 282, -11, 7, -7, 31, 22, 3, 1, 15, + -24, 7, -5, 11, 22, 22, 10, 56, 17, 0, 13, 19, -6, 28, 17, 21, 23, 15, 92, + 3, 1, 311, 42, 1, -8, 36, 40, 14, -5, 28, -31, 1, -12, 13, -27, 4, 15, 36, + 26, 25, -66, 2, -26, 34, 1, 37, 65, 47, 2, 30, -16, 48, -2, 117, 399, 0, 0, + 74, 27, 13, 9, 21, -20, 17, -15, -5, -4, 40, -32, 35, 12, 33, -18, 15, 15, + 55, 13, 25, 23, 1, 0, 16, 63, -2, 23, 17, 42, 0, 4, 139, 213, 3, 133, 15, + 21, 56, -19, 30, 12, 3, 89, -21, 27, 4, 12, -13, 20, 14, 45, -1, 9, -14, 43, + -5, 56, 5, 33, 27, 22, -13, 128, -12, 140, 2, 142, -39, 300, 5, 975, -4, 0, + -179, 264, 1, 0, -90, 401, -3, 3, 91, 124, 1, 68, -47, 28, -7, 40, 44, 51, + 31, 555, -1, -29, 38, 3, 25, 29, 10, 1, 37, 48, 25, 1097, -2, -30, 45, 70, + 13, 319, -15, 259, -415, 88, 35, 44, 37, 110, 22, 20, 11, -62, 42, 68, 104, + 60, 7, 9, -17, -14, -12, 31, -7, 56, 33, 42, 4, 39, 31, 25, 8, 73, -5, 57, + 17, 431, -350, 141, 71, 75, -77, 43, 28, 40, 8, 98, 41, 49, 36, 562, 0, 9, + 15, 31, 12, 101, -6, 93, -21, 71, -34, 863, -50, 182, 188, 65, -5, 2, 244, + 21, 6, 112, -20, 44, 6, 99, -10, 26, 4, 13, 11, -13, 490, 75, 71, -5, 50, + 15, 32, 44, 1, -4, 157, 156, 3, -6, 9, 10, 18, 27, 18, -4, 9, -20, -1, 10, + 25, -19, 15, -36, 5, 6, 9, -16, 20, 13, 18, -12, 12, 20, 10, -30, 5, 19, 67, + 36, 22, 16, 43, -8, 19, -45, 10, -16, -6, -12, 22, 2, 35, 34, 11, -22, 5, + -4, 23, 18, 16, -50, 26, 37, 40, -21, 1, -13, 13, -34, 2, 8, 17, -66, 11, + -12, 20, -24, -7, -10, 15, -82, 37, -33, 60, -16, 9, 4, 18, -31, 18, 0, 12, + 19, 13, -21, 6, 9, 24, -34, 57, -20, 11, -19, 49, 1065, 17, 1, 74, -358, + 101, -137, 79, 349, 122, 53, 298, -37, 76, 60, 23, 127, 117, 83, 35, 50, 62, + 42, 85, 11, 90, -10, 106, 30, 114, 152, 21, 120, -9, 50, 476, 129, 2, 488, + -52, 59, 15, -31, 169, 143, 119, -78, 123, 30, 7, -19, 34, -37, -1, -174, + 305, -2, 133, -25, 31, -33, 3, -43, -17, -41, -1, -64, -28, -12, -12, 1, + -44, -53, -7, -4, -18, -22, -16, -64, -18, -9, 23, -41, -2, 1, -31, -23, + -12, 10, -16, -21, -12, -1, -18, -42, -26, -28, 9, -41, -9, -19, -77, -51, + -12, 4, -11, 37, -15, 4, -12, -60, -18, -31, -36, -5, -23, -47, 1, -55, -20, + -3, 23, -28, -9, -16, 5, 18, 22, -8, 20, 21, 11, 19, 55, -9, 15, -21, 7, + -19, 81, -98, 75, -71, 20, -16, -9, -38, 10, -5, 63, -301, 7, -50, -6, -113, + 15, 9, 102, -13, 87, 43, 5, 2, 34, 162, 16, 175, -4, 396, 75, 0, 55, 135, + 20, 103, -29, 50, 85, -53, 1, 9, 13, -17, 4, 5, 6, 16, -5, 11, 11, -96, 40, + -26, 29, 45, 29, -12, 55, 65, 91, -6, 42, -14, 8, -25, -4, -58, -91, -26, + -21, -52, -3, -24, 6, -37, 29, -18, 60, -21, 22, -83, 35, 12, 21, 96, 19, + 19, 20, 1, 25, -38, 25, -78, 14, -13, 17, 2, 22, 56, 67, 3, 65, -21, 22, + -134, 67, -73, 132, 4, 10, 21, 7, 46, 3, 23, 22, -7, 11, -79, 21, -150, 77, + -159, 60, -60, 33, -64, 74, -6, 35, 22, 103, -8, 140, 14, 13, 40, 5, 40, + -20, 49, 4, 50, 30, 8, 30, -50, 122, -4, 43, -67, 127, 20, 75, -34, 49, -11, + 44, 26, 71, -49, 70, -24, 21, -66, 30, -205, 56, 3, 11, 33, 13, 14, 17, 3, + 19, -26, 19, -103, -26, -60, 4, -22, 7, -55, 50, -80, 30, -41, 4, -140, -11, + -43, -50, -168, -53, -20, 12, 4, 52, -33, 22, -34, -6, -79, -40, -107, 6, + -41, -17, -63, 3, -132, 24, -84, -13, -56, 5, -8, 22, -31, 17, -10, 19, -26, + -1, -18, -25, -25, 14, -6, 27, 26, 18, 20, -1, -5, 18, -28, 6, -50, -27, + -25, 7, -11, 51, 31, 26, -30, 12, -43, -3, -22, 9, -12, 34, 33, 41, 31, 69, + 32, 33, 236, 133, 1, 62, -26, 30, 24, 34, 90, 42, 26, 30, 12, 4, 26, -22, + 18, 4, 5, 19, -17, 51, 17, 51, -11, 8, -66, -13, -19, 28, 19, 18, 84, 34, + 23, 56, 67, 19, 22, 17, -64, 59, 15, 34, -25, 20, 43, 24, -18, 47, -19, 3, + -22, -21, -24, 4, -50, 29, -18, 22, -4, 18, 21, 18, 8, 25, 45, -1, 2, 35, + -49, 17, -3, 14, -28, 19, 19, 10, 13, -14, 17, 0, 9, 41, 56, 36, 50, 7, 11, + -15, -12, -16, 23, -1, 38, 36, -39, 25, 29, 62, -5, 29, 60, 13, 9, 24, 16, + 10, 32, -19, 26, 4, -6, 38, -18, 13, -3, 19, -25, 9, -4, 10, -16, 0, -20, + -17, 6, -26, -42, 16, -11, 25, 22, 19, 43, 8, -19, 16, 6, 25, 50, 6, 12, 28, + -5, 18, 14, 13, 14, 9, 29, -5, 32, 13, -17, 28, 28, 7, 13, 15, 32, 3, 23, + -21, -30, -14, -8, -20, 56, 9, 58, -42, 40, 0, 34, 19, 0, 40, -45, 12, -8, + 24, 26, 24, -2, 20, 13, 16, 26, 11, 28, 33, 70, 16, 46, 63, 19, 82, -56, 73, + 1, 49, 71, 57, -21, 46, 12, 24, 33, -3, 6, -14, 20, -5, 53, 18, 2, 13, -33, + 23, -28, 4, 0, 75, 45, 16, 2, 22, 38, 29, 49, 10, 33, -37, -12, -13, 208, + -75, 20, 27, 568, -130, 615, -250, 127, 2, 146, -30, 1046, 771, -76, 43, + -141, 297, 26, 48, -51, 17, -77, -3, -33, 22, 4, 19, -54, 5, -32, -18, -1, + 21, -15, 7, -28, -12, -31, 4, -19, -20, 22, -11, -39, 0, -19, -22, -39, -4, + 17, -35, -19, -8, -21, -29, -135, -9, -108, -331, -311, 39, -129, 42, -45, + 33, -27, 54, -53, 43, -80, 34, -37, 31, -488, -38, -8, 11, -41, -6, 5, -8, + -194, -12, -71, 124, -30, -19, -29, 21, -29, -4, -27, -20, 24, -57, -35, + -69, -19, -11, -38, -5, 2, 28, -23, 28, -67, 44, 7, 19, 48, 19, 7, 14, -14, + 26, -42, 12, -6, 22, -25, 2, -28, 27, -27, 7, -56, 34, -65, -7, -9, -32, + -29, 0, -11, 5, 8, 28, -11, 10, -107, 3, -57, 53, -25, 38, 49, 19, -3, 45, + 32, 38, -7, 22, -46, 15, -45, 49, 18, 63, 46, 23, -43, 13, -27, -7, -14, 6, + -6, 18, -49, 32, 15, 6, 6, 20, -37, 5, 1, 17, -24, 9, 33, 24, 1, 14, -27, 7, + -26, -14, -17, 8, -2, 23, 37, 15, -21, 14, 2, 26, -65, 7, -28, 17, -6, 41, + 43, 26, -1, 13, -23, 11, -49, -1, -102, -46, -26, -29, -26, -11, -2, -18, + -36, -8, -23, 40, -24, -3, -50, 30, -2, 43, -53, 52, -36, 8, 6, 23, -44, 42, + -48, 17, -32, -18, -28, 2, -10, -24, -138, -78, -21, 7, -125, 111, 70, 23, + 33, 24, 41, 78, -41, 10, -12, 23, 43, 54, 0, 50, 57, 29, -20, 33, 43, 6, -3, + 21, -30, 20, -31, -16, -63, 40, 208, 101, 484, -85, 248, -19, 102, 35, 258, + 153, 28, 10, 60, 5, 61, -24, 629, 63, 299, -69, 279, -10, 43, 151, -40, 115, + 60, 55, 145, 172, 127, 181, 254, 244, 52, 31, 84, 94, 101, 84, 105, 107, + 463, 72, 161, 160, 153, 87, 142, 191, 143, 90, 394, -24, 111, 10, 203, 36, + 132, 79, 63, 106, -417, 207, -24, 178, 23, 47, -17, 8, 35, 44, 52, 234, 22, + 39, 9, 81, -16, 25, -26, 17, -93, 30, -69, 49, -66, 3, -102, 102, 22, -5, + 14, 17, -40, 33, -15, -11, -11, 4, -16, 8, 17, 11, -125, 58, -289, 73, -93, + 71, -268, 171, -197, 267, -111, 85, -182, 85, 126, 450, -43, 180, 16, 41, + 1088, 410, 216, 71, -302, 271, -40, 212, -704, -181, -74, 79, -1055, -98, + -175, -237, -434, -151, -227, 14, -288, 41, -337, -58, -293, 194, -98, 83, + -27, 9, -139, 108, -360, 89, -173, -15, -101, 70, -266, 31, -24, 18, -191, + 178, -250, 562, -225, 276, 7, 15, 185, 139, -453, 31, -203, 23, -108, 18, + -554, 157, -295, 22, -55, 12, -100, -7, -194, -55, -355, -153, -210, 26, + -492, 117, -234, 26, -116, 128, -85, -3, -60, 29, -82, 57, -21, 50, -51, 55, + -59, 118, -101, 86, 28, 36, -23, 50, -15, 238, -172, 198, -74, 104, -66, 33, + -67, 117, -89, 38, -492, -272, -125, -161, -77, -76, -398, -186, -39, -37, + -183, 28, -163, 43, -102, -11, -366, -133, -143, -12, -56, -21, -164, -90, + -54, 35, -48, 13, -52, -13, 0, -18, -30, -9, -40, 40, 16, 23, -7, 12, -41, + -3, -29, 9, -14, 45, -33, 31, 24, 22, -88, 49, -28, 82, 67, 19, 5, 25, -23, + 8, -8, 16, 22, 7, 6, 12, -15, 75, -56, 11, -20, 41, 58, 27, 13, 42, 43, -2, + 25, 27, -19, 13, -55, -8, -27, 10, -15, 27, -39, 29, -17, 36, -48, 13, -38, + -12, -29, 7, -23, -10, -84, 6, -32, -10, -9, -22, -29, -12, -16, 16, -72, + -10, -66, 30, -50, -29, 0, -13, -38, -3, -7, 28, -19, 1, -35, -17, -57, 27, + -52, 72, -68, 29, -284, 50, -46, -50, -98, 39, -124, 138, -240, 13, -110, + 80, -97, 217, -301, 78, -80, 273, 247, 180, 74, 33, -36, 24, -6, 22, -46, + 31, -62, 17, -7, 38, -15, 4, 0, 10, -40, 34, -46, 12, -17, 28, -26, 5, -10, + 17, -21, 4, -48, -19, -22, -74, 24, -203, -5, -44, -50, -14, -74, -4, -199, + -164, -168, 104, -132, 72, -60, 21, -289, 19, -159, -65, -394, -124, -175, + 46, -211, 0, -60, 73, 5, 67, -46, 60, -136, -2, 43, 123, -352, 61, -178, + -196, -189, 99, -469, 152, 33, 121, -26, 168, -182, 237, -1, 79, -33, 180, + -154, 28, -124, -11, -102, -74, -158, -62, -233, -7, -743, 72, 66, -243, + -13, -94, 38, -55, 16, -171, -27, -9, 114, -162, 34, -15, 13, -29, 25, -11, + 1, -41, 14, 1, 73, -83, 29, -13, 4, -44, 49, -40, -97, -28, -165, -70, 0, + -29, 21, -43, -258, -83, -23, 3, -74, -28, 88, -94, -292, -121, -96, -52, + -76, 178, -687, -192, -101, -79, -128, -151, -106, 93, 10, 66, -23, 25, 26, + 10, -28, 62, 228, 71, -183, 176, -178, 133, 10, 7, -39, 29, -60, -38, -181, + 131, -160, 22, -135, 208, -210, 272, -36, 69, -91, 53, -249, 119, -44, 10, + -242, 22, -129, 41, -261, 102, -280, 156, -420, -57, -115, 160, -102, 86, + -68, 43, -189, 206, 26, 58, -85, 7, -108, 81, -44, 15, -64, 41, -115, 47, + -3, 18, -36, 42, -95, 68, -27, 48, -24, 6, -33, 32, -29, -2, -31, 19, 2, 16, + -19, 24, 45, 110, -167, 135, -60, 78, -58, 42, -6, 37, 129, 207, -15, 43, + 17, 175, -10, 307, 116, 95, -25, 86, -55, 24, -213, 27, -143, -3, -87, -21, + -88, -75, -213, 231, -343, 68, -132, 106, -31, 53, -204, 41, -86, 102, -41, + 25, -12, 45, -47, 67, 3, 252, -13, 96, -15, 6, -12, 27, 18, 14, -13, 28, 22, + 82, 128, 3, 68, 35, -2, 102, 65, 126, -141, 19, -38, -38, -42, -20, -252, + -63, -121, -39, -9, -16, -94, -69, -86, -16, -160, -21, -191, 14, -255, 49, + -191, -5, -57, -15, -79, -26, -101, -70, -100, -200, -68, -46, -286, -72, + -63, -30, -16, 14, -342, -132, 12, -12, -78, -32, -166, -50, -169, -68, + -199, -37, -88, -29, -54, 0, -92, 46, -358, -38, 29, -40, -2, -14, -30, -6, + -315, -46, -115, 5, -148, -22, -70, 38, -798, 6, -201, 11, -215, -38, -295, + -88, -31, 2, -6, 10, -9, -15, -33, -3, -50, 12, -34, -8, -4, -17, -176, -83, + -194, -146, -134, -2, -98, -14, -60, -24, -36, -30, -139, -14, -55, -17, + -129, -14, -38, -100, 76, -22, -5, -5, -81, 15, -24, -8, -55, 8, -67, -25, + 28, -54, -23, -24, -44, 4, 3, 15, -29, 0, -21, 11, -111, -57, -91, 0, -139, + -84, -16, -45, -34, -5, -57, 50, -20, 1, -90, -169, -41, -141, -156, -162, + -156, -42, -89, 46, -213, 51, -33, -1, -17, -33, -167, 53, 21, 14, -148, 57, + -75, 51, -71, 29, -91, 62, 88, 67, 88, 12, -26, 17, 51, 21, -63, 122, 196, + 9, -1, 25, -34, 14, -17, 19, 2, 15, -38, 31, -58, 5, -174, -14, -55, -36, + -60, 2, -110, 22, -36, 0, -120, 28, -8, 26, 32, 44, -98, 14, -5, 7, -50, -2, + -89, 33, -55, 48, -12, -7, -58, 34, -16, 50, -144, 19, -335, -19, -65, 196, + -93, -28, -173, -177, -67, -32, -263, -74, 55, -67, -163, -55, -25, -2, -54, + 14, -252, 2, -342, 27, 30, 74, -131, -3, -10, 63, -84, 114, 37, 65, -65, 3, + 45, 69, 58, 55, -283, 198, -129, 25, -91, 58, -26, 81, -96, 60, -104, 89, + -50, 74, -79, 60, -38, 17, -8, 18, -12, -2, -76, 29, -69, -46, -68, 72, + -102, 226, -228, 62, -223, 11, -227, 61, -61, 35, -107, -7, -63, -14, -18, + 45, -85, -2, -151, -17, -50, 7, -57, -24, -74, -2, -72, -19, -74, 9, -20, + -9, -60, 2, -52, -23, -147, -20, -88, -4, -24, -31, -48, -21, -64, 2, -54, + -19, -51, 13, -126, -13, -12, -26, -41, -5, -54, 41, -63, 23, -24, -15, -42, + -2, -28, -10, -28, 10, -4, 17, -23, 10, -71, 8, -74, -9, -20, 7, -174, -162, + -168, 67, -203, -44, -36, 91, -374, -199, 21, -21, 22, -7, 70, 5, 13, -7, 1, + -58, -50, -77, -92, -76, -362, -214, -184, -139, -654, 5, -148, -39, -297, + 33, -21, -24, -248, 153, 105, 73, -144, 85, -35, 99, -98, -3, -6, 374, 35, + 11, -13, 176, -66, 40, -9, 26, -86, 90, -79, -56, 1, -24, 58, 7, 1, 9, 37, + -3, 1, -15, -7, -7, -26, -1, 12, -6, -53, -27, -5, -10, -51, 33, -15, -7, + -13, 11, -7, -10, -12, 12, 11, 8, -22, 24, -23, -6, -6, 6, -11, -12, -115, + 26, 10, 13, -52, 45, -81, 28, -151, 74, -82, 59, -253, 70, -134, 161, -37, + -3, -25, 22, 26, 22, -18, 22, 4, 21, -126, 101, -8, 62, -86, 8, -12, 21, 2, + 36, 41, 47, -2, 38, -80, 114, -1, 28, -29, 36, -68, 42, -308, 137, 33, 20, + 66, 82, 210, 94, 45, 36, -69, 56, 22, 67, -50, 1, -32, -22, -105, 185, -103, + 101, 12, 29, -3, 51, 76, 64, 268, -14, 354, 113, 230, -104, 32, 27, -17, + 121, 90, 81, -157, 27, -52, 1, -33, -11, -81, 38, -46, 48, 41, 49, -25, 27, + 6, 63, -17, 17, 21, 30, 37, 5, 15, 23, 3, 61, 58, 89, -93, 95, 78, 21, -3, + 9, 22, 24, -40, 38, 1, 25, 64, 52, 3, 14, -29, 18, 152, 217, -141, 78, -59, + -15, -60, 1, -80, -12, -50, 10, -165, 112, -182, 48, -36, 36, -10, 52, 92, + 196, -5, 164, -64, 107, -50, 41, -17, 41, -41, 22, -32, -6, -36, 4, -99, 29, + -29, 24, 53, 50, 144, 45, -142, 66, -79, 95, 234, 70, -66, 40, 0, 23, 55, + 15, -16, 42, -81, 49, 3, 49, -50, -9, -142, -1, -68, 22, 58, 68, -73, 2, + -104, -15, -5, 10, -63, -3, 3, 12, -32, -1, -14, 25, -45, -11, -61, 3, -75, + 122, 36, 11, 5, 41, 46, 40, 12, 1, 4, 22, -15, 5, 18, 45, 59, 4, 10, 38, 51, + 43, 18, 1, 20, 14, -31, 33, -64, 113, -17, -1, -98, 48, 3, 11, -21, 27, 93, + 20, -72, 40, -24, 62, -60, 52, 69, -29, 276, -53, 30, 2, 77, 31, 63, 3, 7, + 25, 73, 12, 14, 72, 102, 0, -1, 32, 100, -2, 6, 15, 49, 20, -16, 25, -43, + -1, -8, 26, -27, 0, 3, 22, 32, 17, -1, 36, 63, -1, -19, 41, -31, 6, 14, 40, + -109, 72, -52, -17, -32, 2, -116, 136, -23, -12, -117, 10, -117, -46, -222, + 84, -14, 36, 22, 28, 10, 39, -5, 5, -67, 2, -12, 56, -63, -2, -18, -13, -7, + -45, -12, 8, -8, -23, -58, -14, 10, -23, -9, -19, -30, -7, -2, -16, -29, 3, + 2, -15, -12, -7, -88, -6, -17, -19, -26, 1, -9, -7, -15, 7, -47, -35, -17, + 24, -15, -15, 26, -23, -3, -35, -30, 4, 5, -14, -67, -1, -20, 17, 10, 33, + -45, -17, -36, -26, -1, 28, -15, 5, -20, -6, -46, 29, -227, -96, -8, -22, + -35, -1, -6, -19, -12, 6, -89, -49, -26, 4, -8, 12, -30, 9, -20, 26, -38, + 17, -29, -26, -3, 12, -67, 26, -2, 26, -64, 51, -1, 17, -44, 36, -14, 29, + -45, 20, -14, 22, -58, 31, 5, 31, -28, 31, -46, -28, -10, 12, -36, -13, -24, + 15, -35, 2, 82, 83, 173, -32, 32, 109, -45, 22, -24, 26, -15, -24, -38, 2, + 0, 44, -17, 40, 53, 99, 64, 22, -77, 49, -256, 74, 23, 30, -18, 47, -14, -1, + -48, 38, 15, 8, -24, 31, 46, 10, 10, 21, -149, 87, -21, 43, 107, 58, -33, + 28, -5, 207, 38, 18, 89, 69, -6, 42, -44, 20, 15, 81, 34, 20, 77, 14, -1, + 25, -305, 76, -27, -16, -118, 32, -208, -27, -89, 22, -95, -31, -48, -68, + 17, -44, -11, -22, -77, -50, -66, -22, -80, -40, -19, 134, -59, 20, -55, 0, + -35, 12, -17, 29, -58, 29, -50, 46, -52, 6, -35, -20, -71, -15, -124, 24, + -4, 26, -25, 16, -21, -12, -121, -26, -41, -20, -98, -27, -1, -76, -15, -6, + -49, 5, -56, -24, -7, -65, -38, -27, -106, -18, -129, 2, -34, -8, -34, -19, + 3, -43, -77, -56, -83, 23, -52, 3, -32, -9, -41, -38, -39, 10, -98, 72, + -227, -143, -52, -18, 14, -47, -4, -40, -183, -55, -32, 23, -52, 9, -11, 12, + 13, 22, -11, 12, -18, 1, -54, -21, -162, 100, -54, -70, -67, -31, -130, 27, + -53, 32, -177, -10, -138, 83, -164, -125, -180, 59, -205, 170, -46, 467, + -15, 26, -65, -1, -254, 43, -65, -1, -23, -11, -108, 6, -64, 35, -40, -28, + 17, -53, -28, 3, -185, -133, -394, 189, -175, 3, -103, -107, -247, 21, -50, + 9, -16, 15, -103, 36, -56, 10, -85, 30, -46, 53, -50, 15, -19, 16, -41, -4, + -18, 13, -72, 19, -15, 22, -54, 5, -71, -18, -16, 6, -26, -6, -137, 49, + -171, 29, -528, -341, -241, -17, -232, 113, -106, 24, -288, 114, -134, 21, + 11, 26, 25, 16, -33, 16, 7, 18, 31, 15, 106, 17, 63, -10, 34, 67, -119, 26, + -56, 78, 18, 12, 52, 5, 3, 20, 49, 9, -1, 21, 49, 0, 13, 24, 27, 10, 54, + -35, 25, -2, 62, 158, 97, -36, -16, 71, 12, 20, 67, 23, -76, 124, 67, 43, + 117, -44, 50, 21, 35, -8, 29, -23, -9, -15, 12, -11, 28, -6, 27, 5, -38, 48, + 56, 12, 8, 11, -34, 32, 1, 14, 176, 88, 49, 41, -4, 34, -23, 14, -38, 1, + -10, 14, 49, 15, 17, 13, -10, 10, -59, 27, -44, -3, -136, 45, -57, 10, -14, + 56, -18, 3, -26, -16, 4, -30, -34, -11, -61, -3, -38, 15, -30, -2, 71, 133, + -114, 19, -31, -38, -49, 11, -10, -45, -103, 76, -32, 40, 3, 12, -32, 22, + -48, 12, -15, 25, -36, 8, -87, -29, -90, 3, -15, 98, -58, 78, 64, 43, -48, + 73, 238, 27, -9, 51, 100, 31, -5, 42, -17, 23, -68, 46, -16, 57, 128, 42, + 13, 58, 62, 32, -12, 10, 177, 28, 145, 83, -48, 50, 66, 52, -82, 176, 34, 0, + 38, 20, 70, -3, 46, 27, 0, 52, -45, 29, -76, 15, -11, 92, -100, -1, -5, 23, + -248, -17, -8, 150, -34, -1, -54, 64, 69, 50, -9, 18, -49, 24, -13, 25, -60, + 40, 29, 23, 20, 37, -115, 85, -131, -85, -81, -20, -12, 148, -25, 8, -16, + 105, -287, -36, -26, 276, -124, 213, -145, -30, -81, -2, -28, 13, -117, -8, + 1, 56, -182, 4, -63, 80, -4, 98, -126, -53, -56, 8, -64, 34, 35, 36, 45, 5, + 3, 14, 38, 10, 39, -5, 16, 45, 28, 6, 12, 15, 30, 11, 32, -9, 27, 18, 10, + -5, 21, 14, 8, 34, 66, -5, -1, 14, 40, -13, 20, 49, 110, -3, 36, 18, 42, + -32, 38, 6, 39, 20, 23, -5, 26, -35, 22, 3, 23, 23, 24, -2, 93, 33, 98, 14, + -6, 7, 57, 20, -1, 16, 10, 1, 18, -11, 13, 8, 20, -2, 26, -27, 62, -5, 30, + 49, 51, 42, 75, 0, 51, -30, 27, 3, 55, -34, 171, 56, 110, -11, 22, 7, 9, 15, + 80, -1, 66, 23, 105, -7, 65, 26, 23, 42, 27, 15, 15, 28, 30, 27, 32, 14, 23, + -3, 54, -35, 122, -8, 52, -16, 41, -2, 102, 12, 65, -20, 38, 5, 94, -7, 202, + 40, 25, 12, 96, -28, 5, 21, 25, -1, -7, 6, 13, 7, 38, -5, 0, 20, 33, 2, 27, + 21, 72, -2, 19, 13, -5, 9, 22, 36, 30, 16, 38, -2, 40, 33, 4, 40, 30, 19, + -3, 26, 13, 12, 1, 14, -36, 31, -12, 29, -1, 41, 28, 42, -25, 39, 9, 22, + -53, 26, -2, 16, -27, 15, -7, 34, -31, 4, -56, 39, -98, 48, 3, 32, -19, 25, + -2, 22, 19, 27, -11, 10, 20, 11, 9, 28, 29, 29, -2, 18, 28, 54, -2, 55, -48, + 38, -2, 12, 32, 15, 8, 54, -36, 14, -38, 39, -49, 17, -34, 28, -86, -1, -35, + 31, -1, 17, -30, 38, -61, 0, -61, 22, -39, -2, -13, 41, 19, 18, -54, 43, 9, + 18, -13, 11, -165, 50, -159, 64, -71, 49, 4, 13, -47, 22, -23, 29, -50, 19, + -64, 6, -28, 33, -282, 3, -118, -91, -173, 32, 83, 55, 42, 15, -43, 48, 10, + 119, 16, 27, 51, 13, -114, 69, -175, -33, -135, 75, 67, 78, -111, 76, -113, + 52, 1, 21, 19, 10, -75, 63, -64, 88, -19, 97, -122, 16, -345, -25, -9, 43, + -49, 3, -78, 120, 57, 43, 47, 108, -73, 39, -31, 6, -29, 22, -2, -6, -30, + -1, -33, 6, -30, -12, -46, 11, -75, -25, -61, -1, -18, 7, 4, 38, -31, 29, + -26, 195, 22, -16, 19, 28, -25, 39, 80, 52, -23, 9, -23, 38, -22, 10, -16, + 60, -53, 12, -46, 40, -21, 130, -179, -13, -2, 37, 222, 119, 91, 2, 16, 60, + 41, 21, -115, 85, 3, 41, -13, 29, 37, 21, 66, 13, 13, -5, 36, 47, 84, 64, + -2, 43, 26, 113, 31, 27, -271, 137, -132, -1, -93, 75, -40, 15, -34, 29, + -53, 58, 111, 107, -37, 18, -82, 124, -4, 41, -50, -6, -92, 14, -148, -8, + -75, 5, -153, 70, -22, 21, 37, 129, -17, 31, -189, 71, -76, -36, -51, 32, + -52, 6, -60, 81, 4, 26, 37, 11, -10, 12, -65, -1, -23, 12, -86, 0, -69, 14, + 45, 87, -9, 76, 50, 90, -17, 60, 7, 18, -64, 58, -54, 10, -82, 52, 6, 25, + -29, 72, -117, 39, -14, -3, -15, -24, 4, -26, -25, -14, -89, 55, -151, 0, + -58, -12, -363, 139, 160, 129, -36, 38, -80, 52, -2, 27, -38, 14, -20, 38, + 10, 84, -22, 32, -132, 28, -42, -11, -167, 29, -143, 3, -107, -11, -49, -20, + -214, -23, -29, -40, -84, -6, -54, -23, -73, -6, -27, 1, -73, 90, -15, 4, + -81, -2, -78, -40, -78, -15, -96, 0, -45, -26, 7, -26, 31, -15, -13, -26, + -92, -17, -35, -19, -39, 2, -46, -31, -8, -16, -29, -8, -74, 11, -91, -4, + -50, -25, -54, 12, -22, -3, -90, -28, -120, -18, -186, -114, -31, -6, -67, + -35, 48, -71, -45, -3, -80, 50, -70, 30, -111, 42, -72, 8, 6, -31, 69, -15, + 38, -25, 86, -32, 16, -37, -49, -77, -295, 29, -56, 25, -205, -12, -68, 60, + -102, -16, -54, 31, -17, 25, 22, 29, 25, 5, 30, 23, 14, 77, 223, 43, 83, 97, + -44, 33, 33, 51, -8, 28, -90, 36, -116, -33, -90, 26, -168, 11, -82, 40, + -27, -11, -68, 34, -32, -49, 8, -29, -77, 0, -54, -44, 75, -44, 1, -65, + -180, 36, -158, 43, -18, -11, 6, 32, 46, 0, 15, 12, -10, 69, -52, 3, -23, + -11, -14, 18, -14, 1, -37, -11, -14, 35, 16, 21, 41, 13, -115, 32, -149, 15, + -73, -14, -33, -87, -62, -10, -31, 31, -51, -49, 12, -7, -29, -6, -27, -43, + -143, -15, -7, 8, -60, 1, -90, 25, -76, -1, -36, -11, -36, -37, -175, 76, + -810, 179, -17, -27, + ]), +]); + +let decoded = null; +/** The rings as [[lon, lat], ...] with their boxes, decoded once. */ +function rings() { + if (decoded) return decoded; + const scale = 10 ** DECIMALS; + decoded = RINGS.map((encoded) => { + const ring = []; + let lon = 0; + let lat = 0; + for (let i = 0; i + 1 < encoded.length; i += 2) { + lon += encoded[i]; + lat += encoded[i + 1]; + ring.push([lon / scale, lat / scale]); + } + const box = ring.reduce( + (b, [x, y]) => ({ + west: Math.min(b.west, x), + east: Math.max(b.east, x), + south: Math.min(b.south, y), + north: Math.max(b.north, y), + }), + { west: Infinity, east: -Infinity, south: Infinity, north: -Infinity }, + ); + return { ring, box }; + }); + return decoded; +} + +/** Every ring's box together: the cheap first test. */ +export const NO_TRAIN_POSITION_BOX = Object.freeze( + rings().reduce( + (b, { box }) => ({ + west: Math.min(b.west, box.west), + east: Math.max(b.east, box.east), + south: Math.min(b.south, box.south), + north: Math.max(b.north, box.north), + }), + { west: Infinity, east: -Infinity, south: Infinity, north: -Infinity }, + ), +); + +function inRing(ring, lat, lon) { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + if ( + yi > lat !== yj > lat && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi + ) + inside = !inside; + } + return inside; +} + +/** + * Whether a position lies where no train may be placed. + * @param {number} lat + * @param {number} lon + * @returns {boolean} + */ +export function isNoTrainPositionPoint(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + const all = NO_TRAIN_POSITION_BOX; + if (lat < all.south || lat > all.north || lon < all.west || lon > all.east) + return false; + return rings().some( + ({ ring, box }) => + lat >= box.south && + lat <= box.north && + lon >= box.west && + lon <= box.east && + inRing(ring, lat, lon), + ); +} + +/** + * The country a stop id names, for ids that start with one (IFOPT-style + * `ua:22005:1:1`, parent stations `Pua:22005`), as an upper-case ISO code; + * else null. + * @param {string|null|undefined} stopId + * @returns {string|null} + */ +export function stopIdCountry(stopId) { + return ( + /^P?([a-z]{2}):\d/i.exec(String(stopId ?? ''))?.[1]?.toUpperCase() ?? null + ); +} + +/** + * Whether a stop lies where no train may be placed: when the country its id + * names is one of them, or when its position is inside the outline. Either + * is enough, so a stop is never kept on its id's word alone: a stop whose id + * names another country but whose coordinates are in Ukraine is left out + * too. + * @param {{id?: string, lat?: number, lon?: number}|null|undefined} stop + * @returns {boolean} + */ +export function isNoTrainPositionStop(stop) { + if (!stop) return false; + const country = stopIdCountry(stop.id); + if (country && NO_TRAIN_POSITION_COUNTRIES.includes(country)) return true; + return isNoTrainPositionPoint(Number(stop.lat), Number(stop.lon)); +} diff --git a/src/data/railNoPositions.test.mjs b/src/data/railNoPositions.test.mjs new file mode 100644 index 000000000..3c5e04b33 --- /dev/null +++ b/src/data/railNoPositions.test.mjs @@ -0,0 +1,297 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { PbfWriter } from 'pbf'; +import { + NO_TRAIN_POSITION_BOX, + NO_TRAIN_POSITION_COUNTRIES, + isNoTrainPositionPoint, + isNoTrainPositionStop, + stopIdCountry, +} from './railNoPositions.js'; +import { buildRailSnapshot } from './railProxy.js'; +import { createRunIndex } from './railScheduleIndex.js'; +import { + buildPack, + buildSchedule, + openZip, +} from '../../scripts/build-rail-paths.mjs'; + +const KYIV = [50.45, 30.52]; +const LVIV = [49.84, 24.03]; +const SIMFEROPOL = [44.95, 34.1]; +const AMSTERDAM = [52.37, 4.9]; +const PRZEMYSL = [49.78, 22.77]; +const CHISINAU = [47.0, 28.86]; + +/** A fake feed: every vehicle and route is a train, so only the rule drops. */ +function fakeFeed(extra = {}) { + return Object.freeze({ + id: 'test-no-positions', + name: 'Test', + format: 'gtfs-rt', + classify: () => ({ category: 'international', mode: 'rail' }), + ...extra, + }); +} + +test('the area is Ukraine, Crimea included, behind a cheap box', () => { + assert.deepEqual(NO_TRAIN_POSITION_COUNTRIES, ['UA']); + for (const [lat, lon] of [KYIV, LVIV, SIMFEROPOL, [48.0, 37.8]]) + assert.equal(isNoTrainPositionPoint(lat, lon), true, `${lat},${lon}`); + for (const [lat, lon] of [AMSTERDAM, PRZEMYSL, CHISINAU, [53.9, 27.56]]) + assert.equal(isNoTrainPositionPoint(lat, lon), false, `${lat},${lon}`); + // Outside the box nothing more is computed. + assert.ok(NO_TRAIN_POSITION_BOX.west > 22 && NO_TRAIN_POSITION_BOX.east < 41); + for (const bad of [NaN, null, undefined]) + assert.equal(isNoTrainPositionPoint(bad, 30), false); +}); + +test('stations on the border fall on their own side (OpenStreetMap positions)', () => { + // Outside Ukraine, within 1 km of the border: on the Tisa (Bocicoi), by + // the Polish crossing (Medyka) and on the Slovak line (Čierna nad Tisou). + for (const [name, lat, lon] of [ + ['Bocicoi', 47.95743, 24.02333], + ['Valea Vișeului', 47.91463, 24.15557], + ['Sighetu Marmației', 47.9358, 23.88536], + ['Medyka', 49.80248, 22.93929], + ['Čierna nad Tisou št. hr.', 48.43209, 22.13597], + ['Záhony', 48.4017, 22.18376], + ]) + assert.equal(isNoTrainPositionPoint(lat, lon), false, name); + // Inside Ukraine, as close to the border. + for (const [name, lat, lon] of [ + ['Uhniv', 50.37859, 23.73241], + ['Korchiv', 50.39422, 23.83512], + ['Teresva', 47.99573, 23.70047], + ['Solotvyno-Likarnia', 47.96591, 23.86276], + ]) + assert.equal(isNoTrainPositionPoint(lat, lon), true, name); +}); + +test('a stop is left out when its id names Ukraine or its position is inside', () => { + assert.equal(stopIdCountry('ua:22005:1:1'), 'UA'); + assert.equal(stopIdCountry('Pua:22005'), 'UA'); + assert.equal(stopIdCountry('de:09162:6'), 'DE'); + assert.equal(stopIdCountry('8000105'), null); + // A Ukrainian stop the outline would put in Hungary (Chop, on the border). + assert.equal( + isNoTrainPositionStop({ id: 'ua:22005:1:1', lat: 48.43, lon: 22.1 }), + true, + ); + // A stop whose id names Poland, at a position the outline puts in + // Ukraine (Shehyni, across the border from Medyka): its id does not keep + // it. + assert.equal( + isNoTrainPositionStop({ id: 'pl:1:1', lat: 49.7949, lon: 22.9768 }), + true, + ); + // Border stations on their own side stay, by id or by position alone. + for (const [id, lat, lon] of [ + ['pl:1:2', 49.80248, 22.93929], // Medyka + ['hu:1:1', 48.4017, 22.18376], // Záhony + ['sk:1:1', 48.43209, 22.13597], // Čierna nad Tisou + ['ro:1:1', 47.95743, 24.02333], // Bocicoi + ['5001', 49.80248, 22.93929], + ]) + assert.equal(isNoTrainPositionStop({ id, lat, lon }), false, id); + assert.equal( + isNoTrainPositionStop({ id: '9001', lat: LVIV[0], lon: LVIV[1] }), + true, + ); + assert.equal(isNoTrainPositionStop(null), false); +}); + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +/** + * Wien–Budapest, Budapest–Chop (a `ua:` stop the outline would miss), + * Budapest–Lviv (an id without a country, inside the outline) and + * Sighet–Bocicoi (a Romanian halt on the border river, which a coarser + * outline would place in Ukraine). + */ +const GTFS = { + 'agency.txt': + 'agency_id,agency_name,agency_url,agency_timezone\n1,Test,https://example.invalid,Europe/Vienna\n', + 'routes.txt': 'route_id,route_short_name,route_type\nR,EC,2\n', + 'trips.txt': [ + 'route_id,service_id,trip_id,trip_short_name', + 'R,daily,vienna-budapest,EC 1', + 'R,daily,budapest-chop,EC 2', + 'R,daily,budapest-lviv,EC 3', + 'R,daily,sighet-bocicoi,R 4', + ].join('\n'), + 'stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + 'at:49:1,Wien Hauptbahnhof,48.185,16.376', + 'hu:55:1,Budapest-Keleti,47.500,19.083', + 'ua:22005:1:1,Chop,48.43,22.1', + '9001,Lviv,49.84,24.03', + '43820,Sighetu Marmaţiei,47.9358,23.88536', + '43753,Bocicoi Hm.,47.95743,24.02333', + ].join('\n'), + 'stop_times.txt': [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + 'vienna-budapest,08:00:00,08:00:00,at:49:1,1', + 'vienna-budapest,10:40:00,10:40:00,hu:55:1,2', + 'budapest-chop,11:00:00,11:00:00,hu:55:1,1', + 'budapest-chop,16:00:00,16:00:00,ua:22005:1:1,2', + 'budapest-lviv,12:00:00,12:00:00,hu:55:1,1', + 'budapest-lviv,20:00:00,20:00:00,9001,2', + 'sighet-bocicoi,09:00:00,09:00:00,43820,1', + 'sighet-bocicoi,09:20:00,09:20:00,43753,2', + ].join('\n'), + 'calendar.txt': [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'daily,1,1,1,1,1,1,1,20260101,20271231', + ].join('\n'), +}; + +async function buildFake(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-no-pos-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(GTFS)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + const feed = fakeFeed(); + const { pack } = await buildPack(feed, zip, { url: 'x' }); + return buildSchedule(feed, zip, { url: 'x' }, pack, { + from: '2026-11-09', + days: 2, + }); +} + +test('the build leaves out every timetable trip that stops in Ukraine', async (t) => { + const { schedule, stats } = await buildFake(t); + assert.deepEqual(Object.keys(schedule.trips).sort(), [ + 'sighet-bocicoi', + 'vienna-budapest', + ]); + assert.equal(stats.tripsNoPositions, 2); + assert.deepEqual(schedule.stops.map((stop) => stop.name).sort(), [ + 'Bocicoi Hm.', + 'Budapest-Keleti', + 'Sighetu Marmaţiei', + 'Wien Hauptbahnhof', + ]); +}); + +test('a kept stop records no country', async (t) => { + const { schedule } = await buildFake(t); + for (const stop of schedule.stops) { + assert.equal(Object.hasOwn(stop, 'cc'), false, stop.name); + assert.equal(Object.hasOwn(stop, 'country'), false, stop.name); + } +}); + +test('a left-out trip has no runs for a reported train to take', async (t) => { + const { schedule } = await buildFake(t); + const runs = createRunIndex(schedule); + for (const tripId of ['budapest-chop', 'budapest-lviv']) + assert.deepEqual(runs.runsOf(tripId), [], tripId); + assert.ok(runs.runsOf('vienna-budapest').length > 0); +}); + +/** A GTFS-RT VehiclePositions message with one vehicle per position. */ +function encodeVehicles(positions) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, 1_794_000_000); + writer.writeBytesField(1, header.finish()); + positions.forEach(([lat, lon], i) => { + const entity = new PbfWriter(); + entity.writeStringField(1, `v${i}`); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, `trip-${i}`); + trip.writeStringField(5, 'R'); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, lat); + position.writeFloatField(2, lon); + vehicle.writeBytesField(2, position.finish()); + vehicle.writeVarintField(5, 1_793_999_990); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + }); + return writer.finish(); +} + +test('a live GPS position in Ukraine is dropped and counted, never sent', () => { + const snapshot = buildRailSnapshot( + fakeFeed({ url: 'https://example.invalid/vp' }), + encodeVehicles([AMSTERDAM, KYIV, SIMFEROPOL, PRZEMYSL]), + 1_794_000_000_000, + ); + assert.equal(snapshot.count, 2); + assert.equal(snapshot.dropped, 2); + for (const train of snapshot.trains) + assert.equal(isNoTrainPositionPoint(train.lat, train.lon), false); +}); + +test('an adapter position (Irish Rail XML) in Ukraine is dropped too', () => { + const position = (code, [lat, lon]) => ` + + R + ${lat} + ${lon} + ${code} + 06 Nov 2026 + ${code} + Northbound + `; + const body = ` +${position('A101', AMSTERDAM)}${position('A102', LVIV)} +`; + const snapshot = buildRailSnapshot( + fakeFeed({ format: 'irish-rail-xml', url: 'https://example.invalid/xml' }), + new TextEncoder().encode(body), + 1_794_000_000_000, + ); + assert.deepEqual( + snapshot.trains.map((train) => train.id), + ['A101'], + ); + assert.equal(snapshot.dropped, 1); +}); diff --git a/src/data/railPaths.js b/src/data/railPaths.js new file mode 100644 index 000000000..e876815bb --- /dev/null +++ b/src/data/railPaths.js @@ -0,0 +1,288 @@ +/** + * @module railPaths + * @description Track geometry for the Rail layer: a railway line as an indexed + * polyline measured by arc length, with projection of a position onto it and + * the point at a given arc length. + * + * Paths come from per-feed path packs (scripts/build-rail-paths.mjs), served by + * `/api/rail/paths/`. A pack holds each unique shape as a flat + * `[lat, lon, lat, lon, ...]` array with its cumulative distances in metres. + * + * Distances are measured in one local equirectangular frame per path, centred + * on the path's middle latitude. Over the extent of a commuter network (a few + * hundred kilometres) the east-west scale error stays within a few per cent, + * which moves a projection by metres, well under what a marker shows. + * + * Pure: no Cesium, no DOM, no Node built-ins. + */ + +/** Metres per degree of latitude (and of longitude at the equator). */ +const M_PER_DEG = 111_320; +/** Segments per bounding box in the whole-path search index. */ +const CHUNK = 32; + +/** Longitude difference wrapped into (-180, 180]. */ +function wrapLon(delta) { + return ((((delta + 180) % 360) + 360) % 360) - 180; +} + +/** + * Build an indexed path from a flat lat/lon array. + * @param {ArrayLike} coords `[lat, lon, lat, lon, ...]`, at least two points. + * @param {ArrayLike} [dist] Cumulative metres per point. When absent, + * wrong-sized or decreasing, the distances are measured in the local frame. + * @returns {object|null} The path, or null when the input is not a polyline. + */ +export function createRailPath(coords, dist = null) { + const n = Math.floor((coords?.length ?? 0) / 2); + if (n < 2) return null; + const lat = new Float64Array(n); + const lon = new Float64Array(n); + let minLat = Infinity; + let maxLat = -Infinity; + for (let i = 0; i < n; i++) { + const a = Number(coords[2 * i]); + const o = Number(coords[2 * i + 1]); + if (!Number.isFinite(a) || !Number.isFinite(o) || Math.abs(a) > 90) + return null; + lat[i] = a; + lon[i] = o; + if (a < minLat) minLat = a; + if (a > maxLat) maxLat = a; + } + const lat0 = (minLat + maxLat) / 2; + const lon0 = lon[0]; + const kx = M_PER_DEG * Math.cos((lat0 * Math.PI) / 180); + const ky = M_PER_DEG; + const x = new Float64Array(n); + const y = new Float64Array(n); + for (let i = 0; i < n; i++) { + x[i] = wrapLon(lon[i] - lon0) * kx; + y[i] = (lat[i] - lat0) * ky; + } + const s = new Float64Array(n); + let given = dist?.length === n && Number(dist[0]) >= 0; + for (let i = 0; given && i < n; i++) { + const d = Number(dist[i]); + if (!Number.isFinite(d) || (i > 0 && d < s[i - 1])) given = false; + else s[i] = d; + } + if (!given) { + s[0] = 0; + for (let i = 1; i < n; i++) + s[i] = s[i - 1] + Math.hypot(x[i] - x[i - 1], y[i] - y[i - 1]); + } + const chunks = Math.ceil((n - 1) / CHUNK); + const box = new Float64Array(chunks * 4); + for (let c = 0; c < chunks; c++) { + let x0 = Infinity; + let x1 = -Infinity; + let y0 = Infinity; + let y1 = -Infinity; + const end = Math.min(n - 1, (c + 1) * CHUNK); + for (let i = c * CHUNK; i <= end; i++) { + if (x[i] < x0) x0 = x[i]; + if (x[i] > x1) x1 = x[i]; + if (y[i] < y0) y0 = y[i]; + if (y[i] > y1) y1 = y[i]; + } + box[4 * c] = x0; + box[4 * c + 1] = x1; + box[4 * c + 2] = y0; + box[4 * c + 3] = y1; + } + return Object.freeze({ + n, + lat, + lon, + x, + y, + s, + box, + lat0, + lon0, + kx, + ky, + length: s[n - 1], + }); +} + +/** Index of the segment [i, i+1] whose arc range holds `s`, clamped. */ +function segmentAt(path, s) { + const arc = path.s; + let lo = 0; + let hi = path.n - 2; + if (!(s > arc[0])) return 0; + if (s >= arc[hi + 1]) return hi; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (arc[mid] <= s) lo = mid; + else hi = mid - 1; + } + return lo; +} + +/** + * Squared distance from (px, py) to segment i, writing the parameter to `hit`. + * @returns {number} + */ +function segmentDistance2(path, i, px, py, hit) { + const { x, y } = path; + const dx = x[i + 1] - x[i]; + const dy = y[i + 1] - y[i]; + const len2 = dx * dx + dy * dy; + let t = len2 > 0 ? ((px - x[i]) * dx + (py - y[i]) * dy) / len2 : 0; + if (t < 0) t = 0; + else if (t > 1) t = 1; + const ex = x[i] + dx * t - px; + const ey = y[i] + dy * t - py; + hit.t = t; + return ex * ex + ey * ey; +} + +/** + * Project a position onto a path. + * + * With a hint, only the segments whose arc range overlaps + * `[hintS - windowM, hintS + windowM]` are searched: a train known to be near + * arc `hintS` cannot jump to the far side of a loop or to a parallel branch + * that the same shape passes later. Without one, the whole path is searched. + * + * @param {object} path From createRailPath. + * @param {number} lat + * @param {number} lon + * @param {number} [hintS] Expected arc length (m). + * @param {number} [windowM] Half-width of the search window around the hint (m). + * @param {{s?: number, offsetM?: number, index?: number}} [out] + * @returns {{s: number, offsetM: number, index: number}|null} + */ +export function projectOnPath(path, lat, lon, hintS, windowM, out = {}) { + if (!path || !Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const px = wrapLon(lon - path.lon0) * path.kx; + const py = (lat - path.lat0) * path.ky; + const hit = { t: 0 }; + let best = Infinity; + let bestIndex = -1; + let bestT = 0; + if (Number.isFinite(hintS) && Number.isFinite(windowM) && windowM >= 0) { + const first = segmentAt(path, hintS - windowM); + const last = segmentAt(path, hintS + windowM); + for (let i = first; i <= last; i++) { + const d2 = segmentDistance2(path, i, px, py, hit); + if (d2 < best) { + best = d2; + bestIndex = i; + bestT = hit.t; + } + } + } else { + const { box } = path; + const chunks = box.length / 4; + for (let c = 0; c < chunks; c++) { + const ox = Math.max(box[4 * c] - px, 0, px - box[4 * c + 1]); + const oy = Math.max(box[4 * c + 2] - py, 0, py - box[4 * c + 3]); + if (ox * ox + oy * oy >= best) continue; + const end = Math.min(path.n - 2, (c + 1) * CHUNK - 1); + for (let i = c * CHUNK; i <= end; i++) { + const d2 = segmentDistance2(path, i, px, py, hit); + if (d2 < best) { + best = d2; + bestIndex = i; + bestT = hit.t; + } + } + } + } + if (bestIndex < 0) return null; + const s = path.s; + out.s = s[bestIndex] + (s[bestIndex + 1] - s[bestIndex]) * bestT; + out.offsetM = Math.sqrt(best); + out.index = bestIndex; + return out; +} + +/** + * The position and direction of travel at an arc length, clamped to the path. + * @param {object} path From createRailPath. + * @param {number} s Arc length (m). + * @param {{lat?: number, lon?: number, bearingDeg?: number}} [out] + * @returns {{lat: number, lon: number, bearingDeg: number}|null} + */ +export function pointAtS(path, s, out = {}) { + if (!path || !Number.isFinite(s)) return null; + const clamped = Math.max(0, Math.min(path.length, s)); + let i = segmentAt(path, clamped); + // A zero-length segment has no direction; borrow its neighbour's. + while (i < path.n - 2 && path.s[i + 1] === path.s[i]) i++; + const span = path.s[i + 1] - path.s[i]; + const t = + span > 0 ? Math.max(0, Math.min(1, (clamped - path.s[i]) / span)) : 0; + out.lat = path.lat[i] + (path.lat[i + 1] - path.lat[i]) * t; + let lon = path.lon[i] + wrapLon(path.lon[i + 1] - path.lon[i]) * t; + if (lon > 180) lon -= 360; + else if (lon < -180) lon += 360; + out.lon = lon; + const dx = path.x[i + 1] - path.x[i]; + const dy = path.y[i + 1] - path.y[i]; + out.bearingDeg = + dx === 0 && dy === 0 + ? NaN + : ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; + return out; +} + +/** Path-pack format this module reads. */ +export const RAIL_PATH_PACK_VERSION = 1; + +/** + * Wrap a decoded path pack for lookups. Shapes are indexed lazily, the first + * time a train asks for one. + * @param {object} raw The JSON body of `/api/rail/paths/`. + * @returns {{feedId: string, shapeCount: number, + * pathAt: (index: number) => object|null, + * shapeForTrip: (tripId: string) => number|null, + * shapesForRoute: (routeId: string) => number[]}} + */ +export function createRailPathPack(raw) { + if ( + !raw || + raw.version !== RAIL_PATH_PACK_VERSION || + typeof raw.feedId !== 'string' || + !Array.isArray(raw.shapes) || + !Array.isArray(raw.dist) || + raw.dist.length !== raw.shapes.length + ) + throw new TypeError('Malformed rail path pack'); + const shapes = raw.shapes; + const dist = raw.dist; + const trips = raw.trips && typeof raw.trips === 'object' ? raw.trips : {}; + const routes = raw.routes && typeof raw.routes === 'object' ? raw.routes : {}; + const built = new Map(); + const validIndex = (value) => + Number.isInteger(value) && value >= 0 && value < shapes.length + ? value + : null; + return Object.freeze({ + feedId: raw.feedId, + /** When the pack was built (ISO), matched by a schedule's pathsBuiltAt. */ + builtAt: typeof raw.builtAt === 'string' ? raw.builtAt : null, + shapeCount: shapes.length, + pathAt(index) { + if (validIndex(index) === null) return null; + if (!built.has(index)) + built.set(index, createRailPath(shapes[index], dist[index])); + return built.get(index); + }, + shapeForTrip(tripId) { + const id = String(tripId ?? ''); + return id && Object.hasOwn(trips, id) ? validIndex(trips[id]) : null; + }, + shapesForRoute(routeId) { + const id = String(routeId ?? ''); + const list = id && Object.hasOwn(routes, id) ? routes[id] : null; + return Array.isArray(list) + ? list.filter((value) => validIndex(value) !== null) + : []; + }, + }); +} diff --git a/src/data/railPaths.test.mjs b/src/data/railPaths.test.mjs new file mode 100644 index 000000000..c65a1c6e7 --- /dev/null +++ b/src/data/railPaths.test.mjs @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_PATH_PACK_VERSION, + createRailPath, + createRailPathPack, + pointAtS, + projectOnPath, +} from './railPaths.js'; + +const M_PER_DEG = 111_320; + +/** An L: 1 km east along the equator, then 1 km north. */ +function lPath() { + const dLon = 1000 / M_PER_DEG; + return createRailPath([0, 0, 0, dLon, dLon, dLon]); +} + +/** + * A hairpin: 2 km east along lat 0, a 100 m step north, then 2 km back west. + * The two legs are parallel and 100 m apart, like the two sides of a loop. + */ +function hairpin() { + const d = 1 / M_PER_DEG; + return createRailPath([0, 0, 0, 2000 * d, 100 * d, 2000 * d, 100 * d, 0]); +} + +test('a path measures its arc length in metres', () => { + const path = lPath(); + assert.equal(path.n, 3); + assert.ok(Math.abs(path.length - 2000) < 0.5, String(path.length)); + assert.equal(createRailPath([0, 0]), null, 'one point is not a path'); + assert.equal(createRailPath([0, 0, Number.NaN, 1]), null); + assert.equal(createRailPath(null), null); +}); + +test('given distances are used, and bad ones are measured instead', () => { + const coords = [0, 0, 0, 0.01, 0, 0.02]; + assert.deepEqual([...createRailPath(coords, [0, 5, 10]).s], [0, 5, 10]); + const measured = createRailPath(coords, [0, 10, 5]); + assert.ok(Math.abs(measured.s[2] - 2 * 0.01 * M_PER_DEG) < 0.5); + const wrongSize = createRailPath(coords, [0, 1]); + assert.ok(wrongSize.s[1] > 1000); +}); + +test('projection finds the arc and the offset from the line', () => { + const path = lPath(); + const d = 1 / M_PER_DEG; + const hit = projectOnPath(path, 20 * d, 500 * d); + assert.ok(Math.abs(hit.s - 500) < 0.5, String(hit.s)); + assert.ok(Math.abs(hit.offsetM - 20) < 0.5, String(hit.offsetM)); + assert.equal(hit.index, 0); + const second = projectOnPath(path, 300 * d, 1030 * d); + assert.ok(Math.abs(second.s - 1300) < 0.5, String(second.s)); + assert.ok(Math.abs(second.offsetM - 30) < 0.5); + assert.equal(second.index, 1); + const beyond = projectOnPath(path, 2000 * d, 1000 * d); + assert.ok(Math.abs(beyond.s - 2000) < 0.5, 'clamped to the end'); + assert.ok(Math.abs(beyond.offsetM - 1000) < 1); + assert.equal(projectOnPath(path, Number.NaN, 0), null); + assert.equal(projectOnPath(null, 0, 0), null); +}); + +test('a hint keeps the projection on its own side of a loop', () => { + const path = hairpin(); + const d = 1 / M_PER_DEG; + // 45 m north of the outbound leg, 55 m south of the return leg: the + // whole-path answer is the outbound leg... + const point = [45 * d, 1000 * d]; + const free = projectOnPath(path, ...point); + assert.ok(Math.abs(free.s - 1000) < 1, String(free.s)); + // ...but a train known to be on the return leg stays on it. + const hinted = projectOnPath(path, ...point, 3100, 500); + assert.ok(Math.abs(hinted.s - 3100) < 1, String(hinted.s)); + assert.ok(Math.abs(hinted.offsetM - 55) < 0.5); +}); + +test('the whole-path search agrees with a brute-force scan on a long path', () => { + const coords = []; + for (let i = 0; i < 500; i++) + coords.push(40 + Math.sin(i / 20) * 0.01, -74 + i * 0.001); + const path = createRailPath(coords); + for (const [lat, lon] of [ + [40.004, -73.9], + [40.02, -73.75], + [39.99, -73.6], + ]) { + const fast = projectOnPath(path, lat, lon); + let best = Infinity; + for (let i = 0; i < path.n - 1; i++) { + const hit = projectOnPath( + path, + lat, + lon, + (path.s[i] + path.s[i + 1]) / 2, + 0, + ); + best = Math.min(best, hit.offsetM); + } + assert.ok( + Math.abs(fast.offsetM - best) < 1e-6, + `${fast.offsetM} vs ${best}`, + ); + } +}); + +test('the point at an arc length carries the direction of travel', () => { + const path = lPath(); + const d = 1 / M_PER_DEG; + const east = pointAtS(path, 250); + assert.ok(Math.abs(east.lon - 250 * d) < 1e-9); + assert.equal(east.lat, 0); + assert.ok(Math.abs(east.bearingDeg - 90) < 1e-6, String(east.bearingDeg)); + const north = pointAtS(path, 1500); + assert.ok(Math.abs(north.lat - 500 * d) < 1e-6); + assert.ok( + Math.abs(north.bearingDeg - 0) < 1e-6 || + Math.abs(north.bearingDeg - 360) < 1e-6, + ); + const out = {}; + assert.equal(pointAtS(path, -50, out), out, 'writes into the caller scratch'); + assert.equal(out.lat, 0); + assert.equal(out.lon, 0); + assert.ok(Math.abs(pointAtS(path, 9e9).lat - 1000 * d) < 1e-9); + assert.equal(pointAtS(path, Number.NaN), null); +}); + +test('a pack resolves trips and routes to shapes and builds paths lazily', () => { + const raw = { + version: RAIL_PATH_PACK_VERSION, + feedId: 'grt-ion', + shapes: [ + [43.4, -80.5, 43.41, -80.5], + [43.4, -80.5, 43.4, -80.49], + ], + dist: [ + [0, 1113.2], + [0, 808.9], + ], + trips: { t1: 0, t2: 1, bad: 7 }, + routes: { 301: [0, 1, 9] }, + }; + const pack = createRailPathPack(raw); + assert.equal(pack.feedId, 'grt-ion'); + assert.equal(pack.shapeCount, 2); + assert.equal(pack.shapeForTrip('t1'), 0); + assert.equal(pack.shapeForTrip('bad'), null, 'out-of-range index'); + assert.equal(pack.shapeForTrip('constructor'), null, 'no prototype keys'); + assert.equal(pack.shapeForTrip(''), null); + assert.deepEqual(pack.shapesForRoute('301'), [0, 1]); + assert.deepEqual(pack.shapesForRoute('302'), []); + const path = pack.pathAt(1); + assert.equal(path.length, 808.9); + assert.equal(pack.pathAt(1), path, 'built once'); + assert.equal(pack.pathAt(5), null); + for (const broken of [ + null, + { ...raw, version: 2 }, + { ...raw, feedId: 3 }, + { ...raw, dist: [] }, + ]) + assert.throws(() => createRailPathPack(broken), TypeError); +}); diff --git a/src/data/railProxy.js b/src/data/railProxy.js new file mode 100644 index 000000000..2bea20157 --- /dev/null +++ b/src/data/railProxy.js @@ -0,0 +1,518 @@ +/** + * @module railProxy + * @description Pure server-side mechanics for the `/api/rail` proxy. + * + * The upstream mechanics are the Transit proxy's (src/data/transitProxy.js): + * the same decoder, timestamp repair, redirect rule, conditional headers, cache + * windows and backoff ladder. What differs is which feeds are reachable and + * what leaves the server: only vehicles a feed's classifier recognises as + * trains, each tagged with its category, mode and position source. On top of + * that come the Irish Rail XML and SIRI-VM adapters and trip updates. Every + * train is placed where its operator reports it. Every feed is open: no + * request carries a credential. + */ + +import { + TRANSIT_ADMISSION_MAX_GLOBAL, + TRANSIT_PROXY_MAX_BODY_BYTES, + TRANSIT_PROXY_STALE_MAX_MS, + TRANSIT_PROXY_TTL_MS, + TransitFeedShapeError, + buildTransitSnapshot, +} from './transitProxy.js'; +import { GTFS_INCREMENTALITY_FULL_DATASET } from './gtfsRealtime.js'; +import { + RAIL_FEED_REGISTRY, + classifyTrain, + getRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; +import { decodeIrishRailTrains } from './irishRail.js'; +import { decodeSiriVehicleMonitoring } from './railSiriVm.js'; +import { decodeTripUpdates } from './railTripUpdates.js'; +import { isNoTrainPositionPoint } from './railNoPositions.js'; + +/** + * Text formats with their own adapter, by registry `format`. Each turns the + * document and the fetch time (epoch s) into `{entityCount, truncated, + * vehicles, feedTimestamp?}`. + */ +const TEXT_ADAPTERS = Object.freeze({ + 'irish-rail-xml': decodeIrishRailTrains, + 'siri-vm': decodeSiriVehicleMonitoring, +}); + +export { + TRANSIT_ADMISSION_MAX_PER_FEED as RAIL_ADMISSION_MAX_PER_FEED, + TRANSIT_ADMISSION_WINDOW_MS as RAIL_ADMISSION_WINDOW_MS, + TRANSIT_PROXY_TIMEOUT_MS as RAIL_PROXY_TIMEOUT_MS, + TransitFeedShapeError as RailFeedShapeError, + isAcceptableTransitUpstreamUrl as isAcceptableRailUpstreamUrl, + nextTransitBackoffMs as nextRailBackoffMs, +} from './transitProxy.js'; + +/** + * Largest path or schedule pack the proxy serves or reads (bytes). The + * biggest national packs built so far were 14 to 16 MB; anything far beyond + * that is a build fault, not a pack to ship. A pack over the cap is refused + * with its own answer, not taken for a missing one. + */ +export const RAIL_PATH_PACK_MAX_BYTES = 24 * 1024 * 1024; + +/** + * Default freshness window for trip updates. Predictions move more slowly + * than positions, and several operators refresh them every 30 to 60 s. + */ +export const RAIL_TRIP_UPDATES_TTL_MS = 30_000; +/** Shortest window a registry entry may ask for. */ +export const RAIL_MIN_TTL_MS = 5_000; +/** Trips one `/updates` answer carries, after the rail filter. */ +export const RAIL_UPDATES_MAX_TRIPS = 3_000; +/** Stop updates one `/updates` answer carries, across all its trips. */ +export const RAIL_UPDATES_MAX_STOPS = 40_000; + +/** + * Freshness window for one route of a feed: its own interval when the + * registry sets one (never below RAIL_MIN_TTL_MS), else the default. + * @param {object} feed + * @param {'trains'|'updates'} route + * @returns {number} ms + */ +export function railFeedTtlMs(feed, route = 'trains') { + const own = + route === 'updates' ? feed?.tripUpdatesIntervalMs : feed?.pollIntervalMs; + if (Number.isFinite(own) && own > 0) return Math.max(RAIL_MIN_TTL_MS, own); + return route === 'updates' ? RAIL_TRIP_UPDATES_TTL_MS : TRANSIT_PROXY_TTL_MS; +} + +/** + * Largest byte cap a feed may set for itself (`maxBodyBytes`): nothing + * larger is read into memory. + */ +export const RAIL_PROXY_MAX_BODY_CEILING_BYTES = 64 * 1024 * 1024; + +/** + * Upstream byte cap for a feed: its own cap when it sets one (never above + * RAIL_PROXY_MAX_BODY_CEILING_BYTES), else the Transit proxy's 8 MiB. + * @param {object} feed + * @returns {number} + */ +export function railMaxBodyBytes(feed) { + const own = feed?.maxBodyBytes; + return Number.isFinite(own) && own > 0 + ? Math.min(own, RAIL_PROXY_MAX_BODY_CEILING_BYTES) + : TRANSIT_PROXY_MAX_BODY_BYTES; +} + +/** + * Classify a cache entry against a freshness window: the Transit policy with + * a per-feed TTL. The serve-stale window is the same ten minutes. + * @param {{at: number}|null|undefined} entry + * @param {number} now + * @param {number} [ttlMs] + * @returns {'none'|'fresh'|'stale'|'expired'} + */ +export function railCacheStateFor(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) { + if (!entry || !Number.isFinite(entry.at)) return 'none'; + const age = now - entry.at; + if (age < ttlMs) return 'fresh'; + if (age < Math.max(TRANSIT_PROXY_STALE_MAX_MS, ttlMs)) return 'stale'; + return 'expired'; +} + +/** + * Resolve `/trains/`, `/paths/`, `/schedule/` or + * `/updates/` (the path after the `/api/rail` mount) to a pollable + * feed. Anything else (another route, an unknown or disabled id, an + * `/updates` request for a feed with no trip updates, path tricks) resolves + * to null and the caller 404s. + * @param {string} url Request URL relative to the mount point. + * @returns {{ route: 'feeds' } | + * { route: 'trains'|'paths'|'schedule'|'updates', feed: object } | null} + */ +export function resolveRailRoute(url) { + const pathname = String(url || '').split('?')[0]; + if (pathname === '/feeds' || pathname === '/feeds/') + return { route: 'feeds' }; + const match = /^\/(trains|paths|schedule|updates)\/([^/]+)\/?$/.exec( + pathname, + ); + if (!match) return null; + let id; + try { + id = decodeURIComponent(match[2]); + } catch { + return null; + } + const feed = getRailFeed(id); + if (!feed) return null; + if (match[1] === 'updates' && !railFeedHasTripUpdates(feed)) return null; + return { route: match[1], feed }; +} + +/** + * Classify decoded vehicles and keep only trains. A position where no train + * is placed (src/data/railNoPositions.js: Ukraine) is dropped first, so it + * is only counted and never leaves the server. + */ +function keepTrains(feed, vehicles, routeFor = null) { + const positionSource = railFeedPositions(feed) === 'event' ? 'event' : 'gps'; + const trains = []; + for (const vehicle of vehicles) { + if (isNoTrainPositionPoint(vehicle.lat, vehicle.lon)) continue; + const routeId = resolvedRouteId(feed, vehicle, routeFor); + const kind = classifyTrain(feed, routeId, { tripId: vehicle.tripId }); + if (!kind) continue; + trains.push({ + ...vehicle, + routeId, + category: kind.category, + mode: kind.mode, + ...(kind.tourist ? { tourist: true } : {}), + positionSource, + }); + } + return trains; +} + +/** + * The route a record is classified by. A feed with `routesFrom: 'schedule'` + * publishes trips whose route is missing or not the one its classifier reads; + * `routeFor(tripId)` answers from the feed's schedule pack, whose route ids + * the build wrote to carry the product. A trip the pack does not know keeps + * what the feed said, which its classifier then drops. + */ +function resolvedRouteId(feed, record, routeFor) { + if (feed?.routesFrom !== 'schedule' || typeof routeFor !== 'function') + return record.routeId; + return (record.tripId && routeFor(record.tripId)) || record.routeId; +} + +/** + * For a feed whose positions name each train by its number + * (`positionsByTrainNumber`, Irish Rail's train codes), the record takes + * the schedule pack's trip that number is running now (`tripFor`, given the + * number and the record, whose stated delay moves its timetable), so the + * train is drawn on its line and its card lists its stops; the number stays + * as `trainNumber`. A number the timetable does not place keeps its own id. + */ +function byTrainNumber(feed, vehicles, tripFor) { + if (feed?.positionsByTrainNumber !== true || typeof tripFor !== 'function') + return vehicles; + return vehicles.map((vehicle) => { + const tripId = vehicle.tripId ? tripFor(vehicle.tripId, vehicle) : null; + return tripId + ? { ...vehicle, tripId, trainNumber: vehicle.tripId } + : vehicle; + }); +} + +/** + * For a feed whose header clock cannot be trusted (`headerClock: + * 'untrusted'`, Renfe: local time written as UTC), a time the Transit + * ladder took from the header is replaced by the fetch time and labelled so. + */ +function distrustHeaderClock(feed, vehicles, now) { + if (feed?.headerClock !== 'untrusted') return vehicles; + const fetchedS = Math.floor(now / 1000); + return vehicles.map((vehicle) => + vehicle.timestampSource === 'header' + ? { ...vehicle, timestamp: fetchedS, timestampSource: 'fetch' } + : vehicle, + ); +} + +/** + * An upstream answer that is no document of the feed's: a failed fetch, not + * a reason to distrust the last good snapshot, which the service keeps + * serving while it is young enough (`code: 'UPSTREAM_UNREADABLE'`, a 502). + * @param {string} detail + * @returns {Error & {code: string}} + */ +function unreadableUpstream(detail) { + const failure = new Error(`upstream document unreadable: ${detail}`); + failure.code = 'UPSTREAM_UNREADABLE'; + return failure; +} + +/** + * Run a GTFS-RT decode and refuse what is not a FeedMessage: bytes protobuf + * cannot read (an HTML or JSON error page served as 200), and a message + * without a FeedHeader or its gtfs_realtime_version, which every + * FeedMessage carries. An empty body (a 200 or 204 with nothing in it) + * decodes to exactly that, and would otherwise read as a full snapshot with + * no trains. A DIFFERENTIAL feed's refusal passes through as it is. + * @template {{version: string|null}} T + * @param {() => T} decode + * @returns {T} + */ +function decodeGtfsRealtime(decode) { + let decoded; + try { + decoded = decode(); + } catch (error) { + if (error instanceof TransitFeedShapeError) throw error; + throw unreadableUpstream(error?.message || String(error)); + } + if (decoded?.version === null || decoded?.version === undefined) + throw unreadableUpstream('no FeedHeader with a gtfs_realtime_version'); + return decoded; +} + +/** + * Decode upstream bytes into the JSON snapshot the Rail layer consumes. + * + * For GTFS-RT feeds, decoding, the refusal of DIFFERENTIAL feeds and the + * report-time ladder are the Transit proxy's; a body that is no FeedMessage + * (no header, no version, not protobuf) is refused as unreadable + * (decodeGtfsRealtime). Irish Rail's XML and SIRI-VM go through their + * adapters. The classifier then runs on every vehicle: a vehicle it does + * not recognise (a bus in a mixed feed, a shuttle, an out-of-service vehicle + * with no route in a mixed feed) is dropped and only counted, so the browser + * receives trains and nothing else. Every train carries `positionSource`: + * `gps` for GTFS-RT positions, `event` for signalling events. + * + * @param {object} feed Rail registry entry. + * @param {Uint8Array|ArrayBuffer} bytes Raw upstream body. + * @param {number} [now=Date.now()] Fetch time (ms epoch). + * @param {{routeFor?: Function|null, tripFor?: Function|null}} [lookups] + * `routeFor` resolves a trip's route from the schedule pack, and + * `tripFor(number, record)` a train number's running trip + * (`positionsByTrainNumber`). + * @returns {{ feedId: string, name: string, fetchedAt: number, feedTimestamp: number|null, + * version: string|null, entityCount: number, truncated: boolean, count: number, + * dropped: number, positions: string, trains: object[] }} + */ +export function buildRailSnapshot( + feed, + bytes, + now = Date.now(), + { routeFor = null, tripFor = null } = {}, +) { + const adapter = Object.hasOwn(TEXT_ADAPTERS, feed?.format) + ? TEXT_ADAPTERS[feed.format] + : null; + if (adapter) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const text = new TextDecoder('utf-8').decode(view); + let decoded; + try { + decoded = adapter(text, Math.floor(now / 1000)); + } catch (error) { + // An unreadable document (an error page served as 200, a truncated + // body) is a failed fetch, not a reason to distrust the last good one. + throw unreadableUpstream(error?.message || String(error)); + } + const trains = keepTrains( + feed, + byTrainNumber(feed, decoded.vehicles, tripFor), + routeFor, + ); + return { + feedId: feed.id, + name: feed.name, + fetchedAt: now, + feedTimestamp: Number.isFinite(decoded.feedTimestamp) + ? decoded.feedTimestamp + : null, + version: null, + entityCount: decoded.entityCount, + truncated: decoded.truncated, + count: trains.length, + dropped: decoded.vehicles.length - trains.length, + positions: railFeedPositions(feed), + trains, + }; + } + const decoded = decodeGtfsRealtime(() => + buildTransitSnapshot(feed, bytes, now), + ); + const trains = keepTrains( + feed, + distrustHeaderClock(feed, decoded.vehicles, now), + routeFor, + ); + return { + feedId: decoded.feedId, + name: decoded.name, + fetchedAt: decoded.fetchedAt, + feedTimestamp: + feed.headerClock === 'untrusted' ? null : decoded.feedTimestamp, + version: decoded.version, + entityCount: decoded.entityCount, + truncated: decoded.truncated, + count: trains.length, + dropped: decoded.vehicles.length - trains.length, + positions: railFeedPositions(feed), + trains, + }; +} + +/** + * Decode a TripUpdates feed into the `/api/rail/updates/` body: one + * record per rail trip, filtered with the same classifier as the positions + * (a feed's buses never leave the server), capped in trips and stops. + * + * A DIFFERENTIAL feed is refused, for the same reason positions are: an + * unmentioned trip would read as one with no prediction. A body that is no + * FeedMessage is refused as unreadable (decodeGtfsRealtime). + * + * @param {object} feed Rail registry entry. + * @param {Uint8Array|ArrayBuffer} bytes Raw GTFS-RT FeedMessage. + * @param {number} [now=Date.now()] Fetch time (ms epoch). + * @param {{routeFor?: ((tripId: string) => string|null)|null}} [lookups] + * `routeFor` is the schedule pack's trip-to-route lookup, for a feed + * whose trips do not carry the route its classifier reads (`routesFrom: + * 'schedule'`). + * @returns {{ feedId: string, name: string, fetchedAt: number, + * feedTimestamp: number|null, entityCount: number, truncated: boolean, + * count: number, dropped: number, trips: object[] }} + */ +export function buildRailTripUpdatesSnapshot( + feed, + bytes, + now = Date.now(), + { routeFor = null } = {}, +) { + const decoded = decodeGtfsRealtime(() => decodeTripUpdates(bytes)); + if (decoded.incrementality !== GTFS_INCREMENTALITY_FULL_DATASET) { + throw new TransitFeedShapeError( + `feed is differential (incrementality ${decoded.incrementality})`, + 'differential', + ); + } + const trips = []; + let truncated = decoded.truncated; + let stops = 0; + let rail = 0; + for (const trip of decoded.trips) { + const routeId = resolvedRouteId(feed, trip, routeFor); + const kind = classifyTrain(feed, routeId, { tripId: trip.tripId }); + if (!kind) continue; + rail += 1; + if ( + trips.length >= RAIL_UPDATES_MAX_TRIPS || + stops + trip.stops.length > RAIL_UPDATES_MAX_STOPS + ) { + truncated = true; + continue; + } + stops += trip.stops.length; + trips.push({ + ...trip, + routeId, + category: kind.category, + mode: kind.mode, + ...(kind.tourist ? { tourist: true } : {}), + }); + } + return { + feedId: feed.id, + name: feed.name, + fetchedAt: now, + feedTimestamp: decoded.timestamp, + entityCount: decoded.entityCount, + truncated, + count: trips.length, + dropped: decoded.trips.length - rail, + trips, + }; +} + +/** + * Upstream request headers. Same obligations as Transit's (conditional + * validators, gzip, a User-Agent that says who is asking, any identify-yourself + * header the registry names), with this proxy's own name in the User-Agent. + * A feed's own `headers` (an Accept for an XML feed) come last. + * @param {object} feed Registry entry. + * @param {{etag?: string|null, lastModified?: string|null}} [validators] + * @returns {Record} + */ +export function railUpstreamHeaders(feed, validators = null) { + return { + 'User-Agent': + 'gods-eye-view-rail-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', + Accept: 'application/x-protobuf, application/octet-stream;q=0.9, */*;q=0.1', + 'Accept-Encoding': 'gzip', + ...(validators?.etag ? { 'If-None-Match': validators.etag } : {}), + ...(validators?.lastModified + ? { 'If-Modified-Since': validators.lastModified } + : {}), + ...(feed?.headers || {}), + }; +} + +/** + * Upstream requests one minute may need when every feed in the registry is + * polled at once: one per freshness window for each feed's positions and + * trip updates. + * @param {ReadonlyArray} [feeds] + * @returns {number} + */ +export function railNaturalRequestsPerMinute(feeds = RAIL_FEED_REGISTRY) { + let total = 0; + for (const feed of feeds) { + if (feed?.defaultEnabled !== true) continue; + if (feed.url) total += Math.ceil(60_000 / railFeedTtlMs(feed, 'trains')); + if (railFeedHasTripUpdates(feed)) + total += Math.ceil(60_000 / railFeedTtlMs(feed, 'updates')); + } + return total; +} + +/** + * The process-wide admission cap (requests per admission window, all + * operators): the natural load of every enabled feed, half again on top for + * retries on the backoff ladder, never below the Transit proxy's own cap + * (TRANSIT_ADMISSION_MAX_GLOBAL, 40). It is a backstop against a bug or a + * runaway client, not the protection an operator relies on: that is each + * feed's freshness window and the per-feed admission count. Transit's fixed + * cap alone sat below the natural load once the registry passed about ten + * feeds polled every 15 s, so a viewer of several regions got 503s from a + * healthy proxy. + * @param {ReadonlyArray} [feeds] + * @returns {number} + */ +export function railAdmissionGlobalMax(feeds = RAIL_FEED_REGISTRY) { + return Math.max( + TRANSIT_ADMISSION_MAX_GLOBAL, + Math.ceil(railNaturalRequestsPerMinute(feeds) * 1.5), + ); +} + +/** + * Response headers for a snapshot. `X-GEV-Cache` mirrors the other proxies + * (HIT / MISS / INFLIGHT / STALE-ERROR); `X-Rail-Contact` is when the operator + * last ANSWERED, which a revalidated (304) feed advances without changing the + * body's own fetch time. `max-age` is what is left of the feed's own + * freshness window once the snapshot is `ageMs` old, so a HIT near the end + * of the window is not kept a whole window more downstream. + * @param {'HIT'|'MISS'|'INFLIGHT'|'STALE-ERROR'} cacheState + * @param {string} [upstreamHost] + * @param {number|null} [contactedAt] + * @param {number} [ttlMs] + * @param {number} [ageMs] How old the snapshot is (ms); 0 for a fresh one. + * @returns {Record} + */ +export function railResponseHeaders( + cacheState, + upstreamHost = '', + contactedAt = null, + ttlMs = TRANSIT_PROXY_TTL_MS, + ageMs = 0, +) { + const age = Number.isFinite(ageMs) && ageMs > 0 ? ageMs : 0; + return { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': + cacheState === 'STALE-ERROR' + ? 'no-store' + : `public, max-age=${Math.max(0, Math.ceil((ttlMs - age) / 1000))}`, + 'X-GEV-Cache': cacheState, + ...(upstreamHost ? { 'X-Rail-Upstream': upstreamHost } : {}), + ...(Number.isFinite(contactedAt) + ? { 'X-Rail-Contact': String(Math.floor(contactedAt)) } + : {}), + }; +} diff --git a/src/data/railProxy.test.mjs b/src/data/railProxy.test.mjs new file mode 100644 index 000000000..d5e9ccdc7 --- /dev/null +++ b/src/data/railProxy.test.mjs @@ -0,0 +1,586 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { + RAIL_UPDATES_MAX_TRIPS, + RailFeedShapeError, + buildRailSnapshot, + buildRailTripUpdatesSnapshot, + railAdmissionGlobalMax, + railNaturalRequestsPerMinute, + railCacheStateFor, + railFeedTtlMs, + railMaxBodyBytes, + railResponseHeaders, + railUpstreamHeaders, + resolveRailRoute, +} from './railProxy.js'; +import { + RAIL_FEED_REGISTRY, + getRailFeed, + getRegisteredRailFeed, + railFeedPositions, +} from './railFeeds.js'; + +/** A minimal TripUpdates FeedMessage: `[tripId, routeId, delay]` rows. */ +function encodeTripUpdates(rows, { incrementality = null } = {}) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + if (incrementality !== null) header.writeVarintField(2, incrementality); + header.writeVarintField(3, 1_794_000_000); + writer.writeBytesField(1, header.finish()); + for (const [tripId, routeId, delay, stopCount = 1] of rows) { + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + const update = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + if (routeId) trip.writeStringField(5, routeId); + update.writeBytesField(1, trip.finish()); + for (let i = 0; i < stopCount; i += 1) { + const stop = new PbfWriter(); + stop.writeVarintField(1, i + 1); + const arrival = new PbfWriter(); + arrival.writeVarintField(1, delay); + stop.writeBytesField(2, arrival.finish()); + stop.writeStringField(4, `s${i}`); + update.writeBytesField(2, stop.finish()); + } + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +const IRISH_XML = ` + + + R + 54.0 + -6.4 + A118 + 06 Nov 2026 + A118\\n14:50 - Dublin Connolly to Belfast (0 mins late)\\nDeparted Dundalk next stop Newry + Northbound + + + R + 53.29 + -6.13 + E931 + 06 Nov 2026 + E931\\n15:03 - Greystones to Howth (16 mins late)\\nDeparted Sutton next stop Howth + Northbound + +`; + +/** A minimal FeedMessage whose vehicles carry a trip route id. */ +function encodeFeed({ incrementality = null, entities = [] }) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + if (incrementality !== null) header.writeVarintField(2, incrementality); + header.writeVarintField(3, 1_700_000_000); + writer.writeBytesField(1, header.finish()); + for (const entity of entities) { + const feedEntity = new PbfWriter(); + feedEntity.writeStringField(1, entity.id); + const vehicle = new PbfWriter(); + if (entity.route) { + const trip = new PbfWriter(); + trip.writeStringField(1, `trip-${entity.id}`); + trip.writeStringField(5, entity.route); + vehicle.writeBytesField(1, trip.finish()); + } + const position = new PbfWriter(); + position.writeFloatField(1, entity.lat ?? 42.35); + position.writeFloatField(2, entity.lon ?? -71.06); + vehicle.writeBytesField(2, position.finish()); + vehicle.writeVarintField(5, 1_699_999_990); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, entity.id); + // A vendor extension (field 1999), as Adelaide Metro sends: it must be + // skipped, not fail the snapshot. + const extension = new PbfWriter(); + extension.writeVarintField(1, 1); + descriptor.writeBytesField(1999, extension.finish()); + vehicle.writeBytesField(8, descriptor.finish()); + feedEntity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, feedEntity.finish()); + } + return writer.finish(); +} + +test('route resolution admits only the catalog and enabled rail feeds', () => { + assert.deepEqual(resolveRailRoute('/feeds'), { route: 'feeds' }); + assert.deepEqual(resolveRailRoute('/feeds/?x=1'), { route: 'feeds' }); + assert.equal(resolveRailRoute('/trains/mbta-rail')?.feed?.id, 'mbta-rail'); + assert.equal(resolveRailRoute('/trains/mbta-rail/')?.feed?.id, 'mbta-rail'); + assert.equal(resolveRailRoute('/trains/vre'), null, 'not registered'); + assert.equal( + resolveRailRoute('/trains/mbta'), + null, + 'a transit id is not a rail id', + ); + assert.equal(resolveRailRoute('/vehicles/mbta-rail'), null); + assert.equal(resolveRailRoute('/trains/mbta-rail/extra'), null); + assert.equal(resolveRailRoute('/trains/..%2F..%2Fetc'), null); + assert.equal(resolveRailRoute('/trains/%E0%A4%A'), null); + assert.equal(resolveRailRoute(''), null); + assert.equal(resolveRailRoute(undefined), null); +}); + +test('path packs resolve for enabled rail feeds only', () => { + const route = resolveRailRoute('/paths/mta-lirr'); + assert.equal(route?.route, 'paths'); + assert.equal(route?.feed?.id, 'mta-lirr'); + assert.equal(resolveRailRoute('/paths/mta-lirr/?v=1')?.route, 'paths'); + assert.equal(resolveRailRoute('/trains/mta-lirr')?.route, 'trains'); + assert.equal(resolveRailRoute('/paths/vre'), null, 'not registered'); + assert.equal(resolveRailRoute('/paths/unknown'), null); + assert.equal(resolveRailRoute('/paths/'), null); + assert.equal(resolveRailRoute('/paths/mta-lirr/extra'), null); + assert.equal(resolveRailRoute('/paths/..%2F..%2Fetc%2Fpasswd'), null); + assert.equal(resolveRailRoute('/paths/mta-lirr.json'), null); + assert.equal(resolveRailRoute('/paths/%E0%A4%A'), null); +}); + +test('snapshots keep classified trains only and count what was dropped', () => { + const feed = getRailFeed('mbta-rail'); + const snapshot = buildRailSnapshot( + feed, + encodeFeed({ + entities: [ + { id: 'cr1', route: 'CR-Lowell' }, + { id: 'red1', route: 'Red' }, + { id: 'grn1', route: 'Green-D' }, + { id: 'bus1', route: '39' }, + { id: 'none' }, + ], + }), + 1_700_000_000_000, + ); + assert.equal(snapshot.feedId, 'mbta-rail'); + assert.equal(snapshot.entityCount, 5); + assert.equal(snapshot.count, 3); + assert.equal(snapshot.dropped, 2); + assert.deepEqual( + snapshot.trains.map(({ id, category, mode }) => [id, category, mode]), + [ + ['cr1', 'local', 'rail'], + ['red1', 'transit', 'metro'], + ['grn1', 'transit', 'tram'], + ], + ); + assert.equal(snapshot.trains[0].timestampSource, 'vehicle'); + assert.equal(snapshot.vehicles, undefined, 'buses never leave the server'); +}); + +test('a rail-only feed keeps vehicles that carry no route', () => { + const snapshot = buildRailSnapshot( + getRailFeed('mta-metro-north'), + encodeFeed({ entities: [{ id: 'mnr1' }] }), + ); + assert.equal(snapshot.count, 1); + assert.equal(snapshot.trains[0].category, 'local'); +}); + +test('a differential feed is refused, as in the Transit proxy', () => { + assert.throws( + () => + buildRailSnapshot( + getRailFeed('mbta-rail'), + encodeFeed({ incrementality: 1, entities: [] }), + ), + RailFeedShapeError, + ); +}); + +test('a body that is no FeedMessage is unreadable, not an empty snapshot', () => { + const feed = getRailFeed('mbta-rail'); + const headerless = new PbfWriter(); + const entity = new PbfWriter(); + entity.writeStringField(1, 'x'); + headerless.writeBytesField(2, entity.finish()); + const versionless = new PbfWriter(); + const header = new PbfWriter(); + header.writeVarintField(3, 1_700_000_000); + versionless.writeBytesField(1, header.finish()); + for (const [label, bytes] of [ + ['empty', new Uint8Array(0)], + ['no header', headerless.finish()], + ['no version', versionless.finish()], + ['html', new TextEncoder().encode('')], + ['json', new TextEncoder().encode('{"error":"quota"}')], + ]) { + for (const build of [buildRailSnapshot, buildRailTripUpdatesSnapshot]) + assert.throws( + () => build(feed, bytes, 1_700_000_000_000), + (error) => + error.code === 'UPSTREAM_UNREADABLE' && + !(error instanceof RailFeedShapeError), + `${build.name}: ${label}`, + ); + } + // A FeedMessage with its header and nothing else is a real empty feed. + const quiet = encodeFeed({ entities: [] }); + assert.equal(buildRailSnapshot(feed, quiet).count, 0); + assert.equal(buildRailTripUpdatesSnapshot(feed, quiet).count, 0); +}); + +test('upstream headers identify the rail proxy and let a feed override Accept', () => { + const plain = railUpstreamHeaders(getRailFeed('mbta-rail'), { + etag: '"abc"', + lastModified: 'Thu, 01 Jan 2026 00:00:00 GMT', + }); + assert.match(plain['User-Agent'], /gods-eye-view-rail-proxy/); + assert.equal(plain['If-None-Match'], '"abc"'); + assert.equal(plain['If-Modified-Since'], 'Thu, 01 Jan 2026 00:00:00 GMT'); + const adelaide = railUpstreamHeaders(getRegisteredRailFeed('adelaide-metro')); + assert.equal(adelaide.Accept, 'application/x-google-protobuf'); + assert.equal( + railUpstreamHeaders(getRailFeed('irish-rail')).Accept, + 'text/xml, application/xml;q=0.9', + ); +}); + +test('response headers report cache state and operator contact', () => { + const fresh = railResponseHeaders('MISS', 'cdn.mbta.com', 1_700_000_000_123); + assert.equal(fresh['X-GEV-Cache'], 'MISS'); + assert.equal(fresh['X-Rail-Upstream'], 'cdn.mbta.com'); + assert.equal(fresh['X-Rail-Contact'], '1700000000123'); + assert.match(fresh['Cache-Control'], /max-age=15/); + assert.equal(railResponseHeaders('STALE-ERROR')['Cache-Control'], 'no-store'); + assert.equal(railResponseHeaders('HIT')['X-Rail-Contact'], undefined); +}); + +test('GTFS trains say their positions are GPS', () => { + const snapshot = buildRailSnapshot( + getRailFeed('mbta-rail'), + encodeFeed({ entities: [{ id: 'cr1', route: 'CR-Lowell' }] }), + ); + assert.equal(snapshot.positions, 'gps'); + assert.equal(snapshot.trains[0].positionSource, 'gps'); +}); + +test('Irish Rail XML becomes classified, event-positioned trains', () => { + const now = 1_794_000_000_000; + const snapshot = buildRailSnapshot( + getRailFeed('irish-rail'), + new TextEncoder().encode(IRISH_XML), + now, + ); + assert.equal(snapshot.feedId, 'irish-rail'); + assert.equal(snapshot.positions, 'event'); + assert.equal(snapshot.entityCount, 2); + assert.equal(snapshot.count, 2); + assert.equal(snapshot.feedTimestamp, null); + const byId = new Map(snapshot.trains.map((t) => [t.id, t])); + assert.equal(byId.get('A118').category, 'international'); + assert.equal(byId.get('A118').positionSource, 'event'); + assert.equal(byId.get('A118').timestampSource, 'fetch'); + assert.equal(byId.get('A118').timestamp, now / 1000); + assert.equal(byId.get('E931').category, 'local'); + assert.equal(byId.get('E931').delaySec, 960); + assert.throws( + () => + buildRailSnapshot( + getRailFeed('irish-rail'), + new TextEncoder().encode('Service Unavailable'), + ), + (error) => + error.code === 'UPSTREAM_UNREADABLE' && + !(error instanceof RailFeedShapeError), + ); +}); + +test('no enabled feed is placed from its timetable', () => { + // Every train is where its operator reports it: no feed answers `/trains` + // with a timetable-only list. + for (const feed of RAIL_FEED_REGISTRY.filter((f) => f.defaultEnabled)) + assert.ok( + ['gps', 'event'].includes(railFeedPositions(feed)) && + typeof feed.url === 'string', + feed.id, + ); +}); + +test('trip updates keep rail trips only, with category, and refuse differential feeds', () => { + const feed = getRailFeed('mbta-rail'); + const snapshot = buildRailTripUpdatesSnapshot( + feed, + encodeTripUpdates([ + ['cr', 'CR-Worcester', 120], + ['red', 'Red', -30], + ['bus', '39', 60], + ['none', null, 0], + ]), + 5_000, + ); + assert.equal(snapshot.feedId, 'mbta-rail'); + assert.equal(snapshot.fetchedAt, 5_000); + assert.equal(snapshot.feedTimestamp, 1_794_000_000); + assert.equal(snapshot.count, 2); + assert.equal(snapshot.dropped, 2); + assert.deepEqual( + snapshot.trips.map((t) => [t.tripId, t.routeId, t.delaySec, t.category]), + [ + ['cr', 'CR-Worcester', 120, 'local'], + ['red', 'Red', -30, 'transit'], + ], + ); + assert.deepEqual(snapshot.trips[0].stops[0], { + stopId: 's0', + stopSequence: 1, + arrival: { time: null, delay: 120 }, + departure: null, + }); + assert.throws( + () => + buildRailTripUpdatesSnapshot( + feed, + encodeTripUpdates([], { incrementality: 1 }), + ), + RailFeedShapeError, + ); +}); + +test('a train named by its number takes its run, never a timetable position', () => { + const now = 1_794_000_000_000; + const bytes = new TextEncoder().encode(IRISH_XML); + const plain = buildRailSnapshot(getRailFeed('irish-rail'), bytes, now); + const named = buildRailSnapshot(getRailFeed('irish-rail'), bytes, now, { + tripFor: (code) => (code === 'A118' ? 'pack-trip' : null), + routeFor: (tripId) => + tripId === 'pack-trip' ? 'A~X~DUB-EUROPORT-I' : null, + }); + + const at = (snapshot) => + Object.fromEntries( + snapshot.trains.map((t) => [t.id, [t.lat, t.lon, t.positionSource]]), + ); + assert.deepEqual(at(named), at(plain), 'positions as reported'); + const train = named.trains.find((t) => t.id === 'A118'); + assert.equal(train.tripId, 'pack-trip'); + assert.equal(train.trainNumber, 'A118'); +}); + +test('trip updates are kept by their own route', () => { + const feed = getRailFeed('mbta-rail'); + const snapshot = buildRailTripUpdatesSnapshot( + feed, + encodeTripUpdates([ + ['cr', 'CR-Worcester', 120], + ['bus', '39', 60], + ]), + 5_000, + { routeFor: () => null }, + ); + assert.deepEqual( + snapshot.trips.map((t) => [t.tripId, t.routeId]), + [['cr', 'CR-Worcester']], + ); + assert.equal(snapshot.dropped, 1); +}); + +test('trip update answers are capped in trips', () => { + const rows = Array.from({ length: RAIL_UPDATES_MAX_TRIPS + 2 }, (_, i) => [ + `t${i}`, + 'CR-Lowell', + 0, + ]); + const snapshot = buildRailTripUpdatesSnapshot( + getRailFeed('mbta-rail'), + encodeTripUpdates(rows), + ); + assert.equal(snapshot.count, RAIL_UPDATES_MAX_TRIPS); + assert.equal(snapshot.truncated, true); + assert.equal(snapshot.dropped, 0, 'capped trips are rail, not dropped'); +}); + +test('/updates resolves only for feeds that publish trip updates', () => { + assert.equal(resolveRailRoute('/updates/mbta-rail')?.route, 'updates'); + assert.equal(resolveRailRoute('/updates/marc')?.feed?.id, 'marc'); + assert.equal(resolveRailRoute('/updates/irish-rail'), null, 'no updates'); + assert.equal(resolveRailRoute('/updates/capmetro-rail'), null); + assert.equal(resolveRailRoute('/updates/vre'), null, 'not registered'); + assert.equal(resolveRailRoute('/updates/mbta-rail/x'), null); +}); + +test('feed windows: own interval when set, a floor, and per-route defaults', () => { + assert.equal(railFeedTtlMs(getRailFeed('mbta-rail')), 15_000); + assert.equal(railFeedTtlMs(getRailFeed('mbta-rail'), 'updates'), 30_000); + assert.equal(railFeedTtlMs(getRailFeed('irish-rail')), 30_000); + assert.equal(railFeedTtlMs(getRailFeed('adelaide-metro'), 'updates'), 60_000); + assert.equal(railFeedTtlMs({ pollIntervalMs: 10 }), 5_000); + assert.equal(railMaxBodyBytes(getRailFeed('irish-rail')), 1024 * 1024); + assert.equal(railMaxBodyBytes(getRailFeed('mbta-rail')), 8 * 1024 * 1024); + assert.equal(railCacheStateFor(null, 0), 'none'); + assert.equal(railCacheStateFor({ at: 0 }, 29_000, 30_000), 'fresh'); + assert.equal(railCacheStateFor({ at: 0 }, 31_000, 30_000), 'stale'); + assert.equal(railCacheStateFor({ at: 0 }, 11 * 60_000, 30_000), 'expired'); +}); + +test('response headers follow the feed window', () => { + assert.match( + railResponseHeaders('MISS', 'h', null, 60_000)['Cache-Control'], + /max-age=60/, + ); + // An older snapshot is kept downstream only for the rest of its window. + const maxAge = (ageMs) => + railResponseHeaders('HIT', 'h', null, 60_000, ageMs)['Cache-Control']; + assert.equal(maxAge(45_000), 'public, max-age=15'); + assert.equal(maxAge(59_200), 'public, max-age=1'); + assert.equal(maxAge(60_000), 'public, max-age=0'); + assert.equal(maxAge(-5), 'public, max-age=60', 'a clock step is no age'); +}); + +test('/schedule resolves for every pollable feed', () => { + assert.equal(resolveRailRoute('/schedule/ktmb')?.route, 'schedule'); + assert.equal( + resolveRailRoute('/schedule/irish-rail')?.feed?.id, + 'irish-rail', + ); + assert.equal(resolveRailRoute('/schedule/mbta-rail')?.route, 'schedule'); + assert.equal(resolveRailRoute('/schedule/vre'), null, 'not registered'); + assert.equal(resolveRailRoute('/schedule/ktmb/x'), null); + assert.equal(resolveRailRoute('/schedule/..%2Fktmb'), null); +}); + +test('the global admission cap scales with the registry', () => { + const natural = railNaturalRequestsPerMinute(); + const cap = railAdmissionGlobalMax(); + assert.ok(natural > 40, 'the registry outgrew the old fixed cap'); + assert.ok( + cap >= natural * 1.5, + 'room for retries on top of the natural load', + ); + assert.equal(railAdmissionGlobalMax([]), 40, 'never below the Transit floor'); + const one = [{ defaultEnabled: true, url: 'https://x.test/vp' }]; + assert.equal(railNaturalRequestsPerMinute(one), 4, 'one feed at 15 s'); + const updatesOnly = [ + { defaultEnabled: true, tripUpdatesUrl: 'https://x.test/tu' }, + ]; + assert.equal(railNaturalRequestsPerMinute(updatesOnly), 2, 'no positions'); + assert.equal( + railNaturalRequestsPerMinute([{ ...one[0], defaultEnabled: false }]), + 0, + ); +}); + +/** Vehicles with a trip id and no route or time, as Renfe sends them. */ +function encodeRenfeLike(headerTime, trips) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, headerTime); + writer.writeBytesField(1, header.finish()); + for (const [id, tripId, route] of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, id); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + if (route) trip.writeStringField(5, route); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 40.4); + position.writeFloatField(2, -3.7); + vehicle.writeBytesField(2, position.finish()); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, id); + vehicle.writeBytesField(8, descriptor.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('a feed with an untrusted header clock stamps its trains with the fetch time', () => { + const now = 1_794_000_000_000; + // Renfe writes local time as UTC: the header reads an hour behind. + const bytes = encodeRenfeLike(now / 1000 - 3600, [ + ['04192', '0419212026-11-06'], + ]); + const routes = new Map([['0419212026-11-06', 'ALVIA~X~1700037606GL023']]); + const snapshot = buildRailSnapshot( + getRailFeed('renfe-larga-distancia'), + bytes, + now, + { + routeFor: (tripId) => routes.get(tripId) ?? null, + }, + ); + assert.equal(snapshot.count, 1); + const [train] = snapshot.trains; + assert.equal(train.timestampSource, 'fetch'); + assert.equal(train.timestamp, now / 1000, 'not the header time'); + assert.equal( + snapshot.feedTimestamp, + null, + 'the header clock is not reported', + ); + assert.equal( + train.routeId, + 'ALVIA~X~1700037606GL023', + 'from the schedule pack', + ); + assert.equal(train.category, 'national'); + // A trusted feed keeps the header ladder. + const trusted = buildRailSnapshot( + getRailFeed('mta-metro-north'), + encodeRenfeLike(now / 1000 - 5, [['mnr1', 't1']]), + now, + ); + assert.equal(trusted.trains[0].timestampSource, 'header'); +}); + +test('a trip the schedule pack does not know is dropped, not guessed', () => { + const snapshot = buildRailSnapshot( + getRailFeed('renfe-larga-distancia'), + encodeRenfeLike(1_794_000_000, [ + ['a', 'known'], + ['b', 'unknown'], + ]), + 1_794_000_000_000, + { routeFor: (tripId) => (tripId === 'known' ? 'MD~S~1' : null) }, + ); + assert.deepEqual( + snapshot.trains.map((t) => [t.id, t.category]), + [['a', 'regional']], + ); + assert.equal(snapshot.dropped, 1); + // Without a lookup nothing in this feed can be classified. + const blind = buildRailSnapshot( + getRailFeed('renfe-larga-distancia'), + encodeRenfeLike(1_794_000_000, [['a', 'known']]), + ); + assert.equal(blind.count, 0); +}); + +test('trip updates take their route from the schedule pack too, and carry the tourist flag', () => { + const snapshot = buildRailTripUpdatesSnapshot( + getRailFeed('renfe-larga-distancia'), + encodeTripUpdates([ + ['0419212026-11-06', null, 60], + ['999', null, 0], + ]), + 5_000, + { + routeFor: (tripId) => + tripId === '0419212026-11-06' ? 'ALVIA~X~1700037606GL023' : null, + }, + ); + assert.deepEqual( + snapshot.trips.map((t) => [t.tripId, t.routeId, t.category]), + [['0419212026-11-06', 'ALVIA~X~1700037606GL023', 'national']], + ); + assert.equal(snapshot.dropped, 1, 'the trip the pack does not know'); + const flam = buildRailSnapshot( + getRailFeed('entur-vy'), + encodeFeed({ entities: [{ id: 'flam', route: 'VYG:Line:R45' }] }), + ); + assert.equal(flam.trains[0].tourist, true); + assert.equal(flam.trains[0].category, 'regional'); +}); diff --git a/src/data/railSchedule.js b/src/data/railSchedule.js new file mode 100644 index 000000000..7e15d8669 --- /dev/null +++ b/src/data/railSchedule.js @@ -0,0 +1,741 @@ +import { + dayNumberOf, + formatClock, + isTimeZone, + parseServiceDate, + serviceDayOriginMs, +} from './railTimeZones.js'; + +/** + * @module railSchedule + * @description Timetable tier of the Rail layer: which trains run now + * according to the operator's static GTFS, where along their line the + * timetable puts them (only to match a live train to its run, never drawn), + * and the stop list a train card shows. + * + * Schedule packs come from scripts/build-rail-paths.mjs (`.schedule.json`). + * Service calendars are resolved to concrete dates at build time, so this + * module never evaluates a calendar. Stop times are seconds after the service + * day's GTFS origin (noon minus 12 h, agency time zone) and may exceed 24 h. + * + * Realtime delays follow GTFS-RT TripUpdate semantics: + * + * delays = { [tripId]: { delaySec?, startDate?, relationship?, stops?: [{ + * stopId? | stopSequence?, relationship?, + * arrival?: { time?, delay? }, departure?: { time?, delay? } }] } } + * + * (the trip records of src/data/railTripUpdates.js, keyed by trip id). A + * trip `relationship` of 'CANCELED' or 'DELETED' drops the run from + * activeTrips; a stop `relationship` of 'SKIPPED' (or `skipped: true`) is + * passed without a stop, and one of 'NO_DATA' has no prediction: that stop + * and the stops after it keep their timetable times until the next update. + * + * `time` is POSIX seconds (milliseconds are accepted too), `delay` seconds. + * A stop update's delay carries on to later stops until the next update; + * stops before the first update use the trip's `delaySec` (else the + * timetable). `startDate` ('YYYYMMDD' or 'YYYY-MM-DD') pins the entry to one + * run of a trip that runs on several days. + * + * Clock strings are 24-hour 'HH:MM' in each STATION's own time zone. + * + * Pure: no Cesium, no DOM, no I/O. + */ + +/** Schedule-pack format this module reads. */ +export const RAIL_SCHEDULE_PACK_VERSION = 1; + +/** Earliest a delayed or early train is looked for around its timetable (ms). */ +const EARLY_SLACK_MS = 60 * 60_000; +const LATE_SLACK_MS = 12 * 60 * 60_000; +const MINUS = '−'; +/** TripUpdate relationships under which a scheduled run does not run. */ +const CANCELED = new Set(['CANCELED', 'DELETED']); + +/** + * A relative time chip as the Launches layer writes its countdown: `T−4` + * for an event four minutes ahead, `T+3` for one three minutes past. Minutes + * round up toward the event (Math.ceil, as the Launches countdown rounds its + * seconds), so `T−1` shows until the moment itself and `T+0` for the first + * minute after. An hour or more reads `T−1:05`. + * @param {number} deltaMs Event time minus now. + * @returns {string} + */ +export function formatRelative(deltaMs) { + if (!Number.isFinite(deltaMs)) return ''; + const minutes = Math.ceil(deltaMs / 60_000); + const sign = deltaMs > 0 ? MINUS : '+'; + const total = Math.abs(minutes); + const text = + total >= 60 + ? `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}` + : String(total); + return `T${sign}${text}`; +} + +const isIndex = (value, length) => + Number.isInteger(value) && value >= 0 && value < length; + +/** Largest delay a live report may state, either way (s): a day. */ +const LIVE_DELAY_MAX_S = 86_400; + +/** + * The delay a live train's own report states (`delaySec`: Irish Rail's + * public message, SIRI VM's `Delay`), for a feed that publishes no trip + * updates; null when it states none, or more than a day either way (a unit + * error, not a late train). + * @param {{delaySec?: unknown}|null|undefined} record + * @returns {number|null} Seconds, positive when late. + */ +export function liveDelaySec(record) { + const value = record?.delaySec; + return typeof value === 'number' && + Number.isFinite(value) && + Math.abs(value) <= LIVE_DELAY_MAX_S + ? value + : null; +} + +/** + * Validate and index a decoded schedule pack. + * @param {object} raw The JSON body of `.schedule.json`. + * @returns {object} A schedule for the functions below. + */ +export function createSchedule(raw) { + if ( + !raw || + raw.version !== RAIL_SCHEDULE_PACK_VERSION || + typeof raw.feedId !== 'string' || + !Array.isArray(raw.stops) || + !Array.isArray(raw.patterns) || + !Array.isArray(raw.profiles) || + !raw.trips || + typeof raw.trips !== 'object' || + !raw.services || + typeof raw.services !== 'object' + ) + throw new TypeError('Malformed rail schedule pack'); + const timezone = isTimeZone(raw.timezone) ? raw.timezone : 'UTC'; + const stops = raw.stops.map((stop, index) => ({ + index, + id: String(stop?.id ?? ''), + name: String(stop?.name ?? ''), + lat: Number(stop?.lat), + lon: Number(stop?.lon), + parent: isIndex(stop?.parent, raw.stops.length) ? stop.parent : null, + platform: stop?.platform ? String(stop.platform) : null, + ownZone: isTimeZone(stop?.tz) ? stop.tz : null, + })); + for (const stop of stops) { + const parent = stop.parent === null ? null : stops[stop.parent]; + stop.timezone = stop.ownZone ?? parent?.ownZone ?? timezone; + delete stop.ownZone; + Object.freeze(stop); + } + const stopIndexById = new Map(stops.map((stop) => [stop.id, stop.index])); + const patterns = raw.patterns.map((pattern, index) => { + const list = Array.isArray(pattern?.stops) ? pattern.stops : []; + const n = list.length; + if (n < 2 || !list.every((value) => isIndex(value, stops.length))) + throw new TypeError(`Malformed rail schedule pattern ${index}`); + const dist = + Array.isArray(pattern.dist) && pattern.dist.length === n + ? pattern.dist.map(Number) + : new Array(n).fill(0); + const seq = + Array.isArray(pattern.seq) && pattern.seq.length === n + ? pattern.seq.map(Number) + : list.map((_, k) => k + 1); + return Object.freeze({ + index, + shape: Number.isInteger(pattern.shape) ? pattern.shape : null, + stops: Object.freeze(list.slice()), + seq: Object.freeze(seq), + dist: Object.freeze(dist), + pass: Object.freeze( + Array.isArray(pattern.pass) && pattern.pass.length === n + ? pattern.pass.map((value) => value === 1) + : new Array(n).fill(false), + ), + }); + }); + const origins = new Map(); + const originOf = (date) => { + let origin = origins.get(date); + if (origin === undefined) { + origin = serviceDayOriginMs(date, timezone); + origins.set(date, origin); + } + return origin; + }; + const trips = new Map(); + const instances = []; + let maxDurationMs = 0; + for (const [tripId, value] of Object.entries(raw.trips)) { + const pattern = patterns[value?.pattern]; + const profile = raw.profiles[value?.profile]; + if (!pattern || !Array.isArray(profile)) continue; + if (profile.length !== pattern.stops.length * 2) continue; + const startSec = Number(value.startSec); + if (!Number.isFinite(startSec)) continue; + const trip = Object.freeze({ + id: tripId, + pattern, + offsets: profile, + startSec, + routeId: value.route == null ? null : String(value.route), + service: String(value.service ?? ''), + headsign: value.headsign ? String(value.headsign) : null, + name: value.name ? String(value.name) : null, + direction: Number.isInteger(value.direction) ? value.direction : null, + }); + trips.set(tripId, trip); + const dates = raw.services[trip.service]; + if (!Array.isArray(dates)) continue; + const durationMs = profile[profile.length - 1] * 1000; + if (durationMs > maxDurationMs) maxDurationMs = durationMs; + for (const date of dates) { + if (!parseServiceDate(date)) continue; + const startMs = originOf(date) + startSec * 1000; + if (Number.isFinite(startMs)) + instances.push({ + trip, + serviceDate: date, + startMs, + endMs: startMs + durationMs, + }); + } + } + instances.sort((a, b) => a.startMs - b.startMs); + const runsByTrip = new Map(); + for (const instance of instances) { + let list = runsByTrip.get(instance.trip.id); + if (!list) runsByTrip.set(instance.trip.id, (list = [])); + list.push(instance); + } + return Object.freeze({ + feedId: raw.feedId, + /** + * The `builtAt` of the path pack this timetable's shape indices point + * into (written by the same build), or null in packs that predate it. + */ + pathsBuiltAt: + typeof raw.pathsBuiltAt === 'string' ? raw.pathsBuiltAt : null, + timezone, + window: raw.window ?? null, + routes: raw.routes && typeof raw.routes === 'object' ? raw.routes : {}, + stops: Object.freeze(stops), + patterns: Object.freeze(patterns), + trips, + stopIndexById, + instances: Object.freeze(instances), + runsByTrip, + instanceStarts: Float64Array.from( + instances, + (instance) => instance.startMs, + ), + maxDurationMs, + }); +} + +/** + * When the last run a decoded schedule pack holds arrives (epoch ms): past + * it the pack's window has ended, and it names no train that can still be + * running. Each trip counts on the latest date of its service. Read from the + * raw pack, without indexing it (the server checks a pack it reads only for + * its routes). Null when the pack names no window (`window.to`), so its end + * is not known; -Infinity when it holds no run. + * @param {object} raw The JSON body of `.schedule.json`. + * @returns {number|null} + */ +export function schedulePackEndMs(raw) { + if (!parseServiceDate(raw?.window?.to)) return null; + const trips = raw?.trips && typeof raw.trips === 'object' ? raw.trips : {}; + const services = + raw?.services && typeof raw.services === 'object' ? raw.services : {}; + const profiles = Array.isArray(raw?.profiles) ? raw.profiles : []; + const zone = isTimeZone(raw?.timezone) ? raw.timezone : 'UTC'; + /** Service id → the origin of its latest date (-Infinity: no date). */ + const lastOrigin = new Map(); + const latestOrigin = (service) => { + let origin = lastOrigin.get(service); + if (origin !== undefined) return origin; + const dates = Object.hasOwn(services, service) ? services[service] : null; + let latest = -Infinity; + let latestDate = null; + for (const date of Array.isArray(dates) ? dates : []) { + const day = dayNumberOf(date); + if (day > latest) { + latest = day; + latestDate = date; + } + } + origin = latestDate === null ? NaN : serviceDayOriginMs(latestDate, zone); + if (!Number.isFinite(origin)) origin = -Infinity; + lastOrigin.set(service, origin); + return origin; + }; + let end = -Infinity; + for (const trip of Object.values(trips)) { + const profile = profiles[trip?.profile]; + const startSec = Number(trip?.startSec); + if (!Array.isArray(profile) || !Number.isFinite(startSec)) continue; + const origin = latestOrigin(String(trip.service ?? '')); + if (origin === -Infinity) continue; + const durationSec = Number(profile[profile.length - 1]) || 0; + end = Math.max(end, origin + (startSec + durationSec) * 1000); + } + return end; +} + +/** First index whose start is greater than `ms`. */ +function upperBound(starts, ms) { + let lo = 0; + let hi = starts.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (starts[mid] <= ms) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** The public view of one run of a trip. */ +function runOf(instance) { + const { trip } = instance; + return { + tripId: trip.id, + serviceDate: instance.serviceDate, + routeId: trip.routeId, + headsign: trip.headsign, + name: trip.name, + direction: trip.direction, + pattern: trip.pattern.index, + shape: trip.pattern.shape, + scheduledStartMs: instance.startMs, + scheduledEndMs: instance.endMs, + }; +} + +/** The delay entry for a run, honouring a `startDate` pin. */ +function delayFor(delays, run) { + const entry = + delays && Object.hasOwn(delays, run.tripId) ? delays[run.tripId] : null; + if (!entry || typeof entry !== 'object') return null; + if (entry.startDate != null) { + const pinned = parseServiceDate(entry.startDate); + const own = parseServiceDate(run.serviceDate); + if ( + !pinned || + !own || + pinned.year !== own.year || + pinned.month !== own.month || + pinned.day !== own.day + ) + return null; + } + return entry; +} + +const toMs = (time) => { + const value = Number(time); + if (!Number.isFinite(value) || value <= 0) return NaN; + return value > 1e11 ? value : value * 1000; +}; + +/** The pattern index each stop update applies to, in order. */ +function matchUpdates(schedule, pattern, updates) { + const byIndex = new Map(); + let from = 0; + for (const update of updates) { + if (!update || typeof update !== 'object') continue; + let found = -1; + for (let pass = 0; pass < 2 && found < 0; pass++) { + const start = pass === 0 ? from : 0; + for (let k = start; k < pattern.stops.length; k++) { + if (stopMatches(schedule, pattern, k, update)) { + found = k; + break; + } + } + } + if (found < 0) continue; + byIndex.set(found, update); + from = found + 1; + } + return byIndex; +} + +function stopMatches(schedule, pattern, k, update) { + if (update.stopSequence != null && update.stopSequence !== '') + return Number(update.stopSequence) === pattern.seq[k]; + if (update.stopId == null) return false; + const id = String(update.stopId); + const stop = schedule.stops[pattern.stops[k]]; + if (stop.id === id) return true; + if (stop.parent !== null && schedule.stops[stop.parent].id === id) + return true; + const other = schedule.stopIndexById.get(id); + if (other === undefined) return false; + const otherParent = schedule.stops[other].parent; + return otherParent !== null && otherParent === (stop.parent ?? stop.index); +} + +/** Whether a stop update carries this StopTimeUpdate relationship. */ +function stopRelationshipIs(update, relationship) { + return ( + update.relationship === relationship || + update.scheduleRelationship === relationship + ); +} + +/** + * Scheduled and expected times of every stop of a run. + * @returns {{schedArr: number[], schedDep: number[], arr: number[], + * dep: number[], skipped: boolean[], hasRealtime: boolean}} + */ +function runTimes(schedule, instance, delays) { + const { trip } = instance; + const n = trip.pattern.stops.length; + const schedArr = new Array(n); + const schedDep = new Array(n); + for (let k = 0; k < n; k++) { + schedArr[k] = instance.startMs + trip.offsets[2 * k] * 1000; + schedDep[k] = instance.startMs + trip.offsets[2 * k + 1] * 1000; + } + const arr = schedArr.slice(); + const dep = schedDep.slice(); + const skipped = new Array(n).fill(false); + const entry = delayFor(delays, { + tripId: trip.id, + serviceDate: instance.serviceDate, + }); + if (!entry) + return { schedArr, schedDep, arr, dep, skipped, hasRealtime: false }; + const updates = matchUpdates( + schedule, + trip.pattern, + Array.isArray(entry.stops) ? entry.stops : [], + ); + let carried = Number.isFinite(Number(entry.delaySec)) + ? Number(entry.delaySec) + : 0; + for (let k = 0; k < n; k++) { + const update = updates.get(k); + let arrDelay = carried; + let depDelay = carried; + if (update && stopRelationshipIs(update, 'NO_DATA')) { + // No prediction (GTFS-RT NO_DATA): the delay carried so far no longer + // holds, here or at the stops after it. + carried = 0; + arrDelay = 0; + depDelay = 0; + } else if (update) { + if (update.skipped === true || stopRelationshipIs(update, 'SKIPPED')) + skipped[k] = true; + const a = update.arrival ?? null; + const d = update.departure ?? null; + const aTime = toMs(a?.time); + const dTime = toMs(d?.time); + const aDelay = Number(a?.delay); + const dDelay = Number(d?.delay); + let arrivalGiven = true; + if (Number.isFinite(aTime)) arrDelay = (aTime - schedArr[k]) / 1000; + else if (a && a.delay != null && Number.isFinite(aDelay)) + arrDelay = aDelay; + else arrivalGiven = false; + if (Number.isFinite(dTime)) depDelay = (dTime - schedDep[k]) / 1000; + else if (d && d.delay != null && Number.isFinite(dDelay)) + depDelay = dDelay; + else if (arrivalGiven) depDelay = arrDelay; + else depDelay = carried; + if (!arrivalGiven && (Number.isFinite(dTime) || (d && d.delay != null))) + arrDelay = Math.min(carried, depDelay); + carried = depDelay; + } + arr[k] = schedArr[k] + arrDelay * 1000; + dep[k] = schedDep[k] + depDelay * 1000; + } + // Predictions are not always consistent; time never runs backwards. + for (let k = 0; k < n; k++) { + if (k > 0 && arr[k] < dep[k - 1]) arr[k] = dep[k - 1]; + if (dep[k] < arr[k]) dep[k] = arr[k]; + } + return { schedArr, schedDep, arr, dep, skipped, hasRealtime: true }; +} + +/** The instance of `trip` (a run object or a trip id) meant at `nowMs`. */ +function resolveInstance(schedule, trip, nowMs, delays) { + const pinned = trip && typeof trip === 'object'; + const runs = schedule.runsByTrip.get( + String((pinned ? trip.tripId : trip) ?? ''), + ); + if (!runs) return null; + if (pinned && trip.serviceDate != null) { + const match = runs.find( + (instance) => instance.serviceDate === trip.serviceDate, + ); + if (match) return match; + } + let best = null; + let bestScore = Infinity; + for (const instance of runs) { + const times = runTimes(schedule, instance, delays); + const start = times.dep[0]; + const end = times.arr[times.arr.length - 1]; + // Running now wins; else the nearest run, upcoming before past. + const score = + nowMs >= start && nowMs <= end + ? -1 + : nowMs < start + ? start - nowMs + : (nowMs - end) * 2; + if (score < bestScore) { + bestScore = score; + best = instance; + } + } + return best; +} + +/** + * Trains the timetable has running at `nowMs`: from the departure at the + * first stop to the arrival at the last, shifted by realtime delays. + * @param {object} schedule From createSchedule. + * @param {number} nowMs + * @param {{delays?: object, beforeMs?: number, afterMs?: number}} [options] + * `beforeMs` / `afterMs` widen the window (a train waiting at its origin, + * one just arrived). + * @returns {object[]} Runs: {tripId, serviceDate, routeId, headsign, name, + * direction, pattern, shape, scheduledStartMs, scheduledEndMs, + * expectedStartMs, expectedEndMs}. + */ +export function activeTrips( + schedule, + nowMs, + { delays = null, beforeMs = 0, afterMs = 0 } = {}, +) { + if (!schedule || !Number.isFinite(nowMs)) return []; + const lead = Math.max(0, beforeMs); + const tail = Math.max(0, afterMs); + const last = upperBound( + schedule.instanceStarts, + nowMs + lead + EARLY_SLACK_MS, + ); + const floor = nowMs - tail - LATE_SLACK_MS - schedule.maxDurationMs; + const out = []; + for (let i = last - 1; i >= 0; i--) { + const instance = schedule.instances[i]; + if (instance.startMs < floor) break; + const run = runOf(instance); + let start = instance.startMs + instance.trip.offsets[1] * 1000; + let end = instance.endMs; + const entry = delayFor(delays, run); + if (entry && CANCELED.has(entry.relationship)) continue; + if (entry) { + const times = runTimes(schedule, instance, delays); + start = times.dep[0]; + end = times.arr[times.arr.length - 1]; + } else if (instance.endMs < nowMs - tail) continue; + if (nowMs < start - lead || nowMs > end + tail) continue; + run.expectedStartMs = start; + run.expectedEndMs = end; + out.push(run); + } + return out.reverse(); +} + +/** + * Where the timetable puts a train at `nowMs`, along its pattern's shape. + * Between two stops the arc runs linearly in time from the departure to the + * next arrival; during a dwell it holds at the stop. Before the first + * departure it waits at the origin, after the last arrival at the terminus. + * + * @param {object} schedule From createSchedule. + * @param {object|string} trip A run from activeTrips, or a trip id. + * @param {number} nowMs + * @param {object} [delays] + * @returns {{tripId: string, serviceDate: string, shape: number|null, + * s: number, prevStop: number, nextStop: number, + * prevStopId: string|null, nextStopId: string|null, dwell: boolean, + * state: 'before'|'running'|'arrived', fraction: number, delaySec: number, + * lat?: number, lon?: number}|null} Stop fields are pattern indices (-1 for + * none). `lat`/`lon` interpolate between the stops and are set only when + * the pattern has no shape. + */ +export function scheduledPosition(schedule, trip, nowMs, delays = null) { + if (!schedule || !Number.isFinite(nowMs)) return null; + const instance = resolveInstance(schedule, trip, nowMs, delays); + if (!instance) return null; + const { pattern } = instance.trip; + const times = runTimes(schedule, instance, delays); + // Skipped stops are passed without a stop: their times do not pace the run. + const nodes = []; + for (let k = 0; k < pattern.stops.length; k++) + if (!times.skipped[k]) nodes.push(k); + if (nodes.length < 2) + nodes.splice(0, nodes.length, 0, pattern.stops.length - 1); + const result = { + tripId: instance.trip.id, + serviceDate: instance.serviceDate, + shape: pattern.shape, + s: 0, + prevStop: -1, + nextStop: -1, + prevStopId: null, + nextStopId: null, + dwell: false, + state: 'running', + fraction: 0, + delaySec: 0, + }; + const first = nodes[0]; + const lastNode = nodes[nodes.length - 1]; + let at = -1; + let from = -1; + let to = -1; + if (nowMs < times.dep[first]) { + result.state = 'before'; + at = first; + result.prevStop = -1; + result.nextStop = first; + } else if (nowMs >= times.arr[lastNode]) { + result.state = 'arrived'; + at = lastNode; + result.prevStop = lastNode; + result.nextStop = -1; + } else { + for (let j = 0; j < nodes.length; j++) { + const k = nodes[j]; + if (nowMs >= times.arr[k] && nowMs < times.dep[k]) { + at = k; + result.prevStop = k; + result.nextStop = j + 1 < nodes.length ? nodes[j + 1] : -1; + break; + } + if ( + j + 1 < nodes.length && + nowMs >= times.dep[k] && + nowMs < times.arr[nodes[j + 1]] + ) { + from = k; + to = nodes[j + 1]; + break; + } + } + } + if (at >= 0) { + result.dwell = true; + result.s = pattern.dist[at]; + result.fraction = 0; + result.delaySec = Math.round( + (result.state === 'arrived' + ? times.arr[at] - times.schedArr[at] + : times.dep[at] - times.schedDep[at]) / 1000, + ); + } else { + const span = times.arr[to] - times.dep[from]; + const fraction = + span > 0 ? Math.min(1, Math.max(0, (nowMs - times.dep[from]) / span)) : 1; + result.prevStop = from; + result.nextStop = to; + result.fraction = fraction; + result.s = + pattern.dist[from] + (pattern.dist[to] - pattern.dist[from]) * fraction; + result.delaySec = Math.round((times.arr[to] - times.schedArr[to]) / 1000); + } + if (result.prevStop >= 0) + result.prevStopId = schedule.stops[pattern.stops[result.prevStop]].id; + if (result.nextStop >= 0) + result.nextStopId = schedule.stops[pattern.stops[result.nextStop]].id; + if (pattern.shape === null) { + const a = + schedule.stops[ + pattern.stops[result.prevStop >= 0 ? result.prevStop : result.nextStop] + ]; + const b = result.dwell ? a : schedule.stops[pattern.stops[result.nextStop]]; + result.lat = a.lat + (b.lat - a.lat) * result.fraction; + result.lon = a.lon + (b.lon - a.lon) * result.fraction; + } + return result; +} + +/** + * A run's stop list for a train card. + * + * `state` is 'passed' once the train has left (or, at the terminus, reached) + * the stop, 'next' for the first stop not yet passed (with `atStop` while the + * train dwells there) and 'upcoming' after it. `relative` is the chip from + * formatRelative: for a passed stop the departure (terminus: arrival), for + * the stop the train stands at its departure, else the arrival. + * + * @param {object} schedule + * @param {object|string} trip A run from activeTrips, or a trip id. + * @param {number} nowMs + * @param {object} [delays] + * @returns {object[]} Per stop: {index, stopId, name, platform, parentId, + * parentName, lat, lon, timezone, scheduledArrivalMs, + * scheduledDepartureMs, expectedArrivalMs, expectedDepartureMs, + * arrivalDelaySec, departureDelaySec, scheduledArrival, + * scheduledDeparture, expectedArrival, expectedDeparture (local 'HH:MM'), + * passThrough, skipped, realtime, state, atStop, relative, relativeMs}. + */ +export function tripStops(schedule, trip, nowMs, delays = null) { + if (!schedule || !Number.isFinite(nowMs)) return []; + const instance = resolveInstance(schedule, trip, nowMs, delays); + if (!instance) return []; + const { pattern } = instance.trip; + const times = runTimes(schedule, instance, delays); + const n = pattern.stops.length; + const out = []; + let nextFound = false; + for (let k = 0; k < n; k++) { + const stop = schedule.stops[pattern.stops[k]]; + const parent = stop.parent === null ? null : schedule.stops[stop.parent]; + const zone = stop.timezone; + const terminus = k === n - 1; + const leftAt = terminus ? times.arr[k] : times.dep[k]; + let state; + let atStop = false; + if (nowMs >= leftAt) state = 'passed'; + else if (!nextFound) { + state = 'next'; + nextFound = true; + atStop = nowMs >= times.arr[k] && !times.skipped[k]; + } else state = 'upcoming'; + const eventMs = + state === 'passed' + ? leftAt + : atStop || k === 0 + ? times.dep[k] + : times.arr[k]; + out.push({ + index: k, + stopId: stop.id, + name: stop.name || parent?.name || stop.id, + platform: stop.platform, + parentId: parent?.id ?? null, + parentName: parent?.name ?? null, + lat: stop.lat, + lon: stop.lon, + timezone: zone, + scheduledArrivalMs: times.schedArr[k], + scheduledDepartureMs: times.schedDep[k], + expectedArrivalMs: times.arr[k], + expectedDepartureMs: times.dep[k], + arrivalDelaySec: Math.round((times.arr[k] - times.schedArr[k]) / 1000), + departureDelaySec: Math.round((times.dep[k] - times.schedDep[k]) / 1000), + scheduledArrival: formatClock(times.schedArr[k], zone), + scheduledDeparture: formatClock(times.schedDep[k], zone), + expectedArrival: formatClock(times.arr[k], zone), + expectedDeparture: formatClock(times.dep[k], zone), + passThrough: pattern.pass[k], + skipped: times.skipped[k], + realtime: times.hasRealtime, + state, + atStop, + relativeMs: eventMs - nowMs, + relative: formatRelative(eventMs - nowMs), + }); + } + return out; +} diff --git a/src/data/railSchedule.test.mjs b/src/data/railSchedule.test.mjs new file mode 100644 index 000000000..070b9a2fa --- /dev/null +++ b/src/data/railSchedule.test.mjs @@ -0,0 +1,444 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + activeTrips, + createSchedule, + formatRelative, + liveDelaySec, + schedulePackEndMs, + scheduledPosition, + tripStops, +} from './railSchedule.js'; +import { + formatClock, + localDate, + serviceDayOriginMs, + zonedTimeToEpochMs, +} from './railTimeZones.js'; + +const MIN = 60_000; +const H = 3_600_000; + +/** + * Four stops on one shape, New York time except the last (Chicago). Run + * times: A 0, B arrive +5 min / leave +6 min, C +15 min, D +25 min. + */ +function pack(overrides = {}) { + return { + version: 1, + feedId: 'test-feed', + timezone: 'America/New_York', + window: { from: '2026-10-31', to: '2026-11-03' }, + routes: { R: { type: 2, short: 'R' } }, + stops: [ + { id: 'A', name: 'Alpha', lat: 40, lon: -74 }, + { + id: 'B1', + name: 'Track 1', + lat: 40, + lon: -73.99, + parent: 4, + platform: '1', + }, + { id: 'C', name: 'Charlie', lat: 40, lon: -73.97 }, + { id: 'D', name: 'Delta', lat: 40, lon: -73.94, tz: 'America/Chicago' }, + { id: 'B', name: 'Bravo', lat: 40, lon: -73.99 }, + ], + patterns: [ + { + shape: 0, + stops: [0, 1, 2, 3], + seq: [1, 2, 3, 4], + dist: [0, 1000, 3000, 6000], + }, + { shape: null, stops: [0, 2], seq: [1, 2], dist: [0, 2500] }, + ], + profiles: [ + [0, 0, 300, 360, 900, 900, 1500, 1500], + [0, 0, 600, 600], + ], + services: { + SAT: ['2026-10-31'], + SUN: ['2026-11-01'], + DAILY: ['2026-10-31', '2026-11-01', '2026-11-02'], + }, + trips: { + morning: { + pattern: 0, + profile: 0, + startSec: 8 * 3600, + route: 'R', + service: 'SUN', + headsign: 'Delta', + name: '101', + direction: 0, + }, + late: { + pattern: 0, + profile: 0, + startSec: 25 * 3600, + route: 'R', + service: 'SAT', + name: '199', + }, + fallback: { + pattern: 0, + profile: 0, + startSec: 1.5 * 3600, + route: 'R', + service: 'SUN', + }, + shuttle: { + pattern: 1, + profile: 1, + startSec: 12 * 3600, + route: 'R', + service: 'DAILY', + }, + }, + ...overrides, + }; +} + +const at = (iso) => Date.parse(iso); +/** 08:00 EST on 2026-11-01, the morning trip's departure. */ +const MORNING = at('2026-11-01T13:00:00Z'); + +test('relative chips read T−n ahead and T+n past, rounded like the Launches countdown', () => { + assert.equal(formatRelative(4 * MIN), 'T−4'); + assert.equal(formatRelative(3.5 * MIN), 'T−4'); + assert.equal(formatRelative(30_000), 'T−1'); + assert.equal(formatRelative(0), 'T+0'); + assert.equal(formatRelative(-30_000), 'T+0'); + assert.equal(formatRelative(-3 * MIN), 'T+3'); + assert.equal(formatRelative(-3.5 * MIN), 'T+3'); + assert.equal(formatRelative(65 * MIN), 'T−1:05'); + assert.equal(formatRelative(-125 * MIN), 'T+2:05'); + assert.equal(formatRelative(NaN), ''); +}); + +test('service days count from noon minus 12 h, across both DST changes', () => { + // Ordinary day: local midnight. + assert.equal( + serviceDayOriginMs('2026-10-31', 'America/New_York'), + at('2026-10-31T04:00:00Z'), + ); + // Fall back: 01:00 EDT, so 08:00:00 is 08:00 EST. + const fall = serviceDayOriginMs('20261101', 'America/New_York'); + assert.equal(fall, at('2026-11-01T05:00:00Z')); + assert.equal(formatClock(fall + 8 * H, 'America/New_York'), '08:00'); + // Spring forward: 23:00 EST the evening before, so 06:00:00 is 06:00 EDT. + const spring = serviceDayOriginMs('2026-03-08', 'America/New_York'); + assert.equal(spring, at('2026-03-08T04:00:00Z')); + assert.equal(formatClock(spring + 6 * H, 'America/New_York'), '06:00'); + assert.ok(Number.isNaN(serviceDayOriginMs('2026-02-30', 'UTC'))); +}); + +test('wall times resolve to instants: overlaps take the earlier, gaps move on', () => { + assert.equal( + zonedTimeToEpochMs('America/New_York', 2026, 11, 1, 1, 30), + at('2026-11-01T05:30:00Z'), + ); + assert.equal( + zonedTimeToEpochMs('America/New_York', 2026, 3, 8, 2, 30), + at('2026-03-08T07:30:00Z'), + ); + assert.equal( + zonedTimeToEpochMs('Europe/London', 2026, 7, 1, 12), + at('2026-07-01T11:00:00Z'), + ); + assert.equal( + localDate(at('2026-11-01T03:59:00Z'), 'America/New_York'), + '2026-10-31', + ); + assert.equal(formatClock(at('2026-11-01T23:59:00Z'), 'Asia/Tokyo'), '08:59'); +}); + +test('a malformed pack is refused', () => { + assert.throws(() => createSchedule(null), TypeError); + assert.throws(() => createSchedule({ ...pack(), version: 2 }), TypeError); + assert.throws( + () => createSchedule(pack({ patterns: [{ shape: 0, stops: [0, 99] }] })), + TypeError, + ); +}); + +test('active trips come from service dates in the agency zone', () => { + const schedule = createSchedule(pack()); + const ids = (now) => + activeTrips(schedule, now).map((run) => `${run.tripId}@${run.serviceDate}`); + assert.deepEqual(ids(MORNING + 10 * MIN), ['morning@2026-11-01']); + assert.deepEqual(ids(MORNING - MIN), []); + assert.deepEqual(ids(MORNING + 26 * MIN), []); + // Saturday's 25:00:00 trip runs at 01:00 EDT on Sunday, the first 01:00. + assert.deepEqual(ids(at('2026-11-01T05:10:00Z')), ['late@2026-10-31']); + // Sunday's 01:30:00 trip runs at 01:30 EST, the second 01:30. + assert.deepEqual(ids(at('2026-11-01T06:40:00Z')), ['fallback@2026-11-01']); + // A daily trip appears once per run; widening the window catches a train + // waiting at its origin. + assert.deepEqual(ids(at('2026-11-02T17:05:00Z')), ['shuttle@2026-11-02']); + assert.deepEqual( + activeTrips(schedule, MORNING - 2 * MIN, { beforeMs: 5 * MIN }).map( + (run) => run.tripId, + ), + ['morning'], + ); + const [run] = activeTrips(schedule, MORNING + MIN); + assert.equal(run.name, '101'); + assert.equal(run.headsign, 'Delta'); + assert.equal(run.shape, 0); + assert.equal(run.scheduledStartMs, MORNING); + assert.equal(run.scheduledEndMs, MORNING + 25 * MIN); +}); + +test('the timetable position runs linearly between stops and holds through a dwell', () => { + const schedule = createSchedule(pack()); + const [run] = activeTrips(schedule, MORNING + MIN); + const moving = scheduledPosition(schedule, run, MORNING + 150_000); + assert.equal(moving.state, 'running'); + assert.equal(moving.dwell, false); + assert.equal(moving.s, 500); + assert.equal(moving.prevStop, 0); + assert.equal(moving.nextStop, 1); + assert.equal(moving.nextStopId, 'B1'); + assert.equal( + moving.lat, + undefined, + 'a shaped pattern leaves lat/lon to the path', + ); + const dwell = scheduledPosition(schedule, run, MORNING + 330_000); + assert.equal(dwell.dwell, true); + assert.equal(dwell.s, 1000); + assert.equal(dwell.prevStop, 1); + assert.equal(dwell.nextStop, 2); + const leg = scheduledPosition(schedule, 'morning', MORNING + 630_000); + assert.equal(leg.s, 2000, 'halfway from B (leaves +6) to C (arrives +15)'); + const before = scheduledPosition(schedule, run, MORNING - MIN); + assert.equal(before.state, 'before'); + assert.equal(before.s, 0); + assert.equal(before.nextStop, 0); + const after = scheduledPosition(schedule, run, MORNING + 30 * MIN); + assert.equal(after.state, 'arrived'); + assert.equal(after.s, 6000); + assert.equal(after.nextStop, -1); +}); + +test('a pattern without a shape interpolates between its stops', () => { + const schedule = createSchedule(pack()); + const noon = at('2026-11-01T17:00:00Z'); + const position = scheduledPosition( + schedule, + { tripId: 'shuttle', serviceDate: '2026-11-01' }, + noon + 5 * MIN, + ); + assert.equal(position.shape, null); + assert.equal(position.s, 1250); + assert.ok(Math.abs(position.lon - -73.985) < 1e-9); + assert.equal(position.lat, 40); +}); + +test('the stop list shows station-local 24 h clocks, states and chips', () => { + const schedule = createSchedule(pack()); + const stops = tripStops(schedule, 'morning', MORNING + 320_000); + assert.deepEqual( + stops.map((stop) => [stop.stopId, stop.state, stop.atStop, stop.relative]), + [ + ['A', 'passed', false, 'T+5'], + ['B1', 'next', true, 'T−1'], + ['C', 'upcoming', false, 'T−10'], + ['D', 'upcoming', false, 'T−20'], + ], + ); + assert.equal(stops[0].scheduledDeparture, '08:00'); + assert.equal(stops[1].scheduledArrival, '08:05'); + assert.equal(stops[1].scheduledDeparture, '08:06'); + assert.equal(stops[1].name, 'Track 1'); + assert.equal(stops[1].parentName, 'Bravo'); + assert.equal(stops[1].platform, '1'); + assert.equal(stops[1].timezone, 'America/New_York'); + // Delta keeps Chicago time: 08:25 Eastern is 07:25 Central. + assert.equal(stops[3].timezone, 'America/Chicago'); + assert.equal(stops[3].scheduledArrival, '07:25'); + assert.equal(stops[3].scheduledArrivalMs, MORNING + 25 * MIN); + assert.equal(stops[3].realtime, false); +}); + +test('times past 24:00:00 land on the next calendar day', () => { + const schedule = createSchedule(pack()); + const stops = tripStops(schedule, 'late', at('2026-11-01T05:00:00Z')); + assert.equal(stops[0].scheduledDepartureMs, at('2026-11-01T05:00:00Z')); + assert.equal(stops[0].scheduledDeparture, '01:00'); + assert.equal(stops[1].scheduledArrival, '01:05'); + // The fall-back trip at 01:30:00 runs an hour of real time later. + const fallback = tripStops(schedule, 'fallback', at('2026-11-01T06:30:00Z')); + assert.equal(fallback[0].scheduledDepartureMs, at('2026-11-01T06:30:00Z')); + assert.equal(fallback[0].scheduledDeparture, '01:30'); +}); + +test('a stop delay carries on to later stops until the next update', () => { + const schedule = createSchedule(pack()); + const delays = { + morning: { + stops: [ + { stopId: 'B1', arrival: { delay: 120 } }, + { stopSequence: 4, arrival: { time: (MORNING + 26 * MIN) / 1000 } }, + ], + }, + }; + const stops = tripStops(schedule, 'morning', MORNING, delays); + assert.deepEqual( + stops.map((stop) => [stop.arrivalDelaySec, stop.departureDelaySec]), + [ + [0, 0], + [120, 120], + [120, 120], + [60, 60], + ], + ); + assert.equal(stops[1].expectedArrival, '08:07'); + assert.equal(stops[1].scheduledArrival, '08:05'); + assert.equal(stops[1].realtime, true); + // Between A (left on time) and B (arrives +7) the run is slower. + const position = scheduledPosition( + schedule, + 'morning', + MORNING + 210_000, + delays, + ); + assert.equal(position.s, 500); + assert.equal(position.delaySec, 120); + // A late train is still active after its timetable ends. + const late = { morning: { delaySec: 600 } }; + assert.deepEqual( + activeTrips(schedule, MORNING + 30 * MIN).map((run) => run.tripId), + [], + ); + const [run] = activeTrips(schedule, MORNING + 30 * MIN, { delays: late }); + assert.equal(run.tripId, 'morning'); + assert.equal(run.expectedEndMs, MORNING + 35 * MIN); +}); + +test('a delay pinned to a start date leaves other runs alone; parent ids match platforms', () => { + const schedule = createSchedule(pack()); + const pinned = { morning: { startDate: '20261031', delaySec: 300 } }; + assert.equal( + tripStops(schedule, 'morning', MORNING, pinned)[2].arrivalDelaySec, + 0, + ); + const own = { + morning: { + startDate: '2026-11-01', + stops: [{ stopId: 'B', departure: { delay: 60 } }], + }, + }; + const stops = tripStops(schedule, 'morning', MORNING, own); + assert.equal(stops[1].departureDelaySec, 60); + assert.equal(stops[2].arrivalDelaySec, 60); +}); + +test('a skipped stop is passed without a dwell', () => { + const schedule = createSchedule(pack()); + // The record shape src/data/railTripUpdates.js serves. + const delays = { + morning: { + tripId: 'morning', + startDate: '20261101', + delaySec: null, + stops: [ + { + stopId: null, + stopSequence: 2, + arrival: null, + departure: null, + relationship: 'SKIPPED', + }, + ], + }, + }; + const position = scheduledPosition( + schedule, + 'morning', + MORNING + 450_000, + delays, + ); + assert.equal(position.dwell, false); + assert.equal(position.prevStop, 0); + assert.equal(position.nextStop, 2); + assert.equal(position.s, 1500, 'halfway from A (+0) to C (+15)'); + assert.equal( + tripStops(schedule, 'morning', MORNING, delays)[1].skipped, + true, + ); +}); + +test('a canceled run leaves the active list', () => { + const schedule = createSchedule(pack()); + const now = MORNING + 10 * MIN; + const canceled = { morning: { relationship: 'CANCELED' } }; + assert.equal(activeTrips(schedule, now).length, 1); + assert.equal(activeTrips(schedule, now, { delays: canceled }).length, 0); +}); + +test('NO_DATA at a stop ends the delay carried to it: the timetable applies again', () => { + const schedule = createSchedule(pack()); + const delays = { + morning: { + delaySec: 300, + stops: [ + { stopId: 'B1', arrival: { delay: 240 } }, + { stopId: 'C', relationship: 'NO_DATA' }, + ], + }, + }; + const stops = tripStops(schedule, 'morning', MORNING, delays); + assert.deepEqual( + stops.map((stop) => [stop.arrivalDelaySec, stop.departureDelaySec]), + [ + [300, 300], + [240, 240], + // No prediction from C on: its time and D's are the timetable's. + [0, 0], + [0, 0], + ], + ); + assert.equal(stops[2].expectedArrival, stops[2].scheduledArrival); + // A later update after NO_DATA counts again. + const later = { + morning: { + stops: [ + { stopId: 'B1', arrival: { delay: 240 } }, + { stopId: 'C', scheduleRelationship: 'NO_DATA' }, + { stopSequence: 4, arrival: { delay: 60 } }, + ], + }, + }; + assert.deepEqual( + tripStops(schedule, 'morning', MORNING, later).map( + (stop) => stop.arrivalDelaySec, + ), + [0, 240, 0, 60], + ); +}); + +test('a pack ends when the last run it holds arrives', () => { + // The latest run: the shuttle on its last date, 2 November, at noon New + // York time for 10 minutes (31 October's 25:00 train arrives earlier). + const end = schedulePackEndMs(pack()); + assert.equal( + end, + serviceDayOriginMs('2026-11-02', 'America/New_York') + 12 * H + 10 * MIN, + ); + // Without a window the end is not known; with no run there is none. + assert.equal(schedulePackEndMs(pack({ window: undefined })), null); + assert.equal(schedulePackEndMs(pack({ trips: {} })), -Infinity); + assert.equal(schedulePackEndMs(null), null); +}); + +test('a live report states its own delay; a day or more either way is no delay', () => { + assert.equal(liveDelaySec({ delaySec: 540 }), 540); + assert.equal(liveDelaySec({ delaySec: -120 }), -120); + assert.equal(liveDelaySec({ delaySec: 0 }), 0); + for (const value of [null, undefined, '540', NaN, Infinity, 86_401, -86_401]) + assert.equal(liveDelaySec({ delaySec: value }), null, String(value)); + assert.equal(liveDelaySec(null), null); +}); diff --git a/src/data/railScheduleIndex.js b/src/data/railScheduleIndex.js new file mode 100644 index 000000000..c70b419e2 --- /dev/null +++ b/src/data/railScheduleIndex.js @@ -0,0 +1,124 @@ +/** + * @module railScheduleIndex + * @description Server-side lookup over a schedule pack (the + * `.schedule.json` scripts/build-rail-paths.mjs writes) for a + * realtime source that names a train by its number (Irish Rail's train + * codes): `createRunIndex`, the dated runs of a pack's trips, so a reported + * train takes the trip it is on (its line and stops). The train's position + * is never taken from the timetable. + * + * Pure: no I/O. Times are epoch milliseconds; a run starts at the service + * day's GTFS origin (noon minus 12 h in the pack's time zone) plus the + * trip's start, and lasts its profile's last offset, as + * src/data/railSchedule.js reads them. + */ + +import { serviceDayOriginMs } from './railTimeZones.js'; + +function packTrips(raw) { + return raw?.trips && typeof raw.trips === 'object' ? raw.trips : {}; +} + +function packServices(raw) { + return raw?.services && typeof raw.services === 'object' ? raw.services : {}; +} + +function serviceDates(services, service) { + const dates = services[service]; + return Array.isArray(dates) ? dates : []; +} + +/** + * The dated runs of a pack's trips. Built lazily per trip, so a source that + * looks up a few hundred train numbers never expands the whole pack. It + * keeps the parts of the pack it reads (trips, profiles, services), not the + * pack itself. + * @param {object} raw Decoded schedule pack. + * @returns {{runsOf: (tripId: string) => Array<{tripId: string, date: string, + * startMs: number, endMs: number}>, + * tripsNamed: (name: string) => string[], + * activeTrip: (name: string, nowMs: number, + * options?: {slackMs?: number, delayMs?: number}) => + * ({tripId: string, date: string, startMs: number, endMs: number}|null)}} + */ +export function createRunIndex(raw) { + const trips = packTrips(raw); + const services = packServices(raw); + const zone = typeof raw?.timezone === 'string' ? raw.timezone : 'UTC'; + const profiles = Array.isArray(raw?.profiles) ? raw.profiles : []; + const origins = new Map(); + const originOf = (date) => { + let origin = origins.get(date); + if (origin === undefined) { + origin = serviceDayOriginMs(date, zone); + origins.set(date, origin); + } + return origin; + }; + let names = null; + const runs = new Map(); + function runsOf(tripId) { + let list = runs.get(tripId); + if (list) return list; + list = []; + const trip = Object.hasOwn(trips, tripId) ? trips[tripId] : null; + const profile = trip ? profiles[trip.profile] : null; + if (trip && Array.isArray(profile) && Number.isFinite(trip.startSec)) { + const durationMs = (Number(profile[profile.length - 1]) || 0) * 1000; + for (const date of serviceDates(services, trip.service)) { + const origin = originOf(date); + if (!Number.isFinite(origin)) continue; + const startMs = origin + trip.startSec * 1000; + list.push({ tripId, date, startMs, endMs: startMs + durationMs }); + } + } + runs.set(tripId, list); + return list; + } + return { + runsOf, + tripsNamed(name) { + if (!names) { + names = new Map(); + for (const [tripId, trip] of Object.entries(trips)) { + if (typeof trip?.name !== 'string' || !trip.name) continue; + const list = names.get(trip.name); + if (list) list.push(tripId); + else names.set(trip.name, [tripId]); + } + } + return names.get(String(name ?? '')) ?? []; + }, + /** + * The run of a train number that is under way at `nowMs` (or within + * `slackMs` of its start or end); the one whose span is nearest when + * several qualify, null when none or when two tie. A train running + * `delayMs` late (negative: early) is where its timetable was that long + * ago, so its runs are compared at `nowMs - delayMs`. + */ + activeTrip(name, nowMs, { slackMs = 10 * 60_000, delayMs = 0 } = {}) { + const at = nowMs - (Number.isFinite(delayMs) ? delayMs : 0); + let best = null; + let bestScore = Infinity; + let tie = false; + for (const tripId of this.tripsNamed(name)) { + for (const run of runsOf(tripId)) { + if (at < run.startMs - slackMs || at > run.endMs + slackMs) continue; + const score = + at < run.startMs + ? run.startMs - at + : at > run.endMs + ? at - run.endMs + : 0; + if (score < bestScore) { + best = run; + bestScore = score; + tie = false; + } else if (score === bestScore && best && run.tripId !== best.tripId) + tie = true; + } + } + return tie ? null : best; + }, + }; +} diff --git a/src/data/railSiriVm.js b/src/data/railSiriVm.js new file mode 100644 index 000000000..fba90062b --- /dev/null +++ b/src/data/railSiriVm.js @@ -0,0 +1,188 @@ +/** + * @module railSiriVm + * @description Adapter for SIRI Vehicle Monitoring (SIRI Lite `vm`, as + * Entur publishes it at `api.entur.io/realtime/v1/rest/vm`), turning each + * `` into the train records the GTFS-RT path produces. + * + * Only the handful of elements a position needs are read (RecordedAtTime, + * ValidUntilTime, LineRef, VehicleMode, VehicleLocation, Bearing, Delay, + * VehicleJourneyRef, VehicleRef), with a small scanner instead of an XML + * library: no DTDs, no entity definitions, attributes ignored, an optional + * namespace prefix on any element. A document that declares a DTD or + * entities is refused, and every size is capped (whole text, activity count, + * field length). + * + * The positions are the operator's own GPS reports, each with the time it + * was recorded, so records carry `timestampSource: 'vehicle'`. An activity + * whose `ValidUntilTime` has passed is dropped, as is one whose + * `VehicleMode` says it is not a train (Entur lists replacement buses under + * the train lines). + * + * Pure: no Node built-ins, safe for node:test and any server runtime. + */ + +import { isPlausibleVehiclePosition } from './gtfsRealtime.js'; +import { decodeXmlText } from './irishRail.js'; + +/** Longest document read (characters). SJ Nord's is about 7 KB. */ +export const SIRI_VM_MAX_TEXT_CHARS = 2 * 1024 * 1024; +/** Most activities read from one document. */ +export const SIRI_VM_MAX_ACTIVITIES = 5000; +/** Longest accepted field (characters). */ +export const SIRI_VM_MAX_FIELD_CHARS = 256; + +/** Error for a document this adapter will not read. */ +export class SiriVmFormatError extends Error { + constructor(message) { + super(message); + this.name = 'SiriVmFormatError'; + } +} + +/** `` or `` (no attributes kept), up to its close. */ +function readField(block, name) { + const match = new RegExp( + `<(?:[A-Za-z_][\\w.-]*:)?${name}(?:\\s[^>]*)?>([^<]*)`, + ).exec(block); + if (!match || match[1].length > SIRI_VM_MAX_FIELD_CHARS * 6) return null; + const value = decodeXmlText(match[1]).trim(); + return value.length <= SIRI_VM_MAX_FIELD_CHARS ? value : null; +} + +/** + * Seconds in an ISO 8601 duration as SIRI writes a delay (`PT0S`, + * `PT2M30S`, `-PT45S`, `P1DT1H`), or null. + * @param {string|null} text + * @returns {number|null} + */ +export function parseIsoDurationSec(text) { + const match = + /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec( + String(text ?? '').trim(), + ); + if (!match || !(match[2] || match[3] || match[4] || match[5])) return null; + const [, sign, d = 0, h = 0, m = 0, s = 0] = match; + const total = + Number(d) * 86_400 + Number(h) * 3600 + Number(m) * 60 + Number(s); + return Math.round(sign ? -total : total); +} + +/** Epoch seconds of an ISO date-time with its offset, or null. */ +function epochSeconds(text) { + const ms = Date.parse(String(text ?? '')); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; +} + +/** + * Split a SIRI VM document into raw activity rows. + * @param {string} text + * @returns {{rows: Array>, truncated: boolean, + * responseTimestamp: string|null}} + */ +export function parseSiriVmXml(text) { + if (typeof text !== 'string') throw new SiriVmFormatError('not text'); + if (text.length > SIRI_VM_MAX_TEXT_CHARS) + throw new SiriVmFormatError(`document is ${text.length} characters`); + if (/]/.test(text)) + throw new SiriVmFormatError('not a vehicle monitoring document'); + const open = /<(?:[A-Za-z_][\w.-]*:)?VehicleActivity(?:\s[^>]*)?>/g; + const close = /<\/(?:[A-Za-z_][\w.-]*:)?VehicleActivity>/g; + const rows = []; + let truncated = false; + let found; + while ((found = open.exec(text))) { + close.lastIndex = open.lastIndex; + const end = close.exec(text); + if (!end) break; + if (rows.length >= SIRI_VM_MAX_ACTIVITIES) { + truncated = true; + break; + } + const block = text.slice(open.lastIndex, end.index); + rows.push({ + recordedAt: readField(block, 'RecordedAtTime'), + validUntil: readField(block, 'ValidUntilTime'), + lineRef: readField(block, 'LineRef'), + mode: readField(block, 'VehicleMode'), + latitude: readField(block, 'Latitude'), + longitude: readField(block, 'Longitude'), + bearing: readField(block, 'Bearing'), + delay: readField(block, 'Delay'), + journeyRef: + readField(block, 'VehicleJourneyRef') ?? + readField(block, 'DatedVehicleJourneyRef'), + vehicleRef: readField(block, 'VehicleRef'), + lineName: readField(block, 'PublishedLineName'), + }); + open.lastIndex = end.index + end[0].length; + } + const head = text.slice(0, 4096); + return { + rows, + truncated, + responseTimestamp: readField(head, 'ResponseTimestamp'), + }; +} + +/** + * Turn a SIRI VM document into unclassified train records in the shape the + * GTFS-RT path produces (src/data/gtfsRealtime.js), plus `delaySec`. + * + * `routeId` is the `LineRef` (Entur: the same `:Line:` ids as + * the static GTFS), `tripId` the `VehicleJourneyRef` (Entur: a + * `DatedServiceJourney`, not the GTFS trip id; the timetable matches the + * train to a run by line and position instead). One record per vehicle: + * a vehicle listed twice keeps its latest report. + * + * @param {string} text The XML document. + * @param {number} fetchedAtS Fetch time (epoch seconds), used when an + * activity carries no RecordedAtTime and to drop expired activities. + * @returns {{entityCount: number, truncated: boolean, + * feedTimestamp: number|null, vehicles: object[]}} + */ +export function decodeSiriVehicleMonitoring(text, fetchedAtS) { + const { rows, truncated, responseTimestamp } = parseSiriVmXml(text); + const byId = new Map(); + for (const row of rows) { + if (row.mode && !/^rail$/i.test(row.mode)) continue; + const lat = Number.parseFloat(row.latitude ?? ''); + const lon = Number.parseFloat(row.longitude ?? ''); + if (!isPlausibleVehiclePosition(lat, lon)) continue; + const validUntil = epochSeconds(row.validUntil); + if (validUntil !== null && validUntil < fetchedAtS) continue; + const id = row.vehicleRef || row.journeyRef; + if (!id || !/^[\w:.-]{1,128}$/.test(id)) continue; + const recorded = epochSeconds(row.recordedAt); + const bearing = Number.parseFloat(row.bearing ?? ''); + const record = { + id, + lat: Number(lat.toFixed(6)), + lon: Number(lon.toFixed(6)), + bearing: + Number.isFinite(bearing) && bearing >= 0 && bearing <= 360 + ? bearing + : null, + speedMps: null, + timestamp: recorded ?? fetchedAtS, + timestampSource: recorded === null ? 'fetch' : 'vehicle', + routeId: row.lineRef || null, + tripId: row.journeyRef || null, + directionId: null, + label: row.lineName || null, + stopId: null, + status: null, + occupancy: null, + delaySec: parseIsoDurationSec(row.delay), + }; + const known = byId.get(id); + if (!known || record.timestamp >= known.timestamp) byId.set(id, record); + } + return { + entityCount: rows.length, + truncated, + feedTimestamp: epochSeconds(responseTimestamp), + vehicles: [...byId.values()], + }; +} diff --git a/src/data/railSiriVm.test.mjs b/src/data/railSiriVm.test.mjs new file mode 100644 index 000000000..2479b38be --- /dev/null +++ b/src/data/railSiriVm.test.mjs @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + SIRI_VM_MAX_ACTIVITIES, + SiriVmFormatError, + decodeSiriVehicleMonitoring, + parseIsoDurationSec, + parseSiriVmXml, +} from './railSiriVm.js'; +import { buildRailSnapshot } from './railProxy.js'; +import { getRegisteredRailFeed } from './railFeeds.js'; + +const NOW_S = Date.parse('2026-11-09T16:27:00Z') / 1000; + +/** One ``, shaped like Entur's SIRI Lite answer. */ +function activity({ + recorded = '2026-11-09T17:26:17+01:00', + validUntil = '2026-11-09T17:29:17+01:00', + line = 'SJN:Line:25', + lat = '63.42107333333333', + lon = '10.377486666666666', + delay = 'PT0S', + journey = 'SJN:DatedServiceJourney:2388_ROS-HMR_26-11-09', + vehicle = '957605930081', + extra = '', +} = {}) { + return `${recorded}${validUntil}${line}SJN${extra}${lon}${lat}${delay}${journey}${vehicle}`; +} + +function document(...activities) { + return `2026-11-09T17:26:46.11584667+01:00ENT2026-11-09T17:26:46.115848853+01:00${activities.join('')}`; +} + +test('SIRI delays are ISO 8601 durations', () => { + assert.equal(parseIsoDurationSec('PT0S'), 0); + assert.equal(parseIsoDurationSec('PT2M30S'), 150); + assert.equal(parseIsoDurationSec('-PT45S'), -45); + assert.equal(parseIsoDurationSec('PT1H'), 3600); + assert.equal(parseIsoDurationSec('P1DT1M'), 86_460); + assert.equal(parseIsoDurationSec('PT1.6S'), 2); + assert.equal(parseIsoDurationSec('P'), null); + assert.equal(parseIsoDurationSec('soon'), null); + assert.equal(parseIsoDurationSec(null), null); +}); + +test('a vehicle activity becomes a train record with its own report time', () => { + const decoded = decodeSiriVehicleMonitoring( + document(activity({ delay: 'PT3M' })), + NOW_S, + ); + assert.equal(decoded.entityCount, 1); + assert.equal(decoded.truncated, false); + assert.equal( + decoded.feedTimestamp, + Math.floor(Date.parse('2026-11-09T16:26:46.115Z') / 1000), + ); + assert.deepEqual(decoded.vehicles, [ + { + id: '957605930081', + lat: 63.421073, + lon: 10.377487, + bearing: null, + speedMps: null, + timestamp: Date.parse('2026-11-09T16:26:17Z') / 1000, + timestampSource: 'vehicle', + routeId: 'SJN:Line:25', + tripId: 'SJN:DatedServiceJourney:2388_ROS-HMR_26-11-09', + directionId: null, + label: null, + stopId: null, + status: null, + occupancy: null, + delaySec: 180, + }, + ]); +}); + +test('buses, expired reports, bad positions and duplicates are dropped', () => { + const decoded = decodeSiriVehicleMonitoring( + document( + activity({ vehicle: 'bus', extra: 'bus' }), + activity({ vehicle: 'rail', extra: 'rail' }), + activity({ vehicle: 'old', validUntil: '2026-11-09T17:20:00+01:00' }), + activity({ vehicle: 'nowhere', lat: '0', lon: '0' }), + activity({ vehicle: 'twice', recorded: '2026-11-09T17:25:00+01:00' }), + activity({ vehicle: 'twice', recorded: '2026-11-09T17:26:00+01:00' }), + activity({ vehicle: 'bad id!' }), + ), + NOW_S, + ); + assert.equal(decoded.entityCount, 7); + assert.deepEqual( + decoded.vehicles.map((v) => v.id), + ['rail', 'twice'], + ); + assert.equal( + decoded.vehicles[1].timestamp, + Date.parse('2026-11-09T16:26:00Z') / 1000, + 'the later of two reports', + ); +}); + +test('a report without a time takes the fetch time, labelled so', () => { + const xml = document(activity()).replace( + /[^<]*<\/RecordedAtTime>/, + '', + ); + const [train] = decodeSiriVehicleMonitoring(xml, NOW_S).vehicles; + assert.equal(train.timestamp, NOW_S); + assert.equal(train.timestampSource, 'fetch'); +}); + +test('namespace prefixes, bearings and published line names are read', () => { + const xml = document( + activity({ extra: '91.5' }).replace( + /<(\/?)(VehicleActivity|LineRef|Latitude|Longitude)>/g, + '<$1siri:$2>', + ), + ).replace( + '', + 'F6', + ); + const [train] = decodeSiriVehicleMonitoring(xml, NOW_S).vehicles; + assert.equal(train.routeId, 'SJN:Line:25'); + assert.equal(train.bearing, 91.5); + assert.equal(train.label, 'F6'); +}); + +test('documents that are not vehicle monitoring, or declare a DTD, are refused', () => { + assert.throws(() => parseSiriVmXml(42), SiriVmFormatError); + assert.throws( + () => parseSiriVmXml('Bad gateway'), + SiriVmFormatError, + ); + assert.throws( + () => + parseSiriVmXml(`]>${document(activity())}`), + SiriVmFormatError, + ); + assert.equal(parseSiriVmXml(document()).rows.length, 0); +}); + +test('the activity count is capped', () => { + const many = Array.from( + { length: SIRI_VM_MAX_ACTIVITIES + 2 }, + (_, i) => + `v${i}`, + ); + const parsed = parseSiriVmXml(document(...many)); + assert.equal(parsed.rows.length, SIRI_VM_MAX_ACTIVITIES); + assert.equal(parsed.truncated, true); +}); + +test('the rail proxy reads SJ Nord from SIRI VM and classifies its lines', () => { + const feed = getRegisteredRailFeed('entur-sj-nord'); + assert.equal(feed.format, 'siri-vm'); + const bytes = new TextEncoder().encode( + document( + activity({ vehicle: 'a', line: 'SJN:Line:25' }), + activity({ vehicle: 'b', line: 'SJN:Line:26' }), + activity({ vehicle: 'c', line: 'SJN:Line:72' }), + activity({ vehicle: 'd', line: 'SJN:Line:999' }), + ), + ); + const snapshot = buildRailSnapshot(feed, bytes, NOW_S * 1000); + assert.equal(snapshot.positions, 'gps'); + assert.equal(snapshot.entityCount, 4); + assert.equal(snapshot.dropped, 1, 'an unknown line'); + assert.deepEqual( + snapshot.trains.map((t) => [t.id, t.category, t.positionSource]), + [ + ['a', 'national', 'gps'], + ['b', 'regional', 'gps'], + ['c', 'international', 'gps'], + ], + ); + assert.ok(Number.isFinite(snapshot.feedTimestamp)); + assert.throws( + () => buildRailSnapshot(feed, new TextEncoder().encode(''), 0), + (error) => error.code === 'UPSTREAM_UNREADABLE', + ); +}); diff --git a/src/data/railTimeZones.js b/src/data/railTimeZones.js new file mode 100644 index 000000000..6bc4d6de3 --- /dev/null +++ b/src/data/railTimeZones.js @@ -0,0 +1,174 @@ +/** + * @module railTimeZones + * @description Wall-clock arithmetic in IANA time zones for rail timetables, + * built on Intl.DateTimeFormat only. + * + * GTFS measures stop times from "noon minus 12 hours" of the service day in + * the agency's time zone: midnight on ordinary days, and the hour that keeps + * every timetable time unambiguous on the two days a year the clocks change. + * `serviceDayOriginMs` returns that instant; a stop time of `sec` seconds is + * `origin + sec * 1000`, which also covers times past 24:00:00. + * + * Pure: no Cesium, no DOM, no Node built-ins. + */ + +const DAY_MS = 86_400_000; +const formatters = new Map(); +const clocks = new Map(); + +/** Whether `zone` is an IANA zone this runtime knows. */ +export function isTimeZone(zone) { + if (typeof zone !== 'string' || !zone) return false; + try { + partsFormatter(zone); + return true; + } catch { + return false; + } +} + +function partsFormatter(zone) { + let formatter = formatters.get(zone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + formatters.set(zone, formatter); + } + return formatter; +} + +/** + * The wall-clock fields of an instant in a zone. + * @param {number} epochMs + * @param {string} zone + * @returns {{year: number, month: number, day: number, hour: number, + * minute: number, second: number}} + */ +export function zonedParts(epochMs, zone) { + const out = {}; + for (const part of partsFormatter(zone).formatToParts(epochMs)) + if (part.type !== 'literal') out[part.type] = Number(part.value); + return { + year: out.year, + month: out.month, + day: out.day, + hour: out.hour % 24, + minute: out.minute, + second: out.second, + }; +} + +/** UTC offset of a zone at an instant, in ms (east positive). */ +export function zoneOffsetMs(epochMs, zone) { + const p = zonedParts(epochMs, zone); + const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + return wall - Math.floor(epochMs / 1000) * 1000; +} + +/** + * The instant a wall-clock time occurs in a zone. In a gap (spring forward) + * the result is shifted forward by the gap; in an overlap (fall back) the + * earlier instant is returned. + */ +export function zonedTimeToEpochMs( + zone, + year, + month, + day, + hour = 0, + minute = 0, + second = 0, +) { + const wall = Date.UTC(year, month - 1, day, hour, minute, second); + const first = zoneOffsetMs(wall, zone); + let guess = wall - first; + const offset2 = zoneOffsetMs(guess, zone); + if (offset2 !== first) { + const alternative = wall - offset2; + // Prefer the candidate whose wall time round-trips; else the earlier. + if (zoneOffsetMs(alternative, zone) === offset2) + guess = Math.min(guess, alternative); + } + return guess; +} + +/** Parse 'YYYY-MM-DD' or 'YYYYMMDD' into numbers, or null. */ +export function parseServiceDate(text) { + const match = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(String(text ?? '').trim()); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const check = new Date(Date.UTC(year, month - 1, day)); + if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) + return null; + return { year, month, day }; +} + +/** 'YYYY-MM-DD' of a UTC-day number (days since 1970-01-01). */ +export function dateFromDayNumber(dayNumber) { + return new Date(dayNumber * DAY_MS).toISOString().slice(0, 10); +} + +/** UTC-day number of a service date, or NaN. */ +export function dayNumberOf(text) { + const date = parseServiceDate(text); + return date ? Date.UTC(date.year, date.month - 1, date.day) / DAY_MS : NaN; +} + +/** The local calendar date ('YYYY-MM-DD') of an instant in a zone. */ +export function localDate(epochMs, zone) { + const p = zonedParts(epochMs, zone); + return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`; +} + +/** + * The instant GTFS times of a service date count from: noon minus 12 hours, + * local to `zone`. + * @param {string} date 'YYYY-MM-DD' or 'YYYYMMDD'. + * @param {string} zone IANA zone. + * @returns {number} Epoch ms, or NaN for a malformed date. + */ +export function serviceDayOriginMs(date, zone) { + const parsed = parseServiceDate(date); + if (!parsed) return NaN; + return ( + zonedTimeToEpochMs(zone, parsed.year, parsed.month, parsed.day, 12) - + 12 * 3_600_000 + ); +} + +/** + * A 24-hour 'HH:MM' clock string of an instant in a zone. + * @param {number} epochMs + * @param {string} zone + * @returns {string} + */ +export function formatClock(epochMs, zone) { + if (!Number.isFinite(epochMs)) return ''; + let formatter = clocks.get(zone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-GB', { + timeZone: zone, + hourCycle: 'h23', + hour: '2-digit', + minute: '2-digit', + }); + clocks.set(zone, formatter); + } + let hour = ''; + let minute = ''; + for (const part of formatter.formatToParts(epochMs)) { + if (part.type === 'hour') hour = part.value; + else if (part.type === 'minute') minute = part.value; + } + return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; +} diff --git a/src/data/railTripUpdates.js b/src/data/railTripUpdates.js new file mode 100644 index 000000000..6df2a8102 --- /dev/null +++ b/src/data/railTripUpdates.js @@ -0,0 +1,278 @@ +/** + * @module railTripUpdates + * @description Minimal, bounded GTFS-Realtime decoder for TripUpdates feeds, + * for the Rail layer's live delays. + * + * Same approach as the VehiclePositions decoder (src/data/gtfsRealtime.js): + * `pbf` straight off the wire, field numbers as the contract, no generated + * code. Only what a delay readout needs is read; vehicle positions, alerts and + * every extension are skipped by pbf. + * + * Field numbers (gtfs-realtime.proto, v2.0): + * FeedEntity 3 trip_update + * TripUpdate 1 trip, 2 stop_time_update[], 3 vehicle, 4 timestamp, + * 5 delay + * StopTimeUpdate 1 stop_sequence, 2 arrival, 3 departure, 4 stop_id, + * 5 schedule_relationship + * StopTimeEvent 1 delay (int32), 2 time (int64), 3 uncertainty + * TripDescriptor 1 trip_id, 2 start_time, 3 start_date, + * 4 schedule_relationship, 5 route_id, 6 direction_id + * + * Decoding is bounded the way the vehicle decoder is: entities past the cap + * are left unread, stop updates past the per-trip cap are left unread, and an + * over-long string drops the record it belongs to instead of truncating it. + * + * Pure: safe to import from the browser layer, the Vite proxy, and node:test. + */ + +import { PbfReader } from 'pbf'; +import { + GTFS_INCREMENTALITY_FULL_DATASET, + GTFS_MAX_ENTITIES, + GTFS_MAX_STRING_CHARS, +} from './gtfsRealtime.js'; + +/** Stop updates kept per trip. The longest rail trips call at ~100 stops. */ +export const RAIL_TRIP_UPDATE_MAX_STOPS = 200; +/** Trips kept per decode, before the rail filter. */ +export const RAIL_TRIP_UPDATE_MAX_TRIPS = 20_000; +/** + * Plausible delay range (seconds). A day either way covers every honest + * report; a value outside it is a unit error, not a late train. + */ +export const RAIL_TRIP_UPDATE_MAX_DELAY_S = 86_400; + +/** TripDescriptor.ScheduleRelationship enum → label. */ +const TRIP_RELATIONSHIP = Object.freeze({ + 0: 'SCHEDULED', + 1: 'ADDED', + 2: 'UNSCHEDULED', + 3: 'CANCELED', + 5: 'REPLACEMENT', + 6: 'DUPLICATED', + 7: 'DELETED', + 8: 'NEW', +}); + +/** StopTimeUpdate.ScheduleRelationship enum → label. */ +const STOP_RELATIONSHIP = Object.freeze({ + 0: 'SCHEDULED', + 1: 'SKIPPED', + 2: 'NO_DATA', + 3: 'UNSCHEDULED', +}); + +function readBoundedString(pbf, target) { + const value = pbf.readString(); + if (value.length <= GTFS_MAX_STRING_CHARS) return value; + target.oversize = true; + return null; +} + +function readFeedHeader(tag, header, pbf) { + if (tag === 1) header.version = readBoundedString(pbf, header); + else if (tag === 2) header.incrementality = pbf.readVarint(); + else if (tag === 3) header.timestamp = pbf.readVarint(); +} + +function readTripDescriptor(tag, trip, pbf) { + if (tag === 1) trip.tripId = readBoundedString(pbf, trip); + else if (tag === 2) trip.startTime = readBoundedString(pbf, trip); + else if (tag === 3) trip.startDate = readBoundedString(pbf, trip); + else if (tag === 4) trip.scheduleRelationship = pbf.readVarint(); + else if (tag === 5) trip.routeId = readBoundedString(pbf, trip); + else if (tag === 6) trip.directionId = pbf.readVarint(); +} + +function readVehicleDescriptor(tag, descriptor, pbf) { + if (tag === 1) descriptor.id = readBoundedString(pbf, descriptor); + else if (tag === 2) descriptor.label = readBoundedString(pbf, descriptor); +} + +function readStopTimeEvent(tag, event, pbf) { + // int32 and int64 are plain (not zig-zag) varints: negatives arrive as + // ten-byte two's complement, which the signed read restores. + if (tag === 1) event.delay = pbf.readVarint(true); + else if (tag === 2) event.time = pbf.readVarint(true); +} + +function readStopTimeUpdate(tag, update, pbf) { + if (tag === 1) update.stopSequence = pbf.readVarint(); + else if (tag === 2) update.arrival = pbf.readMessage(readStopTimeEvent, {}); + else if (tag === 3) update.departure = pbf.readMessage(readStopTimeEvent, {}); + else if (tag === 4) update.stopId = readBoundedString(pbf, update); + else if (tag === 5) update.scheduleRelationship = pbf.readVarint(); +} + +function readTripUpdate(tag, tripUpdate, pbf) { + if (tag === 1) tripUpdate.trip = pbf.readMessage(readTripDescriptor, {}); + else if (tag === 2) { + // Past the cap the stop is left unread; pbf skips it whole. + if (tripUpdate.stops.length >= RAIL_TRIP_UPDATE_MAX_STOPS) { + tripUpdate.stopsTruncated = true; + return; + } + tripUpdate.stops.push(pbf.readMessage(readStopTimeUpdate, {})); + } else if (tag === 3) + tripUpdate.vehicle = pbf.readMessage(readVehicleDescriptor, {}); + else if (tag === 4) tripUpdate.timestamp = pbf.readVarint(); + else if (tag === 5) tripUpdate.delay = pbf.readVarint(true); +} + +function readFeedEntity(tag, entity, pbf) { + if (tag === 1) entity.id = readBoundedString(pbf, entity); + else if (tag === 2) entity.isDeleted = pbf.readBoolean(); + else if (tag === 3) + entity.tripUpdate = pbf.readMessage(readTripUpdate, { stops: [] }); + // 4 (vehicle) and 5 (alert) are skipped: a combined feed (the MTA's) costs + // one length varint per vehicle here. +} + +function readFeedMessage(tag, message, pbf) { + if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {}); + else if (tag === 2) { + if (message.entityCount >= GTFS_MAX_ENTITIES) { + message.truncated = true; + return; + } + message.entityCount += 1; + const entity = pbf.readMessage(readFeedEntity, {}); + if (!entity.tripUpdate) return; + if (message.tripUpdates.length >= RAIL_TRIP_UPDATE_MAX_TRIPS) { + message.truncated = true; + return; + } + message.tripUpdates.push(entity); + } +} + +function nonEmptyString(value) { + const text = typeof value === 'string' ? value.trim() : ''; + return text ? text : null; +} + +function plausibleDelay(value) { + return Number.isInteger(value) && + Math.abs(value) <= RAIL_TRIP_UPDATE_MAX_DELAY_S + ? value + : null; +} + +/** Epoch seconds between 2000 and 2100, else null. */ +function plausibleTime(value) { + return Number.isInteger(value) && value > 946_684_800 && value < 4_102_444_800 + ? value + : null; +} + +function normalizeEvent(event) { + if (!event) return null; + const time = plausibleTime(event.time); + const delay = plausibleDelay(event.delay); + if (time === null && delay === null) return null; + return { time, delay }; +} + +/** + * Flatten one decoded FeedEntity into the trip record `/api/rail/updates` + * serves, or null when it carries no usable trip. + * + * `delaySec` is the trip's own delay when the operator states one, else the + * delay at the first stop update that carries one (feeds list the stops still + * ahead, so that is the delay the train is running with now). Feeds that only + * publish predicted times (MBTA) have no delay to report: null, not zero. + * @param {object} entity Decoded FeedEntity. + * @returns {object|null} + */ +export function normalizeTripUpdateEntity(entity) { + if (!entity || entity.isDeleted === true) return null; + const update = entity.tripUpdate; + if (!update || entity.oversize || update.trip?.oversize) return null; + if (update.vehicle?.oversize) return null; + const tripId = nonEmptyString(update.trip?.tripId); + const routeId = nonEmptyString(update.trip?.routeId); + if (!tripId && !routeId) return null; + const stops = []; + for (const stop of update.stops) { + if (stop.oversize) continue; + const arrival = normalizeEvent(stop.arrival); + const departure = normalizeEvent(stop.departure); + const relationship = STOP_RELATIONSHIP[stop.scheduleRelationship] || null; + const stopId = nonEmptyString(stop.stopId); + const stopSequence = Number.isInteger(stop.stopSequence) + ? stop.stopSequence + : null; + if (stopId === null && stopSequence === null) continue; + const record = { stopId, stopSequence, arrival, departure }; + if (relationship && relationship !== 'SCHEDULED') + record.relationship = relationship; + stops.push(record); + } + let delaySec = plausibleDelay(update.delay); + if (delaySec === null) { + for (const stop of stops) { + const delay = stop.arrival?.delay ?? stop.departure?.delay ?? null; + if (delay !== null) { + delaySec = delay; + break; + } + } + } + const relationship = + TRIP_RELATIONSHIP[update.trip?.scheduleRelationship] || 'SCHEDULED'; + const timestamp = + Number.isFinite(update.timestamp) && update.timestamp > 0 + ? update.timestamp + : null; + return { + tripId, + routeId, + startDate: nonEmptyString(update.trip?.startDate), + startTime: nonEmptyString(update.trip?.startTime), + directionId: Number.isInteger(update.trip?.directionId) + ? update.trip.directionId + : null, + vehicleId: nonEmptyString(update.vehicle?.id), + relationship, + timestamp, + delaySec, + stopsTruncated: update.stopsTruncated === true, + stops, + }; +} + +/** + * Decode a TripUpdates FeedMessage, at most GTFS_MAX_ENTITIES entities. + * @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes. + * @returns {{ version: string|null, timestamp: number|null, + * incrementality: number, entityCount: number, truncated: boolean, + * trips: object[] }} + */ +export function decodeTripUpdates(bytes) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const message = new PbfReader(view).readFields(readFeedMessage, { + header: {}, + tripUpdates: [], + entityCount: 0, + truncated: false, + }); + const trips = []; + for (const entity of message.tripUpdates) { + const trip = normalizeTripUpdateEntity(entity); + if (trip) trips.push(trip); + } + const header = message.header || {}; + return { + version: header.oversize ? null : nonEmptyString(header.version), + timestamp: + Number.isFinite(header.timestamp) && header.timestamp > 0 + ? header.timestamp + : null, + incrementality: Number.isFinite(header.incrementality) + ? header.incrementality + : GTFS_INCREMENTALITY_FULL_DATASET, + entityCount: message.entityCount, + truncated: message.truncated === true, + trips, + }; +} diff --git a/src/data/railTripUpdates.test.mjs b/src/data/railTripUpdates.test.mjs new file mode 100644 index 000000000..2fee026d8 --- /dev/null +++ b/src/data/railTripUpdates.test.mjs @@ -0,0 +1,262 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { GTFS_MAX_ENTITIES } from './gtfsRealtime.js'; +import { + RAIL_TRIP_UPDATE_MAX_STOPS, + RAIL_TRIP_UPDATE_MAX_TRIPS, + decodeTripUpdates, + normalizeTripUpdateEntity, +} from './railTripUpdates.js'; + +const NOW_S = 1_794_000_000; + +function event(writer, tag, { delay, time } = {}) { + const message = new PbfWriter(); + if (delay !== undefined) message.writeVarintField(1, delay); + if (time !== undefined) message.writeVarintField(2, time); + writer.writeBytesField(tag, message.finish()); +} + +/** + * A FeedMessage with trip updates. Each trip: + * `{id, tripId, routeId, startDate, delay, relationship, vehicleId, stops}`, + * each stop `{stopId, sequence, arrival, departure, relationship}`. + */ +function encodeTripUpdates({ incrementality = null, trips = [], extra = [] }) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + if (incrementality !== null) header.writeVarintField(2, incrementality); + header.writeVarintField(3, NOW_S); + writer.writeBytesField(1, header.finish()); + for (const trip of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, trip.id ?? trip.tripId ?? 'e'); + if (trip.deleted) entity.writeBooleanField(2, true); + const update = new PbfWriter(); + const descriptor = new PbfWriter(); + if (trip.tripId) descriptor.writeStringField(1, trip.tripId); + if (trip.startDate) descriptor.writeStringField(3, trip.startDate); + if (trip.relationship !== undefined) + descriptor.writeVarintField(4, trip.relationship); + if (trip.routeId) descriptor.writeStringField(5, trip.routeId); + update.writeBytesField(1, descriptor.finish()); + for (const stop of trip.stops || []) { + const stu = new PbfWriter(); + if (stop.sequence !== undefined) stu.writeVarintField(1, stop.sequence); + if (stop.arrival) event(stu, 2, stop.arrival); + if (stop.departure) event(stu, 3, stop.departure); + if (stop.stopId) stu.writeStringField(4, stop.stopId); + if (stop.relationship !== undefined) + stu.writeVarintField(5, stop.relationship); + update.writeBytesField(2, stu.finish()); + } + if (trip.vehicleId) { + const vehicle = new PbfWriter(); + vehicle.writeStringField(1, trip.vehicleId); + update.writeBytesField(3, vehicle.finish()); + } + update.writeVarintField(4, NOW_S - 5); + if (trip.delay !== undefined) update.writeVarintField(5, trip.delay); + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + for (const bytes of extra) writer.writeBytesField(2, bytes); + return writer.finish(); +} + +/** An entity that carries only a vehicle position (a combined feed). */ +function vehicleOnlyEntity() { + const entity = new PbfWriter(); + entity.writeStringField(1, 'veh'); + const vehicle = new PbfWriter(); + const position = new PbfWriter(); + position.writeFloatField(1, 40.7); + position.writeFloatField(2, -73.9); + vehicle.writeBytesField(2, position.finish()); + entity.writeBytesField(4, vehicle.finish()); + return entity.finish(); +} + +test('trip updates decode with stop times, signed delays and the header', () => { + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { + tripId: 'GO202_26_1959', + routeId: '4', + startDate: '20261106', + vehicleId: '7171', + stops: [ + { stopId: '179', sequence: 1, relationship: 2 }, + { + stopId: '33', + sequence: 2, + arrival: { time: NOW_S + 60, delay: 58 }, + departure: { time: NOW_S + 90, delay: -45 }, + }, + ], + }, + ], + extra: [vehicleOnlyEntity()], + }), + ); + assert.equal(decoded.version, '2.0'); + assert.equal(decoded.timestamp, NOW_S); + assert.equal(decoded.incrementality, 0); + assert.equal(decoded.entityCount, 2, 'the vehicle entity is counted'); + assert.equal(decoded.trips.length, 1, 'but is not a trip'); + const [trip] = decoded.trips; + assert.equal(trip.tripId, 'GO202_26_1959'); + assert.equal(trip.routeId, '4'); + assert.equal(trip.startDate, '20261106'); + assert.equal(trip.vehicleId, '7171'); + assert.equal(trip.relationship, 'SCHEDULED'); + assert.equal(trip.delaySec, 58, 'first stated stop delay'); + assert.deepEqual(trip.stops, [ + { + stopId: '179', + stopSequence: 1, + arrival: null, + departure: null, + relationship: 'NO_DATA', + }, + { + stopId: '33', + stopSequence: 2, + arrival: { time: NOW_S + 60, delay: 58 }, + departure: { time: NOW_S + 90, delay: -45 }, + }, + ]); +}); + +test('a trip-level delay wins; a times-only feed reports no delay, not zero', () => { + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { + tripId: 'a', + routeId: 'CR-Lowell', + delay: -120, + stops: [{ stopId: 's', arrival: { delay: 30 } }], + }, + { + tripId: 'b', + routeId: 'CR-Lowell', + stops: [{ stopId: 's', arrival: { time: NOW_S + 300 } }], + }, + { tripId: 'c', routeId: 'CR-Lowell', relationship: 3 }, + ], + }), + ); + const byId = new Map(decoded.trips.map((trip) => [trip.tripId, trip])); + assert.equal(byId.get('a').delaySec, -120); + assert.equal(byId.get('b').delaySec, null); + assert.deepEqual(byId.get('b').stops[0].arrival, { + time: NOW_S + 300, + delay: null, + }); + assert.equal(byId.get('c').relationship, 'CANCELED'); + assert.deepEqual(byId.get('c').stops, []); +}); + +test('implausible values are dropped instead of carried', () => { + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { + tripId: 'x', + routeId: 'r', + delay: 999_999, + stops: [ + { stopId: 'a', arrival: { time: 12, delay: 10 } }, + { stopId: 'b', arrival: { time: 9_999_999_999 } }, + { sequence: 4 }, + {}, + ], + }, + ], + }), + ); + const [trip] = decoded.trips; + assert.equal(trip.delaySec, 10, 'a delay of eleven days is not a delay'); + assert.deepEqual(trip.stops[0].arrival, { time: null, delay: 10 }); + assert.equal(trip.stops[1].arrival, null, 'a time past 2100 is no time'); + assert.equal( + trip.stops.length, + 3, + 'a stop with no id or sequence is dropped', + ); +}); + +test('stops past the per-trip cap are left unread and the trip says so', () => { + const stops = Array.from( + { length: RAIL_TRIP_UPDATE_MAX_STOPS + 5 }, + (_, i) => ({ stopId: `s${i}`, sequence: i, arrival: { delay: 0 } }), + ); + const [trip] = decodeTripUpdates( + encodeTripUpdates({ trips: [{ tripId: 'long', routeId: 'r', stops }] }), + ).trips; + assert.equal(trip.stops.length, RAIL_TRIP_UPDATE_MAX_STOPS); + assert.equal(trip.stopsTruncated, true); +}); + +test('over-long strings drop the record, deleted and empty entities are skipped', () => { + const long = 'x'.repeat(300); + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { tripId: long, routeId: 'r' }, + { tripId: 'gone', routeId: 'r', deleted: true }, + { id: 'empty' }, + { + tripId: 'ok', + routeId: 'r', + stops: [{ stopId: long, sequence: 1 }, { stopId: 'fine' }], + }, + ], + }), + ); + assert.deepEqual( + decoded.trips.map((trip) => trip.tripId), + ['ok'], + ); + assert.deepEqual( + decoded.trips[0].stops.map((stop) => stop.stopId), + ['fine'], + ); + assert.equal(normalizeTripUpdateEntity(null), null); + assert.equal(normalizeTripUpdateEntity({ id: 'v' }), null); +}); + +test('every trip is decoded, up to the shared entity cap, whatever the caller asks', () => { + // The decoder takes no per-caller filter (`keep`) or entity cap + // (`maxEntities`): options like these change nothing. + const trips = Array.from({ length: GTFS_MAX_ENTITIES + 1 }, (_, i) => ({ + tripId: `t${i}`, + })); + const decoded = decodeTripUpdates(encodeTripUpdates({ trips }), { + keep: () => false, + maxEntities: GTFS_MAX_ENTITIES * 2, + }); + assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES); + assert.equal(decoded.truncated, true); + assert.equal( + decoded.trips.length, + Math.min(GTFS_MAX_ENTITIES, RAIL_TRIP_UPDATE_MAX_TRIPS), + ); + assert.equal(decoded.skipped, undefined); + assert.equal(decoded.feedVersion, undefined); +}); + +test('incrementality is reported so the proxy can refuse differential feeds', () => { + assert.equal( + decodeTripUpdates(encodeTripUpdates({ incrementality: 1 })).incrementality, + 1, + ); + const empty = decodeTripUpdates(new Uint8Array(0)); + assert.equal(empty.incrementality, 0); + assert.equal(empty.timestamp, null); + assert.deepEqual(empty.trips, []); +}); diff --git a/src/data/spriteOrder.js b/src/data/spriteOrder.js index 68946ed9a..c954f4b76 100644 --- a/src/data/spriteOrder.js +++ b/src/data/spriteOrder.js @@ -7,6 +7,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([ 'bikeshare', 'transit', 'transit-motion', + 'rail', 'directions', 'ais', 'military', diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 1f3692a60..80097ecc2 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3776,6 +3776,21 @@ "Play speed": "سرعة التشغيل", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل", "1 min/s": "دقيقة/ث", + "Rail": "السكك الحديدية", + "Urban rail": "قطارات حضرية", + "Local rail": "قطارات الضواحي", + "Regional rail": "قطارات إقليمية", + "National rail": "قطارات وطنية", + "International rail": "قطارات دولية", + "Hide {category}": "إخفاء {category}", + "Show {category}": "إظهار {category}", + "Live GPS": "GPS مباشر", + "Estimated from station reports": "مقدَّر من بلاغات المحطات", + "To {headsign}": "إلى {headsign}", + "Next {stop} {time} {relative}": "المحطة التالية {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "المحطة السابقة {stop} {time} {relative}", + "(+{minutes} min late)": "(تأخير {minutes} د)", + "Exit cockpit to fly to a train": "اخرج من القمرة للانتقال إلى قطار", "Photo © {photographer}": "صورة © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", "The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 88ab65f22..0e8931ef1 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3452,6 +3452,21 @@ "Play speed": "Wiedergabegeschwindigkeit", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles", "1 min/s": "1 Min./s", + "Rail": "Bahn", + "Urban rail": "Stadtverkehr", + "Local rail": "Nahverkehr", + "Regional rail": "Regionalverkehr", + "National rail": "Fernverkehr", + "International rail": "International", + "Hide {category}": "{category} ausblenden", + "Show {category}": "{category} einblenden", + "Live GPS": "Live-GPS", + "Estimated from station reports": "Geschätzt aus Bahnhofsmeldungen", + "To {headsign}": "Nach {headsign}", + "Next {stop} {time} {relative}": "Nächster Halt {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Letzter Halt {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min Verspätung)", + "Exit cockpit to fly to a train": "Cockpit verlassen, um zu einem Zug zu fliegen", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt", "The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a16074c79..30c05002b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3533,6 +3533,21 @@ "Play speed": "Velocidad de reproducción", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo", "1 min/s": "1 min/s", + "Rail": "Ferrocarril", + "Urban rail": "Urbano", + "Local rail": "Cercanías", + "Regional rail": "Regional", + "National rail": "Larga distancia", + "International rail": "Internacional", + "Hide {category}": "Ocultar {category}", + "Show {category}": "Mostrar {category}", + "Live GPS": "GPS en vivo", + "Estimated from station reports": "Estimada a partir de avisos de estaciones", + "To {headsign}": "Hacia {headsign}", + "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min de retraso)", + "Exit cockpit to fly to a train": "Sal de la cabina para volar a un tren", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", "The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 49e9b6387..543c5ba5e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3533,6 +3533,21 @@ "Play speed": "Vitesse de lecture", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir", "1 min/s": "1 min/s", + "Rail": "Trains", + "Urban rail": "Urbain", + "Local rail": "Trains locaux", + "Regional rail": "Régional", + "National rail": "Grandes lignes", + "International rail": "International", + "Hide {category}": "Masquer {category}", + "Show {category}": "Afficher {category}", + "Live GPS": "GPS en direct", + "Estimated from station reports": "Estimée d’après les signalements en gare", + "To {headsign}": "Vers {headsign}", + "Next {stop} {time} {relative}": "Prochain arrêt {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Arrêt précédent {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min de retard)", + "Exit cockpit to fly to a train": "Quittez le cockpit pour rejoindre un train", "Photo © {photographer}": "Photo © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue", "The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index b1c523a7a..86ec1d66e 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3357,6 +3357,21 @@ "Reported voyage": "הפלגה שדווחה", "REPORTED VOYAGE": "הפלגה שדווחה", "1 min/s": "דקה/שנ׳", + "Rail": "רכבות", + "Urban rail": "רכבת עירונית", + "Local rail": "רכבת מקומית", + "Regional rail": "רכבת אזורית", + "National rail": "רכבת ארצית", + "International rail": "רכבת בינלאומית", + "Hide {category}": "הסתרת {category}", + "Show {category}": "הצגת {category}", + "Live GPS": "GPS חי", + "Estimated from station reports": "משוער לפי דיווחי תחנות", + "To {headsign}": "אל {headsign}", + "Next {stop} {time} {relative}": "התחנה הבאה {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "התחנה הקודמת {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} דק׳ איחור)", + "Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס לרכבת", "Photo © {photographer}": "צילום © {photographer}", "1 h/s": "שעה/שנ׳", "1 d/s": "יום/שנ׳", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 528964948..00a5bc74d 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3533,6 +3533,21 @@ "Play speed": "Velocità di riproduzione", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto", "1 min/s": "1 min/s", + "Rail": "Ferrovie", + "Urban rail": "Urbano", + "Local rail": "Suburbano", + "Regional rail": "Regionale", + "National rail": "Lunga percorrenza", + "International rail": "Internazionale", + "Hide {category}": "Nascondi {category}", + "Show {category}": "Mostra {category}", + "Live GPS": "GPS in tempo reale", + "Estimated from station reports": "Stimata dalle segnalazioni delle stazioni", + "To {headsign}": "Per {headsign}", + "Next {stop} {time} {relative}": "Prossima fermata {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Fermata precedente {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min di ritardo)", + "Exit cockpit to fly to a train": "Esci dal cockpit per volare a un treno", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", "The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 347156b7a..cc4d529bc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3371,6 +3371,21 @@ "Play speed": "再生速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示", "1 min/s": "1分/秒", + "Rail": "鉄道", + "Urban rail": "都市鉄道", + "Local rail": "近郊列車", + "Regional rail": "地域列車", + "National rail": "長距離列車", + "International rail": "国際列車", + "Hide {category}": "{category}を非表示", + "Show {category}": "{category}を表示", + "Live GPS": "ライブGPS", + "Estimated from station reports": "駅の記録から推定", + "To {headsign}": "{headsign}行き", + "Next {stop} {time} {relative}": "次の駅 {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "前の駅 {stop} {time} {relative}", + "(+{minutes} min late)": "({minutes}分遅れ)", + "Exit cockpit to fly to a train": "列車へ移動するにはコックピットを終了してください", "Photo © {photographer}": "写真 © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています", "The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 233a94f20..6657a28aa 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3533,6 +3533,21 @@ "Play speed": "Velocidade de reprodução", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo", "1 min/s": "1 min/s", + "Rail": "Ferroviário", + "Urban rail": "Urbano", + "Local rail": "Suburbano", + "Regional rail": "Regional", + "National rail": "Longo curso", + "International rail": "Internacional", + "Hide {category}": "Ocultar {category}", + "Show {category}": "Mostrar {category}", + "Live GPS": "GPS ao vivo", + "Estimated from station reports": "Estimada a partir dos registros das estações", + "To {headsign}": "Para {headsign}", + "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min de atraso)", + "Exit cockpit to fly to a train": "Saia da cabine para voar até um trem", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida", "The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3e5a78ef8..ab8a3f896 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3614,6 +3614,21 @@ "Play speed": "Скорость воспроизведения", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё", "1 min/s": "1 мин/с", + "Rail": "Железная дорога", + "Urban rail": "Городской", + "Local rail": "Пригородный", + "Regional rail": "Региональный", + "National rail": "Дальнего следования", + "International rail": "Международный", + "Hide {category}": "Скрыть: {category}", + "Show {category}": "Показать: {category}", + "Live GPS": "GPS в реальном времени", + "Estimated from station reports": "Оценка по отметкам станций", + "To {headsign}": "До {headsign}", + "Next {stop} {time} {relative}": "Следующая {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Предыдущая {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} мин опоздания)", + "Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду", "Photo © {photographer}": "Фото © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место", "The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 7d2828954..93c42d9bd 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3444,6 +3444,21 @@ "Reported voyage": "Angiven resa", "REPORTED VOYAGE": "ANGIVEN RESA", "1 min/s": "1 min/s", + "Rail": "Järnväg", + "Urban rail": "Stadstrafik", + "Local rail": "Lokaltåg", + "Regional rail": "Regionaltåg", + "National rail": "Fjärrtåg", + "International rail": "Internationella tåg", + "Hide {category}": "Dölj {category}", + "Show {category}": "Visa {category}", + "Live GPS": "Live-GPS", + "Estimated from station reports": "Uppskattad från stationsrapporter", + "To {headsign}": "Mot {headsign}", + "Next {stop} {time} {relative}": "Nästa stopp {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Föregående stopp {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min försenad)", + "Exit cockpit to fly to a train": "Lämna cockpiten för att flyga till ett tåg", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Den delade flygningen rapporterar inte just nu – visar senast kända position", "The shared flight isn't reporting now — showing where it was at {time}": "Den delade flygningen rapporterar inte just nu – visar positionen från {time}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 317c7da03..311b851b6 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3614,6 +3614,21 @@ "Play speed": "Швидкість відтворення", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все", "1 min/s": "1 хв/с", + "Rail": "Залізниця", + "Urban rail": "Міський", + "Local rail": "Приміський", + "Regional rail": "Регіональний", + "National rail": "Далекого сполучення", + "International rail": "Міжнародний", + "Hide {category}": "Сховати: {category}", + "Show {category}": "Показати: {category}", + "Live GPS": "GPS у реальному часі", + "Estimated from station reports": "Оцінка за відмітками станцій", + "To {headsign}": "До {headsign}", + "Next {stop} {time} {relative}": "Наступна {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Попередня {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} хв запізнення)", + "Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда", "Photo © {photographer}": "Фото © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце", "The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 54a106fe1..6956fa266 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3371,6 +3371,21 @@ "Play speed": "播放速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部", "1 min/s": "1 分钟/秒", + "Rail": "铁路", + "Urban rail": "城市轨道", + "Local rail": "市郊铁路", + "Regional rail": "区域铁路", + "National rail": "长途铁路", + "International rail": "国际铁路", + "Hide {category}": "隐藏{category}", + "Show {category}": "显示{category}", + "Live GPS": "实时 GPS", + "Estimated from station reports": "根据车站报告估算", + "To {headsign}": "开往 {headsign}", + "Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}", + "(+{minutes} min late)": "(晚点 {minutes} 分钟)", + "Exit cockpit to fly to a train": "请先退出座舱,再飞往列车", "Photo © {photographer}": "照片 © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 93170fa7f..a8bc05998 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3371,6 +3371,21 @@ "Play speed": "播放速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部", "1 min/s": "1 分鐘/秒", + "Rail": "鐵路", + "Urban rail": "城市軌道", + "Local rail": "市郊鐵路", + "Regional rail": "區域鐵路", + "National rail": "長途鐵路", + "International rail": "國際鐵路", + "Hide {category}": "隱藏{category}", + "Show {category}": "顯示{category}", + "Live GPS": "即時 GPS", + "Estimated from station reports": "根據車站報告估算", + "To {headsign}": "開往 {headsign}", + "Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}", + "(+{minutes} min late)": "(誤點 {minutes} 分鐘)", + "Exit cockpit to fly to a train": "請先退出座艙,再飛往列車", "Photo © {photographer}": "照片 © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置", diff --git a/src/layers/rail/controls.js b/src/layers/rail/controls.js new file mode 100644 index 000000000..0899ed8e9 --- /dev/null +++ b/src/layers/rail/controls.js @@ -0,0 +1,202 @@ +import { t } from '../../i18n/index.js'; +import { RAIL_CATEGORIES } from '../../data/railFeeds.js'; +import { aggregateTransitFeedHealth } from '../transit/policy.js'; +import { + ACTIVATION_ALTITUDE_M, + FEED_STALE_AFTER_MS, + RAIL_CATEGORY_COLORS, + RAIL_CATEGORY_LABELS, + normalizeRailCategories, +} from './policy.js'; + +/** + * Category toggles, the layer row and the stats the panel reads. + * @param {object} context + * @returns {object} + */ +export function createControls({ state, parts, timetable }) { + function notify() { + state._controlsListener?.(); + } + + /** Regions with at least one pollable feed, for the empty-coverage note. */ + function regionCount() { + return new Set(parts.viewport.pollableFeeds().map((feed) => feed.region)) + .size; + } + + /** + * Trains per category for the legend, shown or not; a train two feeds + * carry is one train (the copy dedupe.js hides is not counted). + */ + function categoryCounts() { + const counts = new Map(); + for (const entry of state._trains.values()) + if (entry.duplicate !== true) + counts.set(entry.category, (counts.get(entry.category) || 0) + 1); + return counts; + } + + /** + * Trains drawn per feed: those still held (a train missing from one poll + * stays until MISSED_POLLS_TO_DROP) in a shown category, and not hidden as + * another feed's copy. The feed status's own count is what the last poll + * listed, which the loading and health rules need but the map does not + * show, so the coverage line counts drawn trains, as the layer total does. + */ + function drawnCounts() { + const counts = new Map(); + for (const entry of state._trains.values()) + if (parts.rendering.categoryShown(entry)) + counts.set(entry.feedId, (counts.get(entry.feedId) || 0) + 1); + return counts; + } + + /** The coverage line: each active feed and its train count, aged when quiet. */ + function coverageLine(active, now) { + const drawn = drawnCounts(); + return active + .map((feed) => { + const status = parts.ingestion.feedStatus(feed.id); + const quiet = + status.stale === true || + (Number.isFinite(status.upstreamAt) && + now - status.upstreamAt > FEED_STALE_AFTER_MS); + const aged = + quiet && Number.isFinite(status.upstreamAt) + ? ` (${Math.round((now - status.upstreamAt) / 1000)}s old)` + : ''; + return `${feed.name} ${drawn.get(feed.id) || 0}${aged}`; + }) + .join(' · '); + } + + const methods = { + /** Wired by the application so polls can repaint the panel row. */ + attachDataManager(manager) { + state._dataManager = manager; + }, + setRowControlsListener(listener) { + state._controlsListener = + typeof listener === 'function' ? listener : null; + }, + /** The category toggles, for share links and restoration. */ + getParams() { + return { ...state._categories }; + }, + /** Show or hide categories; unknown keys and non-booleans change nothing. */ + setParams(params = {}) { + const next = normalizeRailCategories(params, state._categories); + if (RAIL_CATEGORIES.every((key) => next[key] === state._categories[key])) + return true; + state._categories = next; + const selected = state._trains.get(state._selectedKey); + if (selected && next[selected.category] === false) + parts.selection.clearSelection(); + parts.rendering.applyCategoryVisibility(); + state._revision += 1; + notify(); + state._dataManager?.refreshLayerStats?.(); + return true; + }, + /** One chip per category, with the colour and live count as the legend. */ + getRowControls() { + const counts = categoryCounts(); + const chips = RAIL_CATEGORIES.map((category) => { + const on = state._categories[category] !== false; + const label = t(RAIL_CATEGORY_LABELS[category]); + return { + id: `rail-${category}`, + label: label.toUpperCase(), + title: on + ? t('Hide {category}', { category: label }) + : t('Show {category}', { category: label }), + active: on, + busy: false, + disabled: false, + state: on ? 'active' : 'idle', + params: { [category]: !on }, + }; + }); + const legend = RAIL_CATEGORIES.filter((category) => + counts.has(category), + ).map((category) => ({ + label: t(RAIL_CATEGORY_LABELS[category]), + color: RAIL_CATEGORY_COLORS[category], + count: counts.get(category), + })); + return { chips, legend }; + }, + /** Route-line, stop, schedule and camera-follow state, for QA probes. */ + getRouteDisplayStats() { + return { + ...parts.routes.stats(), + timetable: timetable.stats(), + follow: parts.follow.stats(), + heldFeed: state._heldFeedId, + }; + }, + getStats() { + const active = [...state._activeFeeds.values()]; + const statuses = active.map((feed) => + parts.ingestion.feedStatus(feed.id), + ); + const now = Date.now(); + const health = aggregateTransitFeedHealth(statuses, now); + let count = 0; + for (const entry of state._trains.values()) + if (parts.rendering.categoryShown(entry)) count += 1; + // New trains the layer's cap left out of the last polls + // (MAX_TRAINS_TOTAL): the map shows part of what the feeds list. + const capped = statuses.reduce( + (sum, status) => sum + (status.capped || 0), + 0, + ); + if (state._enabled && active.length === 0) { + // English: the row's prompt and the analyst's empty note, both shown + // through translateStatusMessage (ui/statusText.js), as Transit's. + const note = state._altitudeGateOpen + ? `No feed here yet · ${regionCount()} regions available` + : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`; + return { + count: 0, + lastUpdate: state._lastUpdate, + error: null, + source: 'GTFS-RT', + status: 'zoom-in', + statusMessage: note, + coverage: note, + }; + } + return { + count, + lastUpdate: state._lastUpdate, + error: count === 0 ? health.error : null, + ...(count === 0 && Number.isFinite(health.retryInSec) + ? { retryInSec: health.retryInSec } + : {}), + degraded: count > 0 && health.degraded, + loading: health.loading && count === 0, + loadingLabel: + // i18n-ignore: English data, translated on display (ui/statusText.js) + health.loading && count === 0 + ? `Loading ${active.map((feed) => feed.name).join(', ')}` + : undefined, + stale: health.stale, + // The panel reads this as "PARTIAL · N of M records accepted". + ...(capped > 0 && count > 0 + ? { + partial: true, + acceptedRowCount: count, + rawRowCount: count + capped, + } + : {}), + source: 'GTFS-RT', + coverage: coverageLine(active, now), + feeds: active.map((feed) => feed.id), + }; + }, + }; + + return { methods, notify }; +} diff --git a/src/layers/rail/dedupe.js b/src/layers/rail/dedupe.js new file mode 100644 index 000000000..e1ce79f78 --- /dev/null +++ b/src/layers/rail/dedupe.js @@ -0,0 +1,394 @@ +import { greatCircleKm } from '../../geoDistance.js'; + +/** + * @module rail/dedupe + * @description One physical train drawn once when several loaded feeds carry + * it (an operator's own feed and a national feed that lists its trains too, + * a cross-border train reported on both sides). + * + * Matching, between copies from different feeds: + * - both carry a train number: the same normalised number (its last digit + * group without leading zeros: `RJX 160`, `160`), up to NUMBER_RADIUS_KM + * apart. Numbers that differ are different trains. A name's number is a + * train's when it has three digits or more, or follows a long-distance + * product (`EC 7`, `NJ 40`); a trip id that is an operator prefix and a + * number (`AM:530`) and a bare-number headsign (where a feed with no + * trip names writes the train number) are read too. + * - a shorter number after anything else names a line (`S1`, `RB 1`, + * `U6`), which many trains run: copies on different lines are different + * trains, and copies on one line within MATCH_RADIUS_KM are one train + * only in the same category, the nearest pairs first. + * A train has at most one copy per feed; the strongest matches join first. + * + * Choosing the copy drawn: + * 1. position source: live GPS, then station reports; + * 2. the line: a copy bound to its operator's shape (a built pack), then + * one without. + * The copy drawn stays drawn until another scores strictly higher, so equal + * copies never trade places. A losing copy keeps its playback and is only + * hidden; when a feed leaves the viewport its copies' partners come back. + * The comparison runs once per poll, never per frame. + */ + +/** + * Copies farther apart than this are different trains (km): two copies of + * one train at one moment differ only by where each feed reports it. + */ +export const MATCH_RADIUS_KM = 15; +/** + * Copies with the same number match up to this far apart (km): two feeds + * report one train at different moments, and one polled every few minutes + * can be tens of kilometres behind at speed. + */ +export const NUMBER_RADIUS_KM = 100; +/** + * Grid cell for finding nearby copies (degrees). A row (27.8 km) spans + * MATCH_RADIUS_KM; a column narrows away from the equator (13.9 km at + * 60°N), so as many columns either side are searched as reach it + * (columnSpan). + */ +const CELL_DEG = 0.25; + +const POSITION_RANK = Object.freeze({ + gps: 2, + event: 1, +}); +/** + * Products whose trains carry short numbers of their own (`EC 7`, `NJ 40`). + * Not `IC`: in Switzerland `IC 1` and `IC 5` are lines run by many trains a + * day, and IC train numbers elsewhere have three or more digits anyway. + */ +const LONG_DISTANCE_PRODUCTS = new Set([ + 'EC', + 'ECE', + 'EN', + 'ICE', + 'NJ', + 'RJ', + 'RJX', + 'TGV', +]); + +/** + * A train number comparable across feeds: the last group of digits, without + * leading zeros (`RJX 160` → `160`, `09725` → `9725`), or null. + * @param {string|null|undefined} label + * @returns {string|null} + */ +export function trainNumber(label) { + const digits = /(\d{1,6})\D*$/.exec(String(label ?? ''))?.[1]; + const number = digits?.replace(/^0+/, '') ?? ''; + return number || null; +} + +/** + * What a name's number says: a train's own number (three digits or more, + * or after a long-distance product: `RJX 160`, `EC 7`), or a line's + * (`S1`, `RB 1`, `U6`: each run by many trains), with its product. + * @param {string|null|undefined} label + * @returns {{number: string|null, line: {product: string, + * number: string}|null}} + */ +export function labelNumbers(label) { + const match = /([A-Za-z]*)\s*(\d{1,6})\D*$/.exec(String(label ?? '')); + const number = match?.[2].replace(/^0+/, '') ?? ''; + if (!number) return { number: null, line: null }; + const product = match[1].toUpperCase(); + return number.length >= 3 || LONG_DISTANCE_PRODUCTS.has(product) + ? { number, line: null } + : { number: null, line: { product, number } }; +} + +/** + * A record's train number and line: the number from its name, unless that + * is only the vehicle's own id (a feed that labels each train with its car + * number) or names a line; else from a trip id that is an operator + * prefix and a number (`AM:530`); else from a headsign that is a bare + * number (where a feed with no trip names writes the train number). The line is the name's, + * when it names one. + * @param {{id?: string|null, label?: string|null, tripId?: string|null, + * headsign?: string|null}} record + * @returns {{number: string|null, line: {product: string, + * number: string}|null}} + */ +export function recordNumbers(record) { + const label = String(record?.label ?? '').trim(); + const named = + label && label !== String(record?.id ?? '') + ? labelNumbers(label) + : { number: null, line: null }; + if (named.number) return named; + const trip = /^[A-Za-z]{1,6}:(\d{1,6})$/.exec( + String(record?.tripId ?? '').trim(), + ); + const headsign = String(record?.headsign ?? '').trim(); + const number = trip + ? trainNumber(trip[1]) + : /^\d{3,6}$/.test(headsign) + ? trainNumber(headsign) + : null; + return number ? { number, line: null } : named; +} + +/** + * A record's train number (recordNumbers), or null. + * @param {object} record + * @returns {string|null} + */ +export function recordTrainNumber(record) { + return recordNumbers(record).number; +} + +/** Whether two lines can be one: one number, and one product where both name it. */ +function sameLine(a, b) { + return ( + a.number === b.number && + (!a.product || !b.product || a.product === b.product) + ); +} + +/** A number match outranks any line match. */ +const NUMBER_MATCH = 2; + +/** + * How strongly two copies are the same physical train: 0 when they are not, + * NUMBER_MATCH for the same number, else, for one line in one category, + * between 0.5 and 1, higher the nearer they are. See the module notes. + * @param {object} a A copy (`copyOf` in createDuplicateFilter). + * @param {object} b + * @returns {number} + */ +export function matchStrength(a, b) { + if (a.feedId === b.feedId) return 0; + const km = greatCircleKm(a, b); + if (a.number && b.number) + return km <= NUMBER_RADIUS_KM && a.number === b.number ? NUMBER_MATCH : 0; + if (km > MATCH_RADIUS_KM) return 0; + if (a.line && b.line && !sameLine(a.line, b.line)) return 0; + // Many trains run one line: in one category, the nearest copies pair + // first. + return a.line && b.line && a.category != null && a.category === b.category + ? 1 - km / (2 * MATCH_RADIUS_KM) + : 0; +} + +/** Whether two copies are the same physical train. */ +export function sameTrain(a, b) { + return matchStrength(a, b) > 0; +} + +/** + * Grid columns either side of a copy's that reach MATCH_RADIUS_KM east and + * west of it, measured on the poleward edge of its row's neighbours. + * @param {number} lat + * @returns {number} + */ +function columnSpan(lat) { + const poleward = Math.min(89, Math.abs(lat) + CELL_DEG); + const cellKm = CELL_DEG * 111.32 * Math.cos((poleward * Math.PI) / 180); + return Math.max(1, Math.ceil(MATCH_RADIUS_KM / cellKm)); +} + +/** + * Groups of two or more copies that are one train, at most one copy per + * feed: two trains of one feed are two trains, even where a third feed's + * copy resembles both (coupled TGVs). The strongest matches are joined + * first. Numbered copies are compared wherever they are, the others only + * within MATCH_RADIUS_KM's reach on the grid. + * @param {object[]} copies + * @returns {object[][]} + */ +export function matchCopies(copies) { + const cells = new Map(); + const cellOf = (lat, lon) => + `${Math.floor(lat / CELL_DEG)}|${Math.floor(lon / CELL_DEG)}`; + copies.forEach((copy, i) => { + if (!Number.isFinite(copy.lat) || !Number.isFinite(copy.lon)) return; + const key = cellOf(copy.lat, copy.lon); + if (!cells.has(key)) cells.set(key, []); + cells.get(key).push(i); + }); + const pairs = []; + // Numbered copies pair through their number, wherever they are. + const byNumber = new Map(); + copies.forEach((copy, i) => { + if (!copy.number) return; + if (!byNumber.has(copy.number)) byNumber.set(copy.number, []); + byNumber.get(copy.number).push(i); + }); + for (const list of byNumber.values()) + for (let x = 0; x < list.length; x++) + for (let y = x + 1; y < list.length; y++) { + const strength = matchStrength(copies[list[x]], copies[list[y]]); + if (strength > 0) pairs.push([strength, list[x], list[y]]); + } + // The others through the grid: only copies near each other. + copies.forEach((copy, i) => { + if (!Number.isFinite(copy.lat) || !Number.isFinite(copy.lon)) return; + const row = Math.floor(copy.lat / CELL_DEG); + const col = Math.floor(copy.lon / CELL_DEG); + const span = columnSpan(copy.lat); + for (let dr = -1; dr <= 1; dr++) + for (let dc = -span; dc <= span; dc++) + for (const j of cells.get(`${row + dr}|${col + dc}`) ?? []) { + if (j <= i || (copy.number && copies[j].number)) continue; + const strength = matchStrength(copy, copies[j]); + if (strength > 0) pairs.push([strength, i, j]); + } + }); + pairs.sort((a, b) => b[0] - a[0]); + const parent = copies.map((_, i) => i); + const feeds = copies.map((copy) => new Set([copy.feedId])); + const members = copies.map((_, i) => [i]); + const find = (i) => { + while (parent[i] !== i) i = parent[i] = parent[parent[i]]; + return i; + }; + for (const [, i, j] of pairs) { + const a = find(i); + const b = find(j); + if (a === b || [...feeds[b]].some((feed) => feeds[a].has(feed))) continue; + // Every copy must match every other: a copy resembling two trains (an + // unnumbered train at a busy hub) never joins them into one. + const all = members[a].every((x) => + members[b].every( + (y) => (x === i && y === j) || matchStrength(copies[x], copies[y]) > 0, + ), + ); + if (!all) continue; + parent[b] = a; + for (const feed of feeds[b]) feeds[a].add(feed); + members[a].push(...members[b]); + } + const groups = new Map(); + copies.forEach((copy, i) => { + const root = find(i); + if (!groups.has(root)) groups.set(root, []); + groups.get(root).push(copy); + }); + return [...groups.values()].filter((group) => group.length > 1); +} + +/** + * A copy's score: `[position, line]`, compared in that order. + * @param {object} copy + * @returns {number[]} + */ +export function scoreCopy(copy) { + return [POSITION_RANK[copy.position] ?? 0, copy.onShape === true ? 1 : 0]; +} + +function better(a, b) { + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] > b[i]; + return false; +} + +/** + * The copy each group draws, keeping last time's choice unless another copy + * scores strictly higher. + * @param {object[][]} groups From matchCopies. + * @param {Map} [previous] copy key → key drawn last time. + * @returns {{winners: Map, hidden: Set}} + * `winners`: every grouped copy's key → the key drawn for its train. + */ +export function chooseCopies(groups, previous = new Map()) { + const winners = new Map(); + const hidden = new Set(); + for (const group of groups) { + const scored = group.map((copy) => ({ copy, score: scoreCopy(copy) })); + const keys = new Set(group.map((copy) => copy.key)); + const incumbentKey = group + .map((copy) => previous.get(copy.key)) + .find((key) => key && keys.has(key)); + let best = + scored.find(({ copy }) => copy.key === incumbentKey) ?? + scored.reduce((a, b) => (better(b.score, a.score) ? b : a)); + for (const candidate of scored) + if (better(candidate.score, best.score)) best = candidate; + for (const { copy } of scored) { + winners.set(copy.key, best.copy.key); + if (copy.key !== best.copy.key) hidden.add(copy.key); + } + } + return { winners, hidden }; +} + +/** + * The layer's duplicate filter: after each poll it matches the trains every + * loaded feed carries, marks the copies not drawn (`entry.duplicate`), and + * moves a selection that sat on a copy now hidden to the copy drawn, so the + * card follows the same physical train (its feed line names the new + * source). + * @param {{state: object, parts: object, + * defer?: (fn: Function) => unknown}} context + */ +export function createDuplicateFilter({ + state, + parts, + defer = (fn) => setTimeout(fn, 0), +}) { + let previous = new Map(); + let pending = false; + let lastStats = { groups: 0, hidden: 0 }; + + /** The comparable view of one drawn train. */ + function copyOf(entry) { + const record = entry.record ?? {}; + const { number, line } = recordNumbers(record); + return { + key: entry.key, + feedId: entry.feedId, + lat: record.lat, + lon: record.lon, + number, + line, + category: entry.category ?? record.category ?? null, + position: record.positionSource ?? 'gps', + onShape: Number.isInteger(entry.path?.shapeIndex), + }; + } + + /** Recompute which copies are drawn. Cheap enough for once per poll. */ + function refresh() { + pending = false; + const entries = [...state._trains.values()]; + const { winners, hidden } = chooseCopies( + matchCopies(entries.map(copyOf)), + previous, + ); + previous = winners; + for (const entry of entries) { + const duplicate = hidden.has(entry.key); + if (entry.duplicate === duplicate) continue; + entry.duplicate = duplicate; + parts.rendering.paint(entry); + parts.rendering.schedulePlayback(entry); + } + // A selection stays on its train: it moves to the copy now drawn. + const selected = state._selectedKey; + if (selected && hidden.has(selected)) { + const drawn = winners.get(selected); + if (drawn && state._trains.has(drawn)) parts.selection.selectTrain(drawn); + } + lastStats = { + groups: new Set(winners.values()).size, + hidden: hidden.size, + }; + return lastStats; + } + + /** Coalesce the refreshes of several feeds' polls into one. */ + function scheduleRefresh() { + if (pending) return; + pending = true; + defer(() => { + if (pending) refresh(); + }); + } + + return { + refresh, + scheduleRefresh, + stats: () => ({ ...lastStats }), + }; +} diff --git a/src/layers/rail/dedupe.test.mjs b/src/layers/rail/dedupe.test.mjs new file mode 100644 index 000000000..ac3f61f6b --- /dev/null +++ b/src/layers/rail/dedupe.test.mjs @@ -0,0 +1,364 @@ +// One physical train in several loaded live feeds is drawn once: copies are +// matched by train number, or by line and category when near each other, +// and the copy with the better position source and line is drawn. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + NUMBER_RADIUS_KM, + chooseCopies, + createDuplicateFilter, + labelNumbers, + matchCopies, + recordNumbers, + recordTrainNumber, + sameTrain, + scoreCopy, + trainNumber, +} from './dedupe.js'; +import * as dedupe from './dedupe.js'; + +const ROSENHEIM = { lat: 47.85, lon: 12.119 }; +const SALZBURG = { lat: 47.813, lon: 13.046 }; +const LINZ = { lat: 48.29, lon: 14.291 }; + +/** A fake copy of a live train. */ +function copy(key, feedId, extra = {}) { + return { + key, + feedId, + lat: ROSENHEIM.lat, + lon: ROSENHEIM.lon, + number: null, + line: null, + category: null, + position: 'gps', + onShape: false, + ...extra, + }; +} + +test('train numbers compare across feeds', () => { + assert.equal(trainNumber('RJX 160'), '160'); + assert.equal(trainNumber('160'), '160'); + assert.equal(trainNumber('09725'), '9725'); + assert.equal(trainNumber('TGV 10465'), '10465'); + for (const none of [null, '', 'S', '0', 'RJX']) + assert.equal(trainNumber(none), null, String(none)); +}); + +test('the same number is one train; another number is not', () => { + const a = copy('a', 'feed-a', { number: '160' }); + assert.equal(sameTrain(a, copy('b', 'feed-b', { number: '160' })), true); + assert.equal(sameTrain(a, copy('b', 'feed-b', { number: '161' })), false); + // Never within one feed, never far apart. + assert.equal(sameTrain(a, copy('b', 'feed-a', { number: '160' })), false); + assert.equal( + sameTrain(a, copy('b', 'feed-b', { number: '160', ...LINZ })), + false, + ); + // Two feeds report one train at different moments: 70 km on + // (Rosenheim–Salzburg) it is still the same numbered train. + assert.ok(NUMBER_RADIUS_KM > 70); + assert.equal( + sameTrain(a, copy('b', 'feed-b', { number: '160', ...SALZBURG })), + true, + ); + assert.equal( + matchCopies([a, copy('b', 'feed-b', { number: '160', ...SALZBURG })]) + .length, + 1, + ); +}); + +test('the filter matches nothing from timetables: no stops, dates or homes', () => { + // Copies are live trains only; nothing in the filter reads a schedule. + for (const gone of [ + 'scheduleRunStops', + 'scheduleHomeCountry', + 'copyCountry', + 'shareTimedStops', + 'serviceDateKey', + ]) + assert.equal(dedupe[gone], undefined, gone); + // Unnumbered copies off any line are never joined, however close. + assert.equal(sameTrain(copy('a', 'feed-a'), copy('b', 'feed-b')), false); +}); + +test('a bare-number headsign is the number when there is no name', () => { + assert.equal( + recordTrainNumber({ label: null, headsign: '839162' }), + '839162', + ); + assert.equal( + recordTrainNumber({ label: 'RJX 160', headsign: '9999' }), + '160', + ); + assert.equal(recordTrainNumber({ label: null, headsign: 'Paris-Est' }), null); + assert.equal(recordTrainNumber({ label: null, headsign: 'S 12' }), null); +}); + +test('a live vehicle labelled with its own car number takes the number from its trip', () => { + // A live feed's vehicle 8306 runs train 530. + const live = { id: '8306', label: '8306', tripId: 'AM:530' }; + assert.equal(recordTrainNumber(live), '530'); + // A label that is not the vehicle id is the train's name. + assert.equal( + recordTrainNumber({ id: 'v1', label: '531', tripId: 'AM:530' }), + '531', + ); + // Long trip ids are not train numbers. + assert.equal( + recordTrainNumber({ id: '7', label: '7', tripId: '0519321010VRFV' }), + null, + ); + // The GPS copy matches a station-report copy of train 530, and wins over + // it even on the operator's line. + const groups = matchCopies([ + copy('gps', 'gps-feed', { number: recordTrainNumber(live) }), + copy('event', 'event-feed', { + number: '530', + position: 'event', + shape: 'operator', + }), + ]); + assert.equal(groups.length, 1); + assert.equal(chooseCopies(groups).winners.get('event'), 'gps'); +}); + +test('a group never holds two trains of one feed (coupled trains)', () => { + // Two coupled TGVs side by side in one feed; the other feed names one of + // them. Each pairs with its own number only. + const groups = matchCopies([ + copy('fr-6194', 'feed-fr', { number: '6194' }), + copy('fr-6906', 'feed-fr', { number: '6906' }), + copy('ch-6194', 'feed-ch', { number: '6194' }), + copy('ch-6906', 'feed-ch', { number: '6906' }), + ]); + assert.deepEqual( + groups.map((group) => group.map((c) => c.key).sort()).sort(), + [ + ['ch-6194', 'fr-6194'], + ['ch-6906', 'fr-6906'], + ], + ); + // On one line, one feed's two copies still never share a group. + const s1 = { ...recordNumbers({ label: 'S1' }), category: 'local' }; + const unnamed = matchCopies([ + copy('a1', 'feed-a', s1), + copy('a2', 'feed-a', { ...s1, lat: ROSENHEIM.lat + 0.01 }), + copy('b1', 'feed-b', s1), + ]); + assert.equal(unnamed.length, 1); + assert.equal(new Set(unnamed[0].map((c) => c.feedId)).size, 2); +}); + +test('a copy resembling two different trains never joins them', () => { + // An unnumbered S1 lies near two numbered trains on its line; the two + // carry different numbers, so the three are never one train. + const s1 = { line: { product: 'S', number: '1' }, category: 'local' }; + const groups = matchCopies([ + copy('n1', 'feed-a', { number: '1211', ...s1 }), + copy('n2', 'feed-s', { number: '22739', ...s1 }), + copy('s1', 'feed-c', s1), + ]); + assert.ok( + groups.every((group) => group.length < 3), + 'never all three', + ); +}); + +test('the score: position source, then the line', () => { + const base = copy('a', 'x'); + assert.deepEqual(scoreCopy(base), [2, 0]); + assert.deepEqual(scoreCopy({ ...base, position: 'event' }), [1, 0]); + assert.deepEqual(scoreCopy({ ...base, onShape: true }), [2, 1]); + // A source the layer does not know scores nothing. + assert.deepEqual(scoreCopy({ ...base, position: 'psychic' }), [0, 0]); +}); + +test('equal copies never trade places: the one drawn stays drawn', () => { + const twins = [ + copy('a', 'f1', { number: '160', position: 'event' }), + copy('b', 'f2', { number: '160', position: 'event' }), + ]; + const first = chooseCopies([twins]); + const drawn = first.winners.get('a'); + for (let i = 0; i < 3; i++) { + const again = chooseCopies([[...twins].reverse()], first.winners); + assert.equal(again.winners.get('a'), drawn); + } + // A GPS copy takes over from a station-report one at once. + const live = chooseCopies( + [[twins[0], { ...twins[1], position: 'gps' }]], + new Map([ + ['a', 'a'], + ['b', 'a'], + ]), + ); + assert.equal(live.winners.get('a'), 'b'); +}); + +/** + * A layer's state with one live train per feed, both train RJX 160, and + * spies for what the filter calls. + */ +function fakeLayer({ selected = null } = {}) { + const entry = (feedId, onShape) => ({ + key: `${feedId}:v160`, + feedId, + record: { + id: 'v160', + tripId: 't1', + label: 'RJX 160', + ...ROSENHEIM, + positionSource: 'gps', + }, + path: onShape ? { shapeIndex: 0 } : null, + }); + const trains = [entry('feed-a', false), entry('feed-b', true)]; + const state = { + _trains: new Map(trains.map((t) => [t.key, t])), + _selectedKey: selected, + }; + const calls = { paint: [], selected: [] }; + const parts = { + rendering: { + paint: (e) => calls.paint.push([e.key, e.duplicate]), + schedulePlayback: () => {}, + }, + selection: { + selectTrain: (key) => { + calls.selected.push(key); + state._selectedKey = key; + }, + }, + }; + return { state, parts, calls, trains }; +} + +test('the filter hides the weaker copy and the selection follows its train', () => { + const layer = fakeLayer({ selected: 'feed-a:v160' }); + const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() }); + assert.deepEqual(filter.refresh(), { groups: 1, hidden: 1 }); + const [unbound, operator] = layer.trains; + assert.equal(unbound.duplicate, true, 'not on the operator’s line'); + assert.equal(operator.duplicate, false); + assert.deepEqual(layer.calls.selected, [operator.key]); + // When the copy on the operator's line goes (its feed left the view), the + // other is drawn. + layer.state._trains.delete(operator.key); + filter.scheduleRefresh(); + assert.equal(unbound.duplicate, false); + assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 }); +}); + +test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => { + for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5']) + assert.equal(recordTrainNumber({ label }), null, label); + assert.deepEqual(labelNumbers('RB 1').line, { product: 'RB', number: '1' }); + // Three digits, or a long-distance product's own short number, are a + // train's. + assert.equal(recordTrainNumber({ label: 'EC 7' }), '7'); + assert.equal(recordTrainNumber({ label: 'NJ 40' }), '40'); + assert.equal(recordTrainNumber({ label: 'S 1264' }), '1264'); + // A line name gives way to a trip id that is the train's number. + assert.equal(recordTrainNumber({ label: '5', tripId: 'AM:5' }), '5'); + + // An S1 and an RB 1 25 km apart are never joined as one train numbered 1. + const s1 = copy('s1', 'feed-a', { + ...recordNumbers({ label: 'S1' }), + lat: 48.14, + lon: 11.56, + category: 'local', + }); + const rb1 = copy('rb1', 'feed-b', { + ...recordNumbers({ label: 'RB 1' }), + lat: 48.14 + 25 / 111.32, + lon: 11.56, + category: 'regional', + }); + assert.equal(sameTrain(s1, rb1), false); + assert.deepEqual(matchCopies([s1, rb1]), []); + // Nearer, still two lines. + assert.equal(sameTrain(s1, { ...rb1, lat: 48.15, category: 'local' }), false); +}); + +test('copies on one line are one train in one category, the nearest first', () => { + const at = (key, feedId, km, extra = {}) => + copy(key, feedId, { + ...recordNumbers({ label: extra.label ?? 'S1' }), + lat: 48.14 + km / 111.32, + lon: 11.56, + category: 'local', + ...extra, + }); + const gps = at('gps', 'feed-a', 0); + // The other feed reports two S1 trains near it; the nearer is the same. + const near = at('near', 'feed-b', 1, { position: 'event' }); + const next = at('next', 'feed-b', 6, { label: 'S 1', position: 'event' }); + assert.equal(sameTrain(gps, near), true); + assert.deepEqual( + matchCopies([next, gps, near]).map((group) => + group.map((c) => c.key).sort(), + ), + [['gps', 'near']], + ); + // Another category, or beyond the match radius: not one train. + assert.equal(sameTrain(gps, { ...near, category: 'regional' }), false); + assert.equal(sameTrain(gps, at('far', 'feed-b', 20)), false); +}); + +test('copies on one line 14 km apart east-west at 60°N are compared', () => { + // A 0.25° grid column is 13.9 km wide there: the copies sit two columns + // apart. + const line = { ...recordNumbers({ label: 'R1' }), category: 'regional' }; + const west = copy('west', 'feed-a', { lat: 60, lon: 10.24, ...line }); + const east = copy('east', 'feed-b', { lat: 60, lon: 10.5, ...line }); + assert.equal(sameTrain(west, east), true); + assert.equal(matchCopies([west, east]).length, 1); +}); + +test('the filter reads lines and categories from the trains it compares', () => { + const entry = (feedId, label, category, lat) => ({ + key: `${feedId}:${label}`, + feedId, + category, + record: { + id: `v-${feedId}`, + label, + lat, + lon: 11.56, + positionSource: 'gps', + }, + }); + const trains = [ + entry('munich-live', 'S1', 'local', 48.14), + entry('other-live', 'S 1', 'local', 48.15), + entry('third-live', 'RB 1', 'regional', 48.16), + ]; + const state = { + _trains: new Map(trains.map((t) => [t.key, t])), + _pathPacks: new Map(), + _selectedKey: null, + }; + const parts = { + rendering: { paint() {}, schedulePlayback() {} }, + selection: { selectTrain() {} }, + }; + const filter = createDuplicateFilter({ + state, + parts, + defer: (fn) => fn(), + }); + assert.deepEqual(filter.refresh(), { groups: 1, hidden: 1 }); + assert.equal(trains[2].duplicate, false, 'the RB 1 is its own train'); +}); + +test('a Swiss IC line name is not a train number', () => { + assert.deepEqual(labelNumbers('IC 1'), { + number: null, + line: { product: 'IC', number: '1' }, + }); + assert.deepEqual(labelNumbers('IC 2010'), { number: '2010', line: null }); + assert.deepEqual(labelNumbers('EC 7'), { number: '7', line: null }); +}); diff --git a/src/layers/rail/follow.js b/src/layers/rail/follow.js new file mode 100644 index 000000000..7e5b0075e --- /dev/null +++ b/src/layers/rail/follow.js @@ -0,0 +1,230 @@ +import * as Cesium from 'cesium'; +import { + FOLLOW_HANDOVER_MS, + railFollowOffset, + railTrainFollowable, +} from './policy.js'; + +/** The selected train's kind in the shared world-focus framing. */ +export const RAIL_FOCUS_KIND = 'train'; + +/** + * The selected train's camera. + * + * A moving train is followed as a tracked aircraft or satellite is: the layer + * gives Cesium's `viewer.trackedEntity` an entity that stands on the train's + * marker, framed by the shared world-focus framing for a train, so the camera + * rides with the train and the user still orbits and zooms around it. Any + * other train gets one fly-to through the shared world-focus request, as a + * vessel does. Inside the Cockpit that request is refused with its toast. + * + * The follow lets go when the train is deselected (an empty click, Escape, its + * category hidden, the train leaving its feed, the layer turned off), when + * another train is selected (which takes the camera in its turn), and when + * another owner takes the camera: an aircraft or satellite tracked by another + * layer clears the selection as well, while a navigation that only releases + * the follow camera (a search, a fly-to) leaves the train selected. Letting + * go leaves the camera where it is. + * + * When the selection moves to another feed's copy of the same train + * (dedupe.js), the tracked entity stays and glides to the new copy. + * + * Services (all optional; without them a selection has no camera): + * services.camera = { + * framing: {rangeM, pitchDeg}, // WORLD_FOCUS_FRAMING.train + * requestWorldFocus(detail) → boolean, // src/worldFocus.js + * applyTrackedCameraFrame(viewer, entity, viewFrom) → stop, + * isCockpitActive() → boolean, + * } + * + * @param {object} context + * @returns {object} + */ +export function createFollow({ state, services, parts }) { + const camera = services.camera ?? null; + let removeTrackedListener = null; + + function markerPosition(entry) { + const p = entry?.marker?.position; + return p && + Number.isFinite(p.x) && + Number.isFinite(p.y) && + Number.isFinite(p.z) && + Cesium.Cartesian3.magnitudeSquared(p) > 0 + ? p + : null; + } + + /** + * Where the tracked entity stands: on the selected train's marker, or + * gliding to it after a handover. Never hands out the marker's own vector. + */ + function followedPosition(follow, result) { + const live = markerPosition(state._trains.get(state._selectedKey)); + if (live) Cesium.Cartesian3.clone(live, follow.last); + const out = result ?? new Cesium.Cartesian3(); + const blend = follow.blend; + if (blend) { + const t = (performance.now() - blend.startMs) / FOLLOW_HANDOVER_MS; + if (t < 1) { + const eased = t <= 0 ? 0 : t * t * (3 - 2 * t); + Cesium.Cartesian3.lerp(blend.from, follow.last, eased, out); + return Cesium.Cartesian3.clone(out, follow.shown); + } + follow.blend = null; + } + Cesium.Cartesian3.clone(follow.last, out); + Cesium.Cartesian3.clone(out, follow.shown); + return out; + } + + /** Whether the camera follows this train now. */ + function isFollowing(key) { + const follow = state._follow; + return Boolean( + follow && + follow.key === key && + state._viewer?.trackedEntity === follow.entity, + ); + } + + /** + * Let go of the train. The camera stays where the follow left it. + * @param {{untrack?: boolean}} [options] `untrack: false` when another + * owner already took the camera: its tracked entity is left alone. + */ + function release({ untrack = true } = {}) { + const follow = state._follow; + if (!follow) return; + // Cleared first: the tracked-entity change below is this layer's own. + state._follow = null; + follow.stopFrame?.(); + const viewer = state._viewer; + if (!viewer || viewer.isDestroyed?.()) return; + if (untrack && viewer.trackedEntity === follow.entity) + viewer.trackedEntity = undefined; + viewer.entities.remove(follow.entity); + } + + function startFollow(entry, position) { + const viewer = state._viewer; + const offset = railFollowOffset(camera.framing, viewer.camera?.heading); + const follow = { + key: entry.key, + entity: null, + last: Cesium.Cartesian3.clone(position), + shown: Cesium.Cartesian3.clone(position), + blend: null, + stopFrame: null, + }; + // Cesium's tracking camera engages only on an entity it can bound, so the + // entity has a point: a transparent one, never drawn or picked, leaving + // the marker the train's only visual. + const entity = viewer.entities.add({ + position: new Cesium.CallbackProperty( + (time, result) => followedPosition(follow, result), + false, + ), + // The frame the close-range guard (applyTrackedCameraFrame) uses too. + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + viewFrom: new Cesium.Cartesian3(offset.x, offset.y, offset.z), + point: { pixelSize: 1, color: Cesium.Color.TRANSPARENT }, + }); + entity.gevSelectionOrigin = 'user'; + follow.entity = entity; + state._follow = follow; + // A flight in progress would keep Cesium from applying the frame. + viewer.camera?.cancelFlight?.(); + viewer.trackedEntity = entity; + follow.stopFrame = + camera.applyTrackedCameraFrame?.(viewer, entity, entity.viewFrom) || null; + return true; + } + + /** + * Give the camera to a train the user selected: follow it when it moves, + * else fly to it once. A train already followed keeps its follow. + * @param {object} entry + * @returns {boolean} Whether the camera was taken or kept. + */ + function focus(entry) { + if (!camera || !entry) return false; + if (isFollowing(entry.key)) return true; + release(); + const viewer = state._viewer; + const position = markerPosition(entry); + if (!viewer || !position) return false; + if ( + camera.framing && + !camera.isCockpitActive?.() && + railTrainFollowable(entry) + ) + return startFollow(entry, position); + const label = String(entry.record?.label || entry.record?.id || '').trim(); + return ( + camera.requestWorldFocus?.({ + kind: RAIL_FOCUS_KIND, + id: entry.key, + label: label || entry.key, + position: Cesium.Cartesian3.clone(position), + }) === true + ); + } + + /** + * The selection moved to another feed's copy of the followed train: the + * tracked entity stays, and glides from where it stands to the new copy. + * @param {object} entry The copy now selected. + */ + function handover(entry) { + const follow = state._follow; + if (!follow || !entry || follow.key === entry.key) return; + follow.key = entry.key; + follow.blend = { + from: Cesium.Cartesian3.clone(follow.shown), + startMs: performance.now(), + }; + } + + function onTrackedEntityChanged() { + const follow = state._follow; + const tracked = state._viewer?.trackedEntity; + if (!follow || tracked === follow.entity) return; + // An aircraft or satellite another layer tracks takes the scene, as it + // does from a selected vessel. A navigation that only releases the follow + // camera keeps the train selected. + if (tracked) parts.selection.clearSelection({ untrack: false }); + else release({ untrack: false }); + } + + /** Watch for other camera owners while the layer is on. */ + function install(viewer) { + if (removeTrackedListener || !viewer?.trackedEntityChanged) return; + removeTrackedListener = viewer.trackedEntityChanged.addEventListener( + onTrackedEntityChanged, + ); + } + + function uninstall() { + removeTrackedListener?.(); + removeTrackedListener = null; + } + + /** For QA probes: which train the camera follows, if any. */ + function stats() { + const follow = state._follow; + return follow + ? { key: follow.key, tracked: isFollowing(follow.key) } + : null; + } + + return { + focus, + release, + handover, + isFollowing, + install, + uninstall, + stats, + }; +} diff --git a/src/layers/rail/follow.test.mjs b/src/layers/rail/follow.test.mjs new file mode 100644 index 000000000..98cedad67 --- /dev/null +++ b/src/layers/rail/follow.test.mjs @@ -0,0 +1,454 @@ +// The selected train's camera and its visibility from far out: a moving train +// is followed through Cesium's tracked entity (as an aircraft is), any other +// train is flown to once, and the selected train's feed stays polled at any +// distance while the rest of the network hides as before. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + ACTIVATION_EXIT_ALTITUDE_M, + FOLLOW_HANDOVER_MS, + RAIL_POLL_MS, + railFollowOffset, + railTrainFollowable, + railTrainKey, +} from './policy.js'; +import { WORLD_FOCUS_FRAMING } from '../../worldFocus.js'; +import { createTrack, insertFix } from '../../data/contactPlayback.js'; +import { + BOSTON, + RAIL_HARNESS_T0 as T0, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; + +const MADRID = { lat: 40.4168, lon: -3.7038 }; +const ORBITAL_M = 20_000_000; + +/** + * Feed a train reports that move it, poll after poll, until its playback is + * gliding along them. + */ +async function bringToMotion(app, feedId, id, start) { + let step = 0; + app.feeds.set(feedId, () => [ + train(id, start.lat + step * 0.003, start.lon), + train(`${id}-parked`, start.lat - 0.05, start.lon - 0.05), + ]); + await app.enable(); + const key = railTrainKey(feedId, id); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(key)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(key)), 'the train is moving'); + return key; +} + +test('the follow offset is the shared train framing seen along the heading', () => { + const framing = WORLD_FOCUS_FRAMING.train; + const north = railFollowOffset(framing, 0); + // Behind (south of) the train and above it, looking north and down. + assert.ok(Math.abs(north.x) < 1e-9); + assert.ok(north.y < 0 && north.z > 0); + const range = Math.hypot(north.x, north.y, north.z); + assert.ok(Math.abs(range - framing.rangeM) < 1e-6); + const pitch = (Math.atan2(north.z, -north.y) * 180) / Math.PI; + assert.ok(Math.abs(pitch + framing.pitchDeg) < 1e-9); + // Facing east: the camera stands west of the train. + const east = railFollowOffset(framing, Math.PI / 2); + assert.ok(east.x < 0 && Math.abs(east.y) < 1e-6); +}); + +/** A playback track of reports 15 s apart at these [lat, lon] places. */ +function trackOf(points) { + const track = createTrack(); + points.forEach(([lat, lon], i) => + insertFix(track, { t: T0 + i * 15_000, lat, lon }, { replayed: true }), + ); + return track; +} + +test('a train is followed while it moves, and not once it is parked or unheard', () => { + const entry = (fields, points = [[BOSTON.lat, BOSTON.lon]]) => ({ + sample: { phase: 'playing', latestReportAgeMs: 20_000, ...fields }, + track: trackOf(points), + }); + const moved = [ + [BOSTON.lat, BOSTON.lon], + [BOSTON.lat + 0.003, BOSTON.lon], + ]; + const parked = [ + [BOSTON.lat, BOSTON.lon], + [BOSTON.lat + 0.00005, BOSTON.lon], + ]; + // Gliding between two reports. + assert.equal(railTrainFollowable(entry({ segmentSpeedMps: 25 })), true); + // Dwelling at a stop with its departure already reported. + assert.equal( + railTrainFollowable( + entry({ + segmentSpeedMps: 0, + motion: 'stopped', + displayT: 1_000, + nextMotionT: 31_000, + }), + ), + true, + ); + // Between reports: the playback waits at the latest one (or is still + // buffering) after the last two moved it; this is a moving train. + for (const phase of ['held', 'buffering']) + assert.equal( + railTrainFollowable( + entry({ phase, segmentSpeedMps: 0, motion: 'unknown' }, moved), + ), + true, + phase, + ); + // Parked: the last reports are one place (GPS jitter aside), and nothing + // says it will move. + assert.equal( + railTrainFollowable( + entry( + { + segmentSpeedMps: 0, + motion: 'stopped', + displayT: 1_000, + nextMotionT: Infinity, + }, + parked, + ), + ), + false, + ); + // A single report, a train not heard from lately, or no playback at all. + assert.equal( + railTrainFollowable( + entry({ phase: 'held', segmentSpeedMps: 0, motion: 'unknown' }), + ), + false, + ); + assert.equal( + railTrainFollowable( + entry({ segmentSpeedMps: 25, latestReportAgeMs: 120_000 }, moved), + ), + false, + ); + assert.equal(railTrainFollowable({}), false); + assert.equal(railTrainFollowable(null), false); +}); + +test('selecting a moving train hands Cesium a tracked entity that rides on its marker', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.viewer.camera.heading = Math.PI / 4; + const cancelled = app.viewer.camera.flightsCancelled; + app.parts().selection.selectAndFocusTrain(key); + + const entity = app.viewer.trackedEntity; + assert.ok(entity, 'the camera tracks an entity'); + assert.equal(app.focusRequests.length, 0, 'no one-shot fly-to'); + assert.equal(app.viewer.camera.flightsCancelled, cancelled + 1); + // The tracked-frame guard the aircraft use frames it once, from the + // train's own framing seen along the camera's heading. + assert.equal(app.frames.length, 1); + assert.equal(app.frames[0].entity, entity); + const viewFrom = entity.viewFrom.getValue(Cesium.JulianDate.now()); + const expected = railFollowOffset(WORLD_FOCUS_FRAMING.train, Math.PI / 4); + assert.ok(Math.abs(viewFrom.x - expected.x) < 1e-6); + assert.ok(Math.abs(viewFrom.z - expected.z) < 1e-6); + // Transparent and unbounded by anything but a point: the marker stays the + // only thing drawn and picked there. + assert.equal(entity.point.color.getValue().alpha, 0); + + // The entity stands where the marker is drawn, frame after frame. + const marker = app.entry(key).marker; + for (let i = 0; i < 4; i += 1) { + app.run(1_000); + const shown = app.trackedPosition(); + assert.ok( + Cesium.Cartesian3.distance(shown, marker.position) < 1e-6, + 'on the marker', + ); + } + assert.ok(app.layer.getRouteDisplayStats().follow.tracked); + + // Picking the followed train again keeps the follow as it is. + app.parts().selection.selectAndFocusTrain(key); + assert.equal(app.viewer.trackedEntity, entity); + assert.equal(app.frames.length, 1); +}); + +test('a parked train, or one known from a single report, is flown to once', async (t) => { + const app = harness(t); + await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + const parked = railTrainKey('mbta-rail', 'T1-parked'); + assert.equal(railTrainFollowable(app.entry(parked)), false); + app.parts().selection.selectAndFocusTrain(parked); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.frames.length, 0); + assert.equal(app.focusRequests.length, 1); + const request = app.focusRequests[0]; + assert.equal(request.kind, 'train'); + assert.equal(request.id, parked); + assert.ok( + Cesium.Cartesian3.distance( + request.position, + app.entry(parked).marker.position, + ) < 1e-6, + ); + // A copy, never the billboard's own vector. + assert.notEqual(request.position, app.entry(parked).marker.position); + + // A second click flies again, as for a vessel. + app.parts().selection.selectAndFocusTrain(parked); + assert.equal(app.focusRequests.length, 2); + + // A train with one report has no motion to follow. + app.feeds.set('mbta-rail', () => [ + train('T1', BOSTON.lat, BOSTON.lon), + train('T1-parked', BOSTON.lat - 0.05, BOSTON.lon - 0.05), + train('NEW', BOSTON.lat + 0.02, BOSTON.lon + 0.02), + ]); + app.run(RAIL_POLL_MS); + await app.poll(); + const fresh = railTrainKey('mbta-rail', 'NEW'); + app.parts().selection.selectAndFocusTrain(fresh); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.focusRequests.at(-1).id, fresh); +}); + +test('inside the Cockpit a moving train is not followed: the shared request refuses', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.setCockpit(true); + app.parts().selection.selectAndFocusTrain(key); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.focusRequests.length, 1, 'the request carries the refusal'); + assert.equal(app.state()._selectedKey, key, 'the train is still selected'); +}); + +test('deselecting or Escape lets go of the camera where it is', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + + app.parts().selection.selectAndFocusTrain(key); + const entity = app.viewer.trackedEntity; + app.parts().selection.clearSelection(); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.viewer.entities.contains(entity), false); + assert.equal(app.frames[0].stopped, true, 'the frame guard is released'); + assert.equal(app.state()._follow, null); + + app.parts().selection.selectAndFocusTrain(key); + assert.ok(app.viewer.trackedEntity); + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.viewer.entities.values.length, 0); +}); + +test('orbiting and zooming around the train keep the follow, as for an aircraft', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const entity = app.viewer.trackedEntity; + // Camera input while tracked moves the camera around the target; nothing + // here changes the tracked entity, so the follow stays. + await app.moveCamera({ altitude: 60_000 }); + app.run(2_000); + await app.moveCamera({ altitude: 8_000 }); + assert.equal(app.viewer.trackedEntity, entity); + assert.equal(app.frames[0].stopped, false); + assert.ok(app.parts().follow.isFollowing(key)); +}); + +test('another camera owner ends the follow: a tracked aircraft takes the selection too', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const ours = app.viewer.trackedEntity; + + // An aircraft layer tracks its contact. + const aircraft = app.viewer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(-71, 42.3, 3000), + point: { pixelSize: 4 }, + }); + app.viewer.trackedEntity = aircraft; + assert.equal(app.viewer.trackedEntity, aircraft, 'its entity is untouched'); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.viewer.entities.contains(ours), false); + assert.equal(app.frames[0].stopped, true); +}); + +test('a navigation that releases the follow camera keeps the train selected', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const ours = app.viewer.trackedEntity; + // What the shell's release does before a search or a fly-to. + app.viewer.trackedEntity = undefined; + assert.equal(app.state()._selectedKey, key); + assert.equal(app.state()._follow, null); + assert.equal(app.viewer.entities.contains(ours), false); + // Picking the train again takes the camera back. + app.parts().selection.selectAndFocusTrain(key); + assert.ok(app.parts().follow.isFollowing(key)); +}); + +test('selecting another train hands the camera over with a fresh frame', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const first = app.viewer.trackedEntity; + const parked = railTrainKey('mbta-rail', 'T1-parked'); + app.parts().selection.selectAndFocusTrain(parked); + assert.equal(app.viewer.trackedEntity, undefined, 'the follow let go'); + assert.equal(app.viewer.entities.contains(first), false); + assert.equal(app.focusRequests.at(-1).id, parked, 'and the new train flew'); +}); + +test('from orbit the selected train and its feed stay; the other trains go as before', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + const parked = railTrainKey('mbta-rail', 'T1-parked'); + app.parts().selection.selectAndFocusTrain(key); + + await app.moveCamera({ altitude: ORBITAL_M }); + assert.ok(ORBITAL_M > ACTIVATION_EXIT_ALTITUDE_M); + const state = app.state(); + assert.equal(state._heldFeedId, 'mbta-rail'); + assert.ok(state._activeFeeds.has('mbta-rail'), 'its feed is still polled'); + assert.ok(app.entry(key)?.marker?.show, 'the selected train is drawn'); + assert.equal(app.entry(parked), undefined, 'the others have gone'); + assert.ok(app.viewer.trackedEntity, 'and the follow holds'); + + // The feed keeps updating, for this train alone. + const before = app.requested.length; + const position = Cesium.Cartesian3.clone(app.entry(key).marker.position); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(app.requested.length > before); + assert.equal(app.entry(parked), undefined); + assert.ok( + Cesium.Cartesian3.distance(position, app.entry(key).marker.position) > 1, + 'it still moves', + ); + assert.equal(app.layer.getRouteDisplayStats().heldFeed, 'mbta-rail'); + + // Deselecting returns the feed to the viewport's rules: from orbit, gone. + app.parts().selection.clearSelection(); + await app.recheck(); + assert.equal(state._heldFeedId, null); + assert.equal(state._activeFeeds.has('mbta-rail'), false); + assert.equal(state._trains.size, 0); +}); + +test('a held feed the view wants again brings its other trains back at once', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + const parked = railTrainKey('mbta-rail', 'T1-parked'); + app.parts().selection.selectAndFocusTrain(key); + await app.moveCamera({ altitude: ORBITAL_M }); + assert.equal(app.entry(parked), undefined); + const before = app.requested.length; + await app.moveCamera({ altitude: 30_000 }); + assert.equal(app.state()._heldFeedId, null); + assert.ok(app.requested.length > before, 'polled at once'); + assert.ok(app.entry(parked), 'the others are back'); + assert.ok(app.entry(key), 'and the selection is kept'); + assert.equal(app.state()._selectedKey, key); +}); + +test('far side of the globe: the selected train is hidden behind the Earth', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + app.viewer.trackedEntity = undefined; // the camera is free to go round + await app.moveCamera({ altitude: ORBITAL_M }); + app.viewer.scene.mode = Cesium.SceneMode.SCENE3D; + const marker = app.entry(key).marker; + app.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + BOSTON.lon, + BOSTON.lat, + ORBITAL_M, + ); + app.run(250); + assert.equal(marker.show, true, 'seen from above'); + app.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + BOSTON.lon + 180, + -BOSTON.lat, + ORBITAL_M, + ); + app.run(250); + assert.equal(marker.show, false, 'hidden from the antipode'); +}); + +test('the follow glides to the copy another feed now draws, without letting go', async (t) => { + const app = harness(t, { at: MADRID }); + // One Alvia in two feeds: a station-event copy and, a poll later, a GPS + // copy that outranks it, so the duplicate filter moves the selection. + let step = 0; + let gpsCopy = false; + const near = (offset) => ({ + lat: MADRID.lat + offset + step * 0.003, + lon: MADRID.lon, + }); + app.feeds.set('renfe-cercanias', () => [ + train('A1', near(0).lat, near(0).lon, { + label: 'ALVIA 4073', + positionSource: 'event', + }), + ]); + app.feeds.set('renfe-larga-distancia', () => + gpsCopy + ? [ + train('B1', near(0.01).lat, near(0.01).lon, { + label: '4073', + positionSource: 'gps', + }), + ] + : [], + ); + await app.enable(); + const a = railTrainKey('renfe-cercanias', 'A1'); + const b = railTrainKey('renfe-larga-distancia', 'B1'); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(a))); + app.parts().selection.selectAndFocusTrain(a); + const entity = app.viewer.trackedEntity; + assert.ok(entity); + const onA = app.trackedPosition(); + + gpsCopy = true; + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.state()._selectedKey, b, 'the selection moved'); + assert.equal(app.viewer.trackedEntity, entity, 'the same follow'); + assert.equal(app.frames.length, 1, 'no new frame: no jump'); + assert.equal(app.focusRequests.length, 0); + assert.ok(app.parts().follow.isFollowing(b)); + // It starts where it stood, then glides onto the new copy. + const start = app.trackedPosition(); + assert.ok( + Cesium.Cartesian3.distance(start, onA) < 50, + 'no jump at the handover', + ); + app.run(FOLLOW_HANDOVER_MS + 250); + const settled = app.trackedPosition(); + assert.ok( + Cesium.Cartesian3.distance(settled, app.entry(b).marker.position) < 1e-6, + 'on the new copy', + ); +}); diff --git a/src/layers/rail/groundHeight.test.mjs b/src/layers/rail/groundHeight.test.mjs new file mode 100644 index 000000000..016e12e81 --- /dev/null +++ b/src/layers/rail/groundHeight.test.mjs @@ -0,0 +1,499 @@ +/** + * Where trains and the selected trip's stops stand while their floor cell is + * cold: a train on the floor it last stood on (or a neighbouring cell's), a + * stop on the ellipsoid only until its cell is warm, and the cell is warmed + * off the frame path. Seen on Google's photorealistic tiles, where a train + * running onto unsampled cells dropped about 150 m under its line near + * Chicago, then jumped back once a cell warmed. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; +import { + COLD_CELL_FLUSH_MS, + COLD_CELL_RETRY_MS, + COLD_CELLS_PER_FLUSH, + FLOOR_CELLS_BURST, + FLOOR_CELLS_PER_MINUTE, + FLOOR_WARM_MAX_DIST_KM, + STOP_REFRESH_MS, + TRACK_MAX_GRADE, + TRACK_PRIOR_BELOW_M, + TRACK_SAMPLE_MAX_AGE_MS, + TRACK_SAMPLE_MAX_CAMERA_M, + TRACK_SAMPLE_REFRESH_MS, + TRACK_SAMPLE_RETRY_MS, + railTrainKey, +} from './policy.js'; +import { RAIL_STOP_OVERLAY_SOURCE_ID } from './stopLabels.js'; + +const CHICAGO = { lat: 41.8789, lon: -87.6359 }; +const WEST = { lat: 41.8789, lon: -87.7 }; +/** A camera low enough over Chicago for the mesh to be sampled. */ +const LOW_M = 2_000; +const cellKey = ({ lat, lon }) => + `${Math.round(lat * 1000)},${Math.round(lon * 1000)}`; + +/** A floor service: warm cells by 0.001° key, and the warm requests made. */ +function floorService({ neighbour = null } = {}) { + const warm = new Map(); + const requested = []; + const near = { m: neighbour, coarseCalls: 0 }; + return { + warm, + requested, + near, + ground: { + GROUND_FLOOR_LIFT_M: 1.5, + cachedGroundFloor: (lat, lon) => warm.get(cellKey({ lat, lon })) ?? null, + warmGroundFloor(cells) { + requested.push(...cells.map(cellKey)); + }, + coarseFloorCoord(lat, lon) { + near.coarseCalls += 1; + return { + lat: Math.round(lat * 1000) / 1000, + lon: Math.round(lon * 1000) / 1000, + }; + }, + neighborFloorM: () => near.m, + }, + }; +} + +/** + * Photorealistic tiles in the scene: a shown tileset whose streaming state + * the test sets, and the mesh height every probe reads (recorded). + */ +function photoreal(app, { loaded = true, height = 158 } = {}) { + const tileset = { show: true, tilesLoaded: loaded }; + const mesh = { height, probes: [] }; + app.viewer.scene.primitives.add(tileset); + app.viewer.scene.sampleHeight = (carto) => { + mesh.probes.push({ + lat: (carto.latitude * 180) / Math.PI, + lon: (carto.longitude * 180) / Math.PI, + }); + return mesh.height; + }; + return { tileset, mesh }; +} + +test('a train on a cold cell keeps its last floor, and the cell is warmed', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(CHICAGO), 150); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + const train = {}; + assert.equal(heightFor(CHICAGO.lat, CHICAGO.lon, train), 151.5); + // West of the city, not sampled yet: still on the Chicago floor. + assert.equal(heightFor(WEST.lat, WEST.lon, train), 151.5); + assert.deepEqual(floors.requested, []); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, [cellKey(WEST)]); + // Every frame reads it again; it is asked for once until the retry time. + heightFor(WEST.lat, WEST.lon, train); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(floors.requested.length, 1); + t.mock.timers.tick(COLD_CELL_RETRY_MS); + heightFor(WEST.lat, WEST.lon, train); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(floors.requested.length, 2); + // Once warm, its own floor. + floors.warm.set(cellKey(WEST), 160); + assert.equal(heightFor(WEST.lat, WEST.lon, train), 161.5); + // A point with no floor of its own to keep is on the ellipsoid. + assert.equal(heightFor(41.9, -87.8), 0); +}); + +test('a warm neighbouring cell comes before the last floor', async (t) => { + const floors = floorService({ neighbour: 170 }); + floors.warm.set(cellKey(CHICAGO), 150); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + const train = {}; + heightFor(CHICAGO.lat, CHICAGO.lon, train); + assert.equal(heightFor(WEST.lat, WEST.lon, train), 171.5); +}); + +test('a train gliding across a cold cell asks for its coarse cell once', async (t) => { + const floors = floorService({ neighbour: 170 }); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + const train = {}; + floors.near.coarseCalls = 0; + // Frames inside one 0.001° cell (centred on WEST), then the next cell. + for (let i = 0; i < 20; i += 1) + assert.equal(heightFor(WEST.lat, WEST.lon + i * 0.00002, train), 171.5); + assert.equal(floors.near.coarseCalls, 1); + heightFor(WEST.lat, WEST.lon + 0.001, train); + assert.equal(floors.near.coarseCalls, 2); +}); + +test('from far above, cold cells are not warmed', async (t) => { + const floors = floorService(); + const app = harness(t, { + at: CHICAGO, + altitude: 500_000, + ground: floors.ground, + }); + await app.enable(); + app.parts().rendering.heightFor(WEST.lat, WEST.lon, {}); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, []); +}); + +test('a high floor next to the line is a bump, not a jump (grade limit)', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(CHICAGO), 150); + // The next cell's floor was read on a roof 30 m above the rails. + const NEXT = { lat: CHICAGO.lat, lon: CHICAGO.lon + 0.0013 }; + floors.warm.set(cellKey(NEXT), 180); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k1' }; + assert.equal(displayHeightFor(train, CHICAGO.lat, CHICAGO.lon), 151.5); + const drawn = train.drawnHeight; + const movedM = 0.0013 * 111_320 * Math.cos((CHICAGO.lat * Math.PI) / 180); + const next = displayHeightFor(train, NEXT.lat, NEXT.lon); + assert.ok(next > 151.5, 'climbs toward the higher floor'); + assert.ok( + next <= 151.5 + TRACK_MAX_GRADE * movedM + 0.01, + `no faster than the grade limit (${next})`, + ); + // The drawn height is kept in place, not rebuilt every frame. + assert.equal(train.drawnHeight, drawn); + assert.equal(drawn.h, next); +}); + +test('a real floor replacing the ellipsoid fallback is taken at once', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k2' }; + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 0); + floors.warm.set(cellKey(WEST), 160); + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 161.5); +}); + +test('a neighbouring floor replacing the ellipsoid is taken at once', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k2n' }; + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 0); + // Its own cell is still cold, a neighbour's is warm: no 5% ramp up from + // the ellipsoid, 150 m under the mesh. + floors.near.m = 170; + const step = { lat: WEST.lat, lon: WEST.lon + 0.0002 }; + assert.equal(displayHeightFor(train, step.lat, step.lon), 171.5); + // Between two fallbacks, the grade limit still holds. + floors.near.m = 190; + const after = displayHeightFor(train, step.lat, step.lon + 0.0002); + assert.ok(after > 171.5 && after < 180, `ramps (${after})`); +}); + +test('on photorealistic tiles the mesh is sampled on the line itself', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: LOW_M, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k3' }; + // The exact point on the track, not the floor cell's centre. + const ON_TRACK = { lat: WEST.lat + 0.00031, lon: WEST.lon - 0.00027 }; + assert.equal(displayHeightFor(train, ON_TRACK.lat, ON_TRACK.lon), 151.5); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 1); + assert.ok(Math.abs(mesh.probes[0].lat - ON_TRACK.lat) < 1e-9); + assert.ok(Math.abs(mesh.probes[0].lon - ON_TRACK.lon) < 1e-9); + // The sample is the target now; the drawn height settles toward it. + t.mock.timers.tick(20_000); + assert.equal(displayHeightFor(train, ON_TRACK.lat, ON_TRACK.lon), 159.5); + // Not asked again for the same bucket until the refresh time. + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 1); +}); + +test('a sample far from the floor under it is a coarse tile and is dropped', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: LOW_M, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app, { height: 400 }); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k4' }; + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS + TRACK_SAMPLE_RETRY_MS); + assert.equal(mesh.probes.length, 1); + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 151.5); + // A valley floor read under a viaduct, well below the floor: dropped too. + // (The next track bucket along, in the same floor cell.) + const viaduct = { key: 'k4v' }; + const NEAR = { lat: WEST.lat, lon: WEST.lon - 0.0004 }; + mesh.height = 150 - TRACK_PRIOR_BELOW_M - 5; + displayHeightFor(viaduct, NEAR.lat, NEAR.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.ok(mesh.probes.some((probe) => Math.abs(probe.lon - NEAR.lon) < 1e-9)); + t.mock.timers.tick(60_000); + assert.equal(displayHeightFor(viaduct, NEAR.lat, NEAR.lon), 151.5); +}); + +test('from far above, the line is not sampled', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: 500_000, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app, { height: 151 }); + app.parts().rendering.displayHeightFor({ key: 'k5' }, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 0); +}); + +test('the line is sampled only from a low camera, once the tiles are loaded', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + // 20 km up the tiles near the camera are coarse, loaded or not. + const app = harness(t, { + at: CHICAGO, + altitude: 20_000, + ground: floors.ground, + }); + await app.enable(); + const { tileset, mesh } = photoreal(app); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k6' }; + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 0, 'not from 20 km'); + // Low enough, but the tiles are still streaming. + await app.moveCamera({ altitude: 150 + TRACK_SAMPLE_MAX_CAMERA_M - 100 }); + tileset.tilesLoaded = false; + t.mock.timers.tick(TRACK_SAMPLE_RETRY_MS); + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 0, 'not while streaming'); + tileset.tilesLoaded = true; + t.mock.timers.tick(TRACK_SAMPLE_RETRY_MS); + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 1); +}); + +test('a sample read from lower down replaces one from higher up; an old one goes', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: 4_000, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app, { height: 140 }); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k7' }; + /** Read the bucket again, and let the drawn height settle on its target. */ + const settled = () => { + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + t.mock.timers.tick(60_000); + return displayHeightFor(train, WEST.lat, WEST.lon); + }; + // From 4 km, a coarse tile 10 m low: inside the bounds, so kept for now. + assert.equal(settled(), 141.5); + // From 1 km the tiles are finer: the new reading replaces it. + await app.moveCamera({ altitude: 1_000 }); + mesh.height = 158; + t.mock.timers.tick(TRACK_SAMPLE_REFRESH_MS); + assert.equal(settled(), 159.5); + // Back up at 4.5 km, the coarse reading does not win over it... + await app.moveCamera({ altitude: 4_500 }); + mesh.height = 140; + const reads = mesh.probes.length; + t.mock.timers.tick(TRACK_SAMPLE_REFRESH_MS); + assert.equal(settled(), 159.5); + assert.equal(mesh.probes.length, reads + 1, 'it was read again'); + // ...until the one from lower down is old. + t.mock.timers.tick(TRACK_SAMPLE_MAX_AGE_MS); + assert.equal(settled(), 141.5); +}); + +test('disabling the layer drops the cells and samples still queued', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(CHICAGO), 150); + const app = harness(t, { + at: CHICAGO, + altitude: LOW_M, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app); + const { heightFor, displayHeightFor } = app.parts().rendering; + heightFor(WEST.lat, WEST.lon, {}); + displayHeightFor({ key: 'k8' }, CHICAGO.lat, CHICAGO.lon); + app.layer.disable(app.viewer); + t.mock.timers.tick(COLD_CELL_FLUSH_MS * 3); + assert.deepEqual(floors.requested, []); + assert.equal(mesh.probes.length, 0); + // Enabled again, the cell is asked for at once, not after a retry time. + await app.enable(); + heightFor(WEST.lat, WEST.lon, {}); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, [cellKey(WEST)]); +}); + +test('trains far from the camera do not warm floor cells', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + // Well past FLOOR_WARM_MAX_DIST_KM from the camera over Chicago. + const FAR = { + lat: CHICAGO.lat, + lon: CHICAGO.lon + (FLOOR_WARM_MAX_DIST_KM + 20) / 83, + }; + heightFor(FAR.lat, FAR.lon, {}); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, []); +}); + +/** + * Cold cells for hundreds of trains near the camera, `rounds` flushes of + * them; the cells the floor service was asked for. + */ +function crowd(t, app, floors, rounds) { + const { heightFor } = app.parts().rendering; + let asked = 0; + for (let round = 0; round < rounds; round += 1) { + for (let i = 0; i < 100; i += 1) { + const n = round * 100 + i; + heightFor( + CHICAGO.lat + (n % 60) * 0.002, + CHICAGO.lon + Math.floor(n / 60) * 0.002, + {}, + ); + } + const before = floors.requested.length; + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + const flushed = floors.requested.length - before; + assert.ok(flushed <= COLD_CELLS_PER_FLUSH, `one flush ${flushed}`); + asked += flushed; + } + return asked; +} + +test('floor warming stays within the shared terrain budget', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + // The budget starts with its burst and refills at FLOOR_CELLS_PER_MINUTE: + // 40 flushes (40 s) may spend at most the burst plus 40 s of refill. + const asked = crowd(t, app, floors, 40); + const secondsElapsed = (40 * COLD_CELL_FLUSH_MS) / 1000; + assert.ok( + asked <= + FLOOR_CELLS_BURST + (FLOOR_CELLS_PER_MINUTE * secondsElapsed) / 60 + 1, + `in ${secondsElapsed} s ${asked}`, + ); + assert.ok(asked > 0); +}); + +test('a minute after an idle spell spends no more than a minute of budget plus the burst', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + // Idle long enough for any bucket to fill to the brim. + t.mock.timers.tick(10 * 60_000); + const asked = crowd(t, app, floors, 60); + assert.ok( + asked <= FLOOR_CELLS_PER_MINUTE + FLOOR_CELLS_BURST, + `in a minute ${asked}`, + ); + // About 24 of the 90 terrain requests a minute, not 38. + assert.ok(Math.ceil(asked / 64) <= 24, `${Math.ceil(asked / 64)} requests`); + // And the refill is spent: the crowd is not starved. + assert.ok(asked >= FLOOR_CELLS_PER_MINUTE, `in a minute ${asked}`); +}); + +/** A trip through Boston, its terminus 120 km away (Portland, Maine). */ +const STOPS = [ + { stopId: 'A', name: 'South Station', lat: 42.3523, lon: -71.0552 }, + { stopId: 'B', name: 'North Station', lat: 42.3656, lon: -71.0613 }, + { stopId: 'C', name: 'Woburn', lat: 42.5168, lon: -71.1442 }, + { stopId: 'D', name: 'Portland', lat: 43.6532, lon: -70.3122 }, +].map((stop, i) => ({ + ...stop, + state: i === 0 ? 'passed' : i === 1 ? 'next' : 'upcoming', + relative: i === 0 ? 'T+3' : `T−${i * 10}`, + expectedArrival: '14:32', +})); + +const heightOf = (position) => + Cesium.Cartographic.fromCartesian(position).height; + +test('stops placed before their cells were warm move up onto the floor', async (t) => { + const floors = floorService(); + const app = harness(t, { + at: BOSTON, + altitude: LOW_M, + ground: floors.ground, + stops: { stopsFor: () => ({ stops: STOPS }) }, + }); + app.feeds.set('mbta-rail', () => [train('T1', BOSTON.lat, BOSTON.lon)]); + await app.enable(); + const key = railTrainKey('mbta-rail', 'T1'); + app.parts().selection.selectTrain(key); + const dots = app.primitives.find( + (primitive) => primitive instanceof Cesium.PointPrimitiveCollection, + ); + const dotHeights = () => + Array.from({ length: dots.length }, (_, i) => + heightOf(dots.get(i).position), + ); + const labelHeights = () => + app.overlayEntries + .get(RAIL_STOP_OVERLAY_SOURCE_ID) + .map((entry) => heightOf(entry.position)); + assert.equal(dots.length, STOPS.length); + // Every stop's cell is cold: on the ellipsoid for now. + for (const h of [...dotHeights(), ...labelHeights()]) + assert.ok(Math.abs(h) < 0.01, `on the ellipsoid (${h})`); + // Their cells are warmed, the terminus too though it is 120 km off. + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + for (const stop of STOPS) + assert.ok(floors.requested.includes(cellKey(stop)), stop.name); + // Once warm, the next refresh stands them on the floor. + for (const stop of STOPS) floors.warm.set(cellKey(stop), 20); + t.mock.timers.tick(STOP_REFRESH_MS); + assert.equal(dots.length, STOPS.length); + for (const h of [...dotHeights(), ...labelHeights()]) + assert.ok(Math.abs(h - 21.5) < 0.01, `on the floor (${h})`); + // Placed on a real floor, a stop is not read again. + floors.warm.clear(); + t.mock.timers.tick(STOP_REFRESH_MS * 3); + for (const h of dotHeights()) assert.ok(Math.abs(h - 21.5) < 0.01); +}); diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js new file mode 100644 index 000000000..0a191c62a --- /dev/null +++ b/src/layers/rail/index.js @@ -0,0 +1,196 @@ +import { createHistoryBudget } from '../../data/contactPlayback.js'; +import { createRailSource } from './source.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; +import { createFollow } from './follow.js'; +import { createIngestion } from './ingestion.js'; +import { createViewport } from './viewport.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createRouteDisplay } from './routeDisplay.js'; +import { createRailSchedules } from './schedules.js'; +import { getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { DEFAULT_RAIL_CATEGORY_VISIBILITY, RAIL_POLL_MS } from './policy.js'; + +/** One Rail layer's mutable scene state; nothing is module-global. */ +function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + return { + _overlayHost: Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }), + _historyBudget: createHistoryBudget(), + _viewer: null, + _markers: null, + _enabled: false, + _generation: 0, + _cameraChangedAttached: false, + _cameraDebounceTimer: null, + _altitudeGateOpen: false, + _preRenderRemove: null, + _renderHeld: false, + _clickHandler: null, + /** Trains mid-glide; the frame pass walks only these. @type {Set} */ + _moving: new Set(), + /** feedId → registry entry currently polled. */ + _activeFeeds: new Map(), + _feedStatus: new Map(), + _inFlight: new Map(), + /** train key → runtime entry. */ + _trains: new Map(), + /** + * feedId → path pack slot `{status, pack, retryAt, failures}`. Kept + * across disable/enable, and released with the feed's network and + * schedule pack once the feed is out of use FEED_RELEASE_AFTER_MS + * (routeDisplay.js releaseIdleFeeds). + */ + _pathPacks: new Map(), + /** + * The server's feed catalog (GET /api/rail/feeds), which lists a feed + * that reads its routes from a schedule pack only once that pack is + * built; null until it loads. Until then only the bundled feeds that + * need no pack are used, and a catalog that fails is asked for again + * (viewport.js). + */ + _catalog: null, + /** The catalog request in flight: `{controller}`. */ + _catalogRequest: null, + /** Failed catalog requests in a row, and the timer of the next try. */ + _catalogFailures: 0, + _catalogRetryTimer: null, + /** feedId → faint route network built from that feed's path pack. */ + _networks: new Map(), + /** Network opacity from camera altitude, 0..1. */ + _networkFade: 0, + /** The selected train's line, split where the train is. */ + _route: null, + /** The selected trip's stops and headsign from the stops provider. */ + _trip: null, + _stopsSignature: '', + _stopLabelsSignature: '', + /** + * Stop → `{lat, lon, h, q, position}` for the selected trip, kept per + * selection; one on a fallback height (q 0-1) is read again each refresh. + */ + _stopAnchors: new Map(), + _categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY }, + _selectedKey: null, + _selectedCardAt: 0, + _selectedCardText: null, + /** The camera's follow of the selected train (follow.js), or null. */ + _follow: null, + /** A feed polled only for the selected train (viewport.js), or null. */ + _heldFeedId: null, + _revision: 0, + _controlsListener: null, + _dataManager: null, + _lastUpdate: null, + _error: null, + /** Whether the train cap (MAX_TRAINS_TOTAL) was reported once. */ + _capWarned: false, + }; +} + +/** + * Construct one Rail layer with its own scene state and supplied services. + * + * Trains come from the `/api/rail` proxy (src/sources/railService.js), already + * filtered to trains and tagged with a category. Every train drawn is where + * its operator last reported it (GPS or station reports); none is placed or + * moved from a timetable. Motion between reports is the Transit layer's + * delayed playback, so a train glides at 1x behind real time along its line + * instead of jumping between reports. + * + * Each layer owns a schedule tier (schedules.js): feeds' optional schedule + * packs and trip updates, read through the same source for the selected + * train's stops, headsign and delays. It is the default stops provider: + * + * stops.stopsFor({feedId, tripId, routeId, record}, nowMs) + * → {stops: RailTripStop[], headsign?: string} | null + * stops.onSelect?(train | null) + * + * `stopsFor` is called about once a second while a train is selected, + * synchronously; it returns null while it has nothing (yet) and loads what it + * needs on its own. RailTripStop is described in stopLabels.js. Pass + * `stops: null` to draw the selected line without stops. + * + * A selected train takes the camera through the optional `services.camera` + * (follow.js): a moving train is followed, any other flown to once. + * + * @param {{services: object, source?: object, + * stops?: {stopsFor: Function, onSelect?: Function}|null}} options + * @returns {object} The data-layer module the manager registers. + */ +export function createRailLayer({ + services, + source = createRailSource(), + stops, +}) { + if ( + !services?.overlays || + !services?.render || + !services?.sprites || + !services?.ground + ) { + throw new TypeError( + 'A rail layer needs overlay, render, sprite and ground services', + ); + } + if (typeof source?.requestSnapshot !== 'function') + throw new TypeError('A rail source needs a snapshot operation'); + if ( + stops !== undefined && + stops !== null && + typeof stops?.stopsFor !== 'function' + ) + throw new TypeError('A rail stops provider needs a stopsFor operation'); + const state = createState({ services }); + const parts = {}; + const timetable = createRailSchedules({ + source, + // The server's catalog says which feeds it can read trip updates for. + hasTripUpdates: (feedId) => { + const listed = state._catalog?.find((feed) => feed.id === feedId); + if (listed) return listed.tripUpdates === true; + return typeof getRegisteredRailFeed(feedId)?.tripUpdatesUrl === 'string'; + }, + pathPackFor: (feedId) => state._pathPacks.get(feedId)?.pack || null, + }); + const context = { + state, + services, + parts, + source, + timetable, + stops: stops === undefined ? timetable : stops, + }; + parts.rendering = createRendering(context); + parts.routes = createRouteDisplay(context); + parts.selection = createSelection(context); + parts.follow = createFollow(context); + parts.controls = createControls(context); + parts.ingestion = createIngestion(context); + parts.viewport = createViewport(context); + parts.lifecycle = createLifecycle(context); + + return Object.assign( + { updateInterval: RAIL_POLL_MS }, + parts.lifecycle.methods, + parts.controls.methods, + { + /** Test seams: this instance's own state and parts. */ + _railStateForTest: () => state, + _railPartsForTest: () => parts, + }, + ); +} + +export { + RAIL_CATEGORY_COLORS, + RAIL_POLL_MS, + buildRailSelectionCopy, + railTrainKey, +} from './policy.js'; diff --git a/src/layers/rail/ingestion.js b/src/layers/rail/ingestion.js new file mode 100644 index 000000000..54d885e37 --- /dev/null +++ b/src/layers/rail/ingestion.js @@ -0,0 +1,527 @@ +import { + FIX_FLAGS, + createLagReservoir, + destroyTrack, + readFix, +} from '../../data/contactPlayback.js'; +import { createRailPathPack } from '../../data/railPaths.js'; +import { + initializePlayback, + recordFix, + syncPlayback, + updatePlayback, +} from '../transit/movement.js'; +import { + FEED_EVICT_AFTER_MS, + FEED_STALE_AFTER_MS, + FUTURE_STAMP_TOLERANCE_MS, + MAX_TRAINS_TOTAL, + MISSED_POLLS_TO_DROP, + isLiveRailPositionSource, + isStaleTrainFix, + playbackModeFor, + railTrainKey, + trainReportTimeMs, +} from './policy.js'; +import { bindTrainPath, followFix, rebindTrainPath } from './pathBinding.js'; +import { scheduleRetryDelayMs } from './schedules.js'; +import { createDuplicateFilter } from './dedupe.js'; + +/** + * Fetching rail feeds through the proxy and turning snapshots into trains. + * The aging rules are the Transit layer's: a train is drawn from its own + * report time, a feed is judged by when its operator last answered, and both + * a quiet train and a quiet feed end in removal rather than a frozen marker. + * + * Only live positions are drawn (isLiveRailPositionSource). A feed whose + * positions fail is shown as unavailable: its trains are never placed from + * its timetable, and those already drawn age out as any quiet train does. + * @param {object} context + * @returns {object} + */ +export function createIngestion({ state, services, parts, source }) { + const { governorRequestRender } = services.render; + const { registerDynamicCredit, railFeedCredit } = services.credits; + // One physical train in several loaded feeds is drawn once (dedupe.js). + const duplicates = createDuplicateFilter({ state, parts }); + + function feedStatus(feedId) { + let status = state._feedStatus.get(feedId); + if (!status) { + status = { + count: 0, + lastUpdate: null, + upstreamAt: null, + error: null, + retryInSec: null, + stale: false, + pollSeq: 0, + loading: false, + lag: null, + /** New trains its last poll listed that MAX_TRAINS_TOTAL left out. */ + capped: 0, + }; + state._feedStatus.set(feedId, status); + } + return status; + } + + function removeTrain(key) { + const entry = state._trains.get(key); + if (!entry) return; + if (state._selectedKey === key) parts.selection.clearSelection(); + if (entry.marker) state._markers?.remove(entry.marker); + entry.marker = null; + state._moving.delete(entry); + parts.rendering.cancelWake(entry); + destroyTrack(entry.track); + state._trains.delete(key); + state._revision += 1; + // A copy it hid may now be the one to draw. + duplicates.scheduleRefresh(); + } + + /** + * Remove a feed's trains, except `keepKey` when given (the selected train, + * whose feed is held for it alone: viewport.js). + * @param {string} feedId + * @param {string|null} [keepKey] + */ + function removeFeedTrains(feedId, keepKey = null) { + for (const [key, entry] of state._trains) { + if (entry.feedId === feedId && key !== keepKey) removeTrain(key); + } + } + + /** + * Drop trains whose report aged out and feeds whose operator went quiet. + * @param {number} nowMs + * @returns {number} Trains removed. + */ + function sweepAgedTrains(nowMs) { + let removed = 0; + const deadFeeds = new Set(); + for (const [feedId, status] of state._feedStatus) { + if ( + Number.isFinite(status.upstreamAt) && + nowMs - status.upstreamAt > FEED_EVICT_AFTER_MS + ) + deadFeeds.add(feedId); + } + for (const [key, entry] of state._trains) { + if ( + deadFeeds.has(entry.feedId) || + isStaleTrainFix(entry.record, nowMs, entry.fetchedAt) + ) { + removeTrain(key); + removed += 1; + } + } + for (const feedId of deadFeeds) { + const status = state._feedStatus.get(feedId); + if (status) { + status.count = 0; + status.stale = true; + } + } + if (removed > 0) { + parts.rendering.syncRenderHold(); + governorRequestRender('rail-evict'); + state._dataManager?.refreshLayerStats?.(); + } + return removed; + } + + /** + * Fetch a feed's path pack once. A 404 means the feed has no paths and its + * trains keep straight-line motion; any other failure (a 503 from a pack + * the server cannot read now, say) is retried on the schedule packs' + * stepped delay, 30 s doubling to ten minutes (scheduleRetryDelayMs). + * @param {object} feed Registry entry. + */ + function ensurePaths(feed) { + const known = state._pathPacks.get(feed.id); + if (known && !(known.status === 'none' && Date.now() >= known.retryAt)) + return; + if (typeof source.requestPaths !== 'function') { + state._pathPacks.set(feed.id, { + status: 'none', + pack: null, + retryAt: Infinity, + }); + return; + } + const slot = { + status: 'loading', + pack: null, + retryAt: Infinity, + /** Failed loads in a row. */ + failures: known?.failures ?? 0, + }; + state._pathPacks.set(feed.id, slot); + void (async () => { + try { + const response = await source.requestPaths(feed.id); + if (!response.ok) { + const error = new Error(`rail paths HTTP ${response.status}`); + error.status = response.status; + throw error; + } + const raw = await response.json(); + const pack = createRailPathPack(raw); + if (pack.feedId !== feed.id) + throw new Error('rail path pack is for another feed'); + if (state._pathPacks.get(feed.id) !== slot) return; + slot.pack = pack; + slot.status = 'ready'; + bindFeedTrains(feed.id); + // The faint network is drawn from the same pack, built once here. + parts.routes.buildNetwork(feed.id, raw); + } catch (error) { + slot.status = 'none'; + slot.failures += 1; + slot.retryAt = + error?.status === 404 + ? Infinity + : Date.now() + scheduleRetryDelayMs(slot.failures); + if (error?.status !== 404) + console.warn( + `[Data:Rail] ${feed.id} paths unavailable:`, + error?.message || error, + ); + } + })(); + } + + /** + * Keep a train on its railway line: bind it when its trip or route changes, + * follow each new report, and move it to another of its route's shapes (or + * back to straight lines) when a report lands off the bound one. + */ + function followPath(entry, record, fix) { + const pack = state._pathPacks.get(entry.feedId)?.pack; + if (!pack) return; + const ids = { tripId: record.tripId || '', routeId: record.routeId || '' }; + const key = `${ids.tripId}\n${ids.routeId}`; + if (entry.pathKey !== key) { + entry.pathKey = key; + entry.path = bindTrainPath(pack, ids, fix); + return; + } + if (!entry.path || followFix(entry.path, fix)) return; + entry.path = rebindTrainPath(pack, ids, entry.path, fix); + } + + /** Bind the trains a feed already shows once its pack arrives. */ + function bindFeedTrains(feedId) { + const newest = {}; + for (const entry of state._trains.values()) { + if (entry.feedId !== feedId || entry.pathKey !== undefined) continue; + if (!entry.track?.count) continue; + readFix(entry.track, entry.track.count - 1, newest); + followPath(entry, entry.record, newest); + parts.rendering.schedulePlayback(entry); + } + governorRequestRender('rail-paths'); + } + + /** A playback fix for one record, with the time it was really reported. */ + function fixFor(record, fetchedAt) { + const stamped = trainReportTimeMs(record, fetchedAt); + const reportAt = + stamped > fetchedAt + FUTURE_STAMP_TOLERANCE_MS ? fetchedAt : stamped; + return { + t: reportAt, + lat: record.lat, + lon: record.lon, + bearingDeg: Number.isFinite(record.bearing) ? record.bearing : NaN, + flags: + stamped !== reportAt + ? FIX_FLAGS.RECEIPT_TIME + : record.timestampSource === 'vehicle' + ? FIX_FLAGS.VEHICLE_TIME + : record.timestampSource === 'header' + ? FIX_FLAGS.FEED_TIME + : FIX_FLAGS.RECEIPT_TIME, + }; + } + + /** Record a new report for a train already drawn. */ + function advanceTrain(entry, record, fix, observation) { + const heard = recordFix(entry, fix, observation); + if (!heard.accepted) return heard; + followPath(entry, record, fix); + if (entry.category !== record.category || entry.railMode !== record.mode) { + entry.category = record.category; + entry.railMode = record.mode; + entry.mode = playbackModeFor(record.mode); + parts.rendering.paint(entry); + } + updatePlayback(entry, observation.wallNowMs, observation.monoNowMs); + parts.rendering.schedulePlayback(entry); + return heard; + } + + /** Create and draw a train the layer has not seen before. */ + function addTrain(key, feed, record, fix, observation, status) { + const entry = { + key, + feedId: feed.id, + marker: null, + category: record.category, + railMode: record.mode, + mode: playbackModeFor(record.mode), + // A route the classifier named is a fact about the train, so its + // speed ceiling applies; the movement module reads this flag. + modeInferred: false, + courseDeg: Number.isFinite(record.bearing) ? record.bearing : null, + courseAt: observation.wallNowMs, + courseEvalAt: observation.monoNowMs, + record, + fetchedAt: observation.fetchedAt, + pollSeq: status.pollSeq, + /** Binding to a railway line (pathBinding.js), null for straight lines. */ + path: null, + /** Trip and route the binding was made for; undefined until tried. */ + pathKey: undefined, + }; + initializePlayback(entry, state._historyBudget, status.lag); + const first = recordFix(entry, fix, observation); + if (!first.accepted) { + destroyTrack(entry.track); + return null; + } + followPath(entry, record, fix); + syncPlayback(entry, observation.wallNowMs, observation.monoNowMs); + entry.marker = parts.rendering.addMarker(entry); + state._trains.set(key, entry); + parts.rendering.schedulePlayback(entry); + return entry; + } + + function applySnapshot(feed, snapshot, { stale, contactedAt = null }) { + const now = Date.now(); + const fetchedAt = Math.min( + Number.isFinite(snapshot?.fetchedAt) ? snapshot.fetchedAt : now, + now, + ); + const contacted = Math.min( + Number.isFinite(contactedAt) ? contactedAt : fetchedAt, + now, + ); + const status = feedStatus(feed.id); + status.upstreamAt = Math.max(contacted, fetchedAt); + const upstreamAgeMs = now - status.upstreamAt; + status.loading = false; + status.error = null; + status.retryInSec = null; + status.stale = stale === true || upstreamAgeMs > FEED_STALE_AFTER_MS; + if (upstreamAgeMs > FEED_EVICT_AFTER_MS) { + removeFeedTrains(feed.id); + status.count = 0; + parts.rendering.syncRenderHold(); + state._dataManager?.refreshLayerStats?.(); + return; + } + status.pollSeq += 1; + if (!status.lag) status.lag = createLagReservoir(); + const replayed = status.snapshotAt === fetchedAt || stale === true; + status.snapshotAt = fetchedAt; + const monoNow = performance.now(); + // A feed held only for the selected train (viewport.js) carries it alone, + // or a train its operator now lists on the same trip. + const only = + state._heldFeedId === feed.id + ? state._trains.get(state._selectedKey) + : null; + let seen = 0; + // The cap counts the trains drawn: a copy another feed draws better + // (dedupe.js) is hidden and does not count. + let held = 0; + for (const entry of state._trains.values()) + if (entry.duplicate !== true) held += 1; + let capped = 0; + for (const record of snapshot.trains || []) { + // Only a reported position is drawn, never one from a timetable. + if (!isLiveRailPositionSource(record?.positionSource)) continue; + if (isStaleTrainFix(record, now, fetchedAt)) continue; + const key = railTrainKey(feed.id, record.id); + if ( + only && + key !== only.key && + !(only.record?.tripId && record.tripId === only.record.tripId) + ) + continue; + const fix = fixFor(record, fetchedAt); + const observation = { + receivedAt: now, + monoNowMs: monoNow, + wallNowMs: now, + fetchedAt, + replayed, + modeInferred: false, + context: { + trip: record.tripId || '', + route: record.routeId || '', + mode: playbackModeFor(record.mode), + }, + }; + let entry = state._trains.get(key); + if (entry) { + const heard = advanceTrain(entry, record, fix, observation); + entry.pollSeq = status.pollSeq; + seen += 1; + if (!heard.accepted) continue; + } else { + if (held >= MAX_TRAINS_TOTAL) { + capped += 1; + continue; + } + entry = addTrain(key, feed, record, fix, observation, status); + if (!entry) continue; + held += 1; + seen += 1; + } + entry.record = record; + entry.fetchedAt = fetchedAt; + } + // Said in the layer's stats as a partial snapshot (controls.js). + status.capped = capped; + if (capped > 0 && !state._capWarned) { + state._capWarned = true; + console.warn( + `[Data:Rail] train cap ${MAX_TRAINS_TOTAL} reached; ${capped} trains of ${feed.id} are not drawn`, + ); + } + for (const [key, entry] of state._trains) { + if ( + entry.feedId === feed.id && + status.pollSeq - entry.pollSeq >= MISSED_POLLS_TO_DROP + ) + removeTrain(key); + } + sweepAgedTrains(now); + duplicates.scheduleRefresh(); + status.count = seen; + status.lastUpdate = fetchedAt; + state._lastUpdate = Math.max(state._lastUpdate || 0, fetchedAt); + state._error = null; + if (state._viewer) + registerDynamicCredit(state._viewer, railFeedCredit(feed)); + if (state._trains.get(state._selectedKey)?.feedId === feed.id) + parts.selection.refreshSelectedCard(true); + parts.rendering.warmFloors(feed.id); + state._revision += 1; + parts.rendering.syncRenderHold(); + governorRequestRender('rail-poll'); + state._dataManager?.refreshLayerStats?.(); + parts.controls.notify(); + } + + /** + * A feed whose live positions failed (or that its server has none for): + * its row says it is unavailable. Nothing takes the place of its trains; + * those already drawn go as their reports age (sweepAgedTrains). + * @param {object} feed + * @param {object} status + * @param {{retryInSec?: number}|null} [error] + */ + function markUnavailable(feed, status, error = null) { + // i18n-ignore: English data, translated on display (ui/statusText.js) + status.error = `${feed.name} feed unavailable`; + status.retryInSec = Number.isFinite(error?.retryInSec) + ? error.retryInSec + : null; + status.loading = false; + state._error = status.error; + sweepAgedTrains(Date.now()); + state._dataManager?.refreshLayerStats?.(); + } + + /** + * Poll one feed. A request already in flight is reused rather than aborted, + * so enable() and the manager's first update() share one request. + * @param {object} feed Registry entry. + * @param {number} generation Enable generation the poll belongs to. + * @returns {Promise} + */ + function pollFeed(feed, generation) { + if (!state._enabled || generation !== state._generation) + return Promise.resolve(); + ensurePaths(feed); + const status = feedStatus(feed.id); + const existing = state._inFlight.get(feed.id); + if (existing) return existing.promise; + const controller = new AbortController(); + status.loading = status.count === 0; + const promise = (async () => { + try { + const response = await source.requestSnapshot(feed.id, { + signal: controller.signal, + }); + if (!response.ok) { + const detail = await response.json().catch(() => null); + const error = new Error(`rail proxy HTTP ${response.status}`); + error.status = response.status; + if (Number.isFinite(detail?.retryInSec)) + error.retryInSec = detail.retryInSec; + throw error; + } + const snapshot = await response.json(); + if ( + !state._enabled || + generation !== state._generation || + !state._activeFeeds.has(feed.id) + ) + return; + // An answer whose positions are not live (anything but GPS or + // station reports) has no live data for this feed: none of its + // trains are drawn, and the feed shows unavailable. + if (!isLiveRailPositionSource(snapshot?.positions)) { + console.warn( + `[Data:Rail] ${feed.id} has no live positions on this server`, + ); + markUnavailable(feed, status); + return; + } + const contact = Number.parseInt( + response.headers.get('x-rail-contact') || '', + 10, + ); + applySnapshot(feed, snapshot, { + stale: response.headers.get('x-gev-cache') === 'STALE-ERROR', + contactedAt: Number.isFinite(contact) ? contact : null, + }); + } catch (error) { + if (error?.name === 'AbortError') return; + if (generation !== state._generation) return; + console.warn( + `[Data:Rail] ${feed.id} poll failed:`, + error?.message || error, + ); + markUnavailable(feed, status, error); + } finally { + if (state._inFlight.get(feed.id)?.controller === controller) + state._inFlight.delete(feed.id); + } + })(); + state._inFlight.set(feed.id, { controller, promise }); + return promise; + } + + function abortAllInFlight() { + for (const { controller } of state._inFlight.values()) controller.abort(); + state._inFlight.clear(); + } + + return { + feedStatus, + removeTrain, + removeFeedTrains, + sweepAgedTrains, + applySnapshot, + pollFeed, + abortAllInFlight, + duplicates, + }; +} diff --git a/src/layers/rail/layerStats.test.mjs b/src/layers/rail/layerStats.test.mjs new file mode 100644 index 000000000..7f2f0c6c1 --- /dev/null +++ b/src/layers/rail/layerStats.test.mjs @@ -0,0 +1,101 @@ +// What the layer row and stats count: trains drawn, a train two feeds carry +// once, and a partial snapshot when the train cap leaves trains out. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTrack } from '../../data/contactPlayback.js'; +import { layerFeedState } from '../../data/feedState.js'; +import { MAX_TRAINS_TOTAL, railTrainKey } from './policy.js'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; + +const near = (i) => ({ + lat: BOSTON.lat + 0.001 * i, + lon: BOSTON.lon + 0.001 * i, +}); + +test('a train another feed draws is not counted twice', async (t) => { + const app = harness(t, { at: BOSTON }); + app.feeds.set('mbta-rail', () => + [1, 2, 3].map((i) => train(`T${i}`, near(i).lat, near(i).lon)), + ); + await app.enable(); + assert.equal(app.layer.getStats().count, 3); + // The duplicate filter hides T3 as another feed's copy of a train. + app.entry(railTrainKey('mbta-rail', 'T3')).duplicate = true; + const stats = app.layer.getStats(); + assert.equal(stats.count, 2); + assert.match(stats.coverage, /MBTA 2\b/); + const legend = app.layer.getRowControls().legend; + assert.deepEqual( + legend.map((item) => item.count), + [2], + ); +}); + +/** Trains of another feed held by the layer, outside the view. */ +function fill(app, count, { duplicate }) { + for (let i = 0; i < count; i += 1) { + const key = railTrainKey('elsewhere', `F${i}`); + app.state()._trains.set(key, { + key, + feedId: 'elsewhere', + category: 'regional', + duplicate, + marker: null, + track: createTrack(), + fetchedAt: Date.now(), + record: { + id: `F${i}`, + lat: -40 + (i % 80), + lon: 100 + Math.floor(i / 80), + timestamp: Math.floor(Date.now() / 1000), + }, + }); + } +} + +test('copies hidden as duplicates do not count toward the train cap', async (t) => { + const app = harness(t, { at: BOSTON }); + let trains = []; + app.feeds.set('mbta-rail', () => trains); + await app.enable(); + fill(app, MAX_TRAINS_TOTAL - 1, { duplicate: true }); + trains = [1, 2, 3].map((i) => train(`T${i}`, near(i).lat, near(i).lon)); + await app.poll(); + for (const i of [1, 2, 3]) + assert.ok(app.entry(railTrainKey('mbta-rail', `T${i}`)), `T${i}`); + assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 0); + assert.notEqual(app.layer.getStats().partial, true); +}); + +test('trains the cap leaves out make the snapshot partial', async (t) => { + const app = harness(t, { at: BOSTON }); + let trains = []; + app.feeds.set('mbta-rail', () => trains); + await app.enable(); + const warn = t.mock.method(console, 'warn', () => {}); + fill(app, MAX_TRAINS_TOTAL - 2, { duplicate: false }); + trains = [1, 2, 3, 4, 5].map((i) => train(`T${i}`, near(i).lat, near(i).lon)); + await app.poll(); + const kept = [1, 2, 3, 4, 5].filter((i) => + app.entry(railTrainKey('mbta-rail', `T${i}`)), + ); + assert.equal(kept.length, 2); + assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 3); + const stats = app.layer.getStats(); + assert.equal(stats.partial, true); + assert.equal(stats.rawRowCount - stats.acceptedRowCount, 3); + // The panel row reads it as PARTIAL (a feed in range that the harness + // does not answer would read as degraded first). + assert.equal(layerFeedState({ ...stats, degraded: false }), 'partial'); + assert.equal(warn.mock.callCount(), 1); + // Room again: the next poll draws them all, and the note goes. + for (const [key, entry] of app.state()._trains) + if (entry.feedId === 'elsewhere') app.state()._trains.delete(key); + await app.poll(); + assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 0); + assert.notEqual(app.layer.getStats().partial, true); +}); diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js new file mode 100644 index 000000000..e44dc0ce3 --- /dev/null +++ b/src/layers/rail/lifecycle.js @@ -0,0 +1,170 @@ +import * as Cesium from 'cesium'; +import { destroyTrack } from '../../data/contactPlayback.js'; +import { + claimCameraSensitivity, + releaseCameraSensitivity, +} from '../../data/cameraSensitivity.js'; +import { + CAMERA_PERCENTAGE_CHANGED, + RAIL_SELECTED_OVERLAY_SOURCE_ID, +} from './policy.js'; +import { RAIL_STOP_OVERLAY_SOURCE_ID } from './stopLabels.js'; +import { + RAIL_ENABLED_FEEDS, + railFeedNeedsSchedulePack, +} from '../../data/railFeeds.js'; + +/** + * Init / enable / disable / update / destroy for one Rail layer instance. + * Everything acquired here is released here: the primitive collection, the + * sprite registration, the pick owner, listeners, the render hold and the + * camera sensitivity this layer borrowed. + * @param {object} context + * @returns {object} + */ +export function createLifecycle({ state, services, parts, timetable }) { + const { + registerSpriteCollection, + unregisterSpriteCollection, + restoreSpriteOrder, + } = services.sprites; + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { releaseContinuousRender } = services.render; + + function dropAllTrains() { + for (const entry of state._trains.values()) { + parts.rendering.cancelWake(entry); + destroyTrack(entry.track); + } + state._trains.clear(); + state._moving.clear(); + state._markers?.removeAll(); + } + + const methods = { + id: 'rail', + name: 'Rail', + icon: '🚆', + source: 'GTFS-RT', + + /** Create the marker collection. Called once at bootstrap. */ + init(viewer) { + state._viewer = viewer; + state._markers = new Cesium.BillboardCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + viewer.scene.primitives.add(state._markers); + registerSpriteCollection('rail', state._markers); + state._markers.show = false; + state._enabled = false; + state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, false); + state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, false); + restoreSpriteOrder(viewer); + // A feed that needs its schedule pack runs only once the server's + // catalog lists it (its pack is built); the catalog's own count is + // logged when it arrives (viewport.js). + const packless = RAIL_ENABLED_FEEDS.filter( + (feed) => !railFeedNeedsSchedulePack(feed), + ).length; + console.log( + `[Data:Rail] Initialized: ${RAIL_ENABLED_FEEDS.length} train feeds registered, ${packless} need no schedule pack`, + ); + }, + + /** Show trains, watch the camera, and poll every feed in range. */ + enable(viewer) { + state._enabled = true; + state._generation += 1; + state._error = null; + state._markers.show = true; + state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, true); + state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, true); + parts.selection.installClickHandler(viewer); + registerPickOwner('rail', (pickedId) => state._trains.has(pickedId)); + if (!state._cameraChangedAttached) { + viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); + claimCameraSensitivity( + viewer.camera, + 'rail', + CAMERA_PERCENTAGE_CHANGED, + ); + state._cameraChangedAttached = true; + } + if (!state._preRenderRemove) + state._preRenderRemove = viewer.scene.preRender.addEventListener( + parts.rendering.onPreRender, + ); + state._networkFade = -1; + parts.viewport.loadCatalog(); + parts.viewport.runProximityCheck(); + restoreSpriteOrder(viewer); + }, + + /** Hide everything, stop polling, drop all trains. */ + disable(viewer) { + state._enabled = false; + state._generation += 1; + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = null; + parts.selection.clearSelection(); + state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, false); + state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, false); + parts.selection.removeClickHandler(); + unregisterPickOwner('rail'); + if (state._cameraChangedAttached) { + viewer.camera.changed.removeEventListener( + parts.viewport.onCameraChanged, + ); + releaseCameraSensitivity(viewer.camera, 'rail'); + state._cameraChangedAttached = false; + } + state._preRenderRemove?.(); + state._preRenderRemove = null; + parts.viewport.cancelCatalog(); + timetable.stop(); + parts.ingestion.abortAllInFlight(); + state._activeFeeds.clear(); + state._heldFeedId = null; + state._feedStatus.clear(); + dropAllTrains(); + // Floor cells and track samples queued for trains now gone. + parts.rendering.reset(); + parts.routes.hideAll(); + if (state._markers) state._markers.show = false; + state._altitudeGateOpen = false; + if (state._renderHeld) { + releaseContinuousRender('rail'); + state._renderHeld = false; + } + }, + + /** Manager tick (every RAIL_POLL_MS): sweep, then re-poll active feeds. */ + async update() { + if (!state._enabled) return; + parts.ingestion.sweepAgedTrains(Date.now()); + parts.routes.releaseIdleFeeds(Date.now()); + const generation = state._generation; + await Promise.all( + [...state._activeFeeds.values()].map((feed) => + parts.ingestion.pollFeed(feed, generation), + ), + ); + }, + + /** Tear down the collection and release every shared registration. */ + destroy(viewer) { + this.disable(viewer); + parts.routes.destroy(); + if (state._markers) { + unregisterSpriteCollection('rail', state._markers); + viewer?.scene?.primitives?.remove(state._markers); + state._markers = null; + } + state._overlayHost.clearSource(RAIL_SELECTED_OVERLAY_SOURCE_ID); + state._dataManager = null; + state._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/rail/liveOnly.test.mjs b/src/layers/rail/liveOnly.test.mjs new file mode 100644 index 000000000..86fb4b575 --- /dev/null +++ b/src/layers/rail/liveOnly.test.mjs @@ -0,0 +1,386 @@ +// Only live trains are drawn. A live feed whose positions fail is shown as +// unavailable: no train is placed or moved from its timetable, nothing is +// asked for to place one, and the trains it drew go as their reports age. +// A schedule pack is read only for a selected live train's stops. Feeds +// that read their routes from a schedule pack wait for the server's +// catalog, which is asked for again with a backoff when it fails. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CATALOG_RETRY_FIRST_MS, + CATALOG_RETRY_MAX_MS, + FEED_EVICT_AFTER_MS, + RAIL_POLL_MS, + railCatalogRetryDelayMs, + railTrainKey, +} from './policy.js'; +import * as policy from './policy.js'; +import { + getRegisteredRailFeed, + publicRailCatalog, + railFeedNeedsSchedulePack, +} from '../../data/railFeeds.js'; +import { + RAIL_HARNESS_T0 as T0, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; + +const FEED = 'septa-regional-rail'; +const PHILADELPHIA = { lat: 40.0, lon: -75.25 }; +const MADRID = { lat: 40.42, lon: -3.7 }; +/** The server's catalog, narrowed to SEPTA so no other feed is polled. */ +const CATALOG = publicRailCatalog().filter((feed) => feed.id === FEED); +const LAT = 40.0; +const STEP_M = 0.1 * 111_320 * Math.cos((LAT * Math.PI) / 180); +const DAY = new Date(T0).toISOString().slice(0, 10); +/** Seconds after midnight (UTC) at the tests' start. */ +const T0_SEC = (T0 - Date.parse(`${DAY}T00:00:00Z`)) / 1000; + +/** + * A SEPTA schedule pack of one Paoli/Thorndale run, T1, west to east across + * three stops an hour long, that left its first stop ten minutes before the + * tests start. It is served, and must never place a train. + */ +const SEPTA_PACK = Object.freeze({ + version: 1, + feedId: FEED, + timezone: 'UTC', + window: { from: DAY, to: DAY }, + routes: { PAO: { type: 2 } }, + stops: [ + { id: 'A', name: 'Paoli', lat: LAT, lon: -75.45 }, + { id: 'B', name: 'Bryn Mawr', lat: LAT, lon: -75.35 }, + { id: 'C', name: '30th Street', lat: LAT, lon: -75.25 }, + ], + patterns: [ + { + shape: 0, + stops: [0, 1, 2], + seq: [1, 2, 3], + dist: [0, STEP_M, 2 * STEP_M], + }, + ], + profiles: [[0, 0, 1800, 1860, 3600, 3600]], + services: { WK: [DAY] }, + trips: { + T1: { + pattern: 0, + profile: 0, + startSec: T0_SEC - 600, + route: 'PAO', + service: 'WK', + headsign: '30th Street', + name: '9512', + }, + }, +}); +const LIVE_V1 = railTrainKey(FEED, 'V1'); + +/** SEPTA's live answer, switched by the test; its schedule pack is served. */ +function septa(app, answer = { status: 502 }) { + const live = { answer }; + app.schedules.set(FEED, SEPTA_PACK); + app.feeds.set(FEED, () => + typeof live.answer === 'function' ? live.answer() : live.answer, + ); + return live; +} + +const feedStatus = (app) => app.state()._feedStatus.get(FEED); +const feedKeys = (app) => + [...app.state()._trains.keys()].filter((key) => + key.startsWith(`rail:${FEED}:`), + ); +const asked = (app) => app.requested.filter((id) => id === FEED).length; + +/** Live V1 on trip T1, near where the timetable has the train. */ +function liveV1() { + const minutes = (Date.now() - T0) / 60_000; + return [ + train('V1', LAT, -75.4 + minutes * 0.002, { + tripId: 'T1', + routeId: 'PAO', + category: 'local', + label: '9512', + }), + ]; +} + +test('the layer has no timetable fallback to switch to', () => { + for (const gone of [ + 'LIVE_FAILURES_TO_TIMETABLE', + 'LIVE_PROBE_MIN_MS', + 'LIVE_PROBE_MAX_MS', + 'railLiveFailureKind', + 'railLiveProbeDelayMs', + ]) + assert.equal(policy[gone], undefined, gone); +}); + +test('a live feed whose operator fails shows unavailable, and no timetable is asked for', async (t) => { + t.mock.method(console, 'warn', () => {}); + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app); + await app.enable(); + for (let i = 0; i < 4; i += 1) { + const status = feedStatus(app); + assert.match(status.error, /unavailable/, `poll ${i + 1}`); + assert.equal(status.loading, false); + assert.deepEqual(feedKeys(app), [], 'no train placed from the timetable'); + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.deepEqual(app.requestedSchedules, [], 'no schedule pack asked for'); + // Live positions are asked for again every poll, as for any feed. + assert.equal(asked(app), 5); + assert.match(app.layer.getStats().error, /unavailable/); +}); + +test('trains a failing feed drew are never moved on by its timetable; they age out', async (t) => { + t.mock.method(console, 'warn', () => {}); + const app = harness(t, { + at: PHILADELPHIA, + catalog: CATALOG, + scheduleStops: true, + }); + const live = septa(app, liveV1); + await app.enable(); + app.run(RAIL_POLL_MS); + await app.poll(); + assert.deepEqual(feedKeys(app), [LIVE_V1]); + // A selected live train reads its stops from the schedule pack. + app.parts().selection.selectTrain(LIVE_V1); + await app.settle(); + assert.deepEqual(app.requestedSchedules, [FEED]); + const lastReport = app.entry(LIVE_V1).record; + + // The operator fails: the train keeps its last report, and only that. + live.answer = { status: 502 }; + const failedAt = Date.now(); + while (Date.now() - failedAt <= FEED_EVICT_AFTER_MS) { + app.run(RAIL_POLL_MS); + await app.poll(); + for (const key of feedKeys(app)) + assert.equal(key, LIVE_V1, 'no timetable run is drawn'); + if (app.entry(LIVE_V1)) + assert.equal(app.entry(LIVE_V1).record, lastReport, 'never moved on'); + } + assert.deepEqual(feedKeys(app), [], 'gone as its report aged'); + assert.equal(app.state()._selectedKey, null); + assert.match(feedStatus(app).error, /unavailable/); + assert.deepEqual(app.requestedSchedules, [FEED], 'asked for once, for stops'); +}); + +test('a server that answers with timetable positions has no live data: nothing drawn', async (t) => { + t.mock.method(console, 'warn', () => {}); + // SEPTA's trains route answers 200 with no live positions + // (`positions: 'schedule'`, no trains). + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app, { positions: 'schedule' }); + await app.enable(); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.deepEqual(feedKeys(app), []); + assert.match(feedStatus(app).error, /unavailable/); + assert.deepEqual(app.requestedSchedules, []); + // It is not taken for a timetable feed: live is asked for every poll. + assert.equal(asked(app), 4); +}); + +test('a train record placed from a timetable is never drawn, whatever the feed says', async (t) => { + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app, () => [ + train('V1', LAT, -75.4, { positionSource: 'schedule' }), + train('V2', LAT, -75.3, { positionSource: 'gps' }), + train('V3', LAT, -75.35, { positionSource: 'event' }), + train('V4', LAT, -75.32, { positionSource: undefined }), + ]); + await app.enable(); + assert.deepEqual(feedKeys(app).sort(), [ + railTrainKey(FEED, 'V2'), + railTrainKey(FEED, 'V3'), + railTrainKey(FEED, 'V4'), + ]); + assert.equal(feedStatus(app).error, null); +}); + +test('a catalog feed without live positions is never polled', async (t) => { + // A catalog entry whose positions are not live is left alone. + const app = harness(t, { + at: PHILADELPHIA, + catalog: CATALOG.map((feed) => ({ ...feed, positions: 'schedule' })), + }); + septa(app, liveV1); + await app.enable(); + app.run(RAIL_POLL_MS); + await app.poll(); + assert.ok(!app.state()._activeFeeds.has(FEED)); + assert.equal(asked(app), 1, 'only the first poll, before the catalog'); + assert.deepEqual(feedKeys(app), []); + assert.deepEqual(app.requestedSchedules, []); +}); + +test('a feed the catalog leaves out is never polled once it loads', async (t) => { + // Here the catalog lists SEPTA's regional rail alone, so the other + // bundled feed in range (the trolleys) is dropped as soon as it answers. + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app, liveV1); + await app.enable(); + assert.deepEqual([...app.state()._activeFeeds.keys()], [FEED]); + const before = app.requested.filter((id) => id !== FEED).length; + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.equal(app.requested.filter((id) => id !== FEED).length, before); + assert.deepEqual( + [...app.state()._feedStatus.keys()], + [FEED], + 'no status, so no error, for a feed the catalog left out', + ); +}); + +/** A catalog the test answers (or fails) when it chooses. */ +function pendingCatalog() { + const pending = {}; + pending.promise = new Promise((resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }); + return pending; +} + +const LONG_DISTANCE = 'renfe-larga-distancia'; +const CERCANIAS = 'renfe-cercanias'; + +/** Madrid's two live feeds, both answering with no trains. */ +function liveFeedsAnswer(app) { + app.feeds.set(LONG_DISTANCE, () => []); + app.feeds.set(CERCANIAS, () => []); +} + +const needsPack = (id) => railFeedNeedsSchedulePack(getRegisteredRailFeed(id)); + +test('Renfe long distance reads its routes from a pack: it is held back until the catalog lists it', () => { + assert.equal(needsPack(LONG_DISTANCE), true); + assert.equal(needsPack(CERCANIAS), false); +}); + +test('a pack-routed live feed waits for the catalog, then runs once its pack is built', async (t) => { + // A fresh install builds no packs: until the catalog says which are + // built, no such feed's pack is asked for (it would answer 404). + const catalog = pendingCatalog(); + const app = harness(t, { at: MADRID, catalog: catalog.promise, paths: {} }); + liveFeedsAnswer(app); + await app.enable(); + const asks = () => [...app.requestedSchedules, ...app.requestedPaths]; + assert.ok(app.requestedPaths.includes(CERCANIAS), 'Cercanías starts at once'); + assert.deepEqual(asks().filter(needsPack), []); + assert.ok(!app.state()._activeFeeds.has(LONG_DISTANCE)); + assert.ok(!app.requested.includes(LONG_DISTANCE)); + + catalog.resolve( + publicRailCatalog({ schedulePackBuilt: (id) => id === LONG_DISTANCE }), + ); + await app.settle(); + assert.ok(app.state()._activeFeeds.has(LONG_DISTANCE)); + assert.ok(app.requested.includes(LONG_DISTANCE), 'its live positions'); + assert.ok(app.requestedPaths.includes(LONG_DISTANCE), 'and its line'); + // Its schedule pack is not needed to place anything. + assert.deepEqual(app.requestedSchedules, []); +}); + +test('a pack-routed live feed whose pack is not built is never polled', async (t) => { + const app = harness(t, { + at: MADRID, + catalog: publicRailCatalog({ schedulePackBuilt: () => false }), + paths: {}, + }); + liveFeedsAnswer(app); + await app.enable(); + for (let i = 0; i < 2; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.deepEqual([...app.state()._activeFeeds.keys()], [CERCANIAS]); + assert.ok(!app.requested.includes(LONG_DISTANCE)); + assert.deepEqual( + [...app.requestedSchedules, ...app.requestedPaths].filter(needsPack), + [], + ); +}); + +test('a catalog that fails keeps pack-routed feeds held back and is asked for again', async (t) => { + t.mock.method(console, 'warn', () => {}); + // Two failures, then a catalog with Renfe long distance's pack built. + const answers = [ + () => Promise.reject(new Error('Rail feeds HTTP 500')), + () => Promise.reject(new Error('Rail feeds HTTP 502')), + () => + publicRailCatalog({ schedulePackBuilt: (id) => id === LONG_DISTANCE }), + ]; + const app = harness(t, { + at: MADRID, + catalog: () => answers.shift()(), + paths: {}, + }); + liveFeedsAnswer(app); + await app.enable(); + const asks = () => [...app.requestedSchedules, ...app.requestedPaths]; + assert.equal(app.catalogRequests.length, 1); + assert.ok(app.state()._activeFeeds.has(CERCANIAS), 'live feeds still run'); + assert.deepEqual(asks().filter(needsPack), [], 'no pack asked for'); + assert.ok(!app.state()._activeFeeds.has(LONG_DISTANCE)); + + // Asked for again after 30 s, then after a further minute. + t.mock.timers.tick(CATALOG_RETRY_FIRST_MS - 1); + await app.settle(); + assert.equal(app.catalogRequests.length, 1); + t.mock.timers.tick(1); + await app.settle(); + assert.equal(app.catalogRequests.length, 2); + assert.deepEqual(asks().filter(needsPack), []); + t.mock.timers.tick(2 * CATALOG_RETRY_FIRST_MS); + await app.settle(); + assert.equal(app.catalogRequests.length, 3); + assert.ok(app.state()._activeFeeds.has(LONG_DISTANCE)); + assert.ok(app.requestedPaths.includes(LONG_DISTANCE)); + + // A catalog that loaded is not asked for again. + t.mock.timers.tick(CATALOG_RETRY_MAX_MS); + await app.settle(); + assert.equal(app.catalogRequests.length, 3); +}); + +test('the catalog retry backs off to ten minutes and stops with the layer', async (t) => { + t.mock.method(console, 'warn', () => {}); + assert.equal(railCatalogRetryDelayMs(1), CATALOG_RETRY_FIRST_MS); + assert.equal(railCatalogRetryDelayMs(2), 2 * CATALOG_RETRY_FIRST_MS); + assert.equal(railCatalogRetryDelayMs(30), CATALOG_RETRY_MAX_MS); + const app = harness(t, { + at: MADRID, + catalog: () => Promise.reject(new Error('Rail feeds HTTP 500')), + paths: {}, + }); + liveFeedsAnswer(app); + await app.enable(); + app.layer.disable(app.viewer); + t.mock.timers.tick(CATALOG_RETRY_MAX_MS); + await app.settle(); + assert.equal(app.catalogRequests.length, 1, 'no retry while off'); +}); + +test('a source without a catalog polls only the feeds that need no pack', async (t) => { + const app = harness(t, { at: MADRID, catalog: false, paths: {} }); + liveFeedsAnswer(app); + await app.enable(); + const active = [...app.state()._activeFeeds.keys()]; + assert.deepEqual(active, [CERCANIAS]); + assert.deepEqual( + [...app.requestedSchedules, ...app.requestedPaths].filter(needsPack), + [], + ); +}); diff --git a/src/layers/rail/pathBinding.js b/src/layers/rail/pathBinding.js new file mode 100644 index 000000000..36814c036 --- /dev/null +++ b/src/layers/rail/pathBinding.js @@ -0,0 +1,243 @@ +import { pointAtS, projectOnPath } from '../../data/railPaths.js'; + +/** + * Binding a train to its railway line and moving it along that line. + * + * A train is bound to one shape of its feed's path pack: the shape its trip + * names, else the route's shape its report lies on. Every later report is + * projected onto that shape near the arc the train was last seen at, so a + * report can never snap to the far side of a loop or to a parallel branch. + * A report that lands well off the line means the binding was wrong (a + * diversion, a short-turn shape, a mismatched trip): the train is moved to + * another of its route's shapes if one fits, else it is unbound and drawn + * with straight-line motion again. + * + * Between two reports the marker moves by arc length: both reports are + * projected onto the shape once per playback segment, and each frame only + * interpolates the arc and looks up the point. + * + * Pure: no Cesium, no DOM. + */ + +/** A report farther than this from the bound line unbinds the train (m). */ +export const PATH_OFF_TRACK_M = 150; +/** Fastest train the search window allows for (m/s, about 250 km/h). */ +const PATH_MAX_SPEED_MPS = 70; +/** Window slack for report jitter and clock error (m). */ +const PATH_WINDOW_SLACK_M = 300; +/** Widest hinted search; beyond it the whole shape is searched (m). */ +const PATH_WINDOW_MAX_M = 50_000; +/** + * Among route shapes that fit a report about equally well, the longest wins: + * overlapping shapes are usually a full run and its short turns, and a short + * turn would unbind a train that runs past its end. + */ +const PATH_TIE_M = 10; + +/** + * Half-width of the arc window a train can have moved in `dtMs`, or NaN when + * the gap is unknown or too long for a window to be meaningful. + * @param {number} dtMs + * @returns {number} + */ +export function pathWindowM(dtMs) { + if (!Number.isFinite(dtMs)) return NaN; + const window = + PATH_WINDOW_SLACK_M + (PATH_MAX_SPEED_MPS * Math.abs(dtMs)) / 1000; + return window > PATH_WINDOW_MAX_M ? NaN : window; +} + +function createBinding(shapeIndex, path, via, s, fix) { + return { + shapeIndex, + path, + via, + /** Arc and time of the newest report. */ + fixS: s, + fixT: fix.t, + fixLat: fix.lat, + fixLon: fix.lon, + /** Arc and time last drawn. */ + drawS: s, + drawT: fix.t, + /** Projections of the current playback segment's two reports. */ + segment: { + fromSeq: -1, + toSeq: -1, + fromLat: NaN, + fromLon: NaN, + toLat: NaN, + toLon: NaN, + sFrom: NaN, + sTo: NaN, + ok: false, + }, + }; +} + +/** The route shape a report fits best, preferring the longest near-tie. */ +function bestRouteShape(pack, routeId, fix, accept) { + const candidates = []; + for (const index of pack.shapesForRoute(routeId)) { + const path = pack.pathAt(index); + const hit = projectOnPath(path, fix.lat, fix.lon); + if (!hit || hit.offsetM > PATH_OFF_TRACK_M) continue; + if (!accept(index, path)) continue; + candidates.push({ index, path, s: hit.s, offsetM: hit.offsetM }); + } + if (!candidates.length) return null; + const nearest = Math.min(...candidates.map((c) => c.offsetM)); + let best = null; + for (const candidate of candidates) { + if (candidate.offsetM > nearest + PATH_TIE_M) continue; + if (!best || candidate.path.length > best.path.length) best = candidate; + } + return best; +} + +/** + * Bind a train to a shape from its feed's pack. + * + * The trip's own shape when the trip is in the pack and the report lies on it; + * otherwise the route shape the report lies nearest (within PATH_OFF_TRACK_M), + * which for a single-shape route is that shape. + * + * @param {object} pack From createRailPathPack. + * @param {{tripId?: string, routeId?: string}} ids + * @param {{t: number, lat: number, lon: number}} fix The newest report. + * @returns {object|null} A binding, or null to keep straight-line motion. + */ +export function bindTrainPath(pack, { tripId, routeId } = {}, fix) { + if (!pack || !fix || !Number.isFinite(fix.lat) || !Number.isFinite(fix.lon)) + return null; + const trip = pack.shapeForTrip(tripId); + if (trip !== null) { + const path = pack.pathAt(trip); + const hit = projectOnPath(path, fix.lat, fix.lon); + if (hit && hit.offsetM <= PATH_OFF_TRACK_M) + return createBinding(trip, path, 'trip', hit.s, fix); + } + const best = bestRouteShape(pack, routeId, fix, (index) => index !== trip); + return best + ? createBinding(best.index, best.path, 'route', best.s, fix) + : null; +} + +/** + * Follow a new report on the bound shape. + * @param {object} binding + * @param {{t: number, lat: number, lon: number}} fix + * @returns {boolean} False when the report lies off the line: the binding is wrong. + */ +export function followFix(binding, fix) { + if (!binding || !fix) return false; + const hit = projectOnPath( + binding.path, + fix.lat, + fix.lon, + binding.fixS, + pathWindowM(fix.t - binding.fixT), + ); + if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false; + binding.fixS = hit.s; + binding.fixT = fix.t; + binding.fixLat = fix.lat; + binding.fixLon = fix.lon; + return true; +} + +/** + * After a report fell off the bound shape: another shape of the route that + * holds both the previous report and this one, or null for straight lines. + * @param {object} pack + * @param {{routeId?: string}} ids + * @param {object} binding The binding that no longer fits. + * @param {{t: number, lat: number, lon: number}} fix + * @returns {object|null} + */ +export function rebindTrainPath(pack, { routeId } = {}, binding, fix) { + if (!pack || !binding || !fix) return null; + const best = bestRouteShape(pack, routeId, fix, (index, path) => { + if (index === binding.shapeIndex) return false; + const previous = projectOnPath(path, binding.fixLat, binding.fixLon); + return !!previous && previous.offsetM <= PATH_OFF_TRACK_M; + }); + return best + ? createBinding(best.index, best.path, 'route', best.s, fix) + : null; +} + +/** Project one playback report, near the last drawn arc when that is useful. */ +function projectReport(binding, fix, hintS, hintT) { + const path = binding.path; + const window = pathWindowM(fix.t - hintT); + let hit = Number.isFinite(hintS) + ? projectOnPath(path, fix.lat, fix.lon, hintS, window) + : null; + if (!hit || hit.offsetM > PATH_OFF_TRACK_M) + hit = projectOnPath(path, fix.lat, fix.lon); + return hit && hit.offsetM <= PATH_OFF_TRACK_M ? hit.s : NaN; +} + +/** + * Where a bound train is drawn this frame. + * + * The playback segment's two reports are projected once per segment (the + * from-report reuses the previous segment's to-report when they are the same + * report); every frame interpolates the arc by the playback fraction and + * looks up that point. + * + * @param {object} binding + * @param {{fromSeq: number, toSeq: number, fraction: number}} sample Playback sample. + * @param {{from: {seq:number, t:number, lat:number, lon:number}, + * to: {seq:number, t:number, lat:number, lon:number}}} segment The sample's two reports. + * @param {{lat?: number, lon?: number, bearingDeg?: number}} out + * @returns {boolean} False when the train should stay on its straight-line point. + */ +export function placeOnPath(binding, sample, segment, out) { + if (!binding || !sample || !segment || !(sample.fromSeq >= 0)) return false; + const { from, to } = segment; + if (from.seq !== sample.fromSeq || to.seq !== sample.toSeq) return false; + const cache = binding.segment; + if ( + cache.fromSeq !== sample.fromSeq || + cache.toSeq !== sample.toSeq || + cache.fromLat !== from.lat || + cache.fromLon !== from.lon || + cache.toLat !== to.lat || + cache.toLon !== to.lon + ) { + const chained = + cache.ok && + cache.toSeq === sample.fromSeq && + cache.toLat === from.lat && + cache.toLon === from.lon; + const sFrom = chained + ? cache.sTo + : projectReport(binding, from, binding.drawS, binding.drawT); + const sTo = + sample.toSeq === sample.fromSeq + ? sFrom + : Number.isFinite(sFrom) + ? projectReport(binding, to, sFrom, from.t) + : NaN; + cache.fromSeq = sample.fromSeq; + cache.toSeq = sample.toSeq; + cache.fromLat = from.lat; + cache.fromLon = from.lon; + cache.toLat = to.lat; + cache.toLon = to.lon; + cache.sFrom = sFrom; + cache.sTo = sTo; + cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo); + } + if (!cache.ok) return false; + const f = Number.isFinite(sample.fraction) + ? Math.max(0, Math.min(1, sample.fraction)) + : 1; + const s = cache.sFrom + (cache.sTo - cache.sFrom) * f; + if (!pointAtS(binding.path, s, out)) return false; + binding.drawS = s; + binding.drawT = from.t + (to.t - from.t) * f; + return true; +} diff --git a/src/layers/rail/pathBinding.test.mjs b/src/layers/rail/pathBinding.test.mjs new file mode 100644 index 000000000..c8983a696 --- /dev/null +++ b/src/layers/rail/pathBinding.test.mjs @@ -0,0 +1,203 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRailPathPack } from '../../data/railPaths.js'; +import { + PATH_OFF_TRACK_M, + bindTrainPath, + followFix, + pathWindowM, + placeOnPath, + rebindTrainPath, +} from './pathBinding.js'; + +const D = 1 / 111_320; + +/** + * Three shapes on the equator: + * 0: an L, 2 km east then 2 km north (the full run); + * 1: the first 1 km of the same line (a short turn); + * 2: 2 km east then 2 km south (a branch at the same junction). + */ +function pack() { + return createRailPathPack({ + version: 1, + feedId: 'test-feed', + shapes: [ + [0, 0, 0, 2000 * D, 2000 * D, 2000 * D], + [0, 0, 0, 1000 * D], + [0, 0, 0, 2000 * D, -2000 * D, 2000 * D], + ], + dist: [ + [0, 2000, 4000], + [0, 1000], + [0, 2000, 4000], + ], + trips: { full: 0, branch: 2 }, + routes: { main: [0, 1, 2], short: [1] }, + }); +} + +const fix = (t, eastM, northM) => ({ t, lat: northM * D, lon: eastM * D }); + +test('a trip binds to its own shape, else to the best route shape', () => { + const p = pack(); + const byTrip = bindTrainPath( + p, + { tripId: 'branch', routeId: 'main' }, + fix(0, 500, 10), + ); + assert.equal(byTrip.shapeIndex, 2); + assert.equal(byTrip.via, 'trip'); + assert.ok(Math.abs(byTrip.fixS - 500) < 0.5); + const single = bindTrainPath( + p, + { tripId: 'unknown', routeId: 'short' }, + fix(0, 500, 0), + ); + assert.equal(single.shapeIndex, 1); + assert.equal(single.via, 'route'); + // Near the shared start every main shape fits; the longest wins the tie. + const shared = bindTrainPath(p, { routeId: 'main' }, fix(0, 500, 0)); + assert.ok([0, 2].includes(shared.shapeIndex), String(shared.shapeIndex)); + // Up the north leg only the full run fits. + const north = bindTrainPath(p, { routeId: 'main' }, fix(0, 2010, 1500)); + assert.equal(north.shapeIndex, 0); + assert.ok(Math.abs(north.fixS - 3500) < 1); +}); + +test('a trip whose report is off its shape falls back to the route', () => { + const p = pack(); + const binding = bindTrainPath( + p, + { tripId: 'branch', routeId: 'main' }, + fix(0, 2000, 1000), + ); + assert.equal(binding.shapeIndex, 0); +}); + +test('nothing binds when no shape lies within the off-track distance', () => { + const p = pack(); + assert.equal( + bindTrainPath(p, { routeId: 'main' }, fix(0, 1000, PATH_OFF_TRACK_M + 50)), + null, + ); + assert.equal(bindTrainPath(p, { routeId: 'none' }, fix(0, 0, 0)), null); + assert.equal(bindTrainPath(null, { routeId: 'main' }, fix(0, 0, 0)), null); + assert.equal( + bindTrainPath(p, { routeId: 'main' }, { t: 0, lat: Number.NaN, lon: 0 }), + null, + ); +}); + +test('reports are followed along the line and an off-line report unbinds', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0)); + assert.ok(followFix(binding, fix(30_000, 1200, 5))); + assert.ok(Math.abs(binding.fixS - 1200) < 0.5); + assert.equal(followFix(binding, fix(60_000, 1500, 400)), false); + assert.ok( + Math.abs(binding.fixS - 1200) < 0.5, + 'a rejected report changes nothing', + ); +}); + +test('an off-line report moves the train to a route shape that holds both reports', () => { + const p = pack(); + // Bound to the short turn, the train runs past its end and on up the L. + const short = bindTrainPath(p, { routeId: 'short' }, fix(0, 800, 0)); + assert.ok(followFix(short, fix(20_000, 950, 0))); + const next = fix(60_000, 2000, 300); + assert.equal(followFix(short, next), false); + const moved = rebindTrainPath(p, { routeId: 'main' }, short, next); + assert.equal(moved.shapeIndex, 0); + assert.ok(Math.abs(moved.fixS - 2300) < 1); + assert.equal(rebindTrainPath(p, { routeId: 'short' }, short, next), null); +}); + +test('the search window grows with the gap and gives up when it is too long', () => { + assert.equal(pathWindowM(0), 300); + assert.equal(pathWindowM(-10_000), 1000); + assert.ok(Number.isNaN(pathWindowM(Number.NaN))); + assert.ok(Number.isNaN(pathWindowM(3_600_000))); +}); + +function playback(fromSeq, toSeq, from, to, fraction) { + return { + sample: { fromSeq, toSeq, fraction }, + segment: { + from: { seq: fromSeq, ...from }, + to: { seq: toSeq, ...to }, + }, + }; +} + +test('between two reports a bound train moves along the line, not the chord', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 1500, 0)); + // A corner cut: 1.5 km east, then 500 m up the north leg. + const a = fix(0, 1500, 0); + const b = fix(60_000, 2000, 500); + const out = {}; + const mid = playback(4, 5, a, b, 0.5); + assert.ok(placeOnPath(binding, mid.sample, mid.segment, out)); + // Halfway along the 1 km of track is the corner itself. + assert.ok(Math.abs(out.lat) < 1e-9, String(out.lat)); + assert.ok(Math.abs(out.lon - 2000 * D) < 1e-6, String(out.lon)); + assert.ok(Math.abs(binding.drawS - 2000) < 1); + const later = playback(4, 5, a, b, 0.75); + assert.ok(placeOnPath(binding, later.sample, later.segment, out)); + assert.ok(Math.abs(out.lat - 250 * D) < 1e-6); + assert.ok( + Math.abs(out.bearingDeg) < 1e-6 || Math.abs(out.bearingDeg - 360) < 1e-6, + ); +}); + +test('the two projections are computed once per segment and chained', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0)); + const out = {}; + const first = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.2); + assert.ok(placeOnPath(binding, first.sample, first.segment, out)); + const cache = binding.segment; + assert.equal(cache.fromSeq, 1); + assert.ok(Math.abs(cache.sTo - 600) < 0.5); + // Same segment, later frame: cached arcs, only the fraction moves. + cache.sFrom = 100.25; // a sentinel a recompute would overwrite + const frame = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.4); + assert.ok(placeOnPath(binding, frame.sample, frame.segment, out)); + assert.equal(cache.sFrom, 100.25); + // Next segment starts at the old to-report: its arc is reused. + cache.sTo = 600.5; + const next = playback(2, 3, fix(30_000, 600, 0), fix(60_000, 1100, 0), 0); + assert.ok(placeOnPath(binding, next.sample, next.segment, out)); + assert.equal(cache.sFrom, 600.5); + assert.ok(Math.abs(cache.sTo - 1100) < 0.5); +}); + +test('a parked train sits on the line at its report', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 700, 20)); + const out = {}; + const parked = playback(3, 3, fix(0, 700, 20), fix(0, 700, 20), 1); + assert.ok(placeOnPath(binding, parked.sample, parked.segment, out)); + assert.equal(out.lat, 0, 'pulled onto the track'); + assert.ok(Math.abs(out.lon - 700 * D) < 1e-6); +}); + +test('a segment whose reports do not fit the line keeps the straight line', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0)); + const out = {}; + const off = playback(1, 2, fix(0, 100, 0), fix(30_000, 800, 600), 0.5); + assert.equal(placeOnPath(binding, off.sample, off.segment, out), false); + assert.equal(binding.segment.ok, false); + // No playback position yet, or stale report samples: nothing to place. + assert.equal( + placeOnPath(binding, { fromSeq: -1, toSeq: -1 }, off.segment, out), + false, + ); + const stale = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.5); + stale.segment.to.seq = 9; + assert.equal(placeOnPath(binding, stale.sample, stale.segment, out), false); + assert.equal(placeOnPath(null, stale.sample, stale.segment, out), false); +}); diff --git a/src/layers/rail/policy.js b/src/layers/rail/policy.js new file mode 100644 index 000000000..9d2faa28a --- /dev/null +++ b/src/layers/rail/policy.js @@ -0,0 +1,577 @@ +/** + * @module layers/rail/policy + * @description Constants and pure decisions for the Rail layer. No Cesium + * scene state, no network: everything here is exercised by node:test. + * + * Motion between reports is the Transit layer's delayed playback + * (src/layers/transit/movement.js over src/data/contactPlayback.js), so the + * report-age rules here are Transit's too. + */ + +import { msg } from '../../i18n/marker.js'; +import { + RAIL_CATEGORIES, + RAIL_POSITION_SOURCES, +} from '../../data/railFeeds.js'; +import { displayMotion } from '../transit/movement.js'; +import { FIX_FLAGS, readFix } from '../../data/contactPlayback.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; +import { + stopClock, + stopLateMinutes, + stopName, + stopRoleIndices, +} from './stopLabels.js'; + +export const RAIL_SELECTED_OVERLAY_SOURCE_ID = 'rail-selected'; +export const RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 0, + moving: true, +}); + +/** Poll interval (ms), matching the proxy's 15 s cache window. */ +export const RAIL_POLL_MS = 15_000; +/** + * Feeds activate below this camera altitude, with hysteresis. Rail networks + * are regional, so the gate is Transit's: low enough that a whole-continent + * view does not poll every operator at once. + */ +export const ACTIVATION_ALTITUDE_M = 3_000_000; +export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000; +export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000; +export const CAMERA_DEBOUNCE_MS = 340; +export const CAMERA_PERCENTAGE_CHANGED = 0.05; +/** A feed already active stays active this far past its coverage edge. */ +export const RANGE_SLACK_KM = 40; +export const MAX_TRAINS_TOTAL = 5_000; +/** A train a feed stops listing for this many polls in a row is removed. */ +export const MISSED_POLLS_TO_DROP = 2; +/** A report older than this is not drawn (a frozen feed empties, it does not lie). */ +export const TRAIN_MAX_FIX_AGE_MS = 10 * 60_000; +/** A feed whose operator has not answered for this long reads as stale. */ +export const FEED_STALE_AFTER_MS = 90_000; +/** ...and for this long, its trains are removed. */ +export const FEED_EVICT_AFTER_MS = 5 * 60_000; +/** A stamp this far ahead of the fetch is a clock fault, bounded by the fetch. */ +export const FUTURE_STAMP_TOLERANCE_MS = 10_000; + +/** + * Where a drawn train's position may come from: the operator's GPS, or the + * station or timing point its signalling last reported. No train is placed + * or moved from a timetable; between reports it only glides along its line + * behind real time, as Transit's vehicles do. Every source the registry + * knows (RAIL_POSITION_SOURCES) is a live one. + */ +export const RAIL_LIVE_POSITION_SOURCES = RAIL_POSITION_SOURCES; + +/** + * Whether a feed (a registry or catalog entry) or a train record reports + * live positions. Without a stated source, GPS is meant. + * @param {string|null|undefined} source `feed.positions` or + * `record.positionSource`. + * @returns {boolean} + */ +export function isLiveRailPositionSource(source) { + return RAIL_LIVE_POSITION_SOURCES.includes(source ?? 'gps'); +} +/** + * A feed catalog that fails is asked for again after CATALOG_RETRY_FIRST_MS, + * then twice as long after each failure in a row, up to CATALOG_RETRY_MAX_MS. + */ +export const CATALOG_RETRY_FIRST_MS = 30_000; +export const CATALOG_RETRY_MAX_MS = 10 * 60_000; + +/** + * How long the catalog waits after its nth failure in a row (ms). + * @param {number} failures 1 after the first. + * @returns {number} + */ +export function railCatalogRetryDelayMs(failures) { + return Math.min( + CATALOG_RETRY_MAX_MS, + CATALOG_RETRY_FIRST_MS * 2 ** Math.max(0, failures - 1), + ); +} + +/** + * Floor cells a moving train reaches cold are warmed in batches: at most + * COLD_CELLS_PER_FLUSH per flush, COLD_CELL_FLUSH_MS after the first one is + * queued, and a cell asked for is not asked again for COLD_CELL_RETRY_MS. A + * long-distance train crosses a 0.001° cell (about 111 m) every few seconds. + */ +export const COLD_CELLS_PER_FLUSH = 64; +export const COLD_CELL_FLUSH_MS = 1_000; +/** + * Floor warming shares `/api/terrain/heights` with every other layer: 90 + * requests a minute per client, 64 points each. Rail's budget refills at + * FLOOR_CELLS_PER_MINUTE cells a minute and holds at most FLOOR_CELLS_BURST, + * so no minute spends more than the two together (1,520 cells, about 24 + * requests). It is spent only on trains within FLOOR_WARM_MAX_DIST_KM of the + * camera and on the selected trip's stops, the selected train first, then + * the nearest, so hundreds of trains in view (Paris, the Ruhr) cannot use up + * the limit and leave every layer without floors. + */ +export const FLOOR_CELLS_PER_MINUTE = 1_200; +export const FLOOR_CELLS_BURST = COLD_CELLS_PER_FLUSH * 5; +export const FLOOR_WARM_MAX_DIST_KM = 30; +export const COLD_CELL_RETRY_MS = 5_000; +/** + * Track heights on photorealistic tiles: the rendered mesh is sampled at + * points on the train's own line (where the draped line is the track bed + * or the deck), in buckets of 1/TRACK_SAMPLE_GRID degree (about 55 m), + * at most TRACK_SAMPLES_PER_FLUSH per flush, for trains within + * TRACK_SAMPLE_MAX_DIST_KM of a camera at most TRACK_SAMPLE_MAX_CAMERA_M + * above the floor there, and only while the tileset reports its tiles + * loaded: a higher camera, or tiles still streaming, reads coarse tiles, + * whose height under a viaduct is the valley's (the failure + * src/services/meshFloorSampler.js records). A sample is kept only within + * TRACK_PRIOR_BELOW_M / TRACK_PRIOR_ABOVE_M of the floor under it, and is + * read again after TRACK_SAMPLE_REFRESH_MS: the new reading replaces it + * when the camera is no higher than it was, or once the old one is + * TRACK_SAMPLE_MAX_AGE_MS old. + */ +export const TRACK_SAMPLE_GRID = 2000; +export const TRACK_SAMPLES_PER_FLUSH = 8; +export const TRACK_SAMPLE_RETRY_MS = 4_000; +export const TRACK_SAMPLE_MAX_CAMERA_M = 5_000; +export const TRACK_SAMPLE_MAX_DIST_KM = 8; +export const TRACK_PRIOR_BELOW_M = 25; +export const TRACK_PRIOR_ABOVE_M = 80; +export const TRACK_SAMPLE_REFRESH_MS = 30_000; +export const TRACK_SAMPLE_MAX_AGE_MS = 5 * 60_000; +/** + * A train's drawn height follows its target height no faster than a + * railway climbs: TRACK_MAX_GRADE of the distance it moved (lines rarely + * exceed 4%), plus TRACK_SETTLE_MPS while it stands, so a roof or overpass + * sampled under it is a bump, not a jump. It snaps to a better source (a + * real floor replacing a fallback, or any floor replacing the ellipsoid) + * and after a jump of more than TRACK_SNAP_M. + */ +export const TRACK_MAX_GRADE = 0.05; +export const TRACK_SETTLE_MPS = 0.5; +export const TRACK_SNAP_M = 2_000; +export const MARKER_PIXEL_SIZE = 18; +export const SELECTED_PIXEL_SIZE = 28; +export const SELECTED_CARD_REFRESH_MS = 250; +/** While a train is selected, every other train is drawn at this opacity. */ +export const UNSELECTED_TRAIN_ALPHA = 0.35; +/** + * The faint route network is drawn in full below the first altitude, fades + * out above it and is not drawn at all above the second (m). Above that the + * lines are clutter at the scale of a whole region, and cost frames. + */ +export const NETWORK_FULL_BELOW_M = 150_000; +export const NETWORK_HIDDEN_ABOVE_M = 200_000; +/** The network's opacity, and its share of that while a train is selected. */ +export const NETWORK_ALPHA = 0.4; +export const NETWORK_SELECTED_DIM = 0.4; +export const NETWORK_WIDTH_PX = 2; +/** + * A feed out of use this long gives back its network, path pack and parsed + * schedule pack; it loads them again when next used (ms). + */ +export const FEED_RELEASE_AFTER_MS = 10 * 60_000; +/** The selected train's line: the part ahead, and the part travelled. */ +export const ROUTE_AHEAD_WIDTH_PX = 5; +export const ROUTE_TRAVELLED_ALPHA = 0.3; +/** How often the selected line's travelled/ahead split follows the train. */ +export const ROUTE_SPLIT_INTERVAL_MS = 250; +/** How often the selected train's stops, labels and card are refreshed. */ +export const STOP_REFRESH_MS = 1_000; +/** + * How long the follow camera glides to another feed's copy of the selected + * train when the selection moves to it (ms), so it neither jumps nor lets go. + */ +export const FOLLOW_HANDOVER_MS = 1_200; + +/** + * Category palette. Warm to cool with distance served, and none of the colours + * the Transit modes, flights (white/cyan), military (amber) or vessels use, so + * a train never reads as a bus drawn by the other layer. + */ +export const RAIL_CATEGORY_COLORS = Object.freeze({ + transit: '#FF7A59', + local: '#F2D04B', + regional: '#7BE495', + national: '#5AB8FF', + international: '#C58BFF', +}); + +/** Panel and card words for each category. */ +export const RAIL_CATEGORY_LABELS = Object.freeze({ + transit: msg('Urban rail'), + local: msg('Local rail'), + regional: msg('Regional rail'), + national: msg('National rail'), + international: msg('International rail'), +}); + +/** Card words for what runs on the track, shared with the Transit layer's. */ +const RAIL_MODE_WORDS = Object.freeze({ + rail: msg('Train'), + metro: msg('Subway'), + tram: msg('Tram'), +}); + +/** Default visibility: every category on. */ +export const DEFAULT_RAIL_CATEGORY_VISIBILITY = Object.freeze( + Object.fromEntries(RAIL_CATEGORIES.map((category) => [category, true])), +); + +/** + * Normalise requested category toggles against the current ones. Unknown keys + * are ignored and non-boolean values keep the current setting, so a malformed + * share link can hide nothing it did not name. + * @param {Record} params + * @param {Record} [current] + * @returns {Record} + */ +export function normalizeRailCategories( + params, + current = DEFAULT_RAIL_CATEGORY_VISIBILITY, +) { + const next = {}; + for (const category of RAIL_CATEGORIES) { + next[category] = + typeof params?.[category] === 'boolean' + ? params[category] + : current[category] !== false; + } + return next; +} + +/** + * Stable key for a train across polls. Prefixed so it can never equal a + * Transit vehicle key for the same physical train in a shared pick registry. + * @param {string} feedId + * @param {string} trainId + * @returns {string} + */ +export function railTrainKey(feedId, trainId) { + return `rail:${feedId}:${trainId}`; +} + +/** + * When a report was made, in ms: the train's own stamp, else the fetch time. + * @param {object} record + * @param {number|null} [fetchedAtMs] + * @returns {number} + */ +export function trainReportTimeMs(record, fetchedAtMs = null) { + const seconds = Number(record?.timestamp); + if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000; + if (Number.isFinite(fetchedAtMs)) return fetchedAtMs; + return Date.now(); +} + +/** + * Age of a report in ms (never negative). An unstamped record is aged from + * the snapshot's fetch time, never from now. + * @param {object} record + * @param {number} nowMs + * @param {number|null} [fetchedAtMs] + * @returns {number} + */ +export function trainFixAgeMs(record, nowMs, fetchedAtMs = null) { + const seconds = Number(record?.timestamp); + if (Number.isFinite(seconds) && seconds > 0) + return Math.max(0, nowMs - seconds * 1000); + if (Number.isFinite(fetchedAtMs)) return Math.max(0, nowMs - fetchedAtMs); + return 0; +} + +/** Whether a report is too old to draw. */ +export function isStaleTrainFix(record, nowMs, fetchedAtMs = null) { + return trainFixAgeMs(record, nowMs, fetchedAtMs) > TRAIN_MAX_FIX_AGE_MS; +} + +/** The movement module's speed ceilings are keyed by Transit mode names. */ +export function playbackModeFor(railMode) { + return railMode === 'metro' + ? 'subway' + : railMode === 'tram' + ? 'tram' + : 'rail'; +} + +/** Two reports closer than this are one place: a parked train's jitter (m). */ +export const FOLLOW_MIN_MOVE_M = 15; + +const lastFix = {}; +const priorFix = {}; + +/** How far apart a track's last two reports are (m), 0 with fewer. */ +function lastReportsApartM(track) { + if (!track || track.count < 2) return 0; + const to = readFix(track, track.count - 1, lastFix); + const from = readFix(track, track.count - 2, priorFix); + if (from.epoch !== to.epoch || to.flags & FIX_FLAGS.BREAK) return 0; + const kx = 111_320 * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180); + const dLon = ((((to.lon - from.lon + 180) % 360) + 360) % 360) - 180; + return Math.hypot(dLon * kx, (to.lat - from.lat) * 111_320); +} + +/** + * Whether the camera follows a train once it is selected, as it follows a + * tracked aircraft: its reports are recent, and it is moving on its playback + * now, waits at a stop with its next move already reported, or is between + * reports (playback waiting for the next one) after its last two reports + * moved it. Any other train (parked, known from a single report, or not + * heard from lately) gets one fly-to. + * @param {{sample?: object, track?: object}|null} entry Runtime entry with + * playback state. + * @returns {boolean} + */ +export function railTrainFollowable(entry) { + const sample = entry?.sample; + if (!sample || displayMotion(entry, 0).word === 'NO FIX') return false; + if (sample.phase === 'playing' && sample.segmentSpeedMps > 0) return true; + if ( + sample.phase === 'playing' && + sample.motion === 'stopped' && + Number.isFinite(sample.nextMotionT) && + sample.nextMotionT > sample.displayT + ) + return true; + return lastReportsApartM(entry.track) >= FOLLOW_MIN_MOVE_M; +} + +/** + * The follow camera's offset from the train in east-north-up metres: the + * shared world-focus framing for a train (its range and pitch) seen along the + * camera's current heading, so taking the train does not swing the view round. + * @param {{rangeM: number, pitchDeg: number}} framing + * @param {number} headingRad + * @returns {{x: number, y: number, z: number}} + */ +export function railFollowOffset(framing, headingRad) { + const heading = Number.isFinite(headingRad) ? headingRad : 0; + const pitch = (framing.pitchDeg * Math.PI) / 180; + const across = framing.rangeM * Math.cos(pitch); + return { + x: -Math.sin(heading) * across, + y: -Math.cos(heading) * across, + z: -Math.sin(pitch) * framing.rangeM, + }; +} + +// Tools and the server may import this module, so no catalogue here: the +// layer passes the UI's t(). +function englishText(text, values) { + return String(text).replace(/\{(\w+)\}/g, (match, key) => + values && key in values ? String(values[key]) : match, + ); +} + +/** Card words for where a train's position comes from. */ +const RAIL_POSITION_SOURCE_WORDS = Object.freeze({ + gps: msg('Live GPS'), + event: msg('Estimated from station reports'), +}); + +/** The card never grows past this many detail lines. */ +export const RAIL_CARD_MAX_LINES = 6; + +/** + * The card's title: the route when the feed names one, else the trip's + * headsign, else the vehicle's own label, else its id. + * @param {object} record + * @param {string|null} headsign + * @param {(text: string, values?: object) => string} t + * @returns {string} + */ +function railCardTitle(record, headsign, t) { + const to = headsign ? t('To {headsign}', { headsign }) : ''; + if (record.routeId) { + const route = t('Route {route}', { route: record.routeId }); + return `🚆 ${to ? `${route} · ${to}` : route}`; + } + if (to) return `🚆 ${to}`; + const label = + typeof record.label === 'string' && record.label.trim() + ? record.label.trim() + : ''; + if (label && label !== record.id) return `🚆 ${label}`; + return `🚆 ${t('Vehicle {vehicle}', { vehicle: label || record.id })}`; +} + +/** "Next Yonkers 14:32 T−4 (+3 min late)" / "Prev Riverdale 14:24 T+3". */ +function stopLine(stop, which, t) { + const values = { + stop: stopName(stop), + time: stopClock(stop), + relative: stop.relative || '', + }; + const line = ( + which === 'next' + ? t('Next {stop} {time} {relative}', values) + : t('Prev {stop} {time} {relative}', values) + ) + .replace(/\s+/g, ' ') + .trim(); + const late = which === 'next' ? stopLateMinutes(stop) : 0; + return late > 0 + ? `${line} ${t('(+{minutes} min late)', { minutes: late })}` + : line; +} + +/** + * Card copy for the selected train. The status words, report-age wording and + * "shown behind" note are the Transit card's, so they read the same. + * + * With the trip's stops (see stopLabels.js for the stop shape) the card names + * the next and previous stops; the card stays within RAIL_CARD_MAX_LINES by + * dropping its least useful lines first. + * + * @param {object} feed Registry entry. + * @param {object} record Train record from the snapshot. + * @param {number} nowMs + * @param {number|null} [fetchedAtMs] + * @param {object|null} [entry] Runtime entry with playback state. + * @param {(text: string, values?: object) => string} [t] + * @param {{stops?: object[]|null, headsign?: string|null}|null} [trip] The + * trip's stops and headsign from the feed's schedule pack, when known. + * @returns {{title: string, details: string[]}} + */ +export function buildRailSelectionCopy( + feed, + record, + nowMs, + fetchedAtMs = null, + entry = null, + t = englishText, + trip = null, +) { + const headsign = + (typeof trip?.headsign === 'string' && trip.headsign.trim()) || + (typeof record.headsign === 'string' && record.headsign.trim()) || + null; + const title = railCardTitle(record, headsign, t); + const category = t(RAIL_CATEGORY_LABELS[record.category] || 'Train'); + const mode = t(RAIL_MODE_WORDS[record.mode] || RAIL_MODE_WORDS.rail); + /** Lines with a rank: when the card is too long, the highest ranks go. */ + const lines = []; + const add = (rank, text) => lines.push({ rank, text }); + add(0, `${category} · ${mode} · ${feed.name} · ${feed.region}`); + + const shown = entry ? displayMotion(entry, nowMs) : null; + const motion = + shown?.word === 'STOPPED' + ? t('Stopped', { context: 'vehicle motion' }) + : shown?.word === 'NO FIX' + ? t('No recent fix') + : shown?.word === 'WAITING' + ? t('Waiting for update') + : shown + ? t('Moving') + : ''; + const sourceWord = RAIL_POSITION_SOURCE_WORDS[record.positionSource]; + const source = sourceWord ? t(sourceWord) : ''; + if (motion || source) + add(1, motion && source ? `${motion} · ${source}` : motion || source); + + const stops = Array.isArray(trip?.stops) ? trip.stops : []; + const roles = stopRoleIndices(stops); + if (roles.next >= 0) add(2, stopLine(stops[roles.next], 'next', t)); + if (roles.previous >= 0) + add(3, stopLine(stops[roles.previous], 'previous', t)); + const haveStops = roles.next >= 0 || roles.previous >= 0; + + const ageS = Math.round( + (Number.isFinite(entry?.sample?.latestReportAgeMs) + ? entry.sample.latestReportAgeMs + : trainFixAgeMs(record, nowMs, fetchedAtMs)) / 1000, + ); + const reported = + ageS < 90 + ? t('Reported {seconds} s ago', { seconds: ageS }) + : t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) }); + const ownTime = + Number.isFinite(record.timestamp) && + record.timestamp > 0 && + (!record.timestampSource || record.timestampSource === 'vehicle'); + const lagS = Number.isFinite(entry?.sample?.actualDelayMs) + ? Math.max(0, Math.round(entry.sample.actualDelayMs / 1000)) + : null; + add( + 4, + (ownTime ? reported : t('{reported} (feed time)', { reported })) + + (lagS === null + ? '' + : ` · ${t('shown {seconds} s behind', { seconds: lagS })}`), + ); + + const reportedMotion = []; + if (Number.isFinite(record.speedMps)) + reportedMotion.push( + t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }), + ); + if (Number.isFinite(record.bearing)) + reportedMotion.push( + t('reported hdg {heading}°', { + heading: roundHeadingDegrees(record.bearing), + }), + ); + if (reportedMotion.length) add(7, reportedMotion.join(' · ')); + // The feed's own stop id says less than the schedule pack's stop names. + if (!haveStops) { + if (record.status === 'STOPPED_AT' && record.stopId) + add(8, t('Last report: stopped at stop {stop}', { stop: record.stopId })); + else if (record.status === 'INCOMING_AT' && record.stopId) + add( + 8, + t('Last report: arriving at stop {stop}', { stop: record.stopId }), + ); + else if (record.status === 'IN_TRANSIT_TO' && record.stopId) + add(8, t('Last report: next stop {stop}', { stop: record.stopId })); + } + if (record.mode === 'metro') + add(5, t('Shown at street level · depth not in the feed')); + add( + 6, + `${t('Vehicle {vehicle}', { vehicle: record.id })}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`, + ); + + // Past the limit, the highest ranks go; the rest keep their order. + const kept = lines + .map((line, order) => ({ ...line, order })) + .sort((a, b) => a.rank - b.rank || a.order - b.order) + .slice(0, RAIL_CARD_MAX_LINES) + .sort((a, b) => a.order - b.order); + return { title, details: kept.map((line) => line.text) }; +} + +/** + * Shared-host card for the selected train. + * @param {string} key + * @param {object|(() => object|null)} position Cartesian, or a getter the host + * calls on every paint so the card follows the marker. + * @param {{title: string, details: string[]}} copy + * @param {string} category + * @returns {object|null} + */ +export function createRailSelectedOverlayEntry(key, position, copy, category) { + if (!key || !position) return null; + return { + id: String(key), + position, + variant: 'selected', + selected: true, + protected: true, + paintLane: 'selected', + collisionGroup: 'ambient-card', + priority: Number.MAX_SAFE_INTEGER, + title: copy.title, + details: copy.details, + accent: RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local, + interactive: false, + anchorRadiusPx: 9, + minAnchorGapPx: 11, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; +} diff --git a/src/layers/rail/policy.test.mjs b/src/layers/rail/policy.test.mjs new file mode 100644 index 000000000..38f02a5db --- /dev/null +++ b/src/layers/rail/policy.test.mjs @@ -0,0 +1,521 @@ +// Pure Rail layer decisions: category toggles from share links, train keys, +// report aging, the playback mode a train borrows from Transit, and the +// selected-train card. The copy builder stays free of i18n (tools import +// policy.js); the browser hands it t(), so a fake t() shows which words go +// through translation. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_RAIL_CATEGORY_VISIBILITY, + RAIL_CARD_MAX_LINES, + RAIL_CATEGORY_COLORS, + TRAIN_MAX_FIX_AGE_MS, + RAIL_LIVE_POSITION_SOURCES, + buildRailSelectionCopy, + createRailSelectedOverlayEntry, + isLiveRailPositionSource, + isStaleTrainFix, + normalizeRailCategories, + playbackModeFor, + railTrainKey, + trainFixAgeMs, + trainReportTimeMs, +} from './policy.js'; +import { RAIL_CATEGORIES } from '../../data/railFeeds.js'; + +const NOW = 1_794_000_000_000; +const feed = { name: 'MBTA', region: 'Boston, MA' }; + +/** A t() that marks every string it translates and fills placeholders. */ +function fakeT() { + const calls = []; + const t = (text, values) => { + calls.push({ text, values }); + const filled = String(text).replace(/\{(\w+)\}/g, (match, key) => + values && key in values ? String(values[key]) : match, + ); + return `T(${filled})`; + }; + return { t, calls }; +} + +const stampedTrain = Object.freeze({ + id: '1712', + label: 'Train 1712', + routeId: 'CR-Worcester', + category: 'local', + mode: 'rail', + lat: 42.35, + lon: -71.2, + bearing: 90, + speedMps: 20, + timestamp: (NOW - 30_000) / 1000, + timestampSource: 'vehicle', + status: 'IN_TRANSIT_TO', + stopId: 'place-sstat', +}); + +const unstampedMetro = Object.freeze({ + id: '77', + label: '77', + routeId: null, + category: 'transit', + mode: 'metro', + lat: 42.36, + lon: -71.06, +}); + +test('every category is on by default', () => { + assert.deepEqual(Object.keys(DEFAULT_RAIL_CATEGORY_VISIBILITY), [ + ...RAIL_CATEGORIES, + ]); + assert.ok( + Object.values(DEFAULT_RAIL_CATEGORY_VISIBILITY).every((on) => on === true), + ); + assert.deepEqual( + normalizeRailCategories({}), + DEFAULT_RAIL_CATEGORY_VISIBILITY, + ); + assert.deepEqual( + normalizeRailCategories(null), + DEFAULT_RAIL_CATEGORY_VISIBILITY, + ); +}); + +test('category toggles apply booleans and ignore anything else', () => { + const current = { + transit: true, + local: false, + regional: true, + national: true, + international: true, + }; + assert.deepEqual( + normalizeRailCategories( + { + transit: false, + local: 'true', + regional: 0, + national: null, + international: true, + bus: false, + }, + current, + ), + { + transit: false, + local: false, + regional: true, + national: true, + international: true, + }, + ); +}); + +test('a share link can switch a hidden category back on', () => { + const allOff = Object.fromEntries(RAIL_CATEGORIES.map((key) => [key, false])); + assert.deepEqual(normalizeRailCategories({ regional: true }, allOff), { + ...allOff, + regional: true, + }); +}); + +test('train keys are namespaced so they never collide with Transit keys', () => { + assert.equal(railTrainKey('mbta-rail', '1712'), 'rail:mbta-rail:1712'); + assert.notEqual( + railTrainKey('mbta-rail', '1712'), + railTrainKey('mbta-rail', '1713'), + ); + assert.notEqual( + railTrainKey('mta-lirr', '7'), + railTrainKey('mta-metro-north', '7'), + ); +}); + +test('a stamped report is aged from its own time', () => { + assert.equal(trainFixAgeMs(stampedTrain, NOW), 30_000); + // The fetch time does not override the train's own stamp. + assert.equal(trainFixAgeMs(stampedTrain, NOW, NOW - 1_000), 30_000); + assert.equal(trainReportTimeMs(stampedTrain, NOW - 1_000), NOW - 30_000); +}); + +test('a stamp ahead of now ages to zero, never below', () => { + assert.equal(trainFixAgeMs({ timestamp: (NOW + 5_000) / 1000 }, NOW), 0); +}); + +test('an unstamped report is aged from the snapshot fetch time', () => { + assert.equal(trainFixAgeMs(unstampedMetro, NOW, NOW - 45_000), 45_000); + assert.equal(trainReportTimeMs(unstampedMetro, NOW - 45_000), NOW - 45_000); + // Zero and negative stamps count as no stamp. + assert.equal(trainFixAgeMs({ timestamp: 0 }, NOW, NOW - 2_000), 2_000); + assert.equal(trainFixAgeMs({ timestamp: -1 }, NOW, NOW - 2_000), 2_000); + // With neither a stamp nor a fetch time there is nothing to age from. + assert.equal(trainFixAgeMs(unstampedMetro, NOW), 0); +}); + +test('a report is stale only past the ten-minute limit', () => { + assert.equal(TRAIN_MAX_FIX_AGE_MS, 10 * 60_000); + const at = (ageMs) => ({ timestamp: (NOW - ageMs) / 1000 }); + assert.equal(isStaleTrainFix(at(TRAIN_MAX_FIX_AGE_MS), NOW), false); + assert.equal(isStaleTrainFix(at(TRAIN_MAX_FIX_AGE_MS + 1_000), NOW), true); +}); + +test('an unstamped report from an old snapshot is stale, not live', () => { + const oldFetch = NOW - TRAIN_MAX_FIX_AGE_MS - 1; + assert.equal(isStaleTrainFix(unstampedMetro, NOW, oldFetch), true); + assert.equal(isStaleTrainFix(unstampedMetro, NOW, NOW - 60_000), false); +}); + +test('rail modes map onto the Transit playback modes', () => { + assert.equal(playbackModeFor('metro'), 'subway'); + assert.equal(playbackModeFor('tram'), 'tram'); + assert.equal(playbackModeFor('rail'), 'rail'); + assert.equal(playbackModeFor('monorail'), 'rail'); + assert.equal(playbackModeFor(undefined), 'rail'); +}); + +test('the card for a train with its own timestamp', () => { + const { t, calls } = fakeT(); + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, null, t); + assert.equal(copy.title, '🚆 T(Route CR-Worcester)'); + assert.deepEqual(copy.details, [ + 'T(Local rail) · T(Train) · MBTA · Boston, MA', + 'T(Reported 30 s ago)', + 'T(reported 72 km/h) · T(reported hdg 90°)', + 'T(Last report: next stop place-sstat)', + 'T(Vehicle 1712) · Train 1712', + ]); + // Operator names and regions are data, never sent through t(). + assert.ok(calls.every(({ text }) => !text.includes('MBTA'))); +}); + +test('the card for an unstamped metro train says the time is the feed’s', () => { + const { t } = fakeT(); + const copy = buildRailSelectionCopy( + feed, + unstampedMetro, + NOW, + NOW - 120_000, + null, + t, + ); + assert.equal(copy.title, '🚆 T(Vehicle 77)'); + assert.deepEqual(copy.details, [ + 'T(Urban rail) · T(Subway) · MBTA · Boston, MA', + 'T(T(Reported 2 min ago) (feed time))', + 'T(Shown at street level · depth not in the feed)', + 'T(Vehicle 77)', + ]); +}); + +test('a header-stamped report is feed time even with a timestamp', () => { + const { t } = fakeT(); + const copy = buildRailSelectionCopy( + feed, + { ...stampedTrain, timestampSource: 'header' }, + NOW, + NOW, + null, + t, + ); + assert.equal(copy.details[1], 'T(T(Reported 30 s ago) (feed time))'); +}); + +test('only metro trains carry the street-level note', () => { + const { t } = fakeT(); + const note = 'T(Shown at street level · depth not in the feed)'; + for (const mode of ['rail', 'tram']) { + const copy = buildRailSelectionCopy( + feed, + { ...unstampedMetro, mode }, + NOW, + NOW, + null, + t, + ); + assert.equal(copy.details.includes(note), false, mode); + } + const metro = buildRailSelectionCopy( + feed, + { ...stampedTrain, mode: 'metro', category: 'transit' }, + NOW, + NOW, + null, + t, + ); + assert.equal(metro.details.includes(note), true); +}); + +test('playback state adds the motion word and the display delay', () => { + const { t, calls } = fakeT(); + const entry = { + sample: { + latestReportAgeMs: 5_000, + actualDelayMs: 42_400, + phase: 'playing', + motion: 'stopped', + }, + }; + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, entry, t); + assert.equal(copy.details[1], 'T(Stopped)'); + assert.deepEqual(calls.find(({ text }) => text === 'Stopped').values, { + context: 'vehicle motion', + }); + assert.equal(copy.details[2], 'T(Reported 5 s ago) · T(shown 42 s behind)'); + + const moving = buildRailSelectionCopy( + feed, + stampedTrain, + NOW, + NOW, + { sample: { ...entry.sample, motion: 'moving' } }, + t, + ); + assert.equal(moving.details[1], 'T(Moving)'); +}); + +test('without a t() the card reads in plain English', () => { + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW); + assert.equal(copy.title, '🚆 Route CR-Worcester'); + assert.equal(copy.details[0], 'Local rail · Train · MBTA · Boston, MA'); + assert.equal(copy.details[1], 'Reported 30 s ago'); +}); + +test('the selected card is accented in the category colour', () => { + const copy = { title: 'Route 550', details: ['Local rail'] }; + const position = () => ({ x: 1, y: 2, z: 3 }); + for (const category of RAIL_CATEGORIES) { + const card = createRailSelectedOverlayEntry( + 'rail:capmetro-rail:101', + position, + copy, + category, + ); + assert.equal(card.accent, RAIL_CATEGORY_COLORS[category], category); + } + const card = createRailSelectedOverlayEntry( + 'rail:capmetro-rail:101', + position, + copy, + 'bus', + ); + assert.equal(card.accent, RAIL_CATEGORY_COLORS.local); + assert.equal(card.id, 'rail:capmetro-rail:101'); + assert.equal(card.position, position); + assert.equal(card.title, 'Route 550'); + assert.deepEqual(card.details, ['Local rail']); + assert.equal(card.selected, true); +}); + +test('no card without a key or a position', () => { + const copy = { title: '', details: [] }; + assert.equal( + createRailSelectedOverlayEntry('', () => null, copy), + null, + ); + assert.equal(createRailSelectedOverlayEntry('rail:a:1', null, copy), null); +}); + +/** Two stops as the schedule pack's tripStops() lists them. */ +const tripStopsNearYonkers = Object.freeze([ + Object.freeze({ + stopId: 'riv', + name: 'Riverdale', + lat: 40.9, + lon: -73.91, + state: 'passed', + expectedArrival: '14:23', + expectedDeparture: '14:24', + arrivalDelaySec: 180, + departureDelaySec: 180, + relative: 'T+3', + }), + Object.freeze({ + stopId: 'yon', + name: 'Yonkers', + lat: 40.93, + lon: -73.9, + state: 'next', + atStop: false, + expectedArrival: '14:32', + expectedDeparture: '14:33', + arrivalDelaySec: 200, + departureDelaySec: 200, + relative: 'T−4', + }), +]); + +test('with the trip’s stops the card names the next and previous stops', () => { + const { t } = fakeT(); + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, null, t, { + stops: tripStopsNearYonkers, + headsign: 'Poughkeepsie', + }); + assert.equal(copy.title, '🚆 T(Route CR-Worcester) · T(To Poughkeepsie)'); + assert.deepEqual(copy.details, [ + 'T(Local rail) · T(Train) · MBTA · Boston, MA', + 'T(Next Yonkers 14:32 T−4) T((+3 min late))', + 'T(Prev Riverdale 14:24 T+3)', + 'T(Reported 30 s ago)', + 'T(reported 72 km/h) · T(reported hdg 90°)', + 'T(Vehicle 1712) · Train 1712', + ]); + // The feed's bare stop id is not repeated beside the stop names. + assert.ok(copy.details.every((line) => !line.includes('place-sstat'))); +}); + +test('an on-time next stop has no lateness, and English reads plainly', () => { + const onTime = tripStopsNearYonkers.map((stop) => ({ + ...stop, + arrivalDelaySec: 20, + })); + const copy = buildRailSelectionCopy( + feed, + stampedTrain, + NOW, + NOW, + null, + undefined, + { + stops: onTime, + }, + ); + assert.equal(copy.details[1], 'Next Yonkers 14:32 T−4'); + assert.equal(copy.details[2], 'Prev Riverdale 14:24 T+3'); +}); + +test('the card never grows past six lines', () => { + const { t } = fakeT(); + const entry = { + sample: { + latestReportAgeMs: 5_000, + actualDelayMs: 30_000, + phase: 'playing', + motion: 'moving', + }, + }; + const busy = { ...stampedTrain, mode: 'metro', positionSource: 'gps' }; + const copy = buildRailSelectionCopy(feed, busy, NOW, NOW, entry, t, { + stops: tripStopsNearYonkers, + }); + assert.equal(copy.details.length, RAIL_CARD_MAX_LINES); + assert.equal(RAIL_CARD_MAX_LINES, 6); + // What stays: who, motion, next, previous, report age and the depth note; + // the reported speed and the vehicle line give way first. + assert.deepEqual(copy.details, [ + 'T(Local rail) · T(Subway) · MBTA · Boston, MA', + 'T(Moving) · T(Live GPS)', + 'T(Next Yonkers 14:32 T−4) T((+3 min late))', + 'T(Prev Riverdale 14:24 T+3)', + 'T(Reported 5 s ago) · T(shown 30 s behind)', + 'T(Shown at street level · depth not in the feed)', + ]); + // Without stops a full card also stays within the limit. + const noStops = buildRailSelectionCopy(feed, busy, NOW, NOW, entry, t); + assert.ok(noStops.details.length <= RAIL_CARD_MAX_LINES); +}); + +test('the card says where the position comes from', () => { + const { t } = fakeT(); + const words = { + gps: 'T(Live GPS)', + event: 'T(Estimated from station reports)', + }; + for (const [positionSource, word] of Object.entries(words)) { + const copy = buildRailSelectionCopy( + feed, + { ...stampedTrain, positionSource }, + NOW, + NOW, + null, + t, + ); + // No playback state: the source stands on its own line. + assert.equal(copy.details[1], word, positionSource); + } + // A source the layer does not draw, a timetable's included, is never + // named as one. + for (const positionSource of ['psychic', 'schedule']) { + const unknown = buildRailSelectionCopy( + feed, + { ...stampedTrain, positionSource }, + NOW, + NOW, + null, + t, + ); + assert.equal(unknown.details[1], 'T(Reported 30 s ago)', positionSource); + } +}); + +test('only live positions are drawn: GPS and station reports', () => { + assert.deepEqual([...RAIL_LIVE_POSITION_SOURCES], ['gps', 'event']); + for (const source of ['gps', 'event', undefined, null]) + assert.equal(isLiveRailPositionSource(source), true, String(source)); + for (const source of ['schedule', 'psychic', '']) + assert.equal(isLiveRailPositionSource(source), false, source); +}); + +test('without a route the title prefers the headsign, then the label', () => { + const { t } = fakeT(); + const lirr = { ...stampedTrain, routeId: null, id: '7259', label: '7259' }; + assert.equal( + buildRailSelectionCopy(feed, lirr, NOW, NOW, null, t).title, + '🚆 T(Vehicle 7259)', + ); + assert.equal( + buildRailSelectionCopy( + feed, + { ...lirr, label: 'Train 2715' }, + NOW, + NOW, + null, + t, + ).title, + '🚆 Train 2715', + ); + assert.equal( + buildRailSelectionCopy( + feed, + { ...lirr, headsign: 'Ronkonkoma' }, + NOW, + NOW, + null, + t, + ).title, + '🚆 T(To Ronkonkoma)', + ); + assert.equal( + buildRailSelectionCopy(feed, lirr, NOW, NOW, null, t, { + headsign: 'Babylon', + stops: [], + }).title, + '🚆 T(To Babylon)', + ); + assert.equal( + buildRailSelectionCopy(feed, { ...lirr, label: null }, NOW, NOW, null, t) + .title, + '🚆 T(Vehicle 7259)', + ); +}); + +test('the card names a platform stop by its station', () => { + const stops = tripStopsNearYonkers.map((stop) => ({ + ...stop, + name: `${stop.name} Platform 2`, + parentName: `${stop.name} Station`, + })); + const copy = buildRailSelectionCopy( + feed, + stampedTrain, + NOW, + NOW, + null, + undefined, + { + stops, + }, + ); + assert.equal(copy.details[1], 'Next Yonkers Station 14:32 T−4 (+3 min late)'); + assert.equal(copy.details[2], 'Prev Riverdale Station 14:24 T+3'); +}); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js new file mode 100644 index 000000000..eff1ace98 --- /dev/null +++ b/src/layers/rail/rendering.js @@ -0,0 +1,724 @@ +import * as Cesium from 'cesium'; +import { applyDisplayCourse, updatePlayback } from '../transit/movement.js'; +import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js'; +import { + COLD_CELL_FLUSH_MS, + COLD_CELL_RETRY_MS, + TRACK_MAX_GRADE, + TRACK_PRIOR_ABOVE_M, + TRACK_PRIOR_BELOW_M, + TRACK_SAMPLE_GRID, + TRACK_SAMPLE_MAX_CAMERA_M, + TRACK_SAMPLE_MAX_AGE_MS, + TRACK_SAMPLE_MAX_DIST_KM, + TRACK_SAMPLE_REFRESH_MS, + TRACK_SAMPLE_RETRY_MS, + TRACK_SAMPLES_PER_FLUSH, + TRACK_SETTLE_MPS, + TRACK_SNAP_M, + COLD_CELLS_PER_FLUSH, + FLOOR_CELLS_BURST, + FLOOR_CELLS_PER_MINUTE, + FLOOR_WARM_MAX_DIST_KM, + MARKER_PIXEL_SIZE, + RAIL_CATEGORY_COLORS, + SELECTED_PIXEL_SIZE, + UNSELECTED_TRAIN_ALPHA, +} from './policy.js'; +import { placeOnPath } from './pathBinding.js'; + +const SPRITE_PX = 64; +/** Billboard tints; the setter copies, so sharing one instance is safe. */ +const FULL_TINT = Cesium.Color.WHITE; +const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA); + +/** + * Draw one category/mode sprite: a filled disc in the category colour with a + * dark rim and a white glyph for what runs on the track (a train front, a + * tram with its pantograph, or the metro "M"). Drawn once per pair and reused. + * @param {string} category + * @param {string} mode + * @returns {HTMLCanvasElement} + */ +function drawSprite(category, mode) { + const canvas = document.createElement('canvas'); + canvas.width = SPRITE_PX; + canvas.height = SPRITE_PX; + const g = canvas.getContext('2d'); + const c = SPRITE_PX / 2; + g.beginPath(); + g.arc(c, c, c - 3, 0, Math.PI * 2); + g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local; + g.fill(); + g.lineWidth = 4; + g.strokeStyle = 'rgba(10, 14, 20, 0.85)'; + g.stroke(); + g.fillStyle = '#ffffff'; + g.strokeStyle = '#ffffff'; + if (mode === 'metro') { + g.font = 'bold 34px sans-serif'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText('M', c, c + 2); + return canvas; + } + // Train front: body, windscreen cut out, two lamps. + g.beginPath(); + g.roundRect(c - 13, c - 14, 26, 28, 6); + g.fill(); + g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local; + g.fillRect(c - 9, c - 9, 18, 9); + g.beginPath(); + g.arc(c - 7, c + 7, 2.5, 0, Math.PI * 2); + g.arc(c + 7, c + 7, 2.5, 0, Math.PI * 2); + g.fill(); + if (mode === 'tram') { + g.lineWidth = 3; + g.beginPath(); + g.moveTo(c - 6, c - 14); + g.lineTo(c, c - 22); + g.lineTo(c + 6, c - 14); + g.stroke(); + } + return canvas; +} + +/** + * Markers, ground height and the per-frame pass for one Rail layer. + * @param {object} context + * @returns {object} + */ +export function createRendering({ state, services, parts }) { + const { + governorRequestRender, + holdContinuousRender, + releaseContinuousRender, + } = services.render; + const { + cachedGroundFloor, + warmGroundFloor, + GROUND_FLOOR_LIFT_M, + neighborFloorM = null, + coarseFloorCoord = null, + } = services.ground; + const sprites = new Map(); + const terrainPoint = new Cesium.Cartographic(); + const scratchPosition = new Cesium.Cartesian3(); + /** Scratch for the on-line position; place() runs every frame. */ + const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN }; + + function spriteFor(category, mode) { + const id = `${category}:${mode}`; + if (!sprites.has(id)) sprites.set(id, drawSprite(category, mode)); + return sprites.get(id); + } + + /** Whether ground matters at this camera (Transit's gate, shared). */ + function nearGround() { + const height = state._viewer?.camera?.positionCartographic?.height; + return !Number.isFinite(height) || height <= HEIGHT_SAMPLE_MAX_ALTITUDE_M; + } + + /** Kilometres from the camera's subpoint, or Infinity without a camera. */ + function cameraKm(lat, lon) { + const carto = state._viewer?.camera?.positionCartographic; + if (!carto) return Infinity; + const camLat = Cesium.Math.toDegrees(carto.latitude); + const camLon = Cesium.Math.toDegrees(carto.longitude); + const kLon = Math.cos((camLat * Math.PI) / 180); + return Math.hypot(lat - camLat, (lon - camLon) * kLon) * 111.32; + } + + /** + * A number naming the cell of 1/`perDegree` degree that holds a point. A + * string key would be built for every moving train on every frame. + */ + function cellKey(lat, lon, perDegree) { + return ( + Math.round(lat * perDegree) * (362 * perDegree) + + Math.round(lon * perDegree) + ); + } + + /** + * Cells rail may still ask the floor service for: refilled at + * FLOOR_CELLS_PER_MINUTE, never holding more than FLOOR_CELLS_BURST. + */ + const floorBudget = { cells: FLOOR_CELLS_BURST, at: Date.now() }; + function takeFloorBudget(wanted) { + const now = Date.now(); + floorBudget.cells = Math.min( + FLOOR_CELLS_BURST, + floorBudget.cells + + ((now - floorBudget.at) / 60_000) * FLOOR_CELLS_PER_MINUTE, + ); + floorBudget.at = now; + const granted = Math.max( + 0, + Math.min(wanted, Math.floor(floorBudget.cells)), + ); + floorBudget.cells -= granted; + return granted; + } + /** + * Floor cells a train (or a stop of the selected trip) reached while they + * were cold, warmed in small batches off the frame path. `asked` + * remembers when a cell was last requested, so a frame loop over a cold + * cell does not re-request it. + */ + const coldCells = new Map(); + const asked = new Map(); + let coldTimer = null; + // Track heights on photorealistic tiles, sampled from the rendered mesh at + // points on the train's own line: the mesh under a draped line is the + // track bed or the deck. A floor cell's height is taken at the cell's + // centre, which can be a roof, a cutting's rim or a hillside well above + // the rails, so it is only the fallback. Keyed by bucket of + // 1/TRACK_SAMPLE_GRID degree; each sample keeps the camera height it was + // read from (`camM`, above the floor), when (`at`), and when it was last + // read again (`readAt`). + const trackSamples = new Map(); + const trackQueue = new Map(); + const trackAsked = new Map(); + const trackProbe = new Cesium.Cartographic(); + function cameraCartographic() { + return state._viewer?.camera?.positionCartographic ?? null; + } + /** + * Whether the photorealistic tiles have finished streaming for this view: + * there is a shown tileset, and every shown one reports its tiles loaded. + */ + function tilesLoaded(scene) { + const primitives = scene.primitives; + let found = false; + for (let i = 0; i < (primitives?.length || 0); i++) { + const primitive = primitives.get(i); + if (!primitive?.show || typeof primitive.tilesLoaded !== 'boolean') + continue; + if (!primitive.tilesLoaded) return false; + found = true; + } + return found; + } + function queueTrackSample(lat, lon, key, priority) { + const now = Date.now(); + if (now - (trackAsked.get(key) ?? -Infinity) < TRACK_SAMPLE_RETRY_MS) + return; + // The floor under it is the gross check against coarse tiles; with no + // floor yet, wait (the cold-cell queue is warming it). From higher up, + // the tiles near the camera are coarse. + const prior = cachedGroundFloor(lat, lon); + const height = cameraCartographic()?.height; + if ( + !Number.isFinite(prior) || + !Number.isFinite(height) || + height - prior > TRACK_SAMPLE_MAX_CAMERA_M + ) + return; + if (trackAsked.size > 8192) trackAsked.clear(); + trackAsked.set(key, now); + trackQueue.set(key, { lat, lon, priority }); + coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS); + } + function flushTrackSamples() { + const scene = state._viewer?.scene; + const carto = cameraCartographic(); + if ( + !trackQueue.size || + typeof scene?.sampleHeight !== 'function' || + !Number.isFinite(carto?.height) || + !tilesLoaded(scene) + ) { + trackQueue.clear(); + return; + } + const camLat = Cesium.Math.toDegrees(carto.latitude); + const camLon = Cesium.Math.toDegrees(carto.longitude); + const kLon = Math.cos((camLat * Math.PI) / 180); + const excludeObjects = state._markers ? [state._markers] : []; + const now = performance.now(); + // The selected train first, then the rest in the order they asked. + const items = [...trackQueue].sort( + (a, b) => Number(b[1].priority) - Number(a[1].priority), + ); + trackQueue.clear(); + let sampled = 0; + for (const [key, point] of items) { + if (sampled >= TRACK_SAMPLES_PER_FLUSH) break; + const dKm = + Math.hypot(point.lat - camLat, (point.lon - camLon) * kLon) * 111.32; + if (dKm > TRACK_SAMPLE_MAX_DIST_KM) continue; + const prior = cachedGroundFloor(point.lat, point.lon); + if (!Number.isFinite(prior)) continue; + const camM = carto.height - prior; + if (camM > TRACK_SAMPLE_MAX_CAMERA_M) continue; + let height; + try { + sampled += 1; + Cesium.Cartographic.fromDegrees(point.lon, point.lat, 0, trackProbe); + height = scene.sampleHeight(trackProbe, excludeObjects); + } catch { + continue; + } + const kept = trackSamples.get(key); + if (kept) kept.readAt = now; + if ( + !Number.isFinite(height) || + height < prior - TRACK_PRIOR_BELOW_M || + height > prior + TRACK_PRIOR_ABOVE_M + ) + continue; + // A reading from higher up saw coarser tiles: it replaces one read + // from lower down only once that one is old. + if (kept) { + if (camM > kept.camM && now - kept.at < TRACK_SAMPLE_MAX_AGE_MS) + continue; + kept.h = height; + kept.camM = camM; + kept.at = now; + } else trackSamples.set(key, { h: height, camM, at: now, readAt: now }); + } + if (trackSamples.size > 50_000) trackSamples.clear(); + if (sampled) governorRequestRender('rail-track-height'); + } + function flushColdCells() { + coldTimer = null; + const queued = [...coldCells.values()].sort( + (a, b) => Number(b.priority) - Number(a.priority) || a.km - b.km, + ); + coldCells.clear(); + const granted = takeFloorBudget( + Math.min(queued.length, COLD_CELLS_PER_FLUSH), + ); + // Cells left over are asked for again after COLD_CELL_RETRY_MS. + if (granted) warmGroundFloor(queued.slice(0, granted)); + flushTrackSamples(); + } + /** + * Queue a cold floor cell for warming. Only cells within + * FLOOR_WARM_MAX_DIST_KM of the camera are, unless `anyDistance` (the + * selected trip's stops, which are drawn wherever the camera is). + */ + function queueColdCell(lat, lon, priority = false, anyDistance = false) { + if (!nearGround()) return; + const km = cameraKm(lat, lon); + if (km > FLOOR_WARM_MAX_DIST_KM && !anyDistance) return; + const key = cellKey(lat, lon, 1000); + const now = Date.now(); + if (now - (asked.get(key) ?? -Infinity) < COLD_CELL_RETRY_MS) return; + if (asked.size > 4096) asked.clear(); + asked.set(key, now); + coldCells.set(key, { lat, lon, km, priority }); + coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS); + } + + /** Layer disabled: drop the queued work and the samples taken so far. */ + function reset() { + clearTimeout(coldTimer); + coldTimer = null; + coldCells.clear(); + asked.clear(); + trackQueue.clear(); + trackAsked.clear(); + trackSamples.clear(); + } + + /** floorHeight's answer, reused by every call: read it before the next. */ + const floorAnswer = { h: 0, q: 0 }; + function answer(h, q) { + floorAnswer.h = h; + floorAnswer.q = q; + return floorAnswer; + } + + /** + * Height to draw a train at: the rendered terrain under it on a terrain + * globe, else the shared ground floor (which tracks the photorealistic mesh + * where one is loaded). Where that cell is not warm yet, a neighbouring + * cell's floor, else the floor this train last stood on, while the cell is + * warmed: a train running onto unsampled ground keeps its height instead of + * dropping to the ellipsoid, which lies tens to hundreds of metres under + * the photorealistic mesh (about 150 m around Chicago). Only a train with + * no floor yet, or a stop, stands on the ellipsoid, until its cell is warm. + * @param {number} lat + * @param {number} lon + * @param {{floorM?: number, key?: string}|null} [entry] the train, which + * keeps its floor + * @param {boolean} [stop] a stop of the selected trip: its cell is warmed + * first, however far from the camera + * @returns {{h: number, q: number}} The height, and how good it is: 3 the + * rendered terrain, 2 the cell's floor, 1 a neighbouring cell's or the + * train's last floor, 0 the ellipsoid. The same object every call. + */ + /** The last coarse cell asked for a stop (stops have no entry). */ + const stopCell = { kLat: NaN, kLon: NaN, cell: null }; + + /** + * The point's coarse floor cell, kept on the train (and once for stops) + * while the point stays in the same 0.001° cell: a train gliding across a + * cold cell asks the floor service for it once, not every frame. + * @param {number} lat + * @param {number} lon + * @param {object|null} entry + * @returns {{lat: number, lon: number}} + */ + function coarseCellOf(lat, lon, entry) { + const memo = entry + ? (entry.coarseCell ??= { kLat: NaN, kLon: NaN, cell: null }) + : stopCell; + const kLat = Math.round(lat * 1000); + const kLon = Math.round(lon * 1000); + if (memo.kLat !== kLat || memo.kLon !== kLon) { + memo.kLat = kLat; + memo.kLon = kLon; + memo.cell = coarseFloorCoord(lat, lon); + } + return memo.cell; + } + + function floorHeight(lat, lon, entry = null, stop = false) { + const globe = state._viewer?.scene?.globe; + if (globe?.show === true) { + Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); + const rendered = globe.getHeight?.(terrainPoint); + if (Number.isFinite(rendered)) + return answer(rendered + GROUND_FLOOR_LIFT_M, 3); + } + const floor = cachedGroundFloor(lat, lon); + if (Number.isFinite(floor)) { + if (entry) entry.floorM = floor; + return answer(floor + GROUND_FLOOR_LIFT_M, 2); + } + queueColdCell( + lat, + lon, + stop || (entry !== null && entry.key === state._selectedKey), + stop, + ); + const near = + neighborFloorM && coarseFloorCoord + ? neighborFloorM(coarseCellOf(lat, lon, entry)) + : null; + if (Number.isFinite(near)) return answer(near + GROUND_FLOOR_LIFT_M, 1); + return Number.isFinite(entry?.floorM) + ? answer(entry.floorM + GROUND_FLOOR_LIFT_M, 1) + : answer(0, 0); + } + function heightFor(lat, lon, entry = null) { + return floorHeight(lat, lon, entry).h; + } + + /** + * Where a stop of the selected trip stands, and how good that height is + * (floorHeight's quality): a stop read before its cell was warm is read + * again until it stands on a real floor (routeDisplay.js). + * @param {number} lat + * @param {number} lon + * @returns {{h: number, q: number}} + */ + function stopHeight(lat, lon) { + const { h, q } = floorHeight(lat, lon, null, true); + return { h, q }; + } + + /** + * The height a train is drawn at. Its target is the track sample under it + * on photorealistic tiles (else the floor chain of heightFor), and the + * drawn height moves toward it no faster than a railway climbs: + * TRACK_MAX_GRADE of the distance moved, plus TRACK_SETTLE_MPS over time. + * A single high sample (a roof, an overpass) is a bump, cell steps are + * ramps, and a followed camera rides smoothly. The drawn height snaps when + * a better source replaces a fallback (quality 0-1 to 2-3, or any floor + * replacing the ellipsoid, 0 to 1), and after a jump of more than + * TRACK_SNAP_M. + * @param {object} entry The train. + * @param {number} lat + * @param {number} lon + * @returns {number} + */ + function displayHeightFor(entry, lat, lon) { + const globe = state._viewer?.scene?.globe; + const now = performance.now(); + let targetH = NaN; + let targetQ = 0; + if (globe?.show !== true && nearGround()) { + const key = cellKey(lat, lon, TRACK_SAMPLE_GRID); + const sampled = trackSamples.get(key); + if (sampled) { + targetH = sampled.h + GROUND_FLOOR_LIFT_M; + targetQ = 3; + } + // A sample is read again now and then: from a lower camera, it + // replaces one read from coarser tiles. + if (!sampled || now - sampled.readAt >= TRACK_SAMPLE_REFRESH_MS) + queueTrackSample(lat, lon, key, entry.key === state._selectedKey); + } + if (!Number.isFinite(targetH)) { + const floor = floorHeight(lat, lon, entry); + targetH = floor.h; + targetQ = floor.q; + } + const prev = entry.drawnHeight; + let h = targetH; + if (prev && Number.isFinite(prev.h)) { + const kLon = Math.cos((lat * Math.PI) / 180); + const moved = + Math.hypot(lat - prev.lat, (lon - prev.lon) * kLon) * 111_320; + const better = + (prev.q === 0 && targetQ > 0) || (prev.q <= 1 && targetQ >= 2); + if (moved <= TRACK_SNAP_M && !better) { + const dt = Math.max(0, (now - prev.at) / 1000); + const step = TRACK_MAX_GRADE * moved + TRACK_SETTLE_MPS * dt; + h = prev.h + Math.max(-step, Math.min(step, targetH - prev.h)); + } + } + // Written in place: this runs for every moving train on every frame. + if (prev) { + prev.h = h; + prev.q = targetQ; + prev.lat = lat; + prev.lon = lon; + prev.at = now; + } else entry.drawnHeight = { h, q: targetQ, lat, lon, at: now }; + return h; + } + + /** Ask the floor service for the cells under one feed's trains; off the frame path. */ + function warmFloors(feedId) { + for (const entry of state._trains.values()) { + if (entry.feedId !== feedId) continue; + // A parked train's height was read when it was placed; floors warmed + // since (or finer terrain tiles) land on it here, once per poll. Moving + // trains re-read their height every frame already. + if (entry.marker && !state._moving.has(entry)) place(entry); + const { lat, lon } = entry.record; + if (!Number.isFinite(cachedGroundFloor(lat, lon))) + queueColdCell(lat, lon, entry.key === state._selectedKey); + } + } + + /** + * Whether a train is drawn: its category is on, and it is not a copy of a + * train another feed draws better (dedupe.js). + */ + function categoryShown(entry) { + return ( + state._categories[entry.category] !== false && entry.duplicate !== true + ); + } + + /** + * A train bound to its railway line is drawn on the line, at the arc its + * playback has reached; any other train (or a bound train whose reports do + * not fit the line) is drawn on the time-interpolated straight-line point. + */ + function place(entry) { + const sample = entry.sample; + const onPath = + entry.path && placeOnPath(entry.path, sample, entry.segment, pathPoint); + const lat = onPath + ? pathPoint.lat + : Number.isFinite(sample?.lat) + ? sample.lat + : entry.record.lat; + const lon = onPath + ? pathPoint.lon + : Number.isFinite(sample?.lon) + ? sample.lon + : entry.record.lon; + // Write into scratch, never into `marker.position`: the getter returns the + // billboard's own vector, so filling it in place makes the setter see an + // unchanged position and skip the update that actually moves the sprite. + entry.marker.position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + displayHeightFor(entry, lat, lon), + undefined, + scratchPosition, + ); + entry.marker.show = categoryShown(entry); + } + + /** While a train is selected, every other train is drawn see-through. */ + function tintFor(entry) { + return state._selectedKey !== null && entry.key !== state._selectedKey + ? DIM_TINT + : FULL_TINT; + } + + function paint(entry) { + if (!entry.marker) return; + const selected = entry.key === state._selectedKey; + const size = selected ? SELECTED_PIXEL_SIZE : MARKER_PIXEL_SIZE; + entry.marker.image = spriteFor(entry.category, entry.railMode); + entry.marker.width = size; + entry.marker.height = size; + entry.marker.color = tintFor(entry); + entry.marker.show = categoryShown(entry); + } + + /** Re-tint every train after the selection appeared, moved or cleared. */ + function paintAll() { + for (const entry of state._trains.values()) paint(entry); + } + + function addMarker(entry) { + const marker = state._markers.add({ + id: entry.key, + position: Cesium.Cartesian3.ZERO, + image: spriteFor(entry.category, entry.railMode), + width: MARKER_PIXEL_SIZE, + height: MARKER_PIXEL_SIZE, + color: tintFor(entry), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + entry.marker = marker; + place(entry); + return marker; + } + + function cancelWake(entry) { + if (entry?.wakeTimer) clearTimeout(entry.wakeTimer); + if (entry) entry.wakeTimer = null; + } + + /** + * Track whether a train is mid-glide, so the frame pass walks only those. A + * train parked at a fix whose next segment starts later is woken by a timer + * at that boundary, not by polling every frame. + */ + function schedulePlayback(entry) { + cancelWake(entry); + if (!entry.marker) return; + place(entry); + const sample = entry.sample; + const moving = + categoryShown(entry) && + sample?.phase === 'playing' && + sample.segmentSpeedMps > 0; + if (moving) { + state._moving.add(entry); + return; + } + state._moving.delete(entry); + if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { + entry.wakeTimer = setTimeout( + () => { + entry.wakeTimer = null; + if (!state._enabled || !state._trains.has(entry.key)) return; + updatePlayback(entry, Date.now(), performance.now()); + schedulePlayback(entry); + syncRenderHold(); + governorRequestRender('rail-motion-boundary'); + }, + Math.max(1, sample.nextWakeMonoMs - performance.now()), + ); + } + } + + function syncRenderHold() { + const working = state._enabled && state._moving.size > 0; + if (working && !state._renderHeld) { + holdContinuousRender('rail'); + state._renderHeld = true; + } else if (!working && state._renderHeld) { + releaseContinuousRender('rail'); + state._renderHeld = false; + governorRequestRender('rail-settled'); + } + } + + const horizon = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + const surfaceScratch = new Cesium.Cartesian3(); + + /** Whether a point on the Earth is on the camera's side of it. */ + function facesCamera(position) { + const viewer = state._viewer; + const cameraPosition = viewer?.camera?.positionWC; + if ( + !position || + !cameraPosition || + viewer.scene?.mode !== Cesium.SceneMode.SCENE3D + ) + return true; + const surface = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( + position, + surfaceScratch, + ); + if (!surface) return true; + horizon.cameraPosition = cameraPosition; + return horizon.isPointVisible(surface); + } + + /** + * Markers are drawn over the terrain, never behind it, and the selected + * train is drawn at any distance (viewport.js): from far out, the globe + * would not hide it on its far side, so this pass does, with its stops. + */ + function cullSelected() { + const entry = state._trains.get(state._selectedKey); + if (!entry?.marker) return; + const shown = categoryShown(entry) && facesCamera(entry.marker.position); + if (entry.marker.show !== shown) entry.marker.show = shown; + parts.routes.cullStops(facesCamera); + } + + function onPreRender() { + advanceMoving(); + if (state._enabled && state._selectedKey !== null) cullSelected(); + } + + function advanceMoving() { + if (!state._enabled || state._moving.size === 0) { + syncRenderHold(); + return; + } + if (globalThis.document?.visibilityState === 'hidden') return; + const now = Date.now(); + const monoNow = performance.now(); + for (const entry of state._moving) { + if (!entry.marker) { + state._moving.delete(entry); + continue; + } + updatePlayback(entry, now, monoNow); + applyDisplayCourse(entry, monoNow); + place(entry); + if ( + entry.sample.phase !== 'playing' || + entry.sample.segmentSpeedMps === 0 + ) + schedulePlayback(entry); + } + syncRenderHold(); + } + + /** Re-apply category visibility to every marker after a toggle. */ + function applyCategoryVisibility() { + for (const entry of state._trains.values()) { + if (!entry.marker) continue; + entry.marker.show = categoryShown(entry); + schedulePlayback(entry); + } + syncRenderHold(); + parts.routes.applyNetwork(); + governorRequestRender('rail-categories'); + } + + return { + addMarker, + paint, + paintAll, + heightFor, + stopHeight, + displayHeightFor, + cancelWake, + schedulePlayback, + syncRenderHold, + onPreRender, + warmFloors, + applyCategoryVisibility, + categoryShown, + reset, + }; +} diff --git a/src/layers/rail/routeDisplay.js b/src/layers/rail/routeDisplay.js new file mode 100644 index 000000000..283d19295 --- /dev/null +++ b/src/layers/rail/routeDisplay.js @@ -0,0 +1,788 @@ +import * as Cesium from 'cesium'; +import { classifyTrain, getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { + FEED_RELEASE_AFTER_MS, + NETWORK_ALPHA, + NETWORK_FULL_BELOW_M, + NETWORK_HIDDEN_ABOVE_M, + NETWORK_SELECTED_DIM, + NETWORK_WIDTH_PX, + RAIL_CATEGORY_COLORS, + ROUTE_AHEAD_WIDTH_PX, + ROUTE_SPLIT_INTERVAL_MS, + ROUTE_TRAVELLED_ALPHA, + STOP_REFRESH_MS, +} from './policy.js'; +import { + networkAltitudeFade, + railNetworkLineSteps, + splitFraction, +} from './routeLines.js'; +import { + RAIL_STOP_OVERLAY_SOURCE_ID, + RAIL_STOP_OVERLAY_SOURCE_OPTIONS, + createStopOverlayEntries, + selectStopLabels, +} from './stopLabels.js'; + +/** Longest slice of network building per task, so a frame is never held (ms). */ +const NETWORK_SLICE_MS = 6; +/** The fade is applied in steps, so a slow zoom does not rewrite it per event. */ +const FADE_STEP = 0.05; +/** A split that moved less than this share of the line is not redrawn. */ +const SPLIT_EPSILON = 1e-5; +/** Arc a route-bound train must cover before its direction is trusted (m). */ +const DIRECTION_EVIDENCE_M = 60; +const STOP_DOT_PX = 5; +const STOP_LABELLED_DOT_PX = 8; +const STOP_DOT_OUTLINE = Cesium.Color.fromCssColorString( + 'rgba(10, 14, 20, 0.9)', +); + +/** + * The selected line's material: solid ahead of the train, dimmer behind. + */ +function createSplitMaterial() { + return new Cesium.Material({ + fabric: { + type: 'RailRouteSplit', + uniforms: { + aheadColor: new Cesium.Color(1, 1, 1, 1), + travelledColor: new Cesium.Color(1, 1, 1, ROUTE_TRAVELLED_ALPHA), + splitAt: -1, + direction: 1, + }, + source: `czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material m = czm_getDefaultMaterial(materialInput); + float s = materialInput.st.s; + bool travelled = direction > 0.0 ? s < splitAt : s > splitAt; + vec4 c = travelled ? travelledColor : aheadColor; + // A dark rim keeps the line legible over bright imagery. + bool rim = abs(materialInput.st.t - 0.5) > 0.36; + m.diffuse = czm_gammaCorrect(rim ? c.rgb * 0.22 : c.rgb); + m.alpha = c.a; + return m; + }`, + }, + translucent: true, + }); +} + +/** + * Route lines for one Rail layer: the faint network of every active feed + * (built once per path pack), the selected train's line split where the train + * is, and the selected trip's stops as dots and labels. + * + * Nothing here runs per frame. The network's opacity follows the camera on + * camera events; the split and the stops follow the train on a timer that + * only runs while a train is selected. + * + * @param {object} context + * @returns {object} + */ +export function createRouteDisplay({ + state, + services, + parts, + stops, + timetable, +}) { + const { governorRequestRender } = services.render; + let groundSupported = null; + /** feedId → when the feed was last seen in use (ms). */ + const feedSeenAt = new Map(); + /** Releases idle feeds while the layer is off. */ + let releaseTimer = null; + let stopPoints = null; + let timer = null; + let ticks = 0; + + function scene() { + return state._viewer?.scene || null; + } + + function useGround() { + const s = scene(); + if (!s) return false; + if (groundSupported === null) + groundSupported = + !!s.frameState?.context && + Cesium.GroundPolylinePrimitive.isSupported(s); + return groundSupported; + } + + /** Positions for a flat `[lat, lon, ...]` run, with consecutive repeats dropped. */ + function positionsOf(coords) { + const out = []; + for (let i = 0; i + 1 < coords.length; i += 2) { + const point = Cesium.Cartesian3.fromDegrees(coords[i + 1], coords[i], 0); + if ( + out.length && + Cesium.Cartesian3.equalsEpsilon( + out[out.length - 1], + point, + Cesium.Math.EPSILON7, + ) + ) + continue; + out.push(point); + } + return out; + } + + /** One batched primitive of polylines, ground-clamped where the GPU allows. */ + function linePrimitive(runs, width, material) { + const ground = useGround(); + const instances = []; + for (const positions of runs) { + if (positions.length < 2) continue; + instances.push( + new Cesium.GeometryInstance({ + geometry: ground + ? new Cesium.GroundPolylineGeometry({ positions, width }) + : new Cesium.PolylineGeometry({ + positions, + width, + arcType: Cesium.ArcType.NONE, + vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + }), + }), + ); + } + if (!instances.length) return null; + const appearance = new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }); + const primitive = ground + ? new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances, + appearance, + classificationType: Cesium.ClassificationType.BOTH, + allowPicking: false, + asynchronous: true, + }) + : new Cesium.Primitive({ + geometryInstances: instances, + appearance, + // Off the terrain, a line under a hill still shows through it. + depthFailAppearance: new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }), + allowPicking: false, + asynchronous: false, + }); + return { + primitive, + collection: ground ? scene().groundPrimitives : scene().primitives, + ground, + }; + } + + function removePrimitive(slot) { + if (!slot?.primitive) return; + slot.collection?.remove(slot.primitive); + slot.primitive = null; + } + + // --------------------------------------------------------------------------- + // Network + + /** + * Start building a feed's network from its decoded pack, in slices between + * frames. Called once per pack by ingestion. + * @param {string} feedId + * @param {object} raw The pack's JSON body. + */ + function buildNetwork(feedId, raw) { + const previous = state._networks.get(feedId); + if (previous) dropNetwork(feedId); + const feed = getRegisteredRailFeed(feedId); + const categoryOf = (routeId) => + feed ? classifyTrain(feed, routeId)?.category || null : null; + const network = { + feedId, + status: 'building', + steps: railNetworkLineSteps(raw, categoryOf), + timer: null, + layers: new Map(), + stats: null, + }; + state._networks.set(feedId, network); + const slice = () => { + network.timer = null; + if (state._networks.get(feedId) !== network) return; + const started = performance.now(); + for (;;) { + const next = network.steps.next(); + if (next.done) { + network.steps = null; + finishNetwork(network, next.value); + return; + } + if (performance.now() - started > NETWORK_SLICE_MS) break; + } + network.timer = setTimeout(slice, 0); + }; + network.timer = setTimeout(slice, 0); + } + + function finishNetwork(network, result) { + network.status = 'ready'; + network.stats = { + shapes: result.shapesIn, + pointsIn: result.pointsIn, + lines: result.lines.length, + points: result.pointsOut, + }; + if (!scene()) return; + const byCategory = new Map(); + for (const line of result.lines) { + if (!byCategory.has(line.category)) byCategory.set(line.category, []); + byCategory.get(line.category).push(positionsOf(line.coords)); + } + for (const [category, runs] of byCategory) { + const color = Cesium.Color.fromCssColorString( + RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local, + ); + const material = Cesium.Material.fromType('Color', { + color: color.withAlpha(0), + }); + const slot = linePrimitive(runs, NETWORK_WIDTH_PX, material); + if (!slot) continue; + slot.primitive.show = false; + slot.collection.add(slot.primitive); + network.layers.set(category, { + ...slot, + material, + base: color, + alpha: -1, + }); + } + applyNetwork(); + } + + function dropNetwork(feedId) { + const network = state._networks.get(feedId); + if (!network) return; + clearTimeout(network.timer); + network.timer = null; + network.steps = null; + for (const layer of network.layers.values()) removePrimitive(layer); + network.layers.clear(); + state._networks.delete(feedId); + } + + /** + * Show each feed's network while the feed is active, the category is on and + * the camera is low enough, at an opacity set by altitude and selection. + */ + function applyNetwork() { + const fade = state._networkFade; + const selected = state._selectedKey !== null; + let changed = false; + for (const network of state._networks.values()) { + // A feed held only for the selected train shows that train's line alone. + const active = + state._enabled && + state._activeFeeds.has(network.feedId) && + network.feedId !== state._heldFeedId; + for (const [category, layer] of network.layers) { + const alpha = + active && state._categories[category] !== false + ? NETWORK_ALPHA * fade * (selected ? NETWORK_SELECTED_DIM : 1) + : 0; + const show = alpha > 0.001; + if (layer.primitive.show !== show) { + layer.primitive.show = show; + changed = true; + } + if (show && Math.abs(layer.alpha - alpha) > 0.001) { + layer.material.uniforms.color = layer.base.withAlpha(alpha); + layer.alpha = alpha; + changed = true; + } + } + } + if (changed) governorRequestRender('rail-network'); + } + + /** Camera moved: recompute the altitude fade, applying it only on a step. */ + function onCamera() { + const altitude = parts.viewport.cameraAltitude(); + const raw = networkAltitudeFade( + altitude, + NETWORK_FULL_BELOW_M, + NETWORK_HIDDEN_ABOVE_M, + ); + const fade = Math.round(raw / FADE_STEP) * FADE_STEP; + if (fade === state._networkFade) return; + state._networkFade = fade; + applyNetwork(); + } + + // --------------------------------------------------------------------------- + // Selected route + + /** + * The shape the selected train runs on: its binding, else its route's + * longest. + */ + function routeShapeFor(entry) { + const pack = state._pathPacks.get(entry.feedId)?.pack; + if (entry.path?.path) + return { + shapeIndex: entry.path.shapeIndex, + path: entry.path.path, + binding: entry.path, + }; + const routeId = entry.record?.routeId; + if (!pack || !routeId) return null; + let best = null; + for (const index of pack.shapesForRoute(routeId)) { + const path = pack.pathAt(index); + if (path && (!best || path.length > best.path.length)) + best = { shapeIndex: index, path, binding: null }; + } + return best; + } + + function dropRoute() { + const route = state._route; + if (!route) return; + removePrimitive(route); + state._route = null; + } + + /** + * Draw (or keep) the selected train's line. Rebuilt only when the train + * moves to another shape; the split is a material uniform. + */ + function syncRoute() { + const entry = state._trains.get(state._selectedKey); + const shape = entry && state._enabled ? routeShapeFor(entry) : null; + if (!shape || !scene()) { + if (state._route) { + dropRoute(); + governorRequestRender('rail-route'); + } + return; + } + const id = `${entry.key}\n${entry.feedId}\n${shape.shapeIndex}`; + if (state._route?.id !== id) { + dropRoute(); + const { path } = shape; + const positions = []; + // Cumulative drawn length per path vertex. A ground line's texture + // coordinate runs by length along these chords; a plain polyline's runs + // by vertex, so there it counts the vertices Cesium keeps. + const ground = useGround(); + const chordAt = new Float64Array(path.n); + for (let i = 0; i < path.n; i++) { + const point = Cesium.Cartesian3.fromDegrees( + path.lon[i], + path.lat[i], + 0, + ); + const last = positions[positions.length - 1]; + const repeat = + last && + Cesium.Cartesian3.equalsEpsilon(last, point, Cesium.Math.EPSILON7); + if (i > 0) + chordAt[i] = + chordAt[i - 1] + + (repeat ? 0 : ground ? Cesium.Cartesian3.distance(last, point) : 1); + if (!repeat) positions.push(point); + } + const material = createSplitMaterial(); + const color = Cesium.Color.fromCssColorString( + RAIL_CATEGORY_COLORS[entry.category] || RAIL_CATEGORY_COLORS.local, + ); + material.uniforms.aheadColor = color.withAlpha(1); + material.uniforms.travelledColor = color.withAlpha(ROUTE_TRAVELLED_ALPHA); + const slot = linePrimitive([positions], ROUTE_AHEAD_WIDTH_PX, material); + if (!slot) return; + slot.collection.add(slot.primitive); + state._route = { + ...slot, + id, + material, + path, + chordAt, + binding: shape.binding, + // A trip's own shape runs the way the trip does; a route's shape may + // be drawn either way, so its direction is learnt from the train. + direction: shape.binding?.via === 'trip' ? 1 : 0, + firstS: shape.binding ? shape.binding.drawS : NaN, + fraction: NaN, + }; + governorRequestRender('rail-route'); + } else if (state._route.binding !== shape.binding) { + // Rebound to the same shape (a new trip on it): follow the new binding. + state._route.binding = shape.binding; + state._route.firstS = shape.binding ? shape.binding.drawS : NaN; + if (shape.binding?.via !== 'trip') state._route.direction = 0; + } + updateSplit(); + } + + /** Move the travelled/ahead split to where the train is drawn now. */ + function updateSplit() { + const route = state._route; + if (!route?.primitive) return; + const binding = route.binding; + const s = binding ? binding.drawS : NaN; + if (route.direction === 0 && Number.isFinite(s)) { + const moved = s - route.firstS; + if (Math.abs(moved) >= DIRECTION_EVIDENCE_M) + route.direction = Math.sign(moved); + } + const fraction = + route.direction !== 0 && Number.isFinite(s) + ? splitFraction(route.path, route.chordAt, s) + : NaN; + const uniforms = route.material.uniforms; + if (!Number.isFinite(fraction)) { + // No position on the line yet: draw it all as ahead. + if (uniforms.splitAt !== -1 || uniforms.direction !== 1) { + uniforms.splitAt = -1; + uniforms.direction = 1; + governorRequestRender('rail-route-split'); + } + route.fraction = NaN; + return; + } + if ( + Number.isFinite(route.fraction) && + Math.abs(fraction - route.fraction) < SPLIT_EPSILON && + uniforms.direction === route.direction + ) + return; + route.fraction = fraction; + uniforms.splitAt = fraction; + uniforms.direction = route.direction; + governorRequestRender('rail-route-split'); + } + + // --------------------------------------------------------------------------- + // Stops + + function stopPointsCollection() { + if (!stopPoints && scene()) { + stopPoints = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + scene().primitives.add(stopPoints); + } + return stopPoints; + } + + function clearStops() { + stopPoints?.removeAll(); + state._trip = null; + state._stopsSignature = ''; + state._stopLabelsSignature = ''; + state._stopAnchors.clear(); + state._overlayHost.setEntries( + RAIL_STOP_OVERLAY_SOURCE_ID, + [], + RAIL_STOP_OVERLAY_SOURCE_OPTIONS, + ); + } + + function anchorFor(stop) { + const key = `${stop.stopId ?? ''}\n${stop.lat}\n${stop.lon}`; + let anchor = state._stopAnchors.get(key); + if (!anchor) { + const { h, q } = parts.rendering.stopHeight(stop.lat, stop.lon); + anchor = { + lat: stop.lat, + lon: stop.lon, + h, + q, + position: Cesium.Cartesian3.fromDegrees(stop.lon, stop.lat, h), + }; + state._stopAnchors.set(key, anchor); + } + return anchor.position; + } + + /** + * Stops placed before their floor cell was warm stand on a fallback (the + * ellipsoid at worst, which is drawn over the terrain far off its station + * from a low camera): each refresh reads those again, and once one stands + * better its dot and label are drawn again there. + */ + function settleAnchors() { + let moved = false; + for (const anchor of state._stopAnchors.values()) { + if (anchor.q >= 2) continue; + const { h, q } = parts.rendering.stopHeight(anchor.lat, anchor.lon); + if (q < anchor.q || (q === anchor.q && Math.abs(h - anchor.h) < 1)) + continue; + anchor.h = h; + anchor.q = q; + anchor.position = Cesium.Cartesian3.fromDegrees( + anchor.lon, + anchor.lat, + h, + ); + moved = true; + } + if (moved) { + state._stopsSignature = ''; + state._stopLabelsSignature = ''; + } + } + + /** + * Hide the stop dots on the far side of the Earth. They are drawn over the + * terrain, and the selected trip is drawn at any distance (viewport.js), so + * the rendering pass hands over its horizon test every frame. + * @param {(position: object) => boolean} facesCamera + */ + function cullStops(facesCamera) { + if (!stopPoints) return; + for (let i = 0; i < stopPoints.length; i++) { + const point = stopPoints.get(i); + const shown = facesCamera(point.position); + if (point.show !== shown) point.show = shown; + } + } + + /** Ask the provider for the selected trip's stops and redraw what changed. */ + function refreshStops() { + const entry = state._trains.get(state._selectedKey); + if (!entry || !state._enabled || typeof stops?.stopsFor !== 'function') { + if (state._trip || state._stopsSignature) clearStops(); + return; + } + let trip = null; + try { + trip = stops.stopsFor( + { + feedId: entry.feedId, + tripId: entry.record?.tripId || null, + routeId: entry.record?.routeId || null, + record: entry.record, + }, + Date.now(), + ); + } catch (error) { + console.warn('[Data:Rail] stops unavailable:', error?.message || error); + } + const list = Array.isArray(trip?.stops) ? trip.stops : []; + state._trip = list.length || trip?.headsign ? trip : null; + settleAnchors(); + const { labels, dots } = selectStopLabels(list); + const color = Cesium.Color.fromCssColorString( + RAIL_CATEGORY_COLORS[entry.category] || RAIL_CATEGORY_COLORS.local, + ); + + const signature = `${entry.key}|${labels.map((l) => l.index).join(',')}|${dots + .map((d) => `${d.index}${d.passed ? 'p' : ''}`) + .join(',')}`; + if (signature !== state._stopsSignature) { + state._stopsSignature = signature; + const points = stopPointsCollection(); + if (points) { + points.removeAll(); + const add = (stop, passed, size) => + points.add({ + position: anchorFor(stop), + pixelSize: size, + color: passed ? color.withAlpha(0.45) : color, + outlineColor: STOP_DOT_OUTLINE, + outlineWidth: 1.5, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + for (const dot of dots) add(dot.stop, dot.passed, STOP_DOT_PX); + for (const label of labels) + add(label.stop, label.stop.state === 'passed', STOP_LABELLED_DOT_PX); + } + governorRequestRender('rail-stops'); + } + + const entries = createStopOverlayEntries(entry.key, labels, anchorFor); + const text = JSON.stringify( + entries.map((item) => [item.id, item.title, item.details, item.accent]), + ); + if (text !== state._stopLabelsSignature) { + state._stopLabelsSignature = text; + state._overlayHost.setEntries( + RAIL_STOP_OVERLAY_SOURCE_ID, + entries, + RAIL_STOP_OVERLAY_SOURCE_OPTIONS, + ); + } + } + + // --------------------------------------------------------------------------- + // Selection ticker + + function tick() { + if (globalThis.document?.visibilityState === 'hidden') return; + if (!state._enabled || !state._selectedKey) { + stopTicker(); + return; + } + ticks += 1; + syncRoute(); + if ((ticks * ROUTE_SPLIT_INTERVAL_MS) % STOP_REFRESH_MS === 0) { + refreshStops(); + parts.selection.refreshSelectedCard(false); + } + } + + function startTicker() { + if (timer) return; + ticks = 0; + timer = setInterval(tick, ROUTE_SPLIT_INTERVAL_MS); + } + + function stopTicker() { + clearInterval(timer); + timer = null; + } + + /** A train was selected, or the selection was cleared. */ + function onSelectionChanged() { + state._stopAnchors.clear(); + state._stopsSignature = ''; + state._stopLabelsSignature = ''; + const entry = state._enabled ? state._trains.get(state._selectedKey) : null; + // The provider loads the feed's timetable and polls its delays while a + // train on it is selected. + stops?.onSelect?.( + entry + ? { + feedId: entry.feedId, + tripId: entry.record?.tripId || null, + routeId: entry.record?.routeId || null, + record: entry.record, + } + : null, + ); + if (state._selectedKey && state._enabled) { + syncRoute(); + refreshStops(); + startTicker(); + } else { + stopTicker(); + dropRoute(); + clearStops(); + } + applyNetwork(); + governorRequestRender('rail-route'); + } + + /** + * Give back what a feed out of use for FEED_RELEASE_AFTER_MS holds: its + * faint network, its path pack and its parsed timetable. A feed in use + * (polled, or held for the selected train) keeps everything; a pack still + * loading is left to finish. Called on each poll tick, and once more while + * the layer is off. + * @param {number} nowMs + */ + function releaseIdleFeeds(nowMs) { + for (const feedId of state._activeFeeds.keys()) + feedSeenAt.set(feedId, nowMs); + const held = new Set([ + ...state._networks.keys(), + ...state._pathPacks.keys(), + ...feedSeenAt.keys(), + ]); + for (const feedId of held) { + if (state._activeFeeds.has(feedId)) continue; + const seen = feedSeenAt.get(feedId); + if (seen === undefined) { + feedSeenAt.set(feedId, nowMs); + continue; + } + if (nowMs - seen < FEED_RELEASE_AFTER_MS) continue; + if (state._pathPacks.get(feedId)?.status === 'loading') continue; + dropNetwork(feedId); + state._pathPacks.delete(feedId); + timetable?.forget?.(feedId); + feedSeenAt.delete(feedId); + } + } + + /** + * Layer disabled: hide everything. Built networks stay for a quick return, + * and are released once they have been out of use FEED_RELEASE_AFTER_MS. + */ + function hideAll() { + stops?.onSelect?.(null); + stopTicker(); + dropRoute(); + clearStops(); + applyNetwork(); + releaseIdleFeeds(Date.now()); + clearTimeout(releaseTimer); + releaseTimer = setTimeout(() => { + releaseTimer = null; + releaseIdleFeeds(Date.now()); + }, FEED_RELEASE_AFTER_MS); + } + + function destroy() { + hideAll(); + clearTimeout(releaseTimer); + releaseTimer = null; + feedSeenAt.clear(); + for (const feedId of [...state._networks.keys()]) dropNetwork(feedId); + if (stopPoints) { + scene()?.primitives?.remove(stopPoints); + stopPoints = null; + } + state._overlayHost.clearSource(RAIL_STOP_OVERLAY_SOURCE_ID); + } + + /** Counts for QA and the testing surface. */ + function stats() { + const feeds = {}; + for (const [feedId, network] of state._networks) { + feeds[feedId] = { + status: network.status, + primitives: network.layers.size, + shown: [...network.layers.values()].filter((l) => l.primitive.show) + .length, + ...(network.stats || {}), + }; + } + return { + fade: state._networkFade, + feeds, + route: state._route + ? { + id: state._route.id, + vertices: state._route.path.n, + fraction: state._route.fraction, + direction: state._route.direction, + ground: state._route.ground, + } + : null, + stops: { + points: stopPoints?.length || 0, + trip: state._trip ? state._trip.stops?.length || 0 : 0, + }, + }; + } + + return { + buildNetwork, + dropNetwork, + applyNetwork, + onCamera, + syncRoute, + refreshStops, + cullStops, + onSelectionChanged, + hideAll, + releaseIdleFeeds, + destroy, + stats, + }; +} diff --git a/src/layers/rail/routeLines.js b/src/layers/rail/routeLines.js new file mode 100644 index 000000000..ad86ea03c --- /dev/null +++ b/src/layers/rail/routeLines.js @@ -0,0 +1,335 @@ +/** + * @module layers/rail/routeLines + * @description Pure geometry for the Rail layer's route lines: the faint + * network a feed's path pack draws when zoomed in, and the split of a + * selected train's line into the part travelled and the part ahead. + * + * A pack lists every distinct GTFS shape, and most of them overlap: a + * commuter network's short turns, express patterns and branches share the + * trunk. Drawing each shape would stack hundreds of translucent lines on the + * same track, so the network is a union: shapes are walked longest first, and + * only the stretches no earlier shape already covered are kept. Each kept + * stretch is then thinned to the vertices its curve needs. + * + * Pure: no Cesium, no DOM. + */ + +/** Metres per degree of latitude (and of longitude at the equator). */ +const M_PER_DEG = 111_320; +/** Coverage grid cell (m). A track within about one cell is already drawn. */ +export const NETWORK_CELL_M = 40; +/** Sample spacing when walking a shape for coverage (m). */ +const NETWORK_STEP_M = 40; +/** Uncovered stretches shorter than this are jitter beside a drawn track (m). */ +export const NETWORK_MIN_RUN_M = 200; +/** Douglas-Peucker tolerance for the drawn stretches (m). */ +export const NETWORK_SIMPLIFY_M = 10; +/** Grid key offset, so negative cell indices stay distinct. */ +const KEY_OFFSET = 2 ** 21; + +/** Longitude difference wrapped into (-180, 180]. */ +function wrapLon(delta) { + return ((((delta + 180) % 360) + 360) % 360) - 180; +} + +/** Arc length of a flat `[lat, lon, ...]` shape in a local frame (m). */ +function shapeLength(coords, kx) { + let length = 0; + for (let i = 2; i + 1 < coords.length; i += 2) + length += Math.hypot( + wrapLon(coords[i + 1] - coords[i - 1]) * kx, + (coords[i] - coords[i - 2]) * M_PER_DEG, + ); + return length; +} + +/** + * Douglas-Peucker on a run of local-frame points, returning kept indices. + * Iterative, so a long run cannot overflow the stack. + */ +function simplifyIndices(xs, ys, tolerance) { + const n = xs.length; + if (n <= 2) return Array.from({ length: n }, (_, i) => i); + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const tol2 = tolerance * tolerance; + const stack = [0, n - 1]; + while (stack.length) { + const last = stack.pop(); + const first = stack.pop(); + const ax = xs[first]; + const ay = ys[first]; + const dx = xs[last] - ax; + const dy = ys[last] - ay; + const len2 = dx * dx + dy * dy; + let worst = -1; + let worstD2 = tol2; + for (let i = first + 1; i < last; i++) { + let t = len2 > 0 ? ((xs[i] - ax) * dx + (ys[i] - ay) * dy) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const ex = ax + dx * t - xs[i]; + const ey = ay + dy * t - ys[i]; + const d2 = ex * ex + ey * ey; + if (d2 > worstD2) { + worstD2 = d2; + worst = i; + } + } + if (worst < 0) continue; + keep[worst] = 1; + stack.push(first, worst, worst, last); + } + const out = []; + for (let i = 0; i < n; i++) if (keep[i]) out.push(i); + return out; +} + +/** + * The category each shape is drawn in: that of the first route listing it + * whose category is known. Shapes no classified route lists are not drawn + * (a pack can carry shapes of routes the layer filters out). + * @param {Record} routes routeId → shape indices. + * @param {number} shapeCount + * @param {(routeId: string) => string|null} categoryOf + * @returns {(string|null)[]} + */ +export function shapeCategories(routes, shapeCount, categoryOf) { + const out = new Array(shapeCount).fill(null); + for (const [routeId, list] of Object.entries(routes || {})) { + if (!Array.isArray(list)) continue; + let category; + for (const index of list) { + if (!Number.isInteger(index) || index < 0 || index >= shapeCount) + continue; + if (out[index] !== null) continue; + if (category === undefined) category = categoryOf(routeId) || null; + if (category) out[index] = category; + } + } + return out; +} + +/** + * The union of a pack's shapes as drawable stretches, each tagged with the + * category of the route it came from. + * + * @param {{shapes: ArrayLike[], routes?: Record}} raw + * A decoded path pack (the JSON body, not the indexed wrapper). + * @param {(routeId: string) => string|null} categoryOf Route → category, or + * null for a route the layer does not draw. + * @param {{cellM?: number, minRunM?: number, simplifyM?: number}} [options] + * @returns {{lines: {category: string, coords: Float64Array}[], + * shapesIn: number, pointsIn: number, pointsOut: number}} + */ +export function buildRailNetworkLines(raw, categoryOf, options = {}) { + const steps = railNetworkLineSteps(raw, categoryOf, options); + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + } +} + +/** + * The same union as buildRailNetworkLines(), one shape per step, so a large + * pack can be built in slices between frames instead of in one long task. + * @param {object} raw + * @param {(routeId: string) => string|null} categoryOf + * @param {object} [options] + * @returns {Generator>} + */ +export function* railNetworkLineSteps(raw, categoryOf, options = {}) { + const cellM = options.cellM ?? NETWORK_CELL_M; + const minRunM = options.minRunM ?? NETWORK_MIN_RUN_M; + const simplifyM = options.simplifyM ?? NETWORK_SIMPLIFY_M; + const shapes = Array.isArray(raw?.shapes) ? raw.shapes : []; + const categories = shapeCategories(raw?.routes, shapes.length, categoryOf); + let minLat = Infinity; + let maxLat = -Infinity; + let pointsIn = 0; + const order = []; + for (let index = 0; index < shapes.length; index++) { + const coords = shapes[index]; + if (!categories[index] || !(coords?.length >= 4)) continue; + for (let i = 0; i + 1 < coords.length; i += 2) { + if (coords[i] < minLat) minLat = coords[i]; + if (coords[i] > maxLat) maxLat = coords[i]; + } + pointsIn += coords.length / 2; + order.push(index); + } + if (!order.length) return { lines: [], shapesIn: 0, pointsIn, pointsOut: 0 }; + const lat0 = (minLat + maxLat) / 2; + const lon0 = shapes[order[0]][1]; + const kx = M_PER_DEG * Math.cos((lat0 * Math.PI) / 180); + const lengths = new Map( + order.map((index) => [index, shapeLength(shapes[index], kx)]), + ); + order.sort((a, b) => lengths.get(b) - lengths.get(a)); + + const covered = new Set(); + const lines = []; + let pointsOut = 0; + // Reused per shape: the densified walk in the local frame. + let xs = []; + let ys = []; + let cells = []; + const rowWidth = KEY_OFFSET * 2; + const cellKey = (x, y) => + (Math.floor(x / cellM) + KEY_OFFSET) * rowWidth + + (Math.floor(y / cellM) + KEY_OFFSET); + // Own cell first: on an already drawn track that is almost always the hit, + // so a covered sample costs one lookup and only new track pays for nine. + const around = [ + -rowWidth, + rowWidth, + -1, + 1, + -rowWidth - 1, + -rowWidth + 1, + rowWidth - 1, + rowWidth + 1, + ]; + const isCovered = (key) => { + if (covered.has(key)) return true; + for (let i = 0; i < around.length; i++) + if (covered.has(key + around[i])) return true; + return false; + }; + + function emit(category, from, to) { + const runXs = xs.slice(from, to + 1); + const runYs = ys.slice(from, to + 1); + let length = 0; + for (let i = 1; i < runXs.length; i++) + length += Math.hypot(runXs[i] - runXs[i - 1], runYs[i] - runYs[i - 1]); + if (length < minRunM) return; + const kept = simplifyIndices(runXs, runYs, simplifyM); + const coords = new Float64Array(kept.length * 2); + for (let k = 0; k < kept.length; k++) { + const i = kept[k]; + coords[2 * k] = runYs[i] / M_PER_DEG + lat0; + let lon = runXs[i] / kx + lon0; + if (lon > 180) lon -= 360; + else if (lon < -180) lon += 360; + coords[2 * k + 1] = lon; + } + pointsOut += kept.length; + lines.push({ category, coords }); + } + + for (const index of order) { + const coords = shapes[index]; + xs = []; + ys = []; + for (let i = 0; i + 1 < coords.length; i += 2) { + const x = wrapLon(coords[i + 1] - lon0) * kx; + const y = (coords[i] - lat0) * M_PER_DEG; + if (xs.length) { + const px = xs[xs.length - 1]; + const py = ys[ys.length - 1]; + const steps = Math.ceil(Math.hypot(x - px, y - py) / NETWORK_STEP_M); + for (let k = 1; k < steps; k++) { + xs.push(px + ((x - px) * k) / steps); + ys.push(py + ((y - py) * k) / steps); + } + } + xs.push(x); + ys.push(y); + } + cells = new Array(xs.length); + let runStart = -1; + for (let i = 0; i < xs.length; i++) { + cells[i] = cellKey(xs[i], ys[i]); + const free = !isCovered(cells[i]); + if (free && runStart < 0) runStart = Math.max(0, i - 1); + else if (!free && runStart >= 0) { + emit(categories[index], runStart, i); + runStart = -1; + } + } + if (runStart >= 0) emit(categories[index], runStart, xs.length - 1); + // Marked after the walk, not during it: consecutive samples of one shape + // sit in neighbouring cells, so marking as it goes would cover the shape + // against itself. + for (const key of cells) covered.add(key); + yield; + } + return { lines, shapesIn: order.length, pointsIn, pointsOut }; +} + +/** + * The fraction of a polyline, by cumulative chord length, at which arc `s` + * of a rail path falls. The renderer draws the path's own vertices, so the + * fraction is measured on those vertices: segment `i` of the path holds the + * arc range `[path.s[i], path.s[i+1]]` and the same chord in the drawn line. + * + * @param {{n: number, s: ArrayLike}} path From createRailPath. + * @param {ArrayLike} chordAt Cumulative drawn length per vertex + * (same length as the path), e.g. from cumulativeChords(). + * @param {number} s Arc length (m) on the path. + * @returns {number} 0..1, or NaN when either input is unusable. + */ +export function splitFraction(path, chordAt, s) { + if (!path || !chordAt || chordAt.length !== path.n || !Number.isFinite(s)) + return NaN; + const total = chordAt[path.n - 1]; + if (!(total > 0)) return NaN; + const arc = path.s; + if (s <= arc[0]) return 0; + if (s >= arc[path.n - 1]) return 1; + let lo = 0; + let hi = path.n - 2; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (arc[mid] <= s) lo = mid; + else hi = mid - 1; + } + const span = arc[lo + 1] - arc[lo]; + const t = span > 0 ? (s - arc[lo]) / span : 0; + const chord = chordAt[lo] + (chordAt[lo + 1] - chordAt[lo]) * t; + return Math.max(0, Math.min(1, chord / total)); +} + +/** + * Cumulative length per vertex of a path, measured on the path's own local + * frame (x, y). This is what splitFraction() needs when the drawn line's + * texture coordinate runs by length, as a ground polyline's does. + * @param {{n: number, x: ArrayLike, y: ArrayLike}} path + * @returns {Float64Array} + */ +export function cumulativeChords(path) { + const out = new Float64Array(path?.n || 0); + for (let i = 1; i < out.length; i++) + out[i] = + out[i - 1] + + Math.hypot(path.x[i] - path.x[i - 1], path.y[i] - path.y[i - 1]); + return out; +} + +/** + * Vertex-index positions: what splitFraction() needs when the drawn line's + * texture coordinate runs by vertex index, as a plain PolylineGeometry's does. + * @param {{n: number}} path + * @returns {Float64Array} + */ +export function vertexIndexChords(path) { + const out = new Float64Array(path?.n || 0); + for (let i = 0; i < out.length; i++) out[i] = i; + return out; +} + +/** + * The faint network's opacity at a camera altitude: full below `fullBelowM`, + * none above `hiddenAboveM`, linear between. + * @param {number} altitudeM + * @param {number} fullBelowM + * @param {number} hiddenAboveM + * @returns {number} 0..1 + */ +export function networkAltitudeFade(altitudeM, fullBelowM, hiddenAboveM) { + if (!Number.isFinite(altitudeM)) return 0; + if (altitudeM <= fullBelowM) return 1; + if (altitudeM >= hiddenAboveM) return 0; + return (hiddenAboveM - altitudeM) / (hiddenAboveM - fullBelowM); +} diff --git a/src/layers/rail/routeLines.test.mjs b/src/layers/rail/routeLines.test.mjs new file mode 100644 index 000000000..d16b2d06a --- /dev/null +++ b/src/layers/rail/routeLines.test.mjs @@ -0,0 +1,331 @@ +// The Rail layer's route-line geometry: the network union a path pack draws +// as faint lines, and where a selected train's line splits into the part +// travelled and the part ahead; and, on a whole layer, how the selected +// train's line is drawn and released. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { FEED_RELEASE_AFTER_MS, railTrainKey } from './policy.js'; +import { SCHEDULE_RETRY_FIRST_MS } from './schedules.js'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; +import { + NETWORK_MIN_RUN_M, + buildRailNetworkLines, + cumulativeChords, + networkAltitudeFade, + railNetworkLineSteps, + shapeCategories, + splitFraction, + vertexIndexChords, +} from './routeLines.js'; +import { + createRailPath, + pointAtS, + projectOnPath, +} from '../../data/railPaths.js'; + +const M_PER_DEG = 111_320; +const LAT = 42.36; +const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180); + +/** A flat [lat, lon, ...] line due east from `lon0`, one vertex per `stepM`. */ +function eastLine(lon0, lengthM, stepM = 100, lat = LAT) { + const out = []; + for (let d = 0; d <= lengthM + 1e-6; d += stepM) out.push(lat, lon0 + d / KX); + return out; +} + +/** Total length of a flat line (m), measured like the module does. */ +function lengthOf(coords) { + let length = 0; + for (let i = 2; i < coords.length; i += 2) + length += Math.hypot( + (coords[i + 1] - coords[i - 1]) * KX, + (coords[i] - coords[i - 2]) * M_PER_DEG, + ); + return length; +} + +const everyRoute = () => 'regional'; + +test('shape categories come from the first classified route listing them', () => { + const categories = shapeCategories( + { bus: [0], 'CR-A': [1, 2], 'CR-B': [2, 3], bad: [99, -1, 'x'] }, + 4, + (route) => + route === 'bus' ? null : route === 'CR-A' ? 'local' : 'national', + ); + assert.deepEqual(categories, [null, 'local', 'local', 'national']); +}); + +test('overlapping shapes are drawn once: a short turn adds nothing', () => { + const full = eastLine(-71.2, 20_000); + const shortTurn = eastLine(-71.2, 8_000, 250); // other vertex spacing too + const result = buildRailNetworkLines( + { shapes: [shortTurn, full], routes: { r: [0, 1] } }, + everyRoute, + ); + assert.equal(result.shapesIn, 2); + assert.equal(result.lines.length, 1); + assert.equal(result.lines[0].category, 'regional'); + // The longest shape is walked first, so the one line spans all of it. + assert.ok(Math.abs(lengthOf(result.lines[0].coords) - 20_000) < 60); + // A straight track keeps only its ends. + assert.equal(result.lines[0].coords.length, 4); + assert.ok(result.pointsOut < result.pointsIn); +}); + +test('a branch adds only the stretch the trunk does not already cover', () => { + const trunk = eastLine(-71.2, 20_000); + // Shares the trunk's first 10 km, then turns north for 5 km. + const branch = eastLine(-71.2, 10_000); + const turnLon = branch[branch.length - 1]; + for (let d = 100; d <= 5_000; d += 100) + branch.push(LAT + d / M_PER_DEG, turnLon); + const result = buildRailNetworkLines( + { shapes: [trunk, branch], routes: { a: [0], b: [1] } }, + (route) => (route === 'a' ? 'local' : 'regional'), + ); + assert.equal(result.lines.length, 2); + const [first, second] = result.lines; + assert.equal(first.category, 'local'); + assert.equal(second.category, 'regional'); + const branchLength = lengthOf(second.coords); + // The new stretch is the 5 km spur, joined to the trunk by a sample or so. + assert.ok(branchLength > 4_900 && branchLength < 5_200, String(branchLength)); +}); + +test('a parallel track beside a drawn one is jitter, not a new line', () => { + const a = eastLine(-71.2, 10_000); + const b = eastLine(-71.2, 9_000, 100, LAT + 15 / M_PER_DEG); // 15 m north + const result = buildRailNetworkLines( + { shapes: [a, b], routes: { r: [0, 1] } }, + everyRoute, + ); + assert.equal(result.lines.length, 1); +}); + +test('uncovered stretches shorter than the minimum run are dropped', () => { + const trunk = eastLine(-71.2, 10_000); + // Leaves the trunk for a stub shorter than the minimum, then ends. + const stub = eastLine(-71.2, 5_000); + const endLon = stub[stub.length - 1]; + const stubM = NETWORK_MIN_RUN_M / 2; + for (let d = 20; d <= stubM; d += 20) stub.push(LAT - d / M_PER_DEG, endLon); + const result = buildRailNetworkLines( + { shapes: [trunk, stub], routes: { r: [0, 1] } }, + everyRoute, + ); + assert.equal(result.lines.length, 1); +}); + +test('shapes of routes the layer does not draw are left out', () => { + const result = buildRailNetworkLines( + { shapes: [eastLine(-71.2, 5_000)], routes: { bus: [0] } }, + () => null, + ); + assert.deepEqual(result.lines, []); + assert.equal(result.shapesIn, 0); + assert.deepEqual(buildRailNetworkLines(null, everyRoute).lines, []); +}); + +test('the step builder yields once per shape and ends with the same union', () => { + const raw = { + shapes: [ + eastLine(-71.2, 20_000), + eastLine(-71.2, 8_000), + eastLine(-71.3, 3_000), + ], + routes: { r: [0, 1, 2] }, + }; + const steps = railNetworkLineSteps(raw, everyRoute); + let yields = 0; + let next = steps.next(); + while (!next.done) { + yields += 1; + next = steps.next(); + } + assert.equal(yields, 3); + assert.deepEqual( + next.value.lines.map((line) => Array.from(line.coords)), + buildRailNetworkLines(raw, everyRoute).lines.map((line) => + Array.from(line.coords), + ), + ); +}); + +test('the split falls where the train is on its line, by drawn length', () => { + // An L-shaped line: 3 km east, then 1 km north, with uneven vertices. + const coords = eastLine(-71.2, 3_000, 500); + const cornerLon = coords[coords.length - 1]; + for (let d = 100; d <= 1_000; d += 100) + coords.push(LAT + d / M_PER_DEG, cornerLon); + const path = createRailPath(coords); + const chords = cumulativeChords(path); + assert.equal(chords.length, path.n); + assert.ok(Math.abs(chords[path.n - 1] - path.length) < 1e-6); + + // A train reported 1.5 km along, a little off the track. + const hit = projectOnPath(path, LAT + 8 / M_PER_DEG, -71.2 + 1_500 / KX); + assert.ok(Math.abs(hit.s - 1_500) < 1); + const fraction = splitFraction(path, chords, hit.s); + assert.ok(Math.abs(fraction - 1_500 / 4_000) < 1e-3); + + // The drawn point at the split is the train's point on the line. + const at = pointAtS(path, fraction * path.length); + assert.ok(Math.abs(at.lon - (-71.2 + 1_500 / KX)) < 1e-6); + + // Ends and out-of-range arcs clamp; bad input is NaN, not a guess. + assert.equal(splitFraction(path, chords, -50), 0); + assert.equal(splitFraction(path, chords, path.length + 50), 1); + assert.ok(Number.isNaN(splitFraction(path, chords, NaN))); + assert.ok(Number.isNaN(splitFraction(path, new Float64Array(2), 10))); + assert.ok(Number.isNaN(splitFraction(null, chords, 10))); +}); + +test('by vertex index, the split follows a line drawn per vertex', () => { + // Vertices at 0, 500, 2500 m: by index the middle vertex is halfway. + const coords = [LAT, -71.2, LAT, -71.2 + 500 / KX, LAT, -71.2 + 2_500 / KX]; + const path = createRailPath(coords); + const byIndex = vertexIndexChords(path); + assert.deepEqual(Array.from(byIndex), [0, 1, 2]); + assert.ok(Math.abs(splitFraction(path, byIndex, 500) - 0.5) < 1e-9); + // 1500 m is halfway along the second segment: index 1.5 of 2. + assert.ok(Math.abs(splitFraction(path, byIndex, 1_500) - 0.75) < 1e-9); + // By length the same arc is 0.6 of the line. + assert.ok( + Math.abs(splitFraction(path, cumulativeChords(path), 1_500) - 0.6) < 1e-6, + ); +}); + +test('the network fades out between its two altitudes', () => { + assert.equal(networkAltitudeFade(15_000, 150_000, 200_000), 1); + assert.equal(networkAltitudeFade(150_000, 150_000, 200_000), 1); + assert.equal(networkAltitudeFade(175_000, 150_000, 200_000), 0.5); + assert.equal(networkAltitudeFade(200_000, 150_000, 200_000), 0); + assert.equal(networkAltitudeFade(3_000_000, 150_000, 200_000), 0); + assert.equal(networkAltitudeFade(Infinity, 150_000, 200_000), 0); + assert.equal(networkAltitudeFade(NaN, 150_000, 200_000), 0); +}); + +test('the selected train’s line is its trip’s shape, else its route’s longest', async (t) => { + const pack = { + version: 1, + feedId: 'mbta-rail', + shapes: [eastLine(-71.08, 3000), eastLine(-71.08, 3000, 3000, LAT + 0.01)], + dist: [[], []], + shapeSources: ['operator', 'operator'], + trips: { 'trip-T1': 0 }, + routes: { R1: [0], R2: [1] }, + }; + const app = harness(t, { paths: { 'mbta-rail': pack } }); + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, -71.07, { routeId: 'R1' }), + train('T2', LAT + 0.01, -71.07, { routeId: 'R2' }), + ]); + await app.enable(); + for (const [id, shapeIndex, vertices] of [ + ['T1', 0, 31], + ['T2', 1, 2], + ]) { + const key = railTrainKey('mbta-rail', id); + app.parts().selection.selectTrain(key); + const { route } = app.layer.getRouteDisplayStats(); + assert.equal( + route?.id, + `${key} +mbta-rail +${shapeIndex}`, + id, + ); + assert.equal(route.vertices, vertices, id); + } +}); + +/** A one-shape Boston pack for mbta-rail. */ +const BOSTON_PACK = Object.freeze({ + version: 1, + feedId: 'mbta-rail', + shapes: [eastLine(-71.08, 3000)], + dist: [[]], + trips: {}, + routes: { R1: [0] }, +}); +const OCEAN = { lat: 30, lon: -40 }; +const mbtaPaths = (app) => + app.requestedPaths.filter((id) => id === 'mbta-rail').length; + +test('a path pack the server cannot read now is asked for again on a stepped delay', async (t) => { + t.mock.method(console, 'warn', () => {}); + const paths = { 'mbta-rail': 503 }; + const app = harness(t, { paths }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + assert.equal(mbtaPaths(app), 1); + await app.poll(); + assert.equal(mbtaPaths(app), 1, 'not on every poll'); + t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS); + await app.poll(); + assert.equal(mbtaPaths(app), 2, 'again after 30 s'); + // The second failure waits twice as long. + t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS); + await app.poll(); + assert.equal(mbtaPaths(app), 2); + paths['mbta-rail'] = BOSTON_PACK; + t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS); + await app.poll(); + assert.equal(mbtaPaths(app), 3); + assert.equal(app.state()._pathPacks.get('mbta-rail').status, 'ready'); +}); + +test('a feed out of use for ten minutes gives back its network and packs', async (t) => { + const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + t.mock.timers.tick(0); + assert.ok(app.state()._networks.has('mbta-rail')); + assert.equal(app.state()._pathPacks.get('mbta-rail').status, 'ready'); + // Away from Boston: kept for a quick return... + await app.moveCamera(OCEAN); + assert.ok(!app.state()._activeFeeds.has('mbta-rail')); + t.mock.timers.tick(FEED_RELEASE_AFTER_MS - 60_000); + await app.poll(); + assert.ok(app.state()._networks.has('mbta-rail')); + assert.ok(app.state()._pathPacks.has('mbta-rail')); + // ...and released after ten minutes out of use. + t.mock.timers.tick(60_000); + await app.poll(); + assert.ok(!app.state()._networks.has('mbta-rail')); + assert.ok(!app.state()._pathPacks.has('mbta-rail')); + // Back in Boston, the pack loads again. + await app.moveCamera(BOSTON); + assert.equal(mbtaPaths(app), 2); +}); + +test('a feed in use keeps its network however long it runs', async (t) => { + const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + t.mock.timers.tick(0); + for (let i = 0; i < 3; i += 1) { + t.mock.timers.tick(FEED_RELEASE_AFTER_MS); + await app.poll(); + } + assert.ok(app.state()._networks.has('mbta-rail')); + assert.equal(mbtaPaths(app), 1); +}); + +test('with the layer off, its feeds are released after ten minutes', async (t) => { + const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + t.mock.timers.tick(0); + app.layer.disable(app.viewer); + assert.ok(app.state()._networks.has('mbta-rail'), 'kept for a quick return'); + t.mock.timers.tick(FEED_RELEASE_AFTER_MS); + assert.ok(!app.state()._networks.has('mbta-rail')); + assert.ok(!app.state()._pathPacks.has('mbta-rail')); +}); diff --git a/src/layers/rail/schedules.js b/src/layers/rail/schedules.js new file mode 100644 index 000000000..8e6a5e94c --- /dev/null +++ b/src/layers/rail/schedules.js @@ -0,0 +1,518 @@ +/** + * @module layers/rail/schedules + * @description The Rail layer's schedule tier: each feed's optional schedule + * pack (src/data/railSchedule.js) and live trip-update delays, read for the + * selected live train's stops, headsign and delays (the stops provider, + * `stopsFor`). It never places or moves a train: every train drawn is where + * its operator last reported it. + * + * A schedule pack is fetched the first time a train on that feed is + * selected, and kept until the feed has been out of use for a while + * (`forget`). Trip updates are polled every RAIL_UPDATES_POLL_MS while a + * train on the feed is selected. A feed that publishes none (Irish Rail, SJ + * Nord) may state each train's delay in its report (`delaySec`): that delay + * then moves the train's timetable, for its stops and for finding its run. + * + * No Cesium, no DOM. Network access goes through the injected source and + * timers through injected functions, so node:test drives it. + */ + +import { + activeTrips, + createSchedule, + liveDelaySec, + scheduledPosition, + tripStops, +} from '../../data/railSchedule.js'; +import { pointAtS } from '../../data/railPaths.js'; + +/** Trip updates are re-read this often while needed (ms). */ +export const RAIL_UPDATES_POLL_MS = 30_000; +/** Delays older than this are not applied: a stopped poll must not lie (ms). */ +export const RAIL_UPDATES_MAX_AGE_MS = 2 * RAIL_UPDATES_POLL_MS + 15_000; +/** + * A schedule pack that failed (other than a 404) is tried again after + * SCHEDULE_RETRY_FIRST_MS, then twice as long after each failure in a row, + * up to SCHEDULE_RETRY_MS (ms): one bad answer from the server does not + * leave a selected train without its stops for ten minutes. + */ +export const SCHEDULE_RETRY_FIRST_MS = 30_000; +export const SCHEDULE_RETRY_MS = 10 * 60_000; +/** + * A live train without a known trip id may be matched to a run from this + * long before its departure (waiting at the origin)... + */ +export const MATCH_BEFORE_MS = 5 * 60_000; +/** ...to this long after its arrival, at the terminus. */ +export const MATCH_AFTER_MS = 2 * 60_000; +/** A live train without a known trip id matches a run this close (m). */ +export const MATCH_NEAREST_MAX_M = 3_000; +/** How long a nearest-run match is kept before it is looked for again (ms). */ +export const MATCH_KEEP_MS = 60_000; + +/** + * How long a schedule pack waits after its nth failure in a row (ms). + * @param {number} failures 1 after the first. + * @returns {number} + */ +export function scheduleRetryDelayMs(failures) { + return Math.min( + SCHEDULE_RETRY_MS, + SCHEDULE_RETRY_FIRST_MS * 2 ** Math.max(0, failures - 1), + ); +} + +const M_PER_DEG = 111_320; + +/** Ground distance in metres, equirectangular: fine at a few kilometres. */ +function distanceM(lat1, lon1, lat2, lon2) { + const kx = M_PER_DEG * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + const dLon = ((((lon2 - lon1 + 180) % 360) + 360) % 360) - 180; + return Math.hypot(dLon * kx, (lat2 - lat1) * M_PER_DEG); +} + +/** + * GTFS-RT trip records (the `/api/rail/updates` body) as the `delays` map + * railSchedule reads, keyed by trip id. + * @param {{trips?: object[]}|null} body + * @returns {Record} + */ +export function delaysFromUpdates(body) { + const delays = {}; + for (const trip of Array.isArray(body?.trips) ? body.trips : []) { + if (typeof trip?.tripId !== 'string' || !trip.tripId) continue; + delays[trip.tripId] = { + delaySec: Number.isFinite(trip.delaySec) ? trip.delaySec : null, + startDate: trip.startDate ?? null, + relationship: trip.relationship ?? null, + stops: Array.isArray(trip.stops) ? trip.stops : [], + }; + } + return delays; +} + +/** + * Whether a timetable's shape indices point into this path pack: the two + * come from one build. A path pack rebuilt on its own (or a schedule left + * from an older build) has other shapes at those indices, so its runs are + * compared between their stops instead of along some other line. Packs that + * predate the stamps are taken as a pair. + * @param {{pathsBuiltAt?: string|null}} schedule + * @param {{builtAt?: string|null}|null} pack + * @returns {boolean} + */ +export function pathsMatch(schedule, pack) { + if (!pack) return false; + if (!schedule?.pathsBuiltAt || !pack.builtAt) return true; + return schedule.pathsBuiltAt === pack.builtAt; +} + +/** + * Where the timetable has one run now: on its shape when the path pack has + * it, else between its stops. Only compared with a live train's report, to + * find the run it is on (stopsFor); never drawn. + * @param {object} schedule From createSchedule. + * @param {object|string} run A run from activeTrips, or a trip id. + * @param {number} nowMs + * @param {object|null} delays + * @param {object|null} pack A path pack (createRailPathPack), when loaded. + * @returns {object|null} scheduledPosition's result plus `lat`, `lon`, + * `bearingDeg` (NaN when unknown) and `onShape`. + */ +export function scheduledRunPoint(schedule, run, nowMs, delays, pack) { + const position = scheduledPosition(schedule, run, nowMs, delays); + if (!position) return null; + const path = + position.shape !== null && pathsMatch(schedule, pack) + ? pack.pathAt(position.shape) + : null; + const point = path ? pointAtS(path, position.s) : null; + if (point && Number.isFinite(point.lat) && Number.isFinite(point.lon)) + return { ...position, ...point, onShape: true }; + if (Number.isFinite(position.lat) && Number.isFinite(position.lon)) + return { ...position, bearingDeg: NaN, onShape: false }; + // A shaped pattern whose path pack is not here (yet): between the stops. + const trip = schedule.trips.get(position.tripId); + if (!trip) return null; + const stopAt = (k) => (k >= 0 ? schedule.stops[trip.pattern.stops[k]] : null); + const a = stopAt( + position.prevStop >= 0 ? position.prevStop : position.nextStop, + ); + const b = + position.dwell || position.nextStop < 0 ? a : stopAt(position.nextStop); + if (!a || !b) return null; + const f = position.dwell ? 0 : position.fraction; + return { + ...position, + lat: a.lat + (b.lat - a.lat) * f, + lon: a.lon + (b.lon - a.lon) * f, + bearingDeg: NaN, + onShape: false, + }; +} + +/** + * The schedule tier for one layer instance. + * @param {{source: object, + * hasTripUpdates: (feedId: string) => boolean, + * pathPackFor?: (feedId: string) => object|null, + * now?: () => number, + * setInterval?: Function, clearInterval?: Function, + * onChange?: (feedId: string) => void}} options + * @returns {object} + */ +export function createRailSchedules({ + source, + hasTripUpdates, + pathPackFor = () => null, + now = () => Date.now(), + setInterval: startInterval = (fn, ms) => globalThis.setInterval(fn, ms), + clearInterval: stopInterval = (id) => globalThis.clearInterval(id), + onChange = () => {}, +}) { + /** + * feedId → `{status: 'loading'|'ready'|'none', schedule, retryAt, promise, + * failures}`; `failures` counts failed loads in a row. + */ + const schedules = new Map(); + /** feedId → `{delays, at, timer, reasons: Set, controller}`. */ + const updates = new Map(); + /** `${feedId}\n${trainId}` → `{tripKey, run, via, at}` for live trains. */ + const matches = new Map(); + let selectedFeed = null; + + /** + * The feed's schedule, loading it on first ask. + * @param {string} feedId + * @returns {Promise} The schedule, or null when it has none. + */ + function loadSchedule(feedId) { + const known = schedules.get(feedId); + if (known?.status === 'ready') return Promise.resolve(known.schedule); + if (known?.status === 'loading') return known.promise; + if (known?.status === 'none' && now() < known.retryAt) + return Promise.resolve(null); + if (typeof source?.requestSchedule !== 'function') { + schedules.set(feedId, { + status: 'none', + schedule: null, + retryAt: Infinity, + }); + return Promise.resolve(null); + } + const slot = { + status: 'loading', + schedule: null, + retryAt: Infinity, + failures: known?.failures ?? 0, + }; + slot.promise = (async () => { + try { + const response = await source.requestSchedule(feedId); + if (!response.ok) { + const error = new Error(`rail schedule HTTP ${response.status}`); + error.status = response.status; + throw error; + } + const raw = await response.json(); + let schedule; + try { + schedule = createSchedule(raw); + if (schedule.feedId !== feedId) + throw new TypeError('rail schedule pack is for another feed'); + } catch (error) { + error.permanent = true; + throw error; + } + slot.schedule = schedule; + slot.status = 'ready'; + slot.failures = 0; + onChange(feedId); + return schedule; + } catch (error) { + slot.status = 'none'; + slot.failures += 1; + // A 404 is "no timetable for this feed", not a fault to retry; a + // malformed pack will not mend itself before the next build either. + slot.retryAt = + error?.status === 404 || error?.permanent === true + ? Infinity + : now() + scheduleRetryDelayMs(slot.failures); + if (error?.status !== 404) + console.warn( + `[Data:Rail] ${feedId} timetable unavailable:`, + error?.message || error, + ); + return null; + } finally { + slot.promise = null; + } + })(); + schedules.set(feedId, slot); + return slot.promise; + } + + /** The feed's schedule if loaded; starts the load otherwise. */ + function scheduleFor(feedId) { + const known = schedules.get(feedId); + if (known?.status === 'ready') return known.schedule; + void loadSchedule(feedId); + return null; + } + + function updatesSlot(feedId) { + let slot = updates.get(feedId); + if (!slot) { + slot = { + delays: null, + at: -Infinity, + timer: null, + reasons: new Set(), + controller: null, + }; + updates.set(feedId, slot); + } + return slot; + } + + async function fetchUpdates(feedId) { + const slot = updatesSlot(feedId); + if (slot.controller || typeof source?.requestUpdates !== 'function') return; + const controller = new AbortController(); + slot.controller = controller; + try { + const response = await source.requestUpdates(feedId, { + signal: controller.signal, + }); + if (!response.ok) throw new Error(`rail updates HTTP ${response.status}`); + const body = await response.json(); + if (slot.controller !== controller) return; + slot.delays = delaysFromUpdates(body); + slot.at = now(); + onChange(feedId); + } catch (error) { + if (error?.name !== 'AbortError') + console.warn( + `[Data:Rail] ${feedId} trip updates unavailable:`, + error?.message || error, + ); + } finally { + if (slot.controller === controller) slot.controller = null; + } + } + + /** + * Poll a feed's trip updates while `reason` needs them. Reasons are + * counted, so every reader shares one poll. + * @param {string} feedId + * @param {string} reason + */ + function watchUpdates(feedId, reason) { + if (!hasTripUpdates(feedId)) return; + const slot = updatesSlot(feedId); + slot.reasons.add(reason); + if (slot.timer !== null) return; + void fetchUpdates(feedId); + slot.timer = startInterval( + () => void fetchUpdates(feedId), + RAIL_UPDATES_POLL_MS, + ); + } + + function unwatchUpdates(feedId, reason) { + const slot = updates.get(feedId); + if (!slot) return; + slot.reasons.delete(reason); + if (slot.reasons.size || slot.timer === null) return; + stopInterval(slot.timer); + slot.timer = null; + slot.controller?.abort(); + slot.controller = null; + } + + /** Current delays for a feed, or null when none are fresh. */ + function delaysFor(feedId) { + const slot = updates.get(feedId); + return slot?.delays && now() - slot.at <= RAIL_UPDATES_MAX_AGE_MS + ? slot.delays + : null; + } + + /** + * The run a live train is on. Its trip id when the timetable has it (live + * GTFS trip ids are normally the static ones); otherwise, as a fallback, + * the running run of its route nearest the train's report, kept for a + * minute so the stop list does not hop between neighbouring runs. A train + * its own report says is `liveDelayMs` late is compared with where the + * timetable had each run that long ago. + */ + function matchRun(schedule, train, nowMs, delays, liveDelayMs = 0) { + const tripId = train.tripId || null; + if (tripId && schedule.runsByTrip.has(tripId)) + return { trip: tripId, via: 'trip' }; + const record = train.record || {}; + if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) + return null; + const key = `${train.feedId}\n${record.id ?? ''}`; + const tripKey = `${tripId ?? ''}\n${train.routeId ?? ''}`; + const kept = matches.get(key); + if (kept && kept.tripKey === tripKey && nowMs - kept.at < MATCH_KEEP_MS) + return kept.run ? { trip: kept.run, via: 'nearest' } : null; + const pack = pathPackFor(train.feedId); + // A uniform delay is a time shift: the run as timetabled that long ago. + const matchMs = nowMs - liveDelayMs; + let best = null; + let bestM = MATCH_NEAREST_MAX_M; + for (const run of activeTrips(schedule, matchMs, { + delays, + beforeMs: MATCH_BEFORE_MS, + afterMs: MATCH_AFTER_MS, + })) { + if (train.routeId && run.routeId !== train.routeId) continue; + const point = scheduledRunPoint(schedule, run, matchMs, delays, pack); + if (!point) continue; + const m = distanceM(record.lat, record.lon, point.lat, point.lon); + if (m < bestM) { + bestM = m; + best = run; + } + } + matches.set(key, { tripKey, run: best, at: nowMs }); + return best ? { trip: best, via: 'nearest' } : null; + } + + /** + * The stops provider (see createRailLayer): the selected train's stops and + * headsign, or null until the feed's timetable has loaded (or when it has + * none, or the train matches no run). + * @param {{feedId: string, tripId: string|null, routeId: string|null, + * record: object}} train + * @param {number} nowMs + * @returns {{stops: object[], headsign: string|null, + * match: 'trip'|'nearest'}|null} + */ + function stopsFor(train, nowMs) { + if (!train?.feedId) return null; + const schedule = scheduleFor(train.feedId); + if (!schedule) return null; + // A feed without trip updates: the delay the train's report states. + const liveDelay = hasTripUpdates(train.feedId) + ? null + : liveDelaySec(train.record); + const updates = liveDelay === null ? delaysFor(train.feedId) : null; + const match = matchRun( + schedule, + train, + nowMs, + updates, + (liveDelay ?? 0) * 1000, + ); + if (!match) return null; + const tripId = + typeof match.trip === 'string' ? match.trip : match.trip.tripId; + const delays = + liveDelay === null + ? updates + : { + [tripId]: { + delaySec: liveDelay, + startDate: + typeof match.trip === 'string' ? null : match.trip.serviceDate, + }, + }; + const stops = tripStops(schedule, match.trip, nowMs, delays); + if (!stops.length) return null; + const trip = schedule.trips.get(tripId); + return { + stops, + headsign: trip?.headsign || null, + match: match.via, + }; + } + + /** + * A train was selected (or the selection cleared): poll its delays once + * its feed's timetable is loaded. Delays are shown only at the + * timetable's stops, so a feed without one is not polled. + */ + function onSelect(train) { + const feedId = train?.feedId || null; + if (feedId === selectedFeed) return; + if (selectedFeed) unwatchUpdates(selectedFeed, 'selection'); + selectedFeed = feedId; + if (!feedId) return; + void loadSchedule(feedId).then((schedule) => { + if (schedule && selectedFeed === feedId) + watchUpdates(feedId, 'selection'); + }); + } + + /** Stop every poll; loaded schedules stay for the next enable. */ + function stop() { + onSelect(null); + for (const [feedId, slot] of updates) + for (const reason of [...slot.reasons]) unwatchUpdates(feedId, reason); + matches.clear(); + } + + /** + * Release what is held for a feed no longer in use: its parsed schedule, + * its last trip updates and its live-train matches. A load or a poll + * still under way is left to finish; the feed loads again when next used. + * @param {string} feedId + */ + function forget(feedId) { + if (schedules.get(feedId)?.status !== 'loading') schedules.delete(feedId); + const slot = updates.get(feedId); + if (slot && slot.reasons.size === 0 && slot.timer === null) { + slot.controller?.abort(); + updates.delete(feedId); + } + const prefix = `${feedId} +`; + for (const key of [...matches.keys()]) + if (key.startsWith(prefix)) matches.delete(key); + } + + /** Counts for QA probes and tests. */ + function stats() { + return { + schedules: Object.fromEntries( + [...schedules].map(([feedId, slot]) => [ + feedId, + slot.status === 'ready' + ? { + status: 'ready', + trips: slot.schedule.trips.size, + runs: slot.schedule.instances.length, + } + : { status: slot.status }, + ]), + ), + updates: Object.fromEntries( + [...updates].map(([feedId, slot]) => [ + feedId, + { + polling: slot.timer !== null, + reasons: [...slot.reasons], + trips: slot.delays ? Object.keys(slot.delays).length : 0, + ageMs: Number.isFinite(slot.at) ? now() - slot.at : null, + }, + ]), + ), + }; + } + + return { + stopsFor, + onSelect, + loadSchedule, + scheduleFor, + watchUpdates, + unwatchUpdates, + delaysFor, + stop, + forget, + stats, + }; +} diff --git a/src/layers/rail/schedules.test.mjs b/src/layers/rail/schedules.test.mjs new file mode 100644 index 000000000..0db2cdca9 --- /dev/null +++ b/src/layers/rail/schedules.test.mjs @@ -0,0 +1,593 @@ +// The Rail layer's schedule tier, the stops provider of live trains: null +// before the pack loads, trip-id matching with a nearest-run fallback, delay +// polling only while a train is selected on a feed with a timetable. It +// places no train itself. Built from a small synthetic schedule pack and path +// pack. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + MATCH_NEAREST_MAX_M, + RAIL_UPDATES_MAX_AGE_MS, + RAIL_UPDATES_POLL_MS, + SCHEDULE_RETRY_FIRST_MS, + SCHEDULE_RETRY_MS, + createRailSchedules, + delaysFromUpdates, + pathsMatch, + scheduledRunPoint, + scheduleRetryDelayMs, +} from './schedules.js'; +import * as schedules from './schedules.js'; +import { createSchedule } from '../../data/railSchedule.js'; +import { createRailPathPack } from '../../data/railPaths.js'; + +const FEED = 'test-rail'; +const LAT = 50; +const STEP_M = 0.1 * 111_320 * Math.cos((LAT * Math.PI) / 180); +const at = (hhmm) => Date.parse(`2026-11-06T${hhmm}:00Z`); + +/** Three stops due east 0.1° apart; T1 runs 10:00 → 10:20, dwelling at B. */ +const rawSchedule = Object.freeze({ + version: 1, + feedId: FEED, + timezone: 'UTC', + window: { from: '2026-11-05', to: '2026-11-08' }, + routes: { R1: { type: 2 }, BUS: { type: 3 } }, + stops: [ + { id: 'A', name: 'Alpha', lat: LAT, lon: 0 }, + { id: 'B', name: 'Bravo', lat: LAT, lon: 0.1 }, + { id: 'C', name: 'Charlie', lat: LAT, lon: 0.2 }, + ], + patterns: [ + { + shape: 0, + stops: [0, 1, 2], + seq: [1, 2, 3], + dist: [0, STEP_M, 2 * STEP_M], + }, + { + shape: 1, + stops: [2, 1, 0], + seq: [1, 2, 3], + dist: [0, STEP_M, 2 * STEP_M], + }, + ], + profiles: [[0, 0, 600, 660, 1260, 1260]], + services: { WK: ['2026-11-06'] }, + trips: { + T1: { + pattern: 0, + profile: 0, + startSec: 10 * 3600, + route: 'R1', + service: 'WK', + headsign: 'Charlie', + name: 'Train 1', + }, + T2: { + pattern: 1, + profile: 0, + startSec: 10 * 3600, + route: 'R1', + service: 'WK', + headsign: 'Alpha', + name: 'Train 2', + }, + B9: { + pattern: 0, + profile: 0, + startSec: 10 * 3600, + route: 'BUS', + service: 'WK', + }, + }, +}); + +const rawPaths = Object.freeze({ + version: 1, + feedId: FEED, + shapes: [ + [LAT, 0, LAT, 0.1, LAT, 0.2], + [LAT, 0.2, LAT, 0.1, LAT, 0], + ], + dist: [ + [0, STEP_M, 2 * STEP_M], + [0, STEP_M, 2 * STEP_M], + ], + trips: { T1: 0, T2: 1 }, + routes: { R1: [0, 1] }, +}); + +const reply = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, +}); + +const flush = async () => { + for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); +}; + +/** A provider over a fake source, fake clock and fake interval timers. */ +function harness({ + schedule = () => reply(rawSchedule), + updates = () => reply({ trips: [] }), + tripUpdates = true, + clock = at('10:05'), +} = {}) { + const calls = { schedule: 0, updates: 0 }; + const timers = new Map(); + let nextTimer = 1; + const h = { + calls, + timers, + now: clock, + provider: null, + }; + h.provider = createRailSchedules({ + source: { + async requestSchedule(feedId) { + calls.schedule += 1; + assert.equal(feedId, FEED); + return schedule(); + }, + async requestUpdates(feedId) { + calls.updates += 1; + assert.equal(feedId, FEED); + return updates(); + }, + }, + hasTripUpdates: () => tripUpdates, + pathPackFor: () => createRailPathPack(rawPaths), + now: () => h.now, + setInterval: (fn, ms) => { + const id = nextTimer++; + timers.set(id, { fn, ms }); + return id; + }, + clearInterval: (id) => timers.delete(id), + }); + return h; +} + +const liveTrain = (record) => ({ + feedId: FEED, + tripId: record.tripId ?? null, + routeId: record.routeId ?? null, + record, +}); + +// --------------------------------------------------------------------------- +// Where a run is, compared with a live train's report to find its run + +test('the tier places no trains: it has no train records to offer', () => { + assert.equal(schedules.scheduledTrainRecords, undefined); + const provider = createRailSchedules({ + source: {}, + hasTripUpdates: () => false, + }); + assert.deepEqual(Object.keys(provider).sort(), [ + 'delaysFor', + 'forget', + 'loadSchedule', + 'onSelect', + 'scheduleFor', + 'stats', + 'stop', + 'stopsFor', + 'unwatchUpdates', + 'watchUpdates', + ]); +}); + +test('without the path pack a run’s point lies between its stops', () => { + const schedule = createSchedule(rawSchedule); + const point = scheduledRunPoint(schedule, 'T1', at('10:05'), null, null); + assert.equal(point.onShape, false); + assert.ok(Math.abs(point.lon - 0.05) < 1e-9); + assert.ok(Number.isNaN(point.bearingDeg)); + const onShape = scheduledRunPoint( + schedule, + 'T1', + at('10:05'), + null, + createRailPathPack(rawPaths), + ); + assert.equal(onShape.onShape, true); + assert.ok(Math.abs(onShape.bearingDeg - 90) < 1e-6); +}); + +test('a schedule from another build than its path pack stays between stops', () => { + const built = '2026-11-10T08:00:00.000Z'; + const schedule = createSchedule({ ...rawSchedule, pathsBuiltAt: built }); + const same = createRailPathPack({ ...rawPaths, builtAt: built }); + const rebuilt = createRailPathPack({ + ...rawPaths, + builtAt: '2026-11-10T09:00:00.000Z', + }); + assert.equal(schedule.pathsBuiltAt, built); + assert.equal(same.builtAt, built); + assert.equal(pathsMatch(schedule, same), true); + assert.equal(pathsMatch(schedule, rebuilt), false); + assert.equal(pathsMatch(schedule, null), false); + // Packs from before the stamps are taken as a pair. + assert.equal(pathsMatch(createSchedule(rawSchedule), rebuilt), true); + const onShape = scheduledRunPoint(schedule, 'T1', at('10:05'), null, same); + assert.equal(onShape.onShape, true); + const between = scheduledRunPoint(schedule, 'T1', at('10:05'), null, rebuilt); + assert.equal(between.onShape, false); + assert.ok(Math.abs(between.lon - 0.05) < 1e-9); +}); + +test('trip updates become the delays map the timetable reads', () => { + const delays = delaysFromUpdates({ + trips: [ + { + tripId: 'T1', + routeId: 'R1', + startDate: '20261106', + relationship: 'SCHEDULED', + delaySec: null, + stops: [{ stopId: 'B', arrival: { time: null, delay: 240 } }], + }, + { tripId: '', delaySec: 60 }, + null, + ], + }); + assert.deepEqual(Object.keys(delays), ['T1']); + assert.equal(delays.T1.delaySec, null); + assert.equal(delays.T1.stops[0].arrival.delay, 240); + assert.deepEqual(delaysFromUpdates(null), {}); +}); + +// --------------------------------------------------------------------------- +// Stops provider + +test('the provider answers null until the timetable has loaded', async () => { + const h = harness(); + const train = liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.05, + }); + assert.equal(h.provider.stopsFor(train, h.now), null); + assert.equal(h.calls.schedule, 1); + // Asking again while it loads does not fetch twice. + assert.equal(h.provider.stopsFor(train, h.now), null); + assert.equal(h.calls.schedule, 1); + await flush(); + const trip = h.provider.stopsFor(train, h.now); + assert.equal(trip.match, 'trip'); + assert.equal(trip.headsign, 'Charlie'); + assert.deepEqual( + trip.stops.map((stop) => [stop.name, stop.state, stop.expectedArrival]), + [ + ['Alpha', 'passed', '10:00'], + ['Bravo', 'next', '10:10'], + ['Charlie', 'upcoming', '10:21'], + ], + ); + assert.equal(trip.stops[1].relative, 'T−5'); + assert.equal(h.calls.schedule, 1); + assert.equal(h.provider.stopsFor(null, h.now), null); +}); + +test('a live trip id the timetable lacks falls back to the nearest run', async () => { + const h = harness(); + await h.provider.loadSchedule(FEED); + const near = h.provider.stopsFor( + liveTrain({ + id: 'v7', + tripId: 'live-123', + routeId: 'R1', + lat: LAT, + lon: 0.149, + }), + h.now, + ); + // At 10:05 T2 is at 0.15 (westbound) and T1 at 0.05: T2 is nearest. + assert.equal(near.match, 'nearest'); + assert.equal(near.headsign, 'Alpha'); + // A train with no route searches every run. + const noRoute = h.provider.stopsFor( + liveTrain({ id: 'v8', tripId: null, lat: LAT, lon: 0.051 }), + h.now, + ); + assert.equal(noRoute.headsign, 'Charlie'); + // Too far from any run: no stops rather than a wrong train's. + const farLon = 0.05 + (2 * MATCH_NEAREST_MAX_M) / (STEP_M * 10); + assert.equal( + h.provider.stopsFor( + liveTrain({ + id: 'v9', + tripId: 'x', + routeId: 'R1', + lat: LAT + 0.1, + lon: farLon, + }), + h.now, + ), + null, + ); + // Another route's train does not take a run of R1. + assert.equal( + h.provider.stopsFor( + liveTrain({ id: 'v10', tripId: 'x', routeId: 'R9', lat: LAT, lon: 0.05 }), + h.now, + ), + null, + ); +}); + +test('delays are polled every 30 s only while a train on the feed is selected', async () => { + const h = harness({ + updates: () => + reply({ + trips: [ + { + tripId: 'T1', + delaySec: 240, + stops: [{ stopId: 'B', arrival: { delay: 240 }, departure: null }], + }, + ], + }), + }); + const train = liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.05, + }); + assert.equal(h.calls.updates, 0); + h.provider.onSelect(train); + await flush(); + assert.equal(h.calls.updates, 1); + assert.equal(h.timers.size, 1); + assert.equal([...h.timers.values()][0].ms, RAIL_UPDATES_POLL_MS); + assert.equal(RAIL_UPDATES_POLL_MS, 30_000); + const trip = h.provider.stopsFor(train, h.now); + const bravo = trip.stops[1]; + assert.equal(bravo.arrivalDelaySec, 240); + assert.equal(bravo.expectedArrival, '10:14'); + + // The interval re-reads; reselecting on the same feed adds no second poll. + [...h.timers.values()][0].fn(); + await flush(); + assert.equal(h.calls.updates, 2); + h.provider.onSelect({ ...train, record: { ...train.record, id: 'v2' } }); + assert.equal(h.timers.size, 1); + + // Delays too old to trust are not applied. + h.now += RAIL_UPDATES_MAX_AGE_MS + 1; + assert.equal(h.provider.delaysFor(FEED), null); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 0); + + // Clearing the selection stops the poll. + h.provider.onSelect(null); + assert.equal(h.timers.size, 0); +}); + +test('a selected train’s delays are not polled when its feed has no timetable', async () => { + const h = harness({ + schedule: () => reply({ error: 'No rail schedule' }, 404), + }); + h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + await flush(); + assert.equal(h.calls.schedule, 1); + assert.equal(h.calls.updates, 0); + assert.equal(h.timers.size, 0); + assert.equal(h.provider.stats().updates[FEED], undefined); + + // With the pack, the same selection polls; cleared before the pack + // arrives, it starts no poll. + const ready = harness(); + ready.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + ready.provider.onSelect(null); + await flush(); + assert.equal(ready.calls.updates, 0); + assert.equal(ready.timers.size, 0); + ready.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + await flush(); + assert.equal(ready.calls.updates, 1); + assert.equal(ready.timers.size, 1); +}); + +test('every reader of a feed’s delays shares one poll', async () => { + const h = harness(); + h.provider.watchUpdates(FEED, 'probe'); + h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + await flush(); + assert.deepEqual(h.provider.stats().updates[FEED].reasons, [ + 'probe', + 'selection', + ]); + assert.equal(h.timers.size, 1); + h.provider.onSelect(null); + assert.equal(h.timers.size, 1); + h.provider.unwatchUpdates(FEED, 'probe'); + assert.equal(h.timers.size, 0); + h.provider.watchUpdates(FEED, 'probe'); + h.provider.stop(); + assert.equal(h.timers.size, 0); +}); + +test('a feed without trip updates is never polled for them', async () => { + const h = harness({ tripUpdates: false }); + h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + h.provider.watchUpdates(FEED, 'probe'); + await flush(); + assert.equal(h.calls.updates, 0); + assert.equal(h.timers.size, 0); + assert.equal(h.provider.delaysFor(FEED), null); +}); + +test('without trip updates, the delay a train reports moves its stops', async () => { + // Irish Rail and SJ Nord publish no trip updates, but each report says + // how late the train is: 9 minutes here. + const h = harness({ tripUpdates: false, clock: at('10:12') }); + await h.provider.loadSchedule(FEED); + const late = (record) => + h.provider.stopsFor(liveTrain({ routeId: 'R1', ...record }), h.now); + const byTrip = late({ + id: 'v1', + tripId: 'T1', + lat: LAT, + lon: 0.06, + delaySec: 540, + }); + assert.equal(byTrip.match, 'trip'); + // On time it would have left Bravo at 10:11; 9 minutes late it is still + // to reach it. + assert.deepEqual( + byTrip.stops.map((stop) => [stop.name, stop.state, stop.expectedArrival]), + [ + ['Alpha', 'passed', '10:09'], + ['Bravo', 'next', '10:19'], + ['Charlie', 'upcoming', '10:30'], + ], + ); + assert.equal(byTrip.stops[1].arrivalDelaySec, 540); + // Without a usable trip id the run is found where its timetable was 9 + // minutes ago: T1 at 10:03, not T2, which is nearer the report at 10:12. + const byPlace = late({ + id: 'v2', + tripId: 'SJN:DatedServiceJourney:1', + lat: LAT, + lon: 0.03, + delaySec: 540, + }); + assert.equal(byPlace.match, 'nearest'); + assert.equal(byPlace.headsign, 'Charlie'); + assert.equal(byPlace.stops[1].expectedArrival, '10:19'); + // A train 25 minutes late is still on its run after the timetable ends. + h.now = at('10:40'); + const after = late({ + id: 'v3', + tripId: 'T1', + lat: LAT, + lon: 0.18, + delaySec: 1500, + }); + assert.equal(after.stops[2].state, 'next'); + assert.equal(after.stops[2].expectedArrival, '10:46'); +}); + +test('a feed with trip updates takes its delays from them, not the report', async () => { + const h = harness({ clock: at('10:12') }); + await h.provider.loadSchedule(FEED); + const trip = h.provider.stopsFor( + liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.15, + delaySec: 540, + }), + h.now, + ); + assert.deepEqual( + trip.stops.map((stop) => stop.state), + ['passed', 'passed', 'next'], + ); + assert.equal(trip.stops[2].expectedArrival, '10:21'); +}); + +test('no timetable (404) is final; a failed load is retried later', async () => { + const missing = harness({ + schedule: () => reply({ error: 'No rail schedule' }, 404), + }); + const train = liveTrain({ id: 'v1', tripId: 'T1' }); + assert.equal(missing.provider.stopsFor(train, missing.now), null); + await flush(); + missing.now += SCHEDULE_RETRY_MS * 10; + assert.equal(missing.provider.stopsFor(train, missing.now), null); + await flush(); + assert.equal(missing.calls.schedule, 1); + assert.equal(await missing.provider.loadSchedule(FEED), null); + + let fail = true; + const flaky = harness({ + schedule: () => (fail ? reply({}, 503) : reply(rawSchedule)), + }); + const warn = console.warn; + console.warn = () => {}; + try { + assert.equal(await flaky.provider.loadSchedule(FEED), null); + fail = false; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 1); + flaky.now += SCHEDULE_RETRY_MS; + assert.ok(await flaky.provider.loadSchedule(FEED)); + assert.equal(flaky.calls.schedule, 2); + // A malformed pack is not fetched again. + const broken = harness({ schedule: () => reply({ version: 99 }) }); + assert.equal(await broken.provider.loadSchedule(FEED), null); + broken.now += SCHEDULE_RETRY_MS * 10; + assert.equal(await broken.provider.loadSchedule(FEED), null); + assert.equal(broken.calls.schedule, 1); + } finally { + console.warn = warn; + } +}); + +test('a failing pack is tried again after 30 s, then twice as long each time, up to ten minutes', async () => { + assert.deepEqual( + [1, 2, 3, 4, 5, 6, 7].map(scheduleRetryDelayMs), + [30_000, 60_000, 120_000, 240_000, 480_000, 600_000, 600_000], + ); + let fail = true; + const flaky = harness({ + schedule: () => (fail ? reply({}, 502) : reply(rawSchedule)), + }); + const warn = console.warn; + console.warn = () => {}; + try { + // One transient 5xx: back within a minute, not ten. + assert.equal(await flaky.provider.loadSchedule(FEED), null); + flaky.now += SCHEDULE_RETRY_FIRST_MS - 1; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 1, 'not before 30 s'); + flaky.now += 1; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 2); + // Failing again, it waits twice as long. + flaky.now += 2 * SCHEDULE_RETRY_FIRST_MS - 1; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 2, 'not before 60 s'); + fail = false; + flaky.now += 1; + assert.ok(await flaky.provider.loadSchedule(FEED)); + assert.equal(flaky.calls.schedule, 3); + // Loaded, it is never fetched again. + flaky.now += SCHEDULE_RETRY_MS * 10; + assert.ok(await flaky.provider.loadSchedule(FEED)); + assert.equal(flaky.calls.schedule, 3); + } finally { + console.warn = warn; + } +}); + +test('a forgotten feed gives back its timetable and loads it again when used', async () => { + const h = harness(); + await h.provider.loadSchedule(FEED); + assert.equal(h.provider.stats().schedules[FEED].status, 'ready'); + assert.equal(h.calls.schedule, 1); + h.provider.forget(FEED); + assert.equal(h.provider.stats().schedules[FEED], undefined); + await h.provider.loadSchedule(FEED); + assert.equal(h.calls.schedule, 2); + // Trip updates still polled for a reason are not dropped. + h.provider.watchUpdates(FEED, 'probe'); + await flush(); + h.provider.forget(FEED); + assert.ok(h.provider.stats().updates[FEED].polling); + h.provider.unwatchUpdates(FEED, 'probe'); + h.provider.forget(FEED); + assert.equal(h.provider.stats().updates[FEED], undefined); +}); diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js new file mode 100644 index 000000000..4f4564bc6 --- /dev/null +++ b/src/layers/rail/selection.js @@ -0,0 +1,169 @@ +import * as Cesium from 'cesium'; +import { + RAIL_SELECTED_OVERLAY_SOURCE_ID, + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + SELECTED_CARD_REFRESH_MS, + buildRailSelectionCopy, + createRailSelectedOverlayEntry, +} from './policy.js'; +import { getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; +import { t } from '../../i18n/index.js'; + +/** + * Click-to-select one train, a card that follows it, and the camera handed + * to it (follow.js). Escape or a click on anything else deselects it. + * @param {object} context + * @returns {object} + */ +export function createSelection({ state, services, parts }) { + const { governorRequestRender } = services.render; + + /** The card's anchor: the marker's current position, read on every paint. */ + function anchorFor(entry) { + return () => { + const p = entry.marker?.show !== false ? entry.marker?.position : null; + return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null; + }; + } + + function refreshSelectedCard(force) { + const entry = state._trains.get(state._selectedKey); + if (!entry) return; + const now = Date.now(); + if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) + return; + state._selectedCardAt = now; + const feed = getRegisteredRailFeed(entry.feedId); + if (!feed) return; + const copy = buildRailSelectionCopy( + feed, + entry.record, + now, + entry.fetchedAt, + entry, + t, + state._trip, + ); + const text = JSON.stringify(copy); + if (!force && text === state._selectedCardText) return; + state._selectedCardText = text; + const card = createRailSelectedOverlayEntry( + entry.key, + anchorFor(entry), + copy, + entry.category, + ); + state._overlayHost.setEntries( + RAIL_SELECTED_OVERLAY_SOURCE_ID, + card ? [card] : [], + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + } + + /** + * Select a train without touching the camera. Called by a click (through + * selectAndFocusTrain) and by the duplicate filter, which moves the + * selection to another feed's copy of the same train; a follow on the old + * copy then glides to the new one. + * @param {string} key + */ + function selectTrain(key) { + if (!state._trains.has(key)) return; + const hadSelection = state._selectedKey !== null; + const previous = state._trains.get(state._selectedKey); + state._selectedKey = key; + const entry = state._trains.get(key); + if (previous && previous !== entry) parts.follow.handover(entry); + // The first selection dims every other train; a change of selection only + // swaps the two trains involved. + if (!hadSelection) parts.rendering.paintAll(); + else { + if (previous) parts.rendering.paint(previous); + parts.rendering.paint(entry); + } + // Line and stops first, so the card is built with this trip's stops. + parts.routes.onSelectionChanged(); + refreshSelectedCard(true); + // Its feed is polled for it at any distance from now on. + parts.viewport.requestProximityCheck(); + governorRequestRender('rail-select'); + } + + /** + * Select the train the user picked and give it the camera (follow.js): a + * moving train is followed, any other flown to once. Picking the train + * already followed keeps the follow as it is. + * @param {string} key + */ + function selectAndFocusTrain(key) { + if (!state._trains.has(key)) return; + const following = parts.follow.isFollowing(key); + // Another train's follow lets go first, so this is never taken for a + // move between two copies of one train. + if (!following) parts.follow.release(); + selectTrain(key); + if (!following) parts.follow.focus(state._trains.get(key)); + } + + /** + * Deselect, letting go of the camera where it is. + * @param {{untrack?: boolean}} [options] `untrack: false` when another + * camera owner took over and its tracked entity must stay. + */ + function clearSelection({ untrack = true } = {}) { + const hadSelection = state._selectedKey !== null; + parts.follow.release({ untrack }); + state._selectedKey = null; + state._selectedCardText = null; + if (hadSelection) { + parts.rendering.paintAll(); + parts.routes.onSelectionChanged(); + // A feed held for the train goes back to the viewport's rules. + parts.viewport.requestProximityCheck(); + } + state._overlayHost.setEntries( + RAIL_SELECTED_OVERLAY_SOURCE_ID, + [], + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + governorRequestRender('rail-deselect'); + } + + function onKeyDown(event) { + if (event.key === 'Escape' && state._selectedKey) clearSelection(); + } + + function installClickHandler(viewer) { + if (state._clickHandler) return; + state._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + state._clickHandler.setInputAction((click) => { + if (!state._enabled || !isPointerFree()) return; + const picked = viewer.scene.pick(click.position); + const id = typeof picked?.id === 'string' ? picked.id : null; + if (id && state._trains.has(id)) selectAndFocusTrain(id); + else if (state._selectedKey) clearSelection(); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + globalThis.document?.addEventListener?.('keydown', onKeyDown); + parts.follow.install(viewer); + } + + function removeClickHandler() { + state._clickHandler?.destroy(); + state._clickHandler = null; + globalThis.document?.removeEventListener?.('keydown', onKeyDown); + parts.follow.uninstall(); + } + + return { + refreshSelectedCard, + selectTrain, + selectAndFocusTrain, + clearSelection, + onKeyDown, + installClickHandler, + removeClickHandler, + }; +} diff --git a/src/layers/rail/source.js b/src/layers/rail/source.js new file mode 100644 index 000000000..111fe2fcd --- /dev/null +++ b/src/layers/rail/source.js @@ -0,0 +1,68 @@ +/** Request rail snapshots, path packs, timetables and trip updates through caller-owned transport. */ +export function createRailSource({ + fetchImpl = (...args) => fetch(...args), +} = {}) { + /** + * GET one per-feed JSON resource and hand back a response-like object whose + * body read still honours the caller's abort. + * @param {string} route `trains`, `paths`, `schedule` or `updates`. + * @param {string} feedId + * @param {{signal?: AbortSignal}} [options] + */ + function requestFeedJson(route, feedId, { signal } = {}) { + signal?.throwIfAborted(); + if (typeof feedId !== 'string' || !feedId || feedId.length > 160) + throw new TypeError('A rail feed identifier is required'); + return Promise.resolve( + fetchImpl(`/api/rail/${route}/${encodeURIComponent(feedId)}`, { + signal, + headers: { Accept: 'application/json' }, + }), + ).then((response) => { + signal?.throwIfAborted(); + return { + ok: response.ok, + status: response.status, + headers: response.headers, + async json() { + const body = await response.json(); + signal?.throwIfAborted(); + return body; + }, + }; + }); + } + + return { + /** Read the public catalog of enabled feeds. */ + async getFeeds({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/rail/feeds', { + signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error('Rail feeds HTTP ' + response.status); + const body = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(body?.feeds)) + throw new Error('Malformed rail feed catalog'); + return body.feeds; + }, + /** The feed's current train-only snapshot. */ + requestSnapshot(feedId, options) { + return requestFeedJson('trains', feedId, options); + }, + /** The feed's path pack (route shapes); 404 when none is published. */ + requestPaths(feedId, options) { + return requestFeedJson('paths', feedId, options); + }, + /** The feed's timetable pack; 404 when none is published. */ + requestSchedule(feedId, options) { + return requestFeedJson('schedule', feedId, options); + }, + /** The feed's live trip updates (delays); 404 for a feed without them. */ + requestUpdates(feedId, options) { + return requestFeedJson('updates', feedId, options); + }, + }; +} diff --git a/src/layers/rail/source.test.mjs b/src/layers/rail/source.test.mjs new file mode 100644 index 000000000..2e4510062 --- /dev/null +++ b/src/layers/rail/source.test.mjs @@ -0,0 +1,205 @@ +// The Rail source names a registered feed id and nothing else: the catalog +// route, the per-feed train route with the id encoded as one path segment, +// and the caller's abort signal at every await. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRailSource } from './source.js'; + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const isAbort = (error) => error?.name === 'AbortError'; + +test('the feed catalog is read from the public catalog route', async () => { + const requested = []; + const feeds = [ + { + id: 'mbta-rail', + name: 'MBTA', + center: { lat: 42.36, lon: -71.06 }, + loadRadiusKm: 110, + }, + ]; + const source = createRailSource({ + fetchImpl: async (url, init) => { + requested.push({ url, accept: init.headers.Accept }); + return json({ feeds }); + }, + }); + assert.deepEqual(await source.getFeeds(), feeds); + assert.deepEqual(requested, [ + { url: '/api/rail/feeds', accept: 'application/json' }, + ]); +}); + +test('feed catalog failures are reported', async () => { + const failing = createRailSource({ fetchImpl: async () => json({}, 503) }); + await assert.rejects(failing.getFeeds(), /Rail feeds HTTP 503/); + const malformed = createRailSource({ + fetchImpl: async () => json({ feeds: {} }), + }); + await assert.rejects(malformed.getFeeds(), /Malformed rail feed catalog/); + const aborted = new AbortController(); + aborted.abort(); + await assert.rejects( + createRailSource({ + fetchImpl: async () => json({ feeds: [] }), + }).getFeeds({ signal: aborted.signal }), + isAbort, + ); +}); + +test('a snapshot is requested from the train route for that feed', async () => { + const requested = []; + const snapshot = { feedId: 'mta-lirr', count: 0, dropped: 3, trains: [] }; + const controller = new AbortController(); + const source = createRailSource({ + fetchImpl: async (url, init) => { + requested.push({ + url, + signal: init.signal, + accept: init.headers.Accept, + }); + return new Response(JSON.stringify(snapshot), { + status: 200, + headers: { 'X-GEV-Cache': 'HIT', 'X-Rail-Contact': '1794000000000' }, + }); + }, + }); + const response = await source.requestSnapshot('mta-lirr', { + signal: controller.signal, + }); + assert.deepEqual(requested, [ + { + url: '/api/rail/trains/mta-lirr', + signal: controller.signal, + accept: 'application/json', + }, + ]); + assert.equal(response.ok, true); + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-gev-cache'), 'HIT'); + assert.equal(response.headers.get('x-rail-contact'), '1794000000000'); + assert.deepEqual(await response.json(), snapshot); +}); + +test('path packs are requested from the paths route with the same wrapper', async () => { + const requested = []; + const pack = { version: 1, feedId: 'grt-ion', shapes: [] }; + const source = createRailSource({ + fetchImpl: async (url) => { + requested.push(url); + return new Response(JSON.stringify(pack), { status: 200 }); + }, + }); + const response = await source.requestPaths('grt-ion'); + assert.deepEqual(requested, ['/api/rail/paths/grt-ion']); + assert.equal(response.ok, true); + assert.deepEqual(await response.json(), pack); + assert.throws(() => source.requestPaths(''), TypeError); +}); + +test('the feed id is encoded as a single path segment', async () => { + const requested = []; + const source = createRailSource({ + fetchImpl: async (url) => { + requested.push(url); + return json({}, 404); + }, + }); + const response = await source.requestSnapshot('../feeds?x=1 #'); + assert.equal(response.ok, false); + assert.equal(response.status, 404); + assert.deepEqual(requested, ['/api/rail/trains/..%2Ffeeds%3Fx%3D1%20%23']); +}); + +test('a missing, non-string or oversized feed id is refused', () => { + let calls = 0; + const source = createRailSource({ + fetchImpl: async () => { + calls += 1; + return json({}); + }, + }); + for (const id of ['', undefined, null, 42, {}, 'a'.repeat(161)]) { + assert.throws( + () => source.requestSnapshot(id), + { name: 'TypeError', message: 'A rail feed identifier is required' }, + String(id).slice(0, 12), + ); + } + assert.doesNotThrow(() => source.requestSnapshot('a'.repeat(160))); + assert.equal(calls, 1); +}); + +test('an already aborted request never reaches the network', () => { + let calls = 0; + const source = createRailSource({ + fetchImpl: async () => { + calls += 1; + return json({}); + }, + }); + const controller = new AbortController(); + controller.abort(); + assert.throws( + () => source.requestSnapshot('mbta-rail', { signal: controller.signal }), + isAbort, + ); + assert.equal(calls, 0); +}); + +test('an abort while the response is in flight rejects the snapshot', async () => { + const controller = new AbortController(); + const source = createRailSource({ + fetchImpl: async () => { + controller.abort(); + return json({ trains: [] }); + }, + }); + await assert.rejects( + source.requestSnapshot('mbta-rail', { signal: controller.signal }), + isAbort, + ); +}); + +test('an abort while the body is read rejects the parsed snapshot', async () => { + const controller = new AbortController(); + const source = createRailSource({ + fetchImpl: async () => json({ trains: [] }), + }); + const response = await source.requestSnapshot('mbta-rail', { + signal: controller.signal, + }); + controller.abort(); + await assert.rejects(response.json(), isAbort); +}); + +test('timetables and trip updates use the same per-feed wrapper', async () => { + const requested = []; + const source = createRailSource({ + fetchImpl: async (url, init) => { + requested.push([url, init.headers.Accept]); + return url.includes('/schedule/') + ? json({ error: 'No rail schedule' }, 404) + : json({ trips: [{ tripId: 't1', delaySec: 120 }] }); + }, + }); + const schedule = await source.requestSchedule('mbta-rail'); + assert.equal(schedule.ok, false); + assert.equal(schedule.status, 404); + const updates = await source.requestUpdates('mbta-rail'); + assert.equal(updates.ok, true); + assert.deepEqual(await updates.json(), { + trips: [{ tripId: 't1', delaySec: 120 }], + }); + assert.deepEqual(requested, [ + ['/api/rail/schedule/mbta-rail', 'application/json'], + ['/api/rail/updates/mbta-rail', 'application/json'], + ]); + assert.throws(() => source.requestSchedule(''), TypeError); + assert.throws(() => source.requestUpdates(42), TypeError); +}); diff --git a/src/layers/rail/stopLabels.js b/src/layers/rail/stopLabels.js new file mode 100644 index 000000000..70381d93b --- /dev/null +++ b/src/layers/rail/stopLabels.js @@ -0,0 +1,275 @@ +/** + * @module layers/rail/stopLabels + * @description Which stops of a selected train's trip the map labels, the + * colour of each label's time chip, and the shared-overlay entries for them. + * + * Stops come from an injected provider (see createRailLayer in index.js), + * normally the stop list railSchedule.tripStops() returns. The fields read + * here are: + * + * { + * stopId: string, + * name: string, + * parentName?: string|null, // the station a platform stop belongs to + * lat: number, lon: number, + * state: 'passed' | 'next' | 'upcoming', + * atStop?: boolean, // the train dwells at its next stop + * relative: string, // 'T−4' (due in 4 min), 'T+3' (3 min ago) + * expectedArrival?: string, // 24 h, station local time: '14:32' + * expectedDeparture?: string, + * arrivalDelaySec?: number, // expected minus timetable + * departureDelaySec?: number, + * skipped?: boolean, // cancelled at this stop + * passThrough?: boolean, // the train runs through without stopping + * } + * + * The clock and the delay shown are those of the event `relative` counts to: + * the departure from a stop passed or dwelt at, else the arrival. + * + * The map labels at most four stops: the stop just passed, the next stop, the + * stop after it and the terminus. Every other stop is a dot without a label. + * + * Pure: no Cesium, no DOM. The caller supplies anchors. + */ + +/** Overlay source for the selected train's stop labels. */ +export const RAIL_STOP_OVERLAY_SOURCE_ID = 'rail-stops'; +export const RAIL_STOP_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 4, + collisionCapacity: 4, + maxVisible: 4, + moving: false, +}); + +/** A stop this late or later shows an amber time chip (ms). */ +export const STOP_LATE_MS = 3 * 60_000; +/** ...and this late or later, a red one (ms). */ +export const STOP_VERY_LATE_MS = 10 * 60_000; + +/** Time-chip colours: on time, late, very late. */ +export const STOP_CHIP_COLORS = Object.freeze({ + onTime: '#C9D1D9', + late: '#FFB020', + veryLate: '#FF4D4F', +}); + +/** Label roles, most important first for the overlay host. */ +const ROLE_PRIORITY = Object.freeze({ + next: 4, + previous: 3, + afterNext: 2, + terminus: 1, +}); + +/** Above any ambient label another layer publishes. */ +const STOP_LABEL_BASE_PRIORITY = 1e12; + +/** + * The name a stop is shown by: its station's, when the timetable lists the + * platform as a child of one ("Town Hall Station", not "Town Hall Station + * Platform 3"), else its own. + * @param {object} stop + * @returns {string} + */ +export function stopName(stop) { + const station = + typeof stop?.parentName === 'string' ? stop.parentName.trim() : ''; + if (station) return station; + return typeof stop?.name === 'string' ? stop.name.trim() : ''; +} + +/** Whether a value is a drawable stop: named and placed. */ +function placedStop(stop) { + return ( + !!stop && + stopName(stop) !== '' && + Number.isFinite(stop.lat) && + Number.isFinite(stop.lon) && + Math.abs(stop.lat) <= 90 + ); +} + +/** Whether the train calls at a stop: worth a label, a card line, a role. */ +function callingStop(stop) { + return placedStop(stop) && stop.skipped !== true && stop.passThrough !== true; +} + +/** Whether the stop's shown event is its departure rather than its arrival. */ +function showsDeparture(stop) { + return stop?.state === 'passed' || stop?.atStop === true; +} + +/** + * The 24 h station-local clock of the stop's shown event. + * @param {object} stop + * @returns {string} + */ +export function stopClock(stop) { + const departure = showsDeparture(stop); + const first = departure ? stop?.expectedDeparture : stop?.expectedArrival; + const other = departure ? stop?.expectedArrival : stop?.expectedDeparture; + if (typeof first === 'string' && first) return first; + return typeof other === 'string' ? other : ''; +} + +/** + * How late the stop's shown event is expected, in ms (never negative: an + * early train is on time for the chip), or NaN when the delay is unknown. + * @param {object} stop + * @returns {number} + */ +export function stopDelayMs(stop) { + const departure = showsDeparture(stop); + const first = departure ? stop?.departureDelaySec : stop?.arrivalDelaySec; + const other = departure ? stop?.arrivalDelaySec : stop?.departureDelaySec; + // Only a number is a delay: null or a string is "unknown", not zero. + const seconds = Number.isFinite(first) ? first : other; + return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : NaN; +} + +/** + * The time-chip colour for a delay. + * @param {number} delayMs + * @returns {string} + */ +export function stopChipColor(delayMs) { + if (Number.isFinite(delayMs) && delayMs >= STOP_VERY_LATE_MS) + return STOP_CHIP_COLORS.veryLate; + if (Number.isFinite(delayMs) && delayMs >= STOP_LATE_MS) + return STOP_CHIP_COLORS.late; + return STOP_CHIP_COLORS.onTime; +} + +/** + * Whole minutes late, for the card's "(+n min late)", or 0 when on time or + * unknown. + * @param {object} stop + * @returns {number} + */ +export function stopLateMinutes(stop) { + const delay = stopDelayMs(stop); + return Number.isFinite(delay) ? Math.round(delay / 60_000) : 0; +} + +/** + * The stop just passed and the next stop of a trip, among the stops the train + * calls at. The next stop is the one the timetable marks `next`, else the + * first not yet passed; the previous is the last passed stop before it. + * @param {object[]} stops In trip order. + * @returns {{previous: number, next: number}} Indices, -1 when absent. + */ +export function stopRoleIndices(stops) { + const list = Array.isArray(stops) ? stops : []; + let next = list.findIndex( + (stop) => callingStop(stop) && stop.state === 'next', + ); + if (next < 0) + next = list.findIndex( + (stop) => callingStop(stop) && stop.state !== 'passed', + ); + let previous = -1; + const before = next < 0 ? list.length : next; + for (let i = before - 1; i >= 0; i--) { + if (callingStop(list[i]) && list[i].state === 'passed') { + previous = i; + break; + } + } + return { previous, next }; +} + +/** + * Choose what to draw for a trip: at most four labelled stops (previous, + * next, after-next, terminus) among those the train calls at, and a dot for + * every other placed stop (skipped and run-through stops too: the line passes + * them). + * @param {object[]} stops In trip order. + * @returns {{labels: {stop: object, index: number, role: string, + * priority: number}[], dots: {stop: object, index: number, passed: boolean}[]}} + */ +export function selectStopLabels(stops) { + const list = Array.isArray(stops) ? stops : []; + const { previous, next } = stopRoleIndices(list); + const chosen = new Map(); + const take = (index, role) => { + if (index < 0 || index >= list.length || chosen.has(index)) return; + if (!callingStop(list[index])) return; + chosen.set(index, role); + }; + take(next, 'next'); + take(previous, 'previous'); + if (next >= 0) { + let after = next + 1; + while (after < list.length && !callingStop(list[after])) after++; + take(after, 'afterNext'); + } + let last = list.length - 1; + while (last >= 0 && !callingStop(list[last])) last--; + // The terminus is worth a label while the train still has it ahead. + if (last >= 0 && list[last].state !== 'passed') take(last, 'terminus'); + const labels = [...chosen] + .map(([index, role]) => ({ + stop: list[index], + index, + role, + priority: ROLE_PRIORITY[role], + })) + .sort((a, b) => a.index - b.index); + const dots = []; + for (let i = 0; i < list.length; i++) { + if (chosen.has(i) || !placedStop(list[i])) continue; + dots.push({ stop: list[i], index: i, passed: list[i].state === 'passed' }); + } + return { labels, dots }; +} + +/** + * The text a stop label carries: the name, then the clock and the relative + * time, as `Yonkers · 14:32 T−4` once the host joins title and detail. + * @param {object} stop + * @returns {{title: string, detail: string}} + */ +export function stopLabelText(stop) { + const time = [stopClock(stop), stop?.relative] + .filter((part) => typeof part === 'string' && part !== '') + .join(' '); + return { title: stopName(stop), detail: time }; +} + +/** + * Shared-host entries for the chosen labels. Their time chip (the accent the + * host paints beside the text and along the leader) is coloured by delay. + * @param {string} trainKey The selected train, so ids change with it. + * @param {ReturnType['labels']} labels + * @param {(stop: object) => object|null} anchorFor Cartesian for a stop. + * @returns {object[]} + */ +export function createStopOverlayEntries(trainKey, labels, anchorFor) { + const entries = []; + for (const label of labels || []) { + const position = anchorFor(label.stop); + if (!position) continue; + const { title, detail } = stopLabelText(label.stop); + entries.push({ + id: `${trainKey}:${label.index}:${label.stop.stopId ?? label.stop.name}`, + variant: 'track', + title, + details: detail ? [detail] : [], + accent: stopChipColor(stopDelayMs(label.stop)), + priority: STOP_LABEL_BASE_PRIORITY + label.priority, + collisionGroup: 'ambient-label', + // The selected train's card is in the card domain; labels fit around it. + yieldToCollisionGroup: 'ambient-card', + paintLane: 'ambient-track', + position, + gapPx: 10, + verticalOnly: true, + placement: 'above', + horizonCull: true, + edgeFade: 'keyhole', + interactive: false, + metadata: { role: label.role }, + }); + } + return entries; +} diff --git a/src/layers/rail/stopLabels.test.mjs b/src/layers/rail/stopLabels.test.mjs new file mode 100644 index 000000000..d2620df22 --- /dev/null +++ b/src/layers/rail/stopLabels.test.mjs @@ -0,0 +1,275 @@ +// Which of a selected train's stops the map labels, how the label's time +// chip is coloured by delay, and the overlay entries the layer publishes. +// Stops have the shape railSchedule.tripStops() returns. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + STOP_CHIP_COLORS, + STOP_LATE_MS, + STOP_VERY_LATE_MS, + createStopOverlayEntries, + selectStopLabels, + stopChipColor, + stopClock, + stopDelayMs, + stopLabelText, + stopLateMinutes, + stopName, + stopRoleIndices, +} from './stopLabels.js'; + +/** A stop as tripStops() lists it; times and delays are illustrative. */ +function stop(index, name, state, extra = {}) { + return { + index, + stopId: `s${index}`, + name, + lat: 40.9 + index * 0.01, + lon: -73.9, + state, + atStop: false, + expectedArrival: `14:${String(20 + index * 4).padStart(2, '0')}`, + expectedDeparture: `14:${String(21 + index * 4).padStart(2, '0')}`, + arrivalDelaySec: 0, + departureDelaySec: 0, + skipped: false, + passThrough: false, + relative: state === 'passed' ? 'T+3' : 'T−4', + ...extra, + }; +} + +const trip = [ + stop(0, 'Grand Central', 'passed'), + stop(1, 'Harlem–125 St', 'passed'), + stop(2, 'Riverdale', 'passed'), + stop(3, 'Yonkers', 'next'), + stop(4, 'Glenwood', 'upcoming'), + stop(5, 'Hastings', 'upcoming'), + stop(6, 'Poughkeepsie', 'upcoming'), +]; + +test('the map labels previous, next, after-next and terminus only', () => { + const { labels, dots } = selectStopLabels(trip); + assert.deepEqual( + labels.map((label) => [label.stop.name, label.role]), + [ + ['Riverdale', 'previous'], + ['Yonkers', 'next'], + ['Glenwood', 'afterNext'], + ['Poughkeepsie', 'terminus'], + ], + ); + assert.deepEqual( + dots.map((dot) => [dot.stop.name, dot.passed]), + [ + ['Grand Central', true], + ['Harlem–125 St', true], + ['Hastings', false], + ], + ); + // The next stop outranks the rest when labels compete for room. + const top = labels.reduce((a, b) => (b.priority > a.priority ? b : a)); + assert.equal(top.role, 'next'); +}); + +test('never more than four labels, and none repeated near the end', () => { + const nearEnd = [ + stop(0, 'A', 'passed'), + stop(1, 'B', 'passed'), + stop(2, 'C', 'next'), + stop(3, 'D', 'upcoming'), + ]; + const { labels, dots } = selectStopLabels(nearEnd); + // After-next is the terminus: one label, not two. + assert.deepEqual( + labels.map((label) => label.role), + ['previous', 'next', 'afterNext'], + ); + assert.deepEqual( + dots.map((dot) => dot.stop.name), + ['A'], + ); + for (let n = 0; n <= 12; n++) { + const list = Array.from({ length: n }, (_, i) => + stop( + i, + `S${i}`, + i < n / 2 ? 'passed' : i === Math.ceil(n / 2) ? 'next' : 'upcoming', + ), + ); + const chosen = selectStopLabels(list); + assert.ok(chosen.labels.length <= 4, String(n)); + assert.equal(chosen.labels.length + chosen.dots.length, n); + } +}); + +test('a finished trip labels only the stop it last called at', () => { + const done = trip.map((s) => ({ ...s, state: 'passed' })); + const { labels } = selectStopLabels(done); + assert.deepEqual( + labels.map((label) => [label.stop.name, label.role]), + [['Poughkeepsie', 'previous']], + ); +}); + +test('skipped and run-through stops get a dot, never a label or a role', () => { + const list = [ + stop(0, 'A', 'passed'), + stop(1, 'B', 'passed', { passThrough: true }), + stop(2, 'C', 'next', { skipped: true }), + stop(3, 'D', 'upcoming'), + stop(4, 'E', 'upcoming'), + stop(5, 'F', 'upcoming', { skipped: true }), + ]; + assert.deepEqual(stopRoleIndices(list), { previous: 0, next: 3 }); + const { labels, dots } = selectStopLabels(list); + assert.deepEqual( + labels.map((label) => [label.stop.name, label.role]), + [ + ['A', 'previous'], + ['D', 'next'], + ['E', 'afterNext'], + ], + ); + assert.deepEqual( + dots.map((dot) => dot.stop.name), + ['B', 'C', 'F'], + ); +}); + +test('unplaced or unnamed stops are neither labelled nor dotted', () => { + const list = [ + stop(0, 'A', 'passed'), + { ...stop(1, 'B', 'next'), lat: NaN }, + stop(2, '', 'upcoming'), + stop(3, 'D', 'upcoming'), + ]; + const { labels, dots } = selectStopLabels(list); + assert.deepEqual( + labels.map((label) => label.stop.name), + ['A', 'D'], + ); + assert.deepEqual(dots, []); + assert.deepEqual(selectStopLabels(null), { labels: [], dots: [] }); + assert.deepEqual(stopRoleIndices(undefined), { previous: -1, next: -1 }); +}); + +test('the time chip is neutral on time, amber from 3 min, red from 10 min', () => { + assert.equal(STOP_LATE_MS, 180_000); + assert.equal(STOP_VERY_LATE_MS, 600_000); + assert.equal(stopChipColor(0), STOP_CHIP_COLORS.onTime); + assert.equal(stopChipColor(STOP_LATE_MS - 1), STOP_CHIP_COLORS.onTime); + assert.equal(stopChipColor(STOP_LATE_MS), STOP_CHIP_COLORS.late); + assert.equal(stopChipColor(STOP_VERY_LATE_MS - 1), STOP_CHIP_COLORS.late); + assert.equal(stopChipColor(STOP_VERY_LATE_MS), STOP_CHIP_COLORS.veryLate); + assert.equal(stopChipColor(3 * 3_600_000), STOP_CHIP_COLORS.veryLate); + assert.equal(stopChipColor(NaN), STOP_CHIP_COLORS.onTime); + const colors = new Set(Object.values(STOP_CHIP_COLORS)); + assert.equal(colors.size, 3); +}); + +test('the delay and clock are those of the event the chip counts to', () => { + const late = stop(3, 'Yonkers', 'next', { + arrivalDelaySec: 240, + departureDelaySec: 660, + }); + // Arriving: the arrival's clock and delay. + assert.equal(stopClock(late), '14:32'); + assert.equal(stopDelayMs(late), 240_000); + assert.equal(stopChipColor(stopDelayMs(late)), STOP_CHIP_COLORS.late); + assert.equal(stopLateMinutes(late), 4); + // Standing at the stop, or past it: the departure's. + const dwelling = { ...late, atStop: true }; + assert.equal(stopClock(dwelling), '14:33'); + assert.equal(stopDelayMs(dwelling), 660_000); + assert.equal(stopChipColor(stopDelayMs(dwelling)), STOP_CHIP_COLORS.veryLate); + assert.equal(stopClock({ ...late, state: 'passed' }), '14:33'); + // An early train is on time; a missing delay or clock falls back. + assert.equal(stopDelayMs({ ...late, arrivalDelaySec: -120 }), 0); + assert.equal(stopLateMinutes({ ...late, arrivalDelaySec: -120 }), 0); + assert.equal(stopDelayMs({ ...late, arrivalDelaySec: undefined }), 660_000); + assert.ok( + Number.isNaN( + stopDelayMs({ name: 'X', arrivalDelaySec: null, departureDelaySec: 'x' }), + ), + ); + assert.equal(stopClock({ ...late, expectedArrival: '' }), '14:33'); + assert.equal(stopClock({ name: 'X' }), ''); +}); + +test('a label reads name, then clock and relative time', () => { + assert.deepEqual(stopLabelText(stop(3, 'Yonkers', 'next')), { + title: 'Yonkers', + detail: '14:32 T−4', + }); + assert.deepEqual(stopLabelText(stop(2, 'Riverdale', 'passed')), { + title: 'Riverdale', + detail: '14:29 T+3', + }); + assert.deepEqual(stopLabelText({ name: ' Glenwood ', relative: '' }), { + title: 'Glenwood', + detail: '', + }); +}); + +test('overlay entries collide as labels and yield to the selected card', () => { + const late = trip.map((s) => + s.name === 'Yonkers' ? { ...s, arrivalDelaySec: 720 } : s, + ); + const { labels } = selectStopLabels(late); + const anchors = []; + const entries = createStopOverlayEntries( + 'rail:mta-metro-north:123', + labels, + (s) => { + anchors.push(s.name); + return s.name === 'Glenwood' ? null : { x: 1, y: 2, z: 3 }; + }, + ); + // A stop without an anchor is left out, not drawn at the origin. + assert.deepEqual( + entries.map((entry) => entry.title), + ['Riverdale', 'Yonkers', 'Poughkeepsie'], + ); + assert.equal(anchors.length, 4); + const yonkers = entries[1]; + assert.equal(yonkers.variant, 'track'); + assert.deepEqual(yonkers.details, ['14:32 T−4']); + assert.equal(yonkers.accent, STOP_CHIP_COLORS.veryLate); + assert.equal(yonkers.collisionGroup, 'ambient-label'); + assert.equal(yonkers.yieldToCollisionGroup, 'ambient-card'); + assert.equal(yonkers.protected, undefined); + assert.equal(entries[0].accent, STOP_CHIP_COLORS.onTime); + assert.ok(yonkers.priority > entries[0].priority); + assert.ok(entries[0].priority > entries[2].priority); + assert.equal(new Set(entries.map((entry) => entry.id)).size, entries.length); + assert.ok( + entries.every((entry) => entry.id.startsWith('rail:mta-metro-north:123:')), + ); +}); + +test('a platform stop is shown by its station’s name', () => { + const platform = stop(3, 'Wynyard Station Platform 4', 'next', { + parentName: 'Wynyard Station', + }); + assert.equal(stopName(platform), 'Wynyard Station'); + assert.equal(stopLabelText(platform).title, 'Wynyard Station'); + assert.equal( + stopName({ ...platform, parentName: null }), + 'Wynyard Station Platform 4', + ); + assert.equal( + stopName({ ...platform, parentName: ' ' }), + 'Wynyard Station Platform 4', + ); + // A stop known only by its station is still drawable. + const { labels } = selectStopLabels([ + stop(0, 'A', 'passed'), + { ...stop(1, '', 'next'), parentName: 'Central' }, + ]); + assert.deepEqual( + labels.map((label) => stopName(label.stop)), + ['A', 'Central'], + ); +}); diff --git a/src/layers/rail/viewport.js b/src/layers/rail/viewport.js new file mode 100644 index 000000000..03771bd6c --- /dev/null +++ b/src/layers/rail/viewport.js @@ -0,0 +1,224 @@ +import * as Cesium from 'cesium'; +import { + ACTIVATION_ENTER_ALTITUDE_M, + ACTIVATION_EXIT_ALTITUDE_M, + CAMERA_DEBOUNCE_MS, + RANGE_SLACK_KM, + isLiveRailPositionSource, + railCatalogRetryDelayMs, +} from './policy.js'; +import { + RAIL_ENABLED_FEEDS, + railFeedNeedsSchedulePack, + railFeedsInRange, +} from '../../data/railFeeds.js'; + +/** + * Camera-driven feed activation: only the feeds whose coverage circle contains + * the look-at point are polled, and only below the altitude gate; the selected + * train's feed is polled for it wherever the camera is. + * @param {object} context + * @returns {object} + */ +export function createViewport({ state, services, parts, source }) { + const { governorRequestRender } = services.render; + + function cameraAltitude() { + const carto = state._viewer?.camera?.positionCartographic; + return carto && Number.isFinite(carto.height) ? carto.height : Infinity; + } + + function cameraCenter() { + const viewer = state._viewer; + const rect = viewer?.camera?.computeViewRectangle?.( + viewer.scene.globe?.ellipsoid, + ); + const point = rect + ? Cesium.Rectangle.center(rect) + : viewer?.camera?.positionCartographic; + if (!point) return null; + return { + lat: Cesium.Math.toDegrees(point.latitude), + lon: Cesium.Math.toDegrees(point.longitude), + }; + } + + /** + * Bundled feeds used until the catalog has loaded: those that need no + * schedule pack. A live feed that reads its routes from one is used only + * once the catalog lists it, which it does only once its pack is built. + */ + const PACKLESS_FEEDS = RAIL_ENABLED_FEEDS.filter( + (feed) => + isLiveRailPositionSource(feed.positions) && + !railFeedNeedsSchedulePack(feed), + ); + + /** The server's catalog once it has loaded, else the packless feeds. */ + function pollableFeeds() { + return state._catalog || PACKLESS_FEEDS; + } + + /** + * Read the server's feed catalog once per layer instance. It leaves out + * the feeds whose packs are not built, which are then never polled; until + * it answers (or with a source that has no catalog), only the feeds that + * need no pack are used. A feed it lists without live positions is never + * polled: no train is placed from a timetable. A catalog that fails is + * asked for again after railCatalogRetryDelayMs while the layer is on. + */ + function loadCatalog() { + if (state._catalog || state._catalogRequest) return; + if (typeof source.getFeeds !== 'function') return; + clearTimeout(state._catalogRetryTimer); + state._catalogRetryTimer = null; + const controller = new AbortController(); + const request = { controller }; + state._catalogRequest = request; + (async () => { + try { + const feeds = await source.getFeeds({ signal: controller.signal }); + if (state._catalogRequest !== request) return; + const usable = feeds.filter( + (feed) => + typeof feed?.id === 'string' && + Number.isFinite(feed?.center?.lat) && + Number.isFinite(feed?.center?.lon) && + Number.isFinite(feed?.loadRadiusKm) && + isLiveRailPositionSource(feed?.positions), + ); + state._catalog = Object.freeze(usable); + state._catalogFailures = 0; + state._catalogRequest = null; + logCatalog(usable); + runProximityCheck(); + } catch (error) { + if (error?.name === 'AbortError' || state._catalogRequest !== request) + return; + state._catalogRequest = null; + state._catalogFailures += 1; + const delay = railCatalogRetryDelayMs(state._catalogFailures); + console.warn( + `[Data:Rail] feed catalog unavailable, trying again in ${Math.round(delay / 1000)}s:`, + error?.message || error, + ); + if (!state._enabled) return; + state._catalogRetryTimer = setTimeout(() => { + state._catalogRetryTimer = null; + if (state._enabled) loadCatalog(); + }, delay); + } finally { + if (state._catalogRequest === request) state._catalogRequest = null; + } + })(); + } + + /** Say once how many live feeds the catalog lists. */ + function logCatalog(feeds) { + console.log(`[Data:Rail] Catalog lists ${feeds.length} live train feeds`); + } + + function cancelCatalog() { + state._catalogRequest?.controller.abort(); + state._catalogRequest = null; + clearTimeout(state._catalogRetryTimer); + state._catalogRetryTimer = null; + } + + function runProximityCheck() { + if (globalThis.document?.visibilityState === 'hidden') return; + if (!state._enabled || !state._viewer) return; + // The feeds in use until now are marked seen before any is let go. + parts.routes.releaseIdleFeeds(Date.now()); + const altitude = cameraAltitude(); + state._altitudeGateOpen = state._altitudeGateOpen + ? altitude <= ACTIVATION_EXIT_ALTITUDE_M + : altitude <= ACTIVATION_ENTER_ALTITUDE_M; + const center = state._altitudeGateOpen ? cameraCenter() : null; + const desired = new Map(); + if (center) { + const feeds = pollableFeeds(); + for (const feed of railFeedsInRange(center.lat, center.lon, 0, feeds)) + desired.set(feed.id, feed); + for (const feed of railFeedsInRange( + center.lat, + center.lon, + RANGE_SLACK_KM, + feeds, + )) + if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); + } + // The selected train's feed stays polled however far out or away the + // camera goes, so the train and its line stay on the map; while nothing + // else would keep the feed, it carries the selected train alone and its + // other trains go as they would with the feed. + const selected = state._trains.get(state._selectedKey); + const held = + selected && + !desired.has(selected.feedId) && + state._activeFeeds.has(selected.feedId) + ? selected.feedId + : null; + if (held) desired.set(held, state._activeFeeds.get(held)); + const released = state._heldFeedId !== held ? state._heldFeedId : null; + state._heldFeedId = held; + for (const feedId of [...state._activeFeeds.keys()]) { + if (desired.has(feedId)) continue; + state._inFlight.get(feedId)?.controller.abort(); + state._inFlight.delete(feedId); + state._activeFeeds.delete(feedId); + state._feedStatus.delete(feedId); + parts.ingestion.removeFeedTrains(feedId); + } + if (held) parts.ingestion.removeFeedTrains(held, state._selectedKey); + // A feed the selection held that the view wants again brings its other + // trains back now, not at the next poll. + if (released && desired.has(released)) + void parts.ingestion.pollFeed(desired.get(released), state._generation); + for (const [feedId, feed] of desired) { + if (state._activeFeeds.has(feedId)) continue; + state._activeFeeds.set(feedId, feed); + void parts.ingestion.pollFeed(feed, state._generation); + } + parts.ingestion.sweepAgedTrains(Date.now()); + parts.routes.onCamera(); + parts.routes.applyNetwork(); + parts.rendering.syncRenderHold(); + state._dataManager?.refreshLayerStats?.(); + governorRequestRender('rail-proximity'); + } + + function onCameraChanged() { + // The network's altitude fade follows the zoom without the debounce; it + // only writes when the fade crosses a step. + parts.routes.onCamera(); + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = setTimeout(() => { + state._cameraDebounceTimer = null; + runProximityCheck(); + }, CAMERA_DEBOUNCE_MS); + } + + /** + * Re-run the check off the caller's stack: a selection changed, so the + * feed it holds may change too. + */ + function requestProximityCheck() { + if (!state._enabled) return; + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = setTimeout(() => { + state._cameraDebounceTimer = null; + runProximityCheck(); + }, 0); + } + + return { + runProximityCheck, + onCameraChanged, + requestProximityCheck, + cameraAltitude, + loadCatalog, + cancelCatalog, + pollableFeeds, + }; +} diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 2980b21e6..a9b7a2334 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -25,6 +25,7 @@ const COCKPIT_REFUSALS = Object.freeze({ fire: msg('Exit cockpit to fly to a fire'), sighting: msg('Exit cockpit to fly to a sighting'), 'sighting-area': msg('Exit cockpit to fly to a sighting area'), + train: msg('Exit cockpit to fly to a train'), route: msg('Exit cockpit to fly to a route'), file: msg('Exit cockpit to fly to a file'), drawing: msg('Exit cockpit to fly to a drawing'), diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index f491e209e..4031c9b31 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -304,6 +304,13 @@ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ sinceDays: 0, }), radio: Object.freeze({ filter: 'all', volume: 0.8 }), + rail: Object.freeze({ + transit: true, + local: true, + regional: true, + national: true, + international: true, + }), 'wildlife-sightings': Object.freeze({ confirmed: false, taxa: 'all' }), }); diff --git a/src/sources/railService.js b/src/sources/railService.js new file mode 100644 index 000000000..c6d92344b --- /dev/null +++ b/src/sources/railService.js @@ -0,0 +1,968 @@ +/** + * Realtime train proxy for the Rail data layer. + * + * Only URLs registered in `src/data/railFeeds.js` are ever fetched: the browser + * names a registered id, never a URL. The request policy is the Transit + * proxy's (src/sources/transitService.js), applied to the rail registry: hop-by-hop + * redirects on the feed's own https origin, a byte cap, a memory cache with + * single-flight refresh (15 s unless the feed sets its own window), + * serve-stale-on-failure for up to 10 minutes, a fixed-window admission + * limiter and a per-feed cooldown ladder. No disk cache and no history store. + * + * Every registered feed is open: no request carries a credential. + */ + +import { coalesceProxyRequest, readResponseBytesCapped } from './httpBody.js'; +import { makeRateLimiter } from './rateLimit.js'; +import { + publicRailCatalog, + railEnabledFeeds, + railFeedNeedsSchedulePack, +} from '../data/railFeeds.js'; +import { + TRANSIT_MAX_REDIRECTS, + isTransitRedirectStatus, + transitRedirectDecision, +} from '../data/transitProxy.js'; +import { + RAIL_ADMISSION_MAX_PER_FEED, + RAIL_ADMISSION_WINDOW_MS, + RAIL_PATH_PACK_MAX_BYTES, + RAIL_PROXY_TIMEOUT_MS, + RailFeedShapeError, + buildRailSnapshot, + buildRailTripUpdatesSnapshot, + railAdmissionGlobalMax, + isAcceptableRailUpstreamUrl, + nextRailBackoffMs, + railCacheStateFor, + railFeedTtlMs, + railMaxBodyBytes, + railResponseHeaders, + railUpstreamHeaders, + resolveRailRoute, +} from '../data/railProxy.js'; +import { + RAIL_SCHEDULE_PACK_VERSION, + liveDelaySec, + schedulePackEndMs, +} from '../data/railSchedule.js'; +import { createRunIndex } from '../data/railScheduleIndex.js'; + +const JSON_NO_STORE = Object.freeze({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', +}); + +/** + * A request header, from a Fetch Request's Headers or a Node-style headers + * object (lowercase names), or null. + * @param {{headers?: Headers|Record}} incoming + * @param {string} name Lowercase. + * @returns {string|null} + */ +function requestHeader(incoming, name) { + const headers = incoming?.headers; + if (!headers) return null; + if (typeof headers.get === 'function') return headers.get(name); + const value = headers[name]; + if (Array.isArray(value)) return value.join(', '); + return typeof value === 'string' ? value : null; +} + +/** + * The strong entity tag of a pack version (a build stamp, or a file's time + * and size): percent-encoded, so any version is a valid tag and two versions + * never share one. + * @param {string} version + * @returns {string} + */ +function packEtag(version) { + return `"${encodeURIComponent(version)}"`; +} + +/** Whether an If-None-Match header names this tag (weak comparison). */ +function etagMatches(header, etag) { + if (!header) return false; + const own = etag.replace(/^W\//, ''); + return header + .split(',') + .map((tag) => tag.trim()) + .some((tag) => tag === '*' || tag.replace(/^W\//, '') === own); +} + +/** + * Fetch a registered feed URL, resolving each redirect hop BEFORE following + * it (the Transit rule, transitRedirectDecision), so a hop that leaves the + * URL's https origin is refused without being contacted. + * @param {object} feed Registry entry. + * @param {AbortSignal} signal Abort signal for the whole hop chain. + * @param {typeof fetch} [fetchImpl] Injected for tests. + * @param {{etag?: string|null, lastModified?: string|null}} [validators] + * @param {{url?: string}} [options] Which registered URL: the positions + * `url` by default, or `tripUpdatesUrl`. + * @returns {Promise<{response: Response, finalUrl: string}>} + */ +export async function fetchRailFeed( + feed, + signal, + fetchImpl = fetch, + validators = null, + { url = feed.url } = {}, +) { + let current = url; + for (let hop = 0; hop <= TRANSIT_MAX_REDIRECTS; hop += 1) { + const response = await fetchImpl(current, { + signal, + method: 'GET', + headers: railUpstreamHeaders(feed, validators), + redirect: 'manual', + }); + // A 304 is the answer to our own conditional request, not a redirect. + if (!isTransitRedirectStatus(response?.status)) { + return { response, finalUrl: current }; + } + const decision = transitRedirectDecision( + url, + current, + response.headers.get('location'), + ); + try { + await response.body?.cancel(); + } catch { + /* no-op */ + } + if (!decision.ok) throw new Error(`upstream ${decision.reason}`); + if (hop === TRANSIT_MAX_REDIRECTS) { + throw new Error('upstream redirected too many times'); + } + current = decision.url; + } + throw new Error('upstream redirected too many times'); +} + +/** + * A Response body for a pack as the reader returned it: text as it is, + * bytes as the one chunk of a stream. A Response made from bytes directly + * copies them; a stream hands the reader's own buffer on, so a large pack + * is never decoded or copied on its way out. + * @param {string|Uint8Array|ArrayBuffer} body + * @returns {string|ReadableStream} + */ +function packBody(body) { + if (typeof body === 'string') return body; + const bytes = body instanceof Uint8Array ? body : new Uint8Array(body); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +/** When a viewer asks again for a pack that could not be read (s). */ +const PACK_UNREADABLE_RETRY_S = 60; + +/** How long an answer to "is this feed's schedule pack built?" is kept. */ +export const SCHEDULE_PACK_PROBE_TTL_MS = 60_000; +const SCHEDULE_PACK_PROBE_RETRY_S = SCHEDULE_PACK_PROBE_TTL_MS / 1000; + +/** + * Runtime-agnostic request service for `/api/rail`, shared by the Vite + * plugin and any other host. + * + * GET /api/rail/feeds → public catalog (coverage + credit) + * GET /api/rail/trains/ → decoded, train-only snapshot as JSON + * GET /api/rail/updates/ → the feed's rail trip updates as JSON + * GET /api/rail/paths/ → the feed's track path pack, when built + * GET /api/rail/schedule/ → the feed's timetable pack, when built + * + * Path and schedule packs are offline build output + * (scripts/build-rail-paths.mjs). The service never touches storage itself: + * the host injects a pack reader for each, `readPathPack` and + * `readSchedulePack` (an fs reader in development). A reader receives a + * registered, enabled feed id and the byte cap, and resolves to the pack's + * JSON text or bytes, or null when the feed has no pack; a pack over the cap + * may instead be refused by throwing an error with `code: 'PACK_TOO_LARGE'`. + * Bytes are served as they are, never decoded or copied here. Null is the + * only "no pack" (404); a reader that throws is a fault here (503), which a + * viewer asks again about. + * + * Every train is placed where its operator reports it; a schedule pack only + * adds what the live feed leaves out (line names, stop names, next stops). + * Packs are opt-in build output: a live feed whose trains are named only + * through its pack (`railFeedNeedsSchedulePack`: `routesFrom: 'schedule'`) + * is listed in the catalog, and its trains and trip updates are served, only + * once the pack is built, so feeds nobody has built stay out of sight + * instead of reading as unavailable. Whether a pack + * is built is asked of `hasSchedulePack(feedId)` when given, else of + * `schedulePackVersion` (null: not built), else of `readSchedulePack` (null: + * not built); with none of them no such feed is built. Answers are kept for + * SCHEDULE_PACK_PROBE_TTL_MS; a probe that fails counts the pack as built, + * so the failure shows where its pack is read. + * + * `schedulePackVersion(feedId)` names the pack as it is now (a build stamp, + * or a file's time and size), or null when there is none. With it, what the + * realtime routes read from a pack is rebuilt within + * SCHEDULE_PACK_PROBE_TTL_MS of a new build; without it, within an hour. + * `pathPackVersion(feedId)` does the same for path packs. + * + * A path or schedule pack is served `no-cache`: a feed's two packs are read + * as a pair (the schedule's `pathsBuiltAt` names its path pack's `builtAt`), + * so a browser asks again each time instead of keeping one of them for an + * hour past a rebuild. With the pack's version, the answer carries it as its + * ETag, and a request whose If-None-Match names it is answered 304 without + * reading the pack. + * + * @param {{fetchImpl?: typeof fetch, + * readPathPack?: (feedId: string, options: {maxBytes: number}) => + * Promise, + * readSchedulePack?: (feedId: string, options: {maxBytes: number}) => + * Promise, + * hasSchedulePack?: (feedId: string) => Promise, + * schedulePackVersion?: (feedId: string) => Promise, + * pathPackVersion?: (feedId: string) => Promise, + * now?: () => number}} [options] + * @returns {{handle: (request: Request) => Promise, close: () => void}} + */ +export function createRailService({ + fetchImpl = fetch, + readPathPack = null, + readSchedulePack = null, + hasSchedulePack = null, + schedulePackVersion = null, + pathPackVersion = null, + now: clock = () => Date.now(), +} = {}) { + /** Whether a feed's timetable pack is built, as the host can tell. */ + const probeSchedulePack = + typeof hasSchedulePack === 'function' + ? hasSchedulePack + : typeof schedulePackVersion === 'function' + ? async (feedId) => (await schedulePackVersion(feedId)) != null + : typeof readSchedulePack === 'function' + ? async (feedId) => { + try { + const body = await readSchedulePack(feedId, { + maxBytes: RAIL_PATH_PACK_MAX_BYTES, + }); + return body !== null && body !== undefined; + } catch (error) { + // Built, but refused: the pack's own route says so. + if (error?.code === 'PACK_TOO_LARGE') return true; + throw error; + } + } + : null; + /** Probe answers by feed id: {at, built}. */ + const schedulePackProbes = new Map(); + async function schedulePackBuilt(feedId) { + if (!probeSchedulePack) return false; + const seen = schedulePackProbes.get(feedId); + if (seen && clock() - seen.at < SCHEDULE_PACK_PROBE_TTL_MS) + return seen.built; + let built; + try { + built = (await probeSchedulePack(feedId)) === true; + } catch (error) { + console.warn(`[rail] ${feedId}: schedule pack probe failed`, error); + built = true; + } + schedulePackProbes.set(feedId, { at: clock(), built }); + return built; + } + /** Whether the feed can be served: it needs no pack, or its pack is built. */ + async function feedServable(feed) { + return !railFeedNeedsSchedulePack(feed) || schedulePackBuilt(feed.id); + } + /** The catalog, with pack-routed feeds whose pack is unbuilt left out. */ + async function catalog() { + const built = new Set(); + await Promise.all( + railEnabledFeeds() + .filter((feed) => railFeedNeedsSchedulePack(feed)) + .map(async (feed) => { + if (await schedulePackBuilt(feed.id)) built.add(feed.id); + }), + ); + return publicRailCatalog({ + schedulePackBuilt: (feedId) => built.has(feedId), + }); + } + /** + * Snapshots by `:`, so a feed's positions and its trip + * updates never share an entry, a cooldown or an admission count. + * @type {Map} + */ + const cache = new Map(); + let closed = false; + const inFlight = new Map(); + const controllers = new Set(); + /** @type {Map} */ + const cooldown = new Map(); + const admit = makeRateLimiter({ + windowMs: RAIL_ADMISSION_WINDOW_MS, + max: RAIL_ADMISSION_MAX_PER_FEED, + // Scales with the registry (railAdmissionGlobalMax): a backstop, while + // each feed's freshness window protects its operator. + globalMax: railAdmissionGlobalMax(), + now: clock, + }); + + /** + * What the realtime path reads from a feed's schedule pack, built when the + * pack is read and kept an hour, or until the pack's version changes + * (retried after five minutes when the pack is missing or unreadable). + * The pack's text and its parsed form are released once these are built; + * `runs` keeps the pack's trips, profiles and services for the hour. + * - `routes`: trip id to pack route id, for feeds whose realtime trips do + * not carry the route their classifier reads (`routesFrom: + * 'schedule'`). Without it those trains are dropped, not misfiled. + * - `runs`: dated runs by train number, for positions that name trains + * by number (`positionsByTrainNumber`: Irish Rail's train codes), so a + * reported train takes its line and stops. Its position stays the + * reported one. + * - `endsMs`: when the last run the pack holds arrives (null when it + * names no window), past which the pack is stale. + * A pack is refused, as one that cannot be read, when it is of another + * format version or another feed, or once its window has ended + * (`endsMs`): it names no train running now. A feed that cannot be read + * without its pack answers 503 rather than an empty list when every + * record was dropped (assertPackRead). With `schedulePackVersion`, an + * entry is checked against the pack's version once per + * SCHEDULE_PACK_PROBE_TTL_MS and rebuilt when the pack changed. + * @type {Map}>} + */ + const scheduleLookups = new Map(); + const SCHEDULE_LOOKUPS_TTL_MS = 60 * 60_000; + const SCHEDULE_LOOKUPS_RETRY_MS = 5 * 60_000; + const NO_LOOKUPS = Object.freeze({ + routes: new Map(), + runs: null, + endsMs: null, + }); + + /** Positions that name trains by number (Irish Rail's train codes). */ + function namesTrains(feed) { + return feed.positionsByTrainNumber === true; + } + + /** + * Feeds that read the pack's trip-to-route lookup (`routes`): their + * realtime trips do not carry the route their classifier reads, so + * neither of the feed's routes keeps anything without the pack. + */ + function readsPackRoutes(feed) { + return feed.routesFrom === 'schedule'; + } + + function needsSchedule(feed) { + return readsPackRoutes(feed) || namesTrains(feed); + } + + /** + * Refuse a snapshot in which every record was dropped, for a feed whose + * records are classified by its schedule pack's routes: the pack is + * missing, unreadable or refused, or names none of the trips the feed runs + * now (a stale pack: Renfe's trip ids carry their date). That is + * "unavailable" (503), not a healthy feed with no trains. A snapshot that + * keeps any train still answers (Irish Rail's train codes classify its + * trains without the pack). + */ + function assertPackRead(feed, snapshot) { + if (!readsPackRoutes(feed)) return; + if (snapshot.count > 0) return; + const refused = snapshot.dropped ?? 0; + if (refused === 0) return; + const error = new Error( + `schedule pack unavailable: ${refused} records need it`, + ); + error.code = 'SCHEDULE_UNAVAILABLE'; + throw error; + } + + /** Why a decoded schedule pack cannot be read for this feed now, or null. */ + function packRefusal(feed, raw, endsMs, at) { + if (raw?.version !== RAIL_SCHEDULE_PACK_VERSION) + return `version ${JSON.stringify(raw?.version ?? null)} is unknown`; + if (raw.feedId !== feed.id) + return `it is for ${JSON.stringify(raw.feedId ?? null)}`; + if (endsMs !== null && at >= endsMs) + return Number.isFinite(endsMs) + ? `its window ended ${new Date(endsMs).toISOString()}` + : 'it holds no run'; + return null; + } + + async function readScheduleLookups(feed) { + if (typeof readSchedulePack !== 'function') return null; + const body = await readSchedulePack(feed.id, { + maxBytes: RAIL_PATH_PACK_MAX_BYTES, + }); + if (body === null || body === undefined) return null; + const text = + typeof body === 'string' ? body : new TextDecoder().decode(body); + if (text.length > RAIL_PATH_PACK_MAX_BYTES) + throw new Error('schedule pack over the cap'); + const raw = JSON.parse(text); + const endsMs = schedulePackEndMs(raw); + const refusal = packRefusal(feed, raw, endsMs, clock()); + if (refusal) throw new Error(`schedule pack refused: ${refusal}`); + const routes = new Map(); + const trips = raw.trips; + if (readsPackRoutes(feed) && trips && typeof trips === 'object') + for (const [tripId, trip] of Object.entries(trips)) + if (typeof trip?.route === 'string') routes.set(tripId, trip.route); + return { + routes, + runs: namesTrains(feed) ? createRunIndex(raw) : null, + endsMs, + }; + } + + /** + * A pack's version now, or undefined when the host names none or it + * cannot be read. + * @param {'schedule'|'paths'} kind + * @param {string} feedId + */ + async function packVersionNow(kind, feedId) { + const versionOf = + kind === 'schedule' ? schedulePackVersion : pathPackVersion; + if (typeof versionOf !== 'function') return undefined; + try { + return (await versionOf(feedId)) ?? null; + } catch (error) { + console.warn( + `[rail-proxy] ${feedId} ${kind === 'schedule' ? 'schedule' : 'path'} pack version unreadable: ${error?.message || error}`, + ); + return undefined; + } + } + + /** + * Build a feed's lookups, tied to the pack version read just before the + * pack: a build that lands in between is seen at the next check. + */ + function buildScheduleLookups(feed, at) { + const fresh = { + at, + checkedAt: at, + ttl: SCHEDULE_LOOKUPS_TTL_MS, + version: undefined, + /** The last version check: resolves true while the pack is unchanged. */ + current: true, + lookups: null, + }; + fresh.lookups = (async () => { + fresh.version = await packVersionNow('schedule', feed.id); + return readScheduleLookups(feed); + })().then( + (lookups) => { + if (!lookups) fresh.ttl = SCHEDULE_LOOKUPS_RETRY_MS; + return lookups || NO_LOOKUPS; + }, + (error) => { + fresh.ttl = SCHEDULE_LOOKUPS_RETRY_MS; + console.warn( + `[rail-proxy] ${feed.id} schedule pack unreadable: ${error?.message || error}`, + ); + return NO_LOOKUPS; + }, + ); + scheduleLookups.set(feed.id, fresh); + return fresh; + } + + /** + * Whether a kept entry still matches the pack: with a version from the + * host, asked at most once per SCHEDULE_PACK_PROBE_TTL_MS (concurrent + * askers share the one check). + */ + async function lookupsCurrent(feed, entry, at) { + if (typeof schedulePackVersion !== 'function') return true; + if (at - entry.checkedAt >= SCHEDULE_PACK_PROBE_TTL_MS) { + entry.checkedAt = at; + entry.current = (async () => { + await entry.lookups; + const version = await packVersionNow('schedule', feed.id); + // An unreadable version keeps what is built. + return version === undefined || version === entry.version; + })(); + } + return entry.current; + } + + async function scheduleLookupsFor(feed) { + if (!needsSchedule(feed)) return NO_LOOKUPS; + const at = clock(); + let entry = scheduleLookups.get(feed.id); + if (!entry || at - entry.at >= entry.ttl) { + entry = buildScheduleLookups(feed, at); + } else if (!(await lookupsCurrent(feed, entry, at))) { + // A rebuilt pack: its trips replace the old pack's at once. Another + // caller that saw the same change may have rebuilt already. + const latest = scheduleLookups.get(feed.id); + entry = latest !== entry ? latest : buildScheduleLookups(feed, clock()); + } + const lookups = await entry.lookups; + if (lookups.endsMs !== null && clock() >= lookups.endsMs) { + // The window ended while the entry was kept: refused from now on, and + // read again after five minutes, when a new build may be there. + if (!entry.ended) { + entry.ended = true; + entry.ttl = Math.min( + entry.ttl, + clock() - entry.at + SCHEDULE_LOOKUPS_RETRY_MS, + ); + console.warn( + `[rail-proxy] ${feed.id} schedule pack refused: its window ended ${new Date(lookups.endsMs).toISOString()}`, + ); + } + return NO_LOOKUPS; + } + return lookups; + } + + function reply(status, body, headers) { + return new Response(body, { status, headers }); + } + + function noteFailure(cacheKey, reason, at) { + const failures = (cooldown.get(cacheKey)?.failures || 0) + 1; + const wait = nextRailBackoffMs(failures); + cooldown.set(cacheKey, { failures, nextAttemptAt: at + wait, reason }); + return wait; + } + + /** + * Upstream bodies by URL, for a feed whose positions and trip updates are + * one document (MTA's LIRR and Metro-North): `{at, promise}`, reused for + * the asking route's own freshness window, so the operator is contacted + * once per window rather than once per route. + */ + const sharedUpstream = new Map(); + + function sharesUpstream(feed) { + return typeof feed.url === 'string' && feed.tripUpdatesUrl === feed.url; + } + + /** + * GET a registered URL and read its body, capped: `{notModified: true}` + * for a 304 to our own validators, else `{bytes, finalUrl, etag, + * lastModified, at}` with `at` the time it was asked for. + */ + async function fetchUpstream(feed, url, signal, validators, at) { + const { response, finalUrl } = await fetchRailFeed( + feed, + signal, + fetchImpl, + validators, + { url }, + ); + if (closed) throw new Error('Rail provider closed'); + if (!isAcceptableRailUpstreamUrl(finalUrl)) { + throw new Error('upstream is not https'); + } + if (response.status === 304 && validators) { + try { + await response.body?.cancel(); + } catch { + /* no-op */ + } + return { notModified: true }; + } + if (!response.ok) { + const error = new Error(`upstream HTTP ${response.status}`); + error.upstreamStatus = response.status; + throw error; + } + const bytes = await readResponseBytesCapped( + response, + railMaxBodyBytes(feed), + ); + return { + bytes, + finalUrl, + etag: response.headers.get('etag'), + lastModified: response.headers.get('last-modified'), + at, + }; + } + + /** + * The body of a URL both of a feed's routes read: the one fetched within + * this route's window when there is one (or under way), else a new fetch + * the other route may reuse. Sent without validators: a 304 would leave + * the other route, which may hold no snapshot, with nothing to read. + */ + function sharedBody(resource, signal, at) { + const { feed, url, ttlMs } = resource; + const kept = sharedUpstream.get(url); + if (kept && at - kept.at < ttlMs) return kept.promise; + const fresh = { at, promise: fetchUpstream(feed, url, signal, null, at) }; + sharedUpstream.set(url, fresh); + fresh.promise.catch(() => { + if (sharedUpstream.get(url) === fresh) sharedUpstream.delete(url); + }); + return fresh.promise; + } + + async function refresh(resource, at) { + const { feed, route, url, cacheKey } = resource; + const controller = new AbortController(); + controllers.add(controller); + const timeout = setTimeout(() => controller.abort(), RAIL_PROXY_TIMEOUT_MS); + const previous = cache.get(cacheKey); + try { + const validators = previous + ? { etag: previous.etag, lastModified: previous.lastModified } + : null; + const upstream = sharesUpstream(feed) + ? await sharedBody(resource, controller.signal, at) + : await fetchUpstream(feed, url, controller.signal, validators, at); + if (closed) throw new Error('Rail provider closed'); + // Unchanged upstream: keep the snapshot, restart its freshness window, + // and record that the operator answered. The body keeps the fetch time + // of the payload it holds. + if (upstream.notModified) { + const revalidated = { ...previous, at, contactedAt: at }; + cache.set(cacheKey, revalidated); + cooldown.delete(cacheKey); + return revalidated; + } + // A shared body is read as of when it was fetched. + const fetchedAt = upstream.at; + const lookups = await scheduleLookupsFor(feed); + // The pack's trip-to-route lookup, for a feed that reads routes from it. + const routeFor = readsPackRoutes(feed) + ? (tripId) => lookups.routes.get(tripId) ?? null + : null; + // A train named by its number takes the timetable trip it is on now: + // its line and stops, never a position. A train its report says is + // late is where its timetable was that long ago. + const tripFor = lookups.runs + ? (number, record) => + lookups.runs.activeTrip(number, fetchedAt, { + delayMs: (liveDelaySec(record) ?? 0) * 1000, + })?.tripId ?? null + : null; + const snapshot = + route === 'updates' + ? buildRailTripUpdatesSnapshot(feed, upstream.bytes, fetchedAt, { + routeFor, + }) + : buildRailSnapshot(feed, upstream.bytes, fetchedAt, { + routeFor, + tripFor, + }); + assertPackRead(feed, snapshot); + if (closed) throw new Error('Rail provider closed'); + const entry = { + at: snapshot.fetchedAt, + contactedAt: snapshot.fetchedAt, + body: JSON.stringify(snapshot), + host: new URL(upstream.finalUrl).hostname, + etag: upstream.etag, + lastModified: upstream.lastModified, + }; + cache.set(cacheKey, entry); + cooldown.delete(cacheKey); + return entry; + } finally { + clearTimeout(timeout); + controller.abort(); + controllers.delete(controller); + } + } + + /** + * The feed's path or schedule pack as stored: 404 only when the reader + * says it has none (null), 503 when it is over RAIL_PATH_PACK_MAX_BYTES (a + * build fault to fix, not a pack that was never built) or cannot be read + * now (the reader threw, or answered with something that is not a pack), + * which a viewer asks again about. Both kinds share the reader contract + * and the byte cap. Bytes from the reader go out as they are (packBody), + * `no-cache`, with the pack's version as their ETag when the host names + * one; an If-None-Match that names it is answered 304, unread. + */ + async function servePack(feed, kind, incoming) { + const read = kind === 'schedule' ? readSchedulePack : readPathPack; + const label = kind === 'schedule' ? 'schedule pack' : 'path pack'; + const missing = () => + reply( + 404, + JSON.stringify({ + error: kind === 'schedule' ? 'No rail schedule' : 'No rail paths', + feedId: feed.id, + }), + JSON_NO_STORE, + ); + const tooLarge = (detail) => { + console.warn(`[rail-proxy] ${feed.id} ${label} refused: ${detail}`); + return reply( + 503, + JSON.stringify({ + error: + kind === 'schedule' + ? 'Rail schedule too large' + : 'Rail paths too large', + feedId: feed.id, + }), + JSON_NO_STORE, + ); + }; + const unreadable = (detail) => { + console.warn(`[rail-proxy] ${feed.id} ${label} unreadable: ${detail}`); + return reply( + 503, + JSON.stringify({ + error: + kind === 'schedule' + ? 'Rail schedule unreadable' + : 'Rail paths unreadable', + feedId: feed.id, + retryInSec: PACK_UNREADABLE_RETRY_S, + }), + { ...JSON_NO_STORE, 'Retry-After': String(PACK_UNREADABLE_RETRY_S) }, + ); + }; + if (typeof read !== 'function') return missing(); + // The version is asked before the pack is read: a build landing in + // between tags newer bytes with the older version, which the next + // request corrects, and never the reverse. + const version = await packVersionNow(kind, feed.id); + const etag = typeof version === 'string' ? packEtag(version) : null; + const packHeaders = { + 'Cache-Control': 'no-cache', + ...(etag ? { ETag: etag } : {}), + }; + if (etag && etagMatches(requestHeader(incoming, 'if-none-match'), etag)) + return reply(304, null, packHeaders); + let body; + try { + body = await read(feed.id, { + maxBytes: RAIL_PATH_PACK_MAX_BYTES, + }); + } catch (error) { + if (error?.code === 'PACK_TOO_LARGE') + return tooLarge(error?.message || 'over the cap'); + return unreadable(error?.message || String(error)); + } + if (body === null || body === undefined) return missing(); + // UTF-8 is at most three bytes per UTF-16 unit: only a string near the + // cap is worth encoding to measure. + const size = + typeof body === 'string' + ? body.length * 3 <= RAIL_PATH_PACK_MAX_BYTES + ? body.length + : new TextEncoder().encode(body).byteLength + : body instanceof Uint8Array || body instanceof ArrayBuffer + ? body.byteLength + : NaN; + if (!Number.isFinite(size)) return unreadable('not a pack'); + if (size > RAIL_PATH_PACK_MAX_BYTES) return tooLarge(`${size} bytes`); + if (closed) + return reply( + 503, + JSON.stringify({ error: 'Rail provider closed' }), + JSON_NO_STORE, + ); + return reply(200, packBody(body), { + 'Content-Type': 'application/json; charset=utf-8', + ...packHeaders, + }); + } + + /** Positions or trip updates, through the cache/admission/backoff policy. */ + async function serveUpstream(resource) { + const { feed, cacheKey, ttlMs } = resource; + const at = clock(); + const cached = cache.get(cacheKey); + const state = railCacheStateFor(cached, at, ttlMs); + const ttlSeconds = Math.ceil(ttlMs / 1000); + // A downstream cache keeps the snapshot only for what is left of its + // window here. + const okHeaders = (cacheState, entry) => + railResponseHeaders( + cacheState, + entry.host, + entry.contactedAt, + ttlMs, + clock() - entry.at, + ); + if (state === 'fresh') + return reply(200, cached.body, okHeaders('HIT', cached)); + + // The cached snapshot when there is one, otherwise an honest + // "unavailable, come back in N seconds". The contact time stays as old as + // it is: a feed served from cache during an outage has not answered. + const answerWithoutUpstream = (reason, retryAfterS) => { + if (state === 'stale' && cached) { + return reply(200, cached.body, okHeaders('STALE-ERROR', cached)); + } + return reply( + 503, + JSON.stringify({ + error: 'Rail feed unavailable', + feedId: feed.id, + retryInSec: retryAfterS, + }), + { + ...JSON_NO_STORE, + 'X-GEV-Cache': 'NONE', + 'Retry-After': String(retryAfterS), + 'X-Rail-Backoff': reason, + }, + ); + }; + + const waiting = cooldown.get(cacheKey); + if (waiting && waiting.nextAttemptAt > at && !inFlight.has(cacheKey)) { + return answerWithoutUpstream( + 'cooldown', + Math.max(1, Math.ceil((waiting.nextAttemptAt - at) / 1000)), + ); + } + if (!inFlight.has(cacheKey) && !admit(cacheKey)) + return answerWithoutUpstream('rate-limited', ttlSeconds); + + const request = coalesceProxyRequest(inFlight, cacheKey, () => + refresh(resource, at), + ); + try { + const fresh = await request.promise; + return reply( + 200, + fresh.body, + okHeaders(request.shared ? 'INFLIGHT' : 'MISS', fresh), + ); + } catch (error) { + const differential = error instanceof RailFeedShapeError; + // A shape fault condemns the cached copy too: it was decoded as a full + // snapshot, which the feed has just shown it is not. + if (differential) cache.delete(cacheKey); + if (!request.shared) { + const reason = error?.message || String(error); + const wait = noteFailure(cacheKey, reason, clock()); + console.warn( + `[rail-proxy] ${cacheKey} unavailable: ${reason}; next attempt in ${Math.round(wait / 1000)}s`, + ); + } + if (state === 'stale' && cached && !differential) { + return reply(200, cached.body, okHeaders('STALE-ERROR', cached)); + } + const pending = cooldown.get(cacheKey); + const retryAfterS = pending + ? Math.max(1, Math.ceil((pending.nextAttemptAt - clock()) / 1000)) + : ttlSeconds; + // The operator answered, but this server lacks the schedule pack the + // answer is read with: unavailable here, not an upstream fault. + const noSchedule = error?.code === 'SCHEDULE_UNAVAILABLE'; + return reply( + noSchedule + ? 503 + : differential || + error?.code === 'RESPONSE_TOO_LARGE' || + error?.code === 'UPSTREAM_UNREADABLE' || + Number.isInteger(error?.upstreamStatus) + ? 502 + : 504, + JSON.stringify({ + error: differential + ? 'Rail feed is differential and is not supported' + : noSchedule + ? 'Rail schedule unavailable' + : 'Rail feed unavailable', + feedId: feed.id, + retryInSec: retryAfterS, + }), + { + ...JSON_NO_STORE, + 'X-GEV-Cache': 'NONE', + 'Retry-After': String(retryAfterS), + ...(noSchedule ? { 'X-Rail-Backoff': 'schedule' } : {}), + }, + ); + } + } + + async function handle(incoming) { + const url = new URL(incoming.url); + if (!url.pathname.startsWith('/api/rail/')) + return reply( + 404, + JSON.stringify({ error: 'Unknown rail feed' }), + JSON_NO_STORE, + ); + if (closed) + return reply( + 503, + JSON.stringify({ error: 'Rail provider closed' }), + JSON_NO_STORE, + ); + if (incoming.method !== 'GET') { + return reply(405, JSON.stringify({ error: 'Method Not Allowed' }), { + ...JSON_NO_STORE, + Allow: 'GET', + }); + } + const route = resolveRailRoute( + url.pathname.slice('/api/rail'.length) + url.search, + ); + if (!route) + return reply( + 404, + JSON.stringify({ error: 'Unknown rail feed' }), + JSON_NO_STORE, + ); + if (route.route === 'feeds') { + // Which pack-routed feeds appear follows the packs built; a short + // max-age lets a new build show up without a long wait. + return reply(200, JSON.stringify({ feeds: await catalog() }), { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, max-age=300', + }); + } + if (route.route === 'paths' || route.route === 'schedule') + return servePack(route.feed, route.route, incoming); + const { feed } = route; + const updates = route.route === 'updates'; + // A pack-routed feed whose pack is not built is not served: the + // operator is not contacted for trains this server cannot name. + if (!(await feedServable(feed))) + return reply( + 503, + JSON.stringify({ + error: 'Rail schedule unavailable', + feedId: feed.id, + retryInSec: SCHEDULE_PACK_PROBE_RETRY_S, + }), + { + ...JSON_NO_STORE, + 'X-GEV-Cache': 'NONE', + 'Retry-After': String(SCHEDULE_PACK_PROBE_RETRY_S), + 'X-Rail-Backoff': 'schedule', + }, + ); + return serveUpstream({ + feed, + route: route.route, + url: updates ? feed.tripUpdatesUrl : feed.url, + cacheKey: `${route.route}:${feed.id}`, + ttlMs: railFeedTtlMs(feed, route.route), + }); + } + + function close() { + closed = true; + for (const controller of controllers) controller.abort(); + controllers.clear(); + cache.clear(); + cooldown.clear(); + scheduleLookups.clear(); + sharedUpstream.clear(); + } + return { handle, close }; +} diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index cef9af961..ca92deba0 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -9,6 +9,7 @@ import { import { createCctvSource } from '../layers/cctv/source.js'; import { createRadioSource } from '../layers/radio/source.js'; import { createTransitSource } from '../layers/transit/source.js'; +import { createRailSource } from '../layers/rail/source.js'; import { createTrafficSource } from '../layers/traffic/source.js'; import { createBikeshareSource } from '../layers/bikeshare/source.js'; import { createInstallationSource } from '../layers/installations/source.js'; @@ -55,6 +56,7 @@ export function createStandaloneLayerSources( radio: createRadioSource(), traffic: createTrafficSource({ mapTiles: trafficTiles }), transit: createTransitSource(), + rail: createRailSource(), bikeshare: createBikeshareSource(), installations: createInstallationSource({ mapTiles: installationTiles }), satellites: createSatelliteSource(), diff --git a/src/testSupport/railLayerHarness.mjs b/src/testSupport/railLayerHarness.mjs new file mode 100644 index 000000000..5f7a2ae62 --- /dev/null +++ b/src/testSupport/railLayerHarness.mjs @@ -0,0 +1,409 @@ +// A Rail layer on a stub viewer, a scripted proxy and fake camera services +// that record what the layer asks of the camera. Shared by the Rail layer's +// tests that run a whole layer (camera follow, live-only feeds, ground +// height, layer stats, selected route lines). +import * as Cesium from 'cesium'; +import { createRailLayer } from '../layers/rail/index.js'; +import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js'; +import { WORLD_FOCUS_FRAMING } from '../worldFocus.js'; +import { publicRailCatalog } from '../data/railFeeds.js'; + +/** The tests' wall clock at the start: 2026-09-10T00:26:40Z. */ +export const RAIL_HARNESS_T0 = 1_789_000_000_000; +export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 }); + +/** A train as the rail proxy sends it. */ +export function railTrain(id, lat, lon, extra = {}) { + return { + id, + lat, + lon, + bearing: null, + speedMps: null, + timestamp: Math.floor(Date.now() / 1000), + timestampSource: 'vehicle', + routeId: 'R1', + tripId: `trip-${id}`, + label: id, + category: 'regional', + mode: 'rail', + positionSource: 'gps', + ...extra, + }; +} + +/** A canvas good enough for the sprite drawing: every 2D call is a no-op. */ +function fakeCanvas() { + const context = new Proxy( + {}, + { + get: (target, key) => (key in target ? target[key] : () => {}), + set: (target, key, value) => { + target[key] = value; + return true; + }, + }, + ); + return { width: 0, height: 0, getContext: () => context }; +} + +const headersOf = (values = {}) => ({ + get: (name) => values[String(name).toLowerCase()] ?? null, +}); + +/** + * The harness. `feeds` scripts each feed's live answer, one call per poll: + * an array of trains (200), `{positions: 'schedule'}` (a 200 answer with + * no live positions, which the layer must not draw), `{status, backoff?, retryInSec?}` (a failure as the proxy + * answers it), or nothing (502). `schedules` holds the schedule packs the + * proxy serves for stop labels (404 otherwise). + * `requested`, `requestedSchedules` and `requestedPaths` list the feed ids + * asked for, in order. + * @param {import('node:test').TestContext} t + * @param {{at?: {lat: number, lon: number}, altitude?: number, + * catalog?: object[]|Promise|Function|false, + * ground?: object, + * stops?: object|null, scheduleStops?: boolean, + * paths?: Record|null}} [options] + * `ground`: a floor service in place of the always-cold stub; `catalog`: + * the server's feed catalog (it may list fewer feeds than are bundled; a + * promise answers when it settles, a function is called per request and + * answers so); without it, a server with every pack built (all enabled + * feeds); `false` for a source with no catalog at all; + * `stops`: a stops provider (none by default); `scheduleStops`: the + * layer's own provider, its schedule tier, in its place; + * `paths`: feed id → the path pack the proxy serves, or a + * failure status it answers with (404 for any other feed; without it the + * source has no path route at all). + */ +export function railLayerHarness( + t, + { + at = BOSTON, + altitude = 20_000, + catalog = undefined, + ground = null, + stops = null, + scheduleStops = false, + paths = null, + } = {}, +) { + const T0 = RAIL_HARNESS_T0; + t.mock.timers.enable({ + apis: ['Date', 'setTimeout', 'setInterval'], + now: T0, + }); + t.mock.method(performance, 'now', () => Date.now() - T0); + for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'ImageBitmap', + 'OffscreenCanvas', + ]) { + const prior = globalThis[name]; + globalThis[name] = class {}; + t.after(() => { + if (prior === undefined) delete globalThis[name]; + else globalThis[name] = prior; + }); + } + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + globalThis.document = Object.assign(new EventTarget(), { + visibilityState: 'visible', + documentElement: { dataset: {} }, + createElement: () => fakeCanvas(), + }); + globalThis.window = new EventTarget(); + // The layer logs its feeds and catalog. + t.mock.method(console, 'info', () => {}); + t.mock.method(console, 'log', () => {}); + + const view = { lat: at.lat, lon: at.lon, altitude }; + const cameraChanged = new Cesium.Event(); + const trackedEntityChanged = new Cesium.Event(); + const preRender = new Cesium.Event(); + const primitives = []; + const primitiveCollection = { + add(primitive) { + primitives.push(primitive); + return primitive; + }, + remove(primitive) { + const index = primitives.indexOf(primitive); + if (index >= 0) primitives.splice(index, 1); + return index >= 0; + }, + contains: (primitive) => primitives.includes(primitive), + raiseToTop() {}, + get length() { + return primitives.length; + }, + get: (index) => primitives[index], + }; + let trackedEntity; + const viewer = { + entities: new Cesium.EntityCollection(), + get trackedEntity() { + return trackedEntity; + }, + // Cesium's own setter raises the event on every change, as here. + set trackedEntity(value) { + if (trackedEntity === value) return; + trackedEntity = value; + trackedEntityChanged.raiseEvent(value); + }, + trackedEntityChanged, + isDestroyed: () => false, + camera: { + changed: cameraChanged, + percentageChanged: 0.5, + heading: 0, + flightsCancelled: 0, + positionWC: Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude), + get positionCartographic() { + return { + height: view.altitude, + latitude: Cesium.Math.toRadians(view.lat), + longitude: Cesium.Math.toRadians(view.lon), + }; + }, + computeViewRectangle() { + return Cesium.Rectangle.fromDegrees( + view.lon - 0.1, + view.lat - 0.1, + view.lon + 0.1, + view.lat + 0.1, + ); + }, + cancelFlight() { + this.flightsCancelled += 1; + }, + }, + scene: { + canvas: { addEventListener() {}, removeEventListener() {} }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + pick: () => undefined, + requestRender() {}, + preRender, + primitives: primitiveCollection, + groundPrimitives: primitiveCollection, + }, + }; + + const feeds = new Map(); + const schedules = new Map(); + const requested = []; + const requestedSchedules = []; + const requestedPaths = []; + /** When each catalog request was made. */ + const catalogRequests = []; + /** + * A pack as the proxy serves it: 200 with its body, a number as that + * failure status, else 404. + */ + const packAnswer = (raw) => + raw && typeof raw === 'object' + ? { ok: true, status: 200, headers: headersOf(), json: async () => raw } + : { + ok: false, + status: typeof raw === 'number' ? raw : 404, + headers: headersOf(), + json: async () => ({}), + }; + const source = { + async requestSnapshot(feedId) { + requested.push(feedId); + const answer = feeds.get(feedId)?.() ?? null; + if (Array.isArray(answer)) + return { + ok: true, + status: 200, + headers: headersOf(), + json: async () => ({ fetchedAt: Date.now(), trains: answer }), + }; + if (answer?.positions === 'schedule') + return { + ok: true, + status: 200, + headers: headersOf({ 'x-gev-cache': 'SCHEDULE' }), + json: async () => ({ + fetchedAt: Date.now(), + positions: 'schedule', + trains: [], + }), + }; + const status = answer?.status ?? 502; + return { + ok: false, + status, + headers: headersOf( + answer?.backoff ? { 'x-rail-backoff': answer.backoff } : {}, + ), + json: async () => ({ + error: 'Rail feed unavailable', + ...(Number.isFinite(answer?.retryInSec) + ? { retryInSec: answer.retryInSec } + : {}), + }), + }; + }, + async requestSchedule(feedId) { + requestedSchedules.push(feedId); + return packAnswer(schedules.get(feedId)); + }, + ...(paths + ? { + async requestPaths(feedId) { + requestedPaths.push(feedId); + return packAnswer(paths[feedId]); + }, + } + : {}), + ...(catalog === false + ? {} + : { + async getFeeds() { + catalogRequests.push(Date.now()); + if (catalog === undefined) return publicRailCatalog(); + return typeof catalog === 'function' ? catalog() : catalog; + }, + }), + }; + + const focusRequests = []; + const frames = []; + /** Overlay source id → the entries last published to it. */ + const overlayEntries = new Map(); + let cockpit = false; + const layer = createRailLayer({ + source, + // Left out, the layer reads stops from its own schedule tier. + ...(scheduleStops ? {} : { stops }), + services: { + render: { + governorRequestRender() {}, + holdContinuousRender() {}, + releaseContinuousRender() {}, + }, + sprites: { + registerSpriteCollection() {}, + unregisterSpriteCollection() {}, + restoreSpriteOrder() {}, + }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + overlays: { + setOverlayEntries(sourceId, entries) { + overlayEntries.set(sourceId, entries); + }, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + credits: { + registerDynamicCredit() {}, + railFeedCredit: (feed) => ({ key: `rail-${feed.id}`, html: feed.id }), + }, + ground: ground ?? { + GROUND_FLOOR_LIFT_M: 1.5, + cachedGroundFloor: () => null, + warmGroundFloor() {}, + }, + camera: { + framing: WORLD_FOCUS_FRAMING.train, + requestWorldFocus(detail) { + focusRequests.push(detail); + return true; + }, + applyTrackedCameraFrame(_viewer, entity, viewFrom) { + const frame = { entity, viewFrom, stopped: false }; + frames.push(frame); + return () => { + frame.stopped = true; + }; + }, + isCockpitActive: () => cockpit, + }, + }, + }); + layer.init(viewer); + + t.after(() => { + try { + layer.destroy(viewer); + } catch { + /* a test may already have destroyed it */ + } + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + }); + + const state = () => layer._railStateForTest(); + const parts = () => layer._railPartsForTest(); + /** Let pending promises (a poll's fetch, JSON and packs) settle. */ + const settle = async () => { + for (let i = 0; i < 24; i += 1) await Promise.resolve(); + }; + + return { + layer, + viewer, + state, + parts, + feeds, + schedules, + requested, + requestedSchedules, + requestedPaths, + catalogRequests, + primitives, + overlayEntries, + focusRequests, + frames, + settle, + setCockpit(value) { + cockpit = value; + }, + entry: (key) => state()._trains.get(key), + async enable() { + layer.enable(viewer); + await settle(); + t.mock.timers.tick(0); + await settle(); + }, + async poll() { + await layer.update(); + await settle(); + // The duplicate filter runs off the poll's stack. + t.mock.timers.tick(0); + await settle(); + }, + /** Wall time with frames along the way, as in the app. */ + run(ms, step = 250) { + const until = Date.now() + ms; + while (Date.now() < until) { + t.mock.timers.tick(Math.min(step, until - Date.now())); + preRender.raiseEvent(); + } + }, + /** Move the camera and let the debounced feed check run. */ + async moveCamera(next) { + Object.assign(view, next); + cameraChanged.raiseEvent(); + t.mock.timers.tick(CAMERA_DEBOUNCE_MS); + await settle(); + }, + /** Let a deferred feed check (after a selection change) run. */ + async recheck() { + t.mock.timers.tick(0); + await settle(); + }, + trackedPosition() { + return viewer.trackedEntity?.position?.getValue( + Cesium.JulianDate.now(), + new Cesium.Cartesian3(), + ); + }, + }; +} diff --git a/src/tooling/buildRailPaths.test.mjs b/src/tooling/buildRailPaths.test.mjs new file mode 100644 index 000000000..73b728353 --- /dev/null +++ b/src/tooling/buildRailPaths.test.mjs @@ -0,0 +1,682 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32, deflateRawSync } from 'node:zlib'; +import { + buildPack, + buildSchedule, + entryStream, + fillTimes, + finishShape, + frequencyDepartures, + isRailRouteType, + openZip, + parseGtfsTime, + parseRecord, + projectStops, + readCsv, + resolveServiceDays, + simplifyFlat, +} from '../../scripts/build-rail-paths.mjs'; +import { createRailPath } from '../data/railPaths.js'; +import { createSchedule, tripStops } from '../data/railSchedule.js'; + +/** A minimal zip writer: stored or deflated entries, no zip64. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const { name, text, deflate } of files) { + const data = Buffer.from(text, 'utf8'); + const body = deflate ? deflateRawSync(data) : data; + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(deflate ? 8 : 0, 8); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(deflate ? 8 : 0, 10); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, body); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + body.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +const D = 1 / 111_320; + +/** Straight east along the equator in `step`-metre steps, as shapes.txt rows. */ +function shapeRows(id, steps, step) { + const rows = []; + for (let i = 0; i <= steps; i++) + rows.push(`${id},0,${(i * step * D).toFixed(6)},${i * 10}`); + // Out of order on purpose: the build sorts by sequence. + return rows.reverse(); +} + +async function withZip(t, files) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-build-rail-paths-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(files)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +test('rail route types are the tram, metro and railway families', () => { + for (const type of [ + '0', + '1', + '2', + ' 2 ', + '5', + '7', + '12', + '100', + '109', + '401', + '900', + // Extended funicular, as basic 7 is. + '1400', + ]) + assert.ok(isRailRouteType(type), type); + for (const type of ['3', '4', '6', '11', '200', '700', '1000', '', 'x']) + assert.equal(isRailRouteType(type), false, type); +}); + +test('CSV records honour quotes and trim padded fields', () => { + assert.deepEqual(parseRecord('a, b ,c\r'), ['a', 'b', 'c']); + assert.deepEqual(parseRecord('1,"x, ""y""",z'), ['1', 'x, "y"', 'z']); +}); + +test('the zip reader streams stored and deflated entries', async (t) => { + const zip = await withZip(t, [ + { name: 'stored.txt', text: 'h\n1\n' }, + { + name: 'gtfs/routes.txt', + text: 'route_id,route_long_name\nR1,"A, B\nC"\nR2,D\n', + deflate: true, + }, + ]); + assert.deepEqual([...zip.entries.keys()].sort(), [ + 'gtfs/routes.txt', + 'stored.txt', + ]); + const rows = []; + const count = await readCsv( + await entryStream(zip, zip.entries.get('gtfs/routes.txt')), + (row) => rows.push(row), + ); + assert.equal(count, 2); + assert.deepEqual(rows, [ + { route_id: 'R1', route_long_name: 'A, B\nC' }, + { route_id: 'R2', route_long_name: 'D' }, + ]); +}); + +test('a shape is sorted, rounded, simplified and measured; a chord is dropped', () => { + const triples = [2, 0, 2000 * D, 0, 0, 0, 1, 0, 1000 * D, 1, 0, 1000 * D]; + const shape = finishShape(triples); + assert.deepEqual( + shape.flat.length, + 4, + 'collinear middle point simplified away', + ); + assert.equal(shape.rawPoints, 3, 'duplicate point removed'); + // Haversine metres, a little under the 111,320 m/degree used to place it. + assert.ok(Math.abs(shape.dist[1] - 2000) < 5, String(shape.dist[1])); + // Sparse vertices on straight track (MBTA commuter lines run 2-3.3 km + // between vertices) are track, not a chord. + assert.equal(finishShape([0, 0, 0, 1, 0, 3000 * D]).dropped, undefined); + const chord = finishShape([0, 0, 0, 1, 0, 6000 * D]); + assert.match(chord.dropped, /^vertex gap 59\d\d m$/); + assert.match(finishShape([0, 0, 0]).dropped, /fewer than two points/); +}); + +test('simplification keeps every corner beyond the tolerance', () => { + const flat = [ + 0, + 0, + 0, + 1000 * D, + 0.5 * D, + 1500 * D, + 0, + 2000 * D, + 500 * D, + 2000 * D, + ]; + const simple = simplifyFlat(flat, 1); + assert.deepEqual(simple, [0, 0, 0, 2000 * D, 500 * D, 2000 * D]); +}); + +test('a pack keeps classified rail routes, dedupes shapes and maps trips', async (t) => { + const zip = await withZip(t, [ + { + name: 'routes.txt', + text: 'route_id, route_type, route_long_name\nR1, 2, "Main, line"\nR2,2,Other\nB1,3,Bus\n', + deflate: true, + }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id', + 'R1,wk,t1,A', + 'R1,wk,t2,A2', + 'R1,wk,t3,CHORD', + 'R1,wk,t4,', + 'R2,wk,t5,A', + 'B1,wk,t6,BUS', + ].join('\n'), + deflate: true, + }, + { + name: 'shapes.txt', + text: [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + ...shapeRows('A', 20, 100), + ...shapeRows('A2', 20, 100), + 'CHORD,0,0,1', + `CHORD,0,${(8000 * D).toFixed(6)},2`, + ...shapeRows('BUS', 5, 100), + ].join('\n'), + deflate: true, + }, + ]); + const feed = { + id: 'test-feed', + classify: (routeId) => + routeId === 'R1' || routeId === 'B1' + ? { category: 'local', mode: 'rail' } + : null, + }; + const { pack, stats } = await buildPack(feed, zip, { + url: 'https://example.test/gtfs.zip', + lastModified: null, + }); + assert.equal(pack.version, 1); + assert.equal(pack.feedId, 'test-feed'); + assert.equal(pack.shapes.length, 1, 'identical shapes stored once'); + assert.deepEqual(pack.shapes[0], [0, 0, 0, 0.01797]); + assert.equal(pack.dist[0].length, 2); + assert.deepEqual({ ...pack.trips }, { t1: 0, t2: 0 }); + assert.deepEqual({ ...pack.routes }, { R1: [0] }); + assert.equal( + stats.railRoutes, + 1, + 'the bus route and the unclassified route are out', + ); + assert.equal(stats.shapesDropped, 1); + assert.equal(stats.shapelessTrips, 1); + assert.equal(stats.tripsMapped, 2); + assert.equal(JSON.parse(JSON.stringify(pack)).trips.t1, 0); +}); + +test('GTFS times parse past midnight; blanks and junk are NaN', () => { + assert.equal(parseGtfsTime('25:30:00'), 91_800); + assert.equal(parseGtfsTime(' 7:05:09 '), 25_509); + assert.ok(Number.isNaN(parseGtfsTime(''))); + assert.ok(Number.isNaN(parseGtfsTime('7:5:00'))); +}); + +test('calendars resolve to concrete service days with exceptions', () => { + const day = (text) => Date.parse(`${text}T00:00:00Z`) / 86_400_000; + const days = resolveServiceDays( + [ + { + service_id: 'WK', + monday: '1', + tuesday: '1', + wednesday: '1', + thursday: '1', + friday: '1', + saturday: '0', + sunday: '0', + start_date: '20260101', + end_date: '20261105', + }, + ], + [ + { service_id: 'WK', date: '20261104', exception_type: '2' }, + { service_id: 'X', date: '20261108', exception_type: '1' }, + { service_id: 'Y', date: '20261108', exception_type: '2' }, + ], + day('2026-11-02'), + day('2026-11-08'), + ); + assert.deepEqual(days.get('WK'), [ + day('2026-11-02'), + day('2026-11-03'), + day('2026-11-05'), + ]); + assert.deepEqual(days.get('X'), [day('2026-11-08')]); + assert.equal(days.has('Y'), false); +}); + +test('blank stop times are filled by distance between timed stops', () => { + const arr = [0, NaN, NaN, 300, 400]; + const dep = [0, NaN, NaN, NaN, 400]; + assert.equal(fillTimes(arr, dep, [0, 100, 200, 300, 350]), true); + assert.deepEqual(arr, [0, 100, 200, 300, 400]); + assert.deepEqual(dep, [0, 100, 200, 300, 400]); + assert.equal(fillTimes([NaN, 5], [NaN, 5], [0, 1]), false); +}); + +test('stops project onto a shape in order, binding a twice-passed stop to the first pass', () => { + // Out along the equator and back 20 m north of it. + const path = createRailPath([0, 0, 0, 2000 * D, 20 * D, 2000 * D, 20 * D, 0]); + const { dist, maxOffsetM } = projectStops(path, [ + { lat: 0, lon: 0 }, + // Nearer the return line (5 m) than the outbound (15 m). + { lat: 15 * D, lon: 1000 * D }, + { lat: 20 * D, lon: 0 }, + ]); + assert.ok(Math.abs(dist[1] - 1000) < 2, String(dist[1])); + assert.ok(Math.abs(dist[2] - 4020) < 2, String(dist[2])); + assert.ok(maxOffsetM < 16); + // Never backwards, even when a stop lies behind the previous one. + const back = projectStops(path, [ + { lat: 0, lon: 1500 * D }, + { lat: 0, lon: 500 * D }, + ]); + assert.ok(back.dist[1] >= back.dist[0]); +}); + +test('a stop offset is in great-circle metres, not the path projection', () => { + // North along the meridian; the flat projection is true only at 30°N. + const path = createRailPath([0, 0, 30, 0, 60, 0]); + const eastAt60 = + (500 / (6_371_008.8 * Math.cos(Math.PI / 3))) * (180 / Math.PI); + const { maxOffsetM } = projectStops(path, [ + { lat: 0, lon: 0 }, + { lat: 60, lon: eastAt60 }, + ]); + // The projection alone reads 866 m (500 m × cos 30° / cos 60°). + assert.ok(Math.abs(maxOffsetM - 500) < 5, String(maxOffsetM)); +}); + +const east = (m) => (m * D).toFixed(6); + +function scheduleZip(t) { + return withZip(t, [ + { + name: 'agency.txt', + text: 'agency_id,agency_name,agency_url,agency_timezone\nA,Test,https://example.test,America/New_York\n', + }, + { + name: 'calendar.txt', + text: [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'WK,1,1,1,1,1,0,0,20260101,20261231', + 'WE,0,0,0,0,0,1,1,20260101,20261231', + ].join('\n'), + }, + { + name: 'calendar_dates.txt', + text: 'service_id,date,exception_type\nWK,20261106,2\nHOL,20261107,1\n', + }, + { + name: 'routes.txt', + text: 'route_id,route_type,route_color\nR1,2,ff0000\nB1,3,\n', + }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id,trip_headsign,trip_short_name,direction_id', + 'R1,WK,t1,S,East,101,0', + 'R1,WK,t2,S,East,103,0', + 'R1,WE,t3,S,East,105,0', + 'R1,HOL,t4,,East,107,1', + 'R1,OLD,t5,S,East,109,0', + 'B1,WK,b1,S,,,', + ].join('\n'), + deflate: true, + }, + { + name: 'stops.txt', + text: [ + 'stop_id,stop_name,stop_lat,stop_lon,parent_station,stop_timezone,platform_code', + 'P,Parent Station,0,0.00001,,,', + // A hair east of (0, 0), which the build reads as no coordinates. + 'A1,Alpha 1,0,0.000001,P,,1', + 'Bst,Bravo,0,' + east(1000) + ',,,', + 'C,Charlie,0,' + east(3000) + ',,America/Chicago,', + 'X,Unused,1,1,,,', + ].join('\n'), + }, + { + name: 'stop_times.txt', + text: [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type', + 't1,08:30:00,08:30:00,C,9,,', + 't1,08:00:00,08:00:00,A1,1,,', + 't1,,,Bst,5,,', + 't2,09:00:00,09:00:00,A1,1,,', + 't2,,,Bst,5,,', + 't2,09:30:00,09:30:00,C,9,,', + 't3,23:50:00,23:50:00,A1,1,,', + 't3,,,Bst,5,,', + 't3,24:20:00,24:20:00,C,9,,', + 't4,10:00:00,10:00:00,A1,1,,', + 't4,10:30:00,10:30:00,C,2,1,1', + 't5,07:00:00,07:00:00,A1,1,,', + 't5,07:30:00,07:30:00,C,9,,', + 'b1,07:00:00,07:00:00,A1,1,,', + 'b1,07:30:00,07:30:00,X,2,,', + ].join('\n'), + deflate: true, + }, + { + name: 'shapes.txt', + text: [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + ...shapeRows('S', 40, 100), + ].join('\n'), + }, + ]); +} + +const railFeed = (extra = {}) => ({ + id: 'test-feed', + classify: () => ({ category: 'local', mode: 'rail' }), + ...extra, +}); + +test('a schedule pack resolves services, shares patterns and run times, and keeps served stops', async (t) => { + const zip = await scheduleZip(t); + const feed = railFeed(); + const { pack: paths } = await buildPack(feed, zip, { url: 'x' }); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + paths, + { from: '2026-11-05', days: 4 }, + ); + assert.equal(schedule.version, 1); + assert.equal(schedule.timezone, 'America/New_York'); + assert.deepEqual(schedule.window, { from: '2026-11-05', to: '2026-11-08' }); + assert.equal(schedule.pathsBuiltAt, paths.builtAt); + assert.deepEqual(Object.keys(schedule.trips).sort(), [ + 't1', + 't2', + 't3', + 't4', + ]); + const { t1, t2, t3, t4 } = schedule.trips; + assert.equal(t1.pattern, t2.pattern); + assert.equal(t1.pattern, t3.pattern); + assert.notEqual(t1.pattern, t4.pattern); + assert.equal(t1.profile, t2.profile, 'equal run times share one array'); + assert.equal(t1.profile, t3.profile); + assert.deepEqual(schedule.profiles[t1.profile], [0, 0, 600, 600, 1800, 1800]); + assert.equal(t1.startSec, 8 * 3600); + assert.equal(t3.startSec, 23 * 3600 + 50 * 60); + assert.deepEqual( + { + name: t1.name, + headsign: t1.headsign, + direction: t1.direction, + route: t1.route, + }, + { name: '101', headsign: 'East', direction: 0, route: 'R1' }, + ); + // t3 ends past midnight, so the pack looks one day back. + assert.equal(stats.lookbackDays, 1); + assert.deepEqual( + { ...schedule.services }, + { + WK: ['2026-11-04', '2026-11-05'], + WE: ['2026-11-07', '2026-11-08'], + HOL: ['2026-11-07'], + }, + ); + assert.deepEqual( + { ...schedule.routes }, + { R1: { type: 2, color: 'FF0000' } }, + ); + const ids = schedule.stops.map((stop) => stop.id); + assert.deepEqual(ids.sort(), ['A1', 'Bst', 'C', 'P']); + const byId = Object.fromEntries( + schedule.stops.map((stop) => [stop.id, stop]), + ); + assert.equal(schedule.stops[byId.A1.parent].id, 'P'); + assert.equal(byId.A1.platform, '1'); + assert.equal(byId.C.tz, 'America/Chicago'); + assert.equal(byId.Bst.tz, undefined); + const shaped = schedule.patterns[t1.pattern]; + assert.equal(shaped.shape, 0); + assert.deepEqual(shaped.seq, [1, 5, 9]); + assert.ok(Math.abs(shaped.dist[1] - 1000) < 5, String(shaped.dist[1])); + assert.ok(Math.abs(shaped.dist[2] - 3000) < 5, String(shaped.dist[2])); + const bare = schedule.patterns[t4.pattern]; + assert.equal( + bare.shape, + null, + 'a position feed draws a shapeless trip between stops', + ); + assert.deepEqual(bare.pass, [0, 1]); + assert.ok(Math.abs(bare.dist[1] - 3000) < 5); + assert.equal(stats.patternsShapeless, 1); + + // The browser module reads it back: the 24:20:00 arrival lands in Chicago + // time on the next day. + const live = createSchedule(JSON.parse(JSON.stringify(schedule))); + const stops = tripStops( + live, + { tripId: 't3', serviceDate: '2026-11-07' }, + Date.parse('2026-11-08T04:55:00Z'), + ); + assert.deepEqual( + stops.map((stop) => [stop.stopId, stop.scheduledArrival, stop.state]), + [ + ['A1', '23:50', 'passed'], + ['Bst', '00:00', 'next'], + ['C', '23:20', 'upcoming'], + ], + ); +}); + +test('no line is invented where the GTFS has no shape', async (t) => { + const zip = await scheduleZip(t); + { + const feed = railFeed(); + const { pack: paths } = await buildPack(feed, zip, { url: 'x' }); + assert.equal(paths.shapes.length, 1); + assert.deepEqual(paths.shapeSources, ['operator']); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + paths, + { from: '2026-11-05' }, + ); + const pattern = schedule.patterns[schedule.trips.t4.pattern]; + assert.equal(pattern.shape, null, 'the shapeless trip gets no shape'); + // Great-circle metres between the two stops, a little under 3 km. + assert.equal(pattern.dist.length, 2); + assert.ok(Math.abs(pattern.dist[1] - 3000) < 5, String(pattern.dist[1])); + + assert.equal(stats.patternsShapeless, 1); + assert.equal(paths.shapes.length, 1, 'the path pack is left as built'); + } + const chord = [ + ['shape_id', 'shape_pt_lat', 'shape_pt_lon', 'shape_pt_sequence'].join(), + `S,0,0,1`, + `S,0,${(6000 * D).toFixed(6)},2`, + ].join('\n'); + const sparse = await withZip(t, [ + { name: 'routes.txt', text: 'route_id,route_type\nR1,2\n' }, + { + name: 'trips.txt', + text: 'route_id,service_id,trip_id,shape_id\nR1,WK,t1,S\n', + }, + { name: 'shapes.txt', text: chord }, + ]); + const { pack, stats } = await buildPack( + railFeed({ positions: 'schedule' }), + sparse, + { url: 'x' }, + ); + assert.equal(pack.shapes.length, 0, 'a chord is dropped on every feed'); + assert.equal(stats.shapesDropped, 1); +}); + +test('frequency windows give one departure per headway before each end', () => { + const h = (hours, minutes = 0) => hours * 3600 + minutes * 60; + // Santiago's Tren Nos: back-to-back windows of different headways. + assert.deepEqual( + frequencyDepartures([h(6), h(6, 10), 600, h(6, 10), h(6, 42), 480]), + [h(6), h(6, 10), h(6, 18), h(6, 26), h(6, 34)], + ); + // Overlapping windows do not repeat a departure; malformed rows add none. + assert.deepEqual( + frequencyDepartures([ + ...[h(7), h(7, 20), 600], + ...[h(7, 10), h(7, 11), 600], + ...[h(8), h(9), 0], + ...[Number.NaN, h(9), 600], + ...[h(9), h(8), 600], + ]), + [h(7), h(7, 10)], + ); + // One row is capped at a run a minute for a day. + assert.equal(frequencyDepartures([0, 2 * 86_400, 1]).length, 1440); +}); + +test('a frequency-based trip becomes one run per departure on its template', async (t) => { + const zip = await withZip(t, [ + { + name: 'agency.txt', + text: 'agency_id,agency_name,agency_url,agency_timezone\nA,Test,https://example.test,America/Santiago\n', + }, + { + name: 'calendar.txt', + text: [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'L,1,1,1,1,1,0,0,20261003,20261231', + ].join('\n'), + }, + { name: 'routes.txt', text: 'route_id,route_type\nMTN,0\nMTR,0\n' }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id,trip_headsign,direction_id', + 'MTN,L,MTN-I-L-M01,S,Nos,0', + 'MTN,L,MTN-I-L-M02,S,Nos,0', + 'MTR,L,MTR-I-L-1,S,Rancagua,0', + ].join('\n'), + }, + { + name: 'frequencies.txt', + text: [ + 'trip_id,start_time,end_time,headway_secs,exact_times', + 'MTN-I-L-M01,06:00:00,06:10:00,600,0', + 'MTN-I-L-M02,06:10:00,06:30:00,480,0', + 'MTN-I-L-M02,23:58:00,24:10:00,1200,0', + ].join('\n'), + }, + { + name: 'stops.txt', + text: [ + 'stop_id,stop_name,stop_lat,stop_lon', + 'A,Alpha,0,' + east(10), + 'B,Bravo,0,' + east(2000), + ].join('\n'), + }, + { + name: 'stop_times.txt', + text: [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + // Templates timed from 00:00:00, as the DTPM file has them. + 'MTN-I-L-M01,00:00:00,00:00:00,A,1', + 'MTN-I-L-M01,00:05:00,00:05:00,B,2', + 'MTN-I-L-M02,00:00:00,00:00:00,A,1', + 'MTN-I-L-M02,00:05:00,00:05:00,B,2', + 'MTR-I-L-1,07:00:00,07:00:00,A,1', + 'MTR-I-L-1,07:04:00,07:04:00,B,2', + ].join('\n'), + }, + { + name: 'shapes.txt', + text: [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + ...shapeRows('S', 25, 100), + ].join('\n'), + }, + ]); + const feed = railFeed(); + const { pack: paths } = await buildPack(feed, zip, { url: 'x' }); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + paths, + { from: '2026-11-10', days: 1 }, + ); + + assert.deepEqual(Object.keys(schedule.trips).sort(), [ + 'MTN-I-L-M01@06:00:00', + 'MTN-I-L-M02@06:10:00', + 'MTN-I-L-M02@06:18:00', + 'MTN-I-L-M02@06:26:00', + 'MTN-I-L-M02@23:58:00', + 'MTR-I-L-1', + ]); + const runs = Object.entries(schedule.trips).filter(([id]) => + id.startsWith('MTN'), + ); + assert.deepEqual( + runs.map(([, trip]) => trip.startSec), + [21_600, 22_200, 22_680, 23_160, 86_280], + ); + // Every run shares its template's pattern and run times. + assert.equal(new Set(runs.map(([, trip]) => trip.pattern)).size, 1); + assert.equal(new Set(runs.map(([, trip]) => trip.profile)).size, 1); + assert.deepEqual(schedule.profiles[runs[0][1].profile], [0, 0, 300, 300]); + assert.equal(runs[0][1].headsign, 'Nos'); + assert.equal(schedule.trips['MTR-I-L-1'].startSec, 25_200); + assert.equal(stats.frequencyTrips, 2); + assert.equal(stats.frequencyRuns, 5); + assert.equal(stats.tripsKept, 6); + // The 23:58 run arrives after midnight: the pack looks a day back for it. + assert.equal(stats.lookbackDays, 1); + assert.deepEqual([...schedule.services.L], ['2026-11-09', '2026-11-10']); + // The browser module times a run from its own start (Santiago is UTC-3 + // in November). + const live = createSchedule(JSON.parse(JSON.stringify(schedule))); + const stops = tripStops( + live, + { tripId: 'MTN-I-L-M02@06:18:00', serviceDate: '2026-11-10' }, + Date.parse('2026-11-10T09:20:00Z'), + ); + assert.deepEqual( + stops.map((stop) => [stop.stopId, stop.scheduledArrival, stop.state]), + [ + ['A', '06:18', 'passed'], + ['B', '06:23', 'next'], + ], + ); +}); diff --git a/src/tooling/buildRailPathsCli.test.mjs b/src/tooling/buildRailPathsCli.test.mjs new file mode 100644 index 000000000..23eed6932 --- /dev/null +++ b/src/tooling/buildRailPathsCli.test.mjs @@ -0,0 +1,305 @@ +// The pack build's download and command line: https on every hop, a byte +// cap and a time limit; the redirect target recorded as the source; the +// output folder the server reads; `--no-schedule` refused for a feed named +// only through its schedule pack; and exit code 2 when a feed fails. No test +// here touches the network: downloads are a stubbed fetch, builds read a +// local zip. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + FEED_FAILED_EXIT_CODE, + buildRefusal, + downloadStatic, + packSource, + parseBuildArgs, +} from '../../scripts/build-rail-paths.mjs'; +import { getRegisteredRailFeed } from '../data/railFeeds.js'; + +const SCRIPT = fileURLToPath( + new URL('../../scripts/build-rail-paths.mjs', import.meta.url), +); + +async function tempDir(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-cli-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** A fetch stub answering each URL from `answers`, recording each call. */ +function stubFetch(t, answers) { + const calls = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, init }); + const answer = answers(url, init); + return typeof answer === 'function' ? answer() : answer; + }); + return calls; +} + +const redirect = (location, status = 302) => + new Response(null, { status, headers: { location } }); + +test('a download follows https redirects hop by hop and records where it ended', async (t) => { + const dir = await tempDir(t); + const calls = stubFetch(t, (url) => + url === 'https://example.test/gtfs.zip' + ? redirect('https://cdn.example.test/files/gtfs.zip') + : new Response('zip bytes', { + status: 200, + headers: { 'last-modified': 'Thu, 05 Nov 2026 10:00:00 GMT' }, + }), + ); + const dest = path.join(dir, 'gtfs.zip'); + const got = await downloadStatic('https://example.test/gtfs.zip', dest, { + 'Digitraffic-User': 'GodsEyeView/RailLayer', + }); + assert.deepEqual( + calls.map((call) => call.url), + [ + 'https://example.test/gtfs.zip', + 'https://cdn.example.test/files/gtfs.zip', + ], + ); + for (const { init } of calls) { + assert.equal(init.redirect, 'manual'); + assert.match(init.headers['User-Agent'], /gods-eye-view-rail-paths/); + assert.equal(init.headers['Digitraffic-User'], 'GodsEyeView/RailLayer'); + } + assert.equal(got.finalUrl, 'https://cdn.example.test/files/gtfs.zip'); + assert.equal(got.lastModified, 'Thu, 05 Nov 2026 10:00:00 GMT'); + assert.equal(got.bytes, 9); + assert.equal(await readFile(dest, 'utf8'), 'zip bytes'); + // The packs record the URL the file came from, not the one first asked. + const feed = getRegisteredRailFeed('mbta-rail'); + assert.deepEqual(packSource(feed, got), { + url: 'https://cdn.example.test/files/gtfs.zip', + lastModified: 'Thu, 05 Nov 2026 10:00:00 GMT', + }); + assert.deepEqual( + packSource(feed, { + file: '/tmp/x.zip', + localName: 'x.zip', + lastModified: 'Wed, 04 Nov 2026 10:00:00 GMT', + }), + { + url: feed.staticGtfsUrl, + file: 'x.zip', + lastModified: 'Wed, 04 Nov 2026 10:00:00 GMT', + }, + ); +}); + +test('a download is https only: plain http is refused before it is asked', async (t) => { + const dir = await tempDir(t); + const calls = stubFetch(t, () => + redirect('http://example.test/gtfs.zip', 301), + ); + await assert.rejects( + downloadStatic('https://example.test/gtfs.zip', path.join(dir, 'a.zip')), + /refusing non-https URL http:\/\/example\.test\/gtfs\.zip/, + ); + assert.equal(calls.length, 1, 'the http hop is never contacted'); + await assert.rejects( + downloadStatic('http://example.test/gtfs.zip', path.join(dir, 'b.zip')), + /refusing non-https/, + ); + assert.equal(calls.length, 1); +}); + +test('a download stops at the redirect limit', async (t) => { + const dir = await tempDir(t); + let n = 0; + const calls = stubFetch(t, () => redirect(`https://example.test/${++n}`)); + await assert.rejects( + downloadStatic('https://example.test/0', path.join(dir, 'a.zip')), + /too many redirects/, + ); + assert.equal(calls.length, 6, 'the first ask and five redirects'); +}); + +test('a download over the byte cap is refused, declared or streamed', async (t) => { + const dir = await tempDir(t); + stubFetch( + t, + (url) => () => + url.endsWith('declared') + ? new Response('x'.repeat(10), { + status: 200, + headers: { 'content-length': '2000' }, + }) + : new Response( + new ReadableStream({ + start(controller) { + for (let i = 0; i < 4; i += 1) + controller.enqueue(new Uint8Array(400)); + controller.close(); + }, + }), + { status: 200 }, + ), + ); + await assert.rejects( + downloadStatic( + 'https://example.test/declared', + path.join(dir, 'a.zip'), + null, + { maxBytes: 1000 }, + ), + /download is 2000 bytes/, + ); + await assert.rejects( + downloadStatic( + 'https://example.test/streamed', + path.join(dir, 'b.zip'), + null, + { maxBytes: 1000 }, + ), + /exceeds the size cap/, + ); + // Under the cap it is written whole. + const got = await downloadStatic( + 'https://example.test/streamed', + path.join(dir, 'c.zip'), + null, + { maxBytes: 1600 }, + ); + assert.equal(got.bytes, 1600); +}); + +test('a download that does not finish in time is abandoned', async (t) => { + const dir = await tempDir(t); + stubFetch( + t, + (url, init) => () => + new Promise((resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason)); + }), + ); + await assert.rejects( + downloadStatic('https://example.test/slow', path.join(dir, 'a.zip'), null, { + timeoutMs: 50, + }), + (error) => error?.name === 'TimeoutError', + ); +}); + +test('the build writes where the server reads: RAIL_PATHS_DIR unless --out names a folder', () => { + const served = path.join(os.tmpdir(), 'served-packs'); + assert.equal( + parseBuildArgs(['--feed', 'a'], { RAIL_PATHS_DIR: served }).out, + path.resolve(served), + ); + const own = path.join(os.tmpdir(), 'own-packs'); + assert.equal( + parseBuildArgs(['--feed', 'a', '--out', own], { RAIL_PATHS_DIR: served }) + .out, + path.resolve(own), + ); + assert.match( + parseBuildArgs(['--feed', 'a'], {}).out, + /[\\/]\.cache[\\/]rail-paths$/, + ); + // The served folder is held to the same rule as --out. + const src = fileURLToPath(new URL('../packs', import.meta.url)); + assert.throws( + () => parseBuildArgs(['--feed', 'a'], { RAIL_PATHS_DIR: src }), + /--out must not be inside src\//, + ); +}); + +test('--no-schedule is refused for a feed named only through its schedule pack', () => { + const ktmb = getRegisteredRailFeed('ktmb'); + const mbta = getRegisteredRailFeed('mbta-rail'); + assert.match(buildRefusal(ktmb, { schedule: false }), /ktmb.*schedule pack/); + assert.match( + buildRefusal(getRegisteredRailFeed('renfe-larga-distancia'), { + schedule: false, + }), + /renfe-larga-distancia/, + ); + assert.equal(buildRefusal(ktmb, { schedule: true }), null); + assert.equal(buildRefusal(mbta, { schedule: false }), null); +}); + +/** Run the build script with the given arguments and environment. */ +function runBuild(args, env = {}) { + const base = { ...process.env }; + delete base.RAIL_PATHS_DIR; + return spawnSync(process.execPath, [SCRIPT, ...args], { + encoding: 'utf8', + env: { ...base, ...env }, + timeout: 60_000, + }); +} + +test('a build in which a feed fails exits with code 2', async (t) => { + const dir = await tempDir(t); + const broken = path.join(dir, 'broken.zip'); + await writeFile(broken, 'this is not a zip'); + const out = path.join(dir, 'packs'); + const run = runBuild([ + '--feed', + 'mbta-rail', + '--local-gtfs', + `mbta-rail=${broken}`, + '--out', + out, + ]); + assert.equal(run.status, FEED_FAILED_EXIT_CODE, run.stderr); + assert.match(run.stderr, /mbta-rail skipped/); + assert.match(run.stdout, /done: 0 built, failed: mbta-rail/); +}); + +test('a build with no --out writes to RAIL_PATHS_DIR', async (t) => { + const dir = await tempDir(t); + const broken = path.join(dir, 'broken.zip'); + await writeFile(broken, 'this is not a zip'); + const served = path.join(dir, 'served'); + const run = runBuild( + ['--feed', 'mbta-rail', '--local-gtfs', `mbta-rail=${broken}`], + { RAIL_PATHS_DIR: served }, + ); + assert.equal(run.status, FEED_FAILED_EXIT_CODE, run.stderr); + assert.ok( + run.stdout.includes(`1 feeds → ${path.resolve(served)}`), + run.stdout, + ); +}); + +test('--no-schedule leaves a pack-routed feed and its schedule pack as they were', async (t) => { + const dir = await tempDir(t); + const zip = path.join(dir, 'ktmb.zip'); + await writeFile(zip, 'not read: the build is refused first'); + const out = path.join(dir, 'packs'); + const runRefused = async () => { + const run = runBuild([ + '--feed', + 'ktmb', + '--no-schedule', + '--local-gtfs', + `ktmb=${zip}`, + '--out', + out, + ]); + assert.equal(run.status, FEED_FAILED_EXIT_CODE, run.stderr); + assert.match( + run.stderr, + /ktmb skipped: --no-schedule: ktmb names its trains only through its schedule pack/, + ); + return run; + }; + await runRefused(); + const schedule = path.join(out, 'ktmb.schedule.json'); + await writeFile(schedule, '{"version":1,"feedId":"ktmb"}'); + await runRefused(); + assert.equal( + await readFile(schedule, 'utf8'), + '{"version":1,"feedId":"ktmb"}', + 'the schedule pack stays', + ); +}); diff --git a/src/tooling/buildRailPathsLocal.test.mjs b/src/tooling/buildRailPathsLocal.test.mjs new file mode 100644 index 000000000..420e11725 --- /dev/null +++ b/src/tooling/buildRailPathsLocal.test.mjs @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { + createRouteNames, + localStatic, + openZip, + parseLocalGtfs, + readTripEnds, +} from '../../scripts/build-rail-paths.mjs'; +import { classifyTrain } from '../data/railFeeds.js'; + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +async function tempDir(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-local-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** + * A GTFS in a top-level folder: a train from Venezia into Austria, a + * domestic train, a train inside Italy, and a bus. + */ +const GTFS_FILES = { + 'GTFS_2026/agency.txt': + 'agency_id,agency_name,agency_url,agency_timezone\n01,Example Rail,https://example.test,Europe/Vienna\n', + 'GTFS_2026/routes.txt': [ + 'route_id,agency_id,route_short_name,route_type', + 'I3,01,I3,2', + 'A9,01,A9,2', + 'SV1,01,SV1,3', + ].join('\n'), + 'GTFS_2026/trips.txt': [ + 'route_id,service_id,trip_id,trip_short_name', + 'I3,TA,it-domestic,R 134', + 'A9,TA,cross-border,EC 134', + 'A9,TA,at-domestic,RJ 650', + 'SV1,TA,bus,', + ].join('\n'), + 'GTFS_2026/stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + 'it:22021:1,Venezia Santa Lucia,45.441,12.321', + 'it:22021:2,Tarvisio Citta Boscoverde,46.507,13.583', + 'at:42:1:1,Villach Hauptbahnhof,46.618,13.848', + 'at:42:2:1,Klagenfurt Hauptbahnhof,46.616,14.312', + 'at:49:1:1,Wien Hauptbahnhof,48.185,16.376', + ].join('\n'), + 'GTFS_2026/stop_times.txt': [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + 'it-domestic,09:56:00,09:56:00,it:22021:1,1', + 'it-domestic,12:41:00,12:41:00,it:22021:2,2', + 'cross-border,12:57:00,12:57:00,it:22021:2,1', + 'cross-border,13:30:00,13:32:00,at:42:1:1,2', + 'cross-border,17:07:00,17:07:00,at:49:1:1,3', + 'at-domestic,08:00:00,08:00:00,at:42:2:1,1', + 'at-domestic,12:00:00,12:00:00,at:49:1:1,2', + 'bus,08:00:00,08:00:00,at:42:1:1,1', + 'bus,08:30:00,08:30:00,at:42:2:1,2', + ].join('\n'), +}; + +/** + * A feed whose category rule needs each trip's ends (`staticTripEnds: + * true`): a trip is international when it stops in more than one country. + */ +const ENDS_FEED = Object.freeze({ + id: 'test-ends', + staticTripEnds: true, + staticTripRename: (trip, routeId, { ends } = {}) => + `${(ends?.countries ?? []).length > 1 ? 'INT' : 'DOM'}~${routeId}`, + classify: (routeId) => + String(routeId).startsWith('INT~') + ? { category: 'international', mode: 'rail' } + : String(routeId).startsWith('DOM~') + ? { category: 'national', mode: 'rail' } + : null, +}); + +test('--local-gtfs names a feed and a zip path, made absolute', () => { + const parsed = parseLocalGtfs('test-ends=downloads/GTFS_2026.zip'); + assert.equal(parsed.id, 'test-ends'); + assert.ok(path.isAbsolute(parsed.file)); + assert.equal(path.basename(parsed.file), 'GTFS_2026.zip'); + // A Windows path keeps its drive letter. + assert.equal( + parseLocalGtfs('irish-rail=C:\\Users\\me\\Downloads\\gtfs.zip').file, + path.resolve('C:\\Users\\me\\Downloads\\gtfs.zip'), + ); + for (const bad of ['', 'test-ends', 'test-ends=', '=x.zip', 'Bad_Id=x.zip']) + assert.throws(() => parseLocalGtfs(bad), /--local-gtfs/, bad); +}); + +test('a local zip stands in for a download, by name only, and is left as it was', async (t) => { + const dir = await tempDir(t); + const file = path.join(dir, 'GTFS_2026.zip'); + const bytes = makeZip(GTFS_FILES); + await writeFile(file, bytes); + const before = await stat(file); + const got = await localStatic(file); + assert.equal(got.file, file); + assert.equal(got.localName, 'GTFS_2026.zip'); + assert.equal(got.lastModified, before.mtime.toUTCString()); + // Reading it through the build leaves the file unchanged. + const zip = await openZip(got.file); + await readTripEnds(ENDS_FEED, zip); + await zip.handle.close(); + assert.deepEqual(await readFile(file), bytes); + assert.equal((await stat(file)).mtimeMs, before.mtimeMs); + await assert.rejects(localStatic(path.join(dir, 'missing.zip'))); + await assert.rejects(localStatic(dir), /not a file/); +}); + +test('staticTripEnds gives each rail trip its ends and every country it stops in', async (t) => { + const dir = await tempDir(t); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(GTFS_FILES)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + const feed = ENDS_FEED; + const ends = await readTripEnds(feed, zip); + assert.deepEqual([...ends.keys()].sort(), [ + 'at-domestic', + 'cross-border', + 'it-domestic', + ]); + assert.deepEqual(ends.get('cross-border').countries, ['AT', 'IT']); + assert.deepEqual(ends.get('at-domestic').countries, ['AT']); + assert.deepEqual(ends.get('it-domestic').countries, ['IT']); + assert.equal(ends.get('cross-border').from.name, 'Tarvisio Citta Boscoverde'); + assert.equal(ends.get('cross-border').to.name, 'Wien Hauptbahnhof'); + + const names = await createRouteNames(feed, zip); + const route = names.route({ route_id: 'A9', route_type: '2' }); + const category = (tripId, shortName) => + classifyTrain( + feed, + names.trip({ trip_id: tripId, trip_short_name: shortName }, route), + )?.category ?? null; + assert.equal(category('cross-border', 'EC 134'), 'international'); + assert.equal(category('at-domestic', 'RJ 650'), 'national'); + assert.equal(names.route({ route_id: 'SV1', route_type: '3' }), null); +}); diff --git a/src/tooling/buildRailPathsRoutes.test.mjs b/src/tooling/buildRailPathsRoutes.test.mjs new file mode 100644 index 000000000..37ed4c4c7 --- /dev/null +++ b/src/tooling/buildRailPathsRoutes.test.mjs @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { + buildPack, + createRouteNames, + createStateLookup, + openZip, +} from '../../scripts/build-rail-paths.mjs'; +import { getRegisteredRailFeed } from '../data/railFeeds.js'; + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +async function withZip(t, files) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-routes-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(files)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +test('states: Spanish provinces group into communities; abroad is abroad', async () => { + const at = await createStateLookup(); + assert.deepEqual(at(40.4712, -3.683), { country: 'ES', state: 'ES-MD' }); + assert.deepEqual(at(38.8907, -6.9818), { country: 'ES', state: 'ES-EX' }); + assert.equal(at(41.3799, 2.141).state, 'ES-CT', 'Barcelona'); + assert.equal(at(36.5287, -6.2896).state, 'ES-AN', 'Cádiz, on the coast'); + assert.equal(at(43.3035, 5.3822).country, 'FR'); + assert.equal(at(Number.NaN, 0), null); + assert.equal(at(0, -30), null, 'mid-ocean'); +}); + +test('Renfe routes are renamed with product and community scope', async (t) => { + const zip = await withZip(t, { + 'stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + '17000,Madrid-Chamartín,40.4711789,-3.6829524', + '37606,Badajoz,38.8906960,-6.9817530', + '10200,Guadalajara,40.6440,-3.1820', + '18000,Madrid-Atocha,40.4064,-3.6906', + ].join('\n'), + }); + const names = await createRouteNames( + getRegisteredRailFeed('renfe-larga-distancia'), + zip, + ); + const route = (id, short, type = '2') => + names.route({ route_id: id, route_short_name: short, route_type: type }); + assert.equal(route('1700037606GL023', 'MD'), 'MD~X~1700037606GL023'); + assert.equal( + route('1700018000GL001', 'REGIONAL'), + 'REGIONAL~S~1700018000GL001', + ); + assert.equal( + route('1700010200GL001', 'MD'), + 'MD~X~1700010200GL001', + 'Madrid to Castilla-La Mancha', + ); + assert.equal(route('1700037606GL023', 'AUTOBUS'), null, 'an unknown product'); + assert.equal(route('1700037606GL023', 'MD', '3'), null, 'not rail'); + assert.equal(names.trip({ trip_id: 'x' }, 'MD~S~1'), 'MD~S~1'); +}); + +test('a renamed route keeps its shapes under the new id in the path pack', async (t) => { + const zip = await withZip(t, { + 'routes.txt': [ + 'route_id,agency_id,route_short_name,route_type', + '114876,IFF:NS,Intercity,2', + '9,GVB,Tram 9,0', + ].join('\n'), + 'trips.txt': [ + 'route_id,service_id,trip_id,shape_id', + '114876,s,338806760,sh1', + '9,s,t9,sh2', + ].join('\n'), + 'shapes.txt': [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + 'sh1,52.0,5.0,1', + 'sh1,52.001,5.001,2', + 'sh1,52.002,5.002,3', + 'sh2,52.3,4.9,1', + 'sh2,52.301,4.901,2', + ].join('\n'), + }); + // A rename that names the operator, as a feed that sorts its routes by + // agency writes it; the classifier reads the renamed id. + const feed = { + id: 'test-feed', + staticRouteRename: (route) => + route.route_type === '2' + ? `${route.agency_id.split(':').pop()}~${route.route_short_name}~${route.route_id}` + : null, + classify: (routeId) => + routeId?.startsWith('NS~') + ? { category: 'national', mode: 'rail' } + : null, + }; + const { pack } = await buildPack(feed, zip, { url: 'x' }); + assert.deepEqual(Object.keys(pack.routes), ['NS~Intercity~114876']); + + assert.deepEqual(Object.keys(pack.trips), ['338806760']); +}); diff --git a/src/tooling/buildRailPathsTripEnds.test.mjs b/src/tooling/buildRailPathsTripEnds.test.mjs new file mode 100644 index 000000000..d07dd518c --- /dev/null +++ b/src/tooling/buildRailPathsTripEnds.test.mjs @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { + buildPack, + buildSchedule, + createRouteNames, + openZip, + readTripEnds, +} from '../../scripts/build-rail-paths.mjs'; +import { classifyTrain, getRegisteredRailFeed } from '../data/railFeeds.js'; + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +async function withZip(t, files) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-ends-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(files)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +/** A small KTMB-like GTFS: an ETS run KL–JB, an ETS run KL–Hat Yai, a bus. */ +const KTMB_FILES = { + 'agency.txt': + 'agency_id,agency_name,agency_url,agency_timezone\nktmb,KTMB,https://ktmb.com.my,Asia/Kuala_Lumpur\n', + 'routes.txt': [ + 'route_id,route_short_name,route_type', + 'ETS,ETS,2', + 'KC05_KB18,Seremban,2', + 'B1,Bus,3', + ].join('\n'), + 'trips.txt': [ + 'route_id,service_id,trip_id', + 'ETS,daily,9001', + 'ETS,daily,9002', + 'KC05_KB18,daily,2001', + 'B1,daily,b1', + ].join('\n'), + 'stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + '19100,KL SENTRAL,3.134167,101.686111', + '37500,JB SENTRAL,1.463147,103.764445', + '22700,SEREMBAN,2.719169,101.940792', + '99000,HAT YAI,7.0033,100.4687', + '50600,BATU CAVES,3.237796,101.681215', + ].join('\n'), + 'stop_times.txt': [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + // Out of order on purpose: the ends come from stop_sequence. + '9001,08:40:00,08:42:00,22700,2', + '9001,08:00:00,08:00:00,19100,1', + '9001,12:00:00,12:00:00,37500,3', + '9002,09:00:00,09:00:00,19100,1', + '9002,17:00:00,17:00:00,99000,2', + '2001,07:00:00,07:00:00,50600,1', + '2001,07:30:00,07:30:00,19100,2', + 'b1,07:00:00,07:00:00,19100,1', + 'b1,07:30:00,07:30:00,22700,2', + ].join('\n'), + 'calendar.txt': [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'daily,1,1,1,1,1,1,1,20260101,20271231', + ].join('\n'), + 'calendar_dates.txt': [ + 'service_id,date,exception_type', + // Far outside any build window: read and ignored. + 'daily,20271225,2', + 'daily,20261110,2', + ].join('\n'), +}; + +test('readTripEnds keeps rail trips only, ends by stop_sequence, with states', async (t) => { + const zip = await withZip(t, KTMB_FILES); + const ends = await readTripEnds(getRegisteredRailFeed('ktmb'), zip); + assert.deepEqual([...ends.keys()].sort(), ['2001', '9001', '9002']); + const ets = ends.get('9001'); + assert.equal(ets.from.name, 'KL SENTRAL'); + assert.equal(ets.to.name, 'JB SENTRAL'); + assert.deepEqual([ets.from.state, ets.to.state], ['MY-14', 'MY-01']); + assert.deepEqual(ets.countries, ['MY']); + assert.deepEqual(ends.get('9002').countries, ['MY', 'TH']); +}); + +test('a feed with staticTripEnds names each trip by where it runs', async (t) => { + const zip = await withZip(t, KTMB_FILES); + const feed = getRegisteredRailFeed('ktmb'); + const names = await createRouteNames(feed, zip); + const etsKl = names.trip( + { trip_id: '9001' }, + names.route({ route_id: 'ETS', route_type: '2' }), + ); + assert.equal(etsKl, 'ETS~X~ETS'); + assert.equal(classifyTrain(feed, etsKl).category, 'national'); + const etsHatYai = names.trip({ trip_id: '9002' }, 'ETS'); + assert.equal(classifyTrain(feed, etsHatYai).category, 'international'); + assert.equal( + classifyTrain(feed, names.trip({ trip_id: '2001' }, 'KC05_KB18')).category, + 'local', + ); + assert.equal(names.route({ route_id: 'B1', route_type: '3' }), null); +}); + +test('the packs carry the scoped route ids, and the realtime trip resolves to one', async (t) => { + const zip = await withZip(t, KTMB_FILES); + const feed = getRegisteredRailFeed('ktmb'); + const names = await createRouteNames(feed, zip); + const { pack } = await buildPack(feed, zip, { url: 'x' }, names); + const { schedule } = await buildSchedule(feed, zip, { url: 'x' }, pack, { + names, + from: '2026-11-09', + days: 3, + }); + assert.deepEqual( + Object.fromEntries( + Object.entries(schedule.trips).map(([id, trip]) => [id, trip.route]), + ), + { 9001: 'ETS~X~ETS', 9002: 'ETS~B~ETS', 2001: 'KC05_KB18~X~KC05_KB18' }, + ); + assert.deepEqual(schedule.services.daily, ['2026-11-09', '2026-11-11']); +}); diff --git a/src/tooling/buildRailPathsUnlocated.test.mjs b/src/tooling/buildRailPathsUnlocated.test.mjs new file mode 100644 index 000000000..b80c09629 --- /dev/null +++ b/src/tooling/buildRailPathsUnlocated.test.mjs @@ -0,0 +1,199 @@ +// Stops a GTFS gives no coordinates, or (0, 0) as some feeds do for foreign +// stations their international trains serve: left out of their runs, never +// placed by guesswork, so no line is ever drawn to (0, 0). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32, deflateRawSync } from 'node:zlib'; +import { + buildPack, + buildSchedule, + hasStopCoordinates, + openZip, + readTripEnds, +} from '../../scripts/build-rail-paths.mjs'; + +/** A minimal zip writer: deflated entries, no zip64. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const { name, text } of files) { + const data = Buffer.from(text, 'utf8'); + const body = deflateRawSync(data); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(8, 8); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(8, 10); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, body); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + body.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +// A line 20 km due east from 51° N 7° E (in Germany), x in metres. +const LAT = 51; +const LON = 7; +const DLON = 1 / (111_320 * Math.cos((LAT * Math.PI) / 180)); +const lonAt = (x) => LON + x * DLON; +const at = (x) => `${LAT.toFixed(6)},${lonAt(x).toFixed(6)}`; + +async function feedZip(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-unlocated-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile( + file, + makeZip([ + { + name: 'agency.txt', + text: 'agency_id,agency_name,agency_url,agency_timezone\nA,Test,https://example.test,Europe/Berlin\n', + }, + { + name: 'calendar.txt', + text: 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date\nWK,1,1,1,1,1,1,1,20260101,20261231\n', + }, + { name: 'routes.txt', text: 'route_id,route_type\nR1,2\n' }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id', + 'R1,WK,t1,', + 'R1,WK,t2,', + 'R1,WK,t3,', + 'R1,WK,t4,', + ].join('\n'), + }, + { + name: 'stops.txt', + text: [ + 'stop_id,stop_name,stop_lat,stop_lon,parent_station', + `A,Alpha,${at(0)},`, + `C,Charlie,${at(20_000)},`, + // (0, 0) in the feed, parent and all. + 'PZ,Zeta (Westf.),0,0,', + 'Z,Zeta (Westf.),0,0,PZ', + 'Y,Ypsilon,0,0,', + // Blank coordinates are none either. + 'Q,Quebec,,,', + ].join('\n'), + }, + { + name: 'stop_times.txt', + text: [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + 't1,08:00:00,08:00:00,A,1', + 't1,08:10:00,08:10:00,Z,2', + 't1,08:20:00,08:20:00,C,3', + 't2,09:00:00,09:00:00,A,1', + 't2,09:10:00,09:10:00,Y,2', + 't2,09:20:00,09:20:00,C,3', + 't3,10:00:00,10:00:00,A,1', + 't3,10:10:00,10:10:00,Y,2', + 't4,11:00:00,11:00:00,A,1', + 't4,11:10:00,11:10:00,Q,2', + 't4,11:20:00,11:20:00,C,3', + ].join('\n'), + }, + ]), + ); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +const feed = { + id: 'test-feed', + classify: () => ({ category: 'regional', mode: 'rail' }), +}; +const options = { from: '2026-11-09', days: 2 }; + +/** Every vertex of every shape the pack holds, as [lat, lon]. */ +function vertices(pack) { + const out = []; + for (const flat of pack.shapes) + for (let i = 0; i < flat.length; i += 2) out.push([flat[i], flat[i + 1]]); + return out; +} + +const stopNames = (schedule, id) => + schedule.patterns[schedule.trips[id].pattern].stops.map( + (k) => schedule.stops[k].name, + ); + +test('(0, 0) and blank coordinates are no coordinates', () => { + assert.equal(hasStopCoordinates(0, 0), false); + assert.equal(hasStopCoordinates(-0, 0), false); + assert.equal(hasStopCoordinates(Number.NaN, 7), false); + assert.equal(hasStopCoordinates(undefined, undefined), false); + assert.equal(hasStopCoordinates(0, 0.00001), true); + assert.equal(hasStopCoordinates(51.68, 7.81), true); +}); + +test('an unlocated stop is left out of its runs, never drawn to (0, 0)', async (t) => { + const zip = await feedZip(t); + const { pack } = await buildPack(feed, zip, { url: 'x' }); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + pack, + options, + ); + // t1, t2 and t4 keep their two located stops; t3 has only one left. + assert.deepEqual(Object.keys(schedule.trips).sort(), ['t1', 't2', 't4']); + for (const id of ['t1', 't2', 't4']) + assert.deepEqual(stopNames(schedule, id), ['Alpha', 'Charlie']); + // The stop's times go with it. + const t1 = schedule.trips.t1; + assert.deepEqual(schedule.profiles[t1.profile], [0, 0, 1200, 1200]); + assert.deepEqual(schedule.patterns[t1.pattern].seq, [1, 3]); + assert.equal(stats.tripsUnknownStop, 1); + assert.equal(stats.tripsStopsLeftOut, 3); + assert.equal(stats.stopsLeftOut, 3); + // Nothing is guessed: no stop without coordinates, nor its parent, is in + // the pack. + for (const id of ['Z', 'PZ', 'Y', 'Q']) + assert.equal( + schedule.stops.find((s) => s.id === id), + undefined, + id, + ); + assert.ok(schedule.stops.every((s) => hasStopCoordinates(s.lat, s.lon))); + assert.ok(vertices(pack).every(([lat, lon]) => hasStopCoordinates(lat, lon))); +}); + +test('a run ends at its last located stop when its last has no coordinates', async (t) => { + const zip = await feedZip(t); + const ends = await readTripEnds({}, zip); + assert.equal(ends.get('t1').from.name, 'Alpha'); + assert.equal(ends.get('t1').to.name, 'Charlie'); + // t3: Alpha, then Ypsilon at (0, 0). + assert.equal(ends.get('t3').to.name, 'Alpha'); + assert.deepEqual(ends.get('t3').countries, ['DE']); +}); diff --git a/src/tooling/buildRailPathsWrite.test.mjs b/src/tooling/buildRailPathsWrite.test.mjs new file mode 100644 index 000000000..a4c405d61 --- /dev/null +++ b/src/tooling/buildRailPathsWrite.test.mjs @@ -0,0 +1,318 @@ +// How the path build writes a feed's packs: the path and schedule packs as +// a pair, never one without the other it indexes; nothing over the size the +// server serves; no collapse in trips without --force; one build per folder +// at a time; and a command line that never starts a build of every feed by +// itself. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + BUILD_LOCK, + FEED_FAILED_EXIT_CODE, + MIN_TRIP_SHARE, + acquireDirLock, + buildExitCode, + isInside, + operatorShapeTrips, + parseBuildArgs, + scheduleTripsPerDay, + tripFloorProblem, + writeFeedPacks, +} from '../../scripts/build-rail-paths.mjs'; + +async function tempDir(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-write-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** A path pack with `trips` trips on one operator shape. */ +function pathPack(builtAt, trips, { shapes = 1, source = 'operator' } = {}) { + return { + version: 1, + feedId: 'f', + builtAt, + source: { url: 'https://example.test/gtfs.zip' }, + shapes: Array.from({ length: shapes }, () => [0, 0, 0.001, 0.001]), + dist: Array.from({ length: shapes }, () => [0, 157]), + shapeSources: Array.from({ length: shapes }, () => source), + trips: Object.fromEntries( + Array.from({ length: trips }, (_, i) => [`t${i}`, 0]), + ), + routes: { R: [0] }, + }; +} + +/** A schedule pack with `trips` trips over `days` service days. */ +function schedulePack(pathsBuiltAt, trips, days = 4) { + const from = '2026-11-09'; + const to = new Date(Date.parse(from) + (days - 1) * 86_400_000) + .toISOString() + .slice(0, 10); + return { + version: 1, + feedId: 'f', + builtAt: pathsBuiltAt, + pathsBuiltAt, + window: { from, to }, + trips: Object.fromEntries( + Array.from({ length: trips }, (_, i) => [`t${i}`, { pattern: 0 }]), + ), + }; +} + +const read = async (dir, name) => + JSON.parse(await readFile(path.join(dir, name), 'utf8')); +const tempFiles = async (dir) => + (await readdir(dir)).filter((name) => name.endsWith('.tmp')); + +async function seed(dir, { trips = 10, scheduleTrips = 100 } = {}) { + await writeFile( + path.join(dir, 'f.json'), + JSON.stringify(pathPack('old', trips)), + ); + await writeFile( + path.join(dir, 'f.schedule.json'), + JSON.stringify(schedulePack('old', scheduleTrips)), + ); +} + +test('both packs are written as a pair, through temporary names of their own', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const written = await writeFeedPacks(dir, 'f', { + pack: pathPack('new', 10), + schedule: schedulePack('new', 100), + }); + assert.equal(written.removedSchedule, false); + assert.equal((await read(dir, 'f.json')).builtAt, 'new'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'new'); + assert.equal(JSON.parse(written.scheduleText).pathsBuiltAt, 'new'); + assert.deepEqual(await tempFiles(dir), []); +}); + +test('a schedule that cannot be written leaves the previous path pack in place', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const broken = schedulePack('new', 100); + broken.trips.t0.startSec = 1n; // JSON cannot hold it. + await assert.rejects( + writeFeedPacks(dir, 'f', { pack: pathPack('new', 10), schedule: broken }), + /BigInt/, + ); + assert.equal((await read(dir, 'f.json')).builtAt, 'old'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'old'); + assert.deepEqual(await tempFiles(dir), []); +}); + +test('without a schedule the previous one, which indexes the old shapes, is removed', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const written = await writeFeedPacks(dir, 'f', { pack: pathPack('new', 10) }); + assert.equal(written.removedSchedule, true); + assert.equal(written.scheduleText, null); + assert.equal((await read(dir, 'f.json')).builtAt, 'new'); + assert.deepEqual((await readdir(dir)).sort(), ['f.json']); + // Nothing to remove the next time. + const again = await writeFeedPacks(dir, 'f', { pack: pathPack('newer', 10) }); + assert.equal(again.removedSchedule, false); +}); + +test('a pack over the size the server serves is not written, and says what thins it', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const pack = pathPack('new', 10); + const maxBytes = Buffer.byteLength(JSON.stringify(pack)) - 1; + await assert.rejects( + writeFeedPacks(dir, 'f', { pack, maxBytes }), + (error) => + /over the .* the server serves/.test(error.message) && + /its shapes hold 2 points/.test(error.message) && + /staticSimplifyM/.test(error.message), + ); + const schedule = schedulePack('new', 100); + await assert.rejects( + writeFeedPacks(dir, 'f', { + pack: pathPack('new', 10, { shapes: 0 }), + schedule, + maxBytes: Buffer.byteLength(JSON.stringify(schedule)) - 1, + }), + /schedule is .* --days/, + ); + assert.equal((await read(dir, 'f.json')).builtAt, 'old'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'old'); +}); + +test('a collapse in trips keeps the previous packs unless --force', async (t) => { + const dir = await tempDir(t); + await seed(dir, { trips: 10, scheduleTrips: 100 }); + // The classifier kept nothing. + await assert.rejects( + writeFeedPacks(dir, 'f', { + pack: pathPack('new', 0), + schedule: schedulePack('new', 0), + }), + (error) => + /trips on operator shapes 0 against 10/.test(error.message) && + /schedule trips per day 0 against 25/.test(error.message) && + /--force/.test(error.message), + ); + // The calendar went missing: the shapes are fine, the timetable is not. + await assert.rejects( + writeFeedPacks(dir, 'f', { + pack: pathPack('new', 10), + schedule: schedulePack('new', 4), + }), + /schedule trips per day 1 against 25/, + ); + assert.equal((await read(dir, 'f.json')).builtAt, 'old'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'old'); + // A shorter window is not a collapse: trips are compared per day. + await writeFeedPacks(dir, 'f', { + pack: pathPack('short', 10), + schedule: schedulePack('short', 30, 1), + }); + assert.equal((await read(dir, 'f.json')).builtAt, 'short'); + // --force writes what the floor refused. + await writeFeedPacks(dir, 'f', { + pack: pathPack('forced', 0), + schedule: schedulePack('forced', 0), + force: true, + }); + assert.equal((await read(dir, 'f.json')).builtAt, 'forced'); + // A first build has nothing to fall from. + const fresh = await tempDir(t); + await writeFeedPacks(fresh, 'f', { + pack: pathPack('first', 0), + schedule: schedulePack('first', 0), + }); + assert.equal((await read(fresh, 'f.json')).builtAt, 'first'); +}); + +test('the trip floor counts what the build makes alike each time', () => { + // Only the operator's shapes count: a shape of any other source does not. + + const pack = pathPack('x', 0, { shapes: 2 }); + pack.shapeSources = ['operator', 'straight']; + pack.trips = { a: 0, b: 1, c: 1 }; + assert.equal(operatorShapeTrips(pack), 1); + assert.equal(operatorShapeTrips(null), 0); + assert.equal(scheduleTripsPerDay(schedulePack('x', 14, 7)), 2); + assert.equal(MIN_TRIP_SHARE, 0.2); + assert.equal(tripFloorProblem('trips', 2, 10), null); + assert.match(tripFloorProblem('trips', 1, 10), /trips 1 against 10/); + assert.equal(tripFloorProblem('trips', 0, 0), null); +}); + +test('one build holds a folder’s lock; a second refuses, a dead one’s is taken over', async (t) => { + const dir = await tempDir(t); + const held = await acquireDirLock(dir, BUILD_LOCK, { what: 'test build' }); + const record = JSON.parse(await readFile(held.file, 'utf8')); + assert.equal(record.pid, process.pid); + await assert.rejects( + acquireDirLock(dir, BUILD_LOCK, { what: 'test build' }), + (error) => + /another test build holds/.test(error.message) && + error.message.includes(String(process.pid)), + ); + await held.release(); + assert.deepEqual(await readdir(dir), []); + + // A lock whose process is gone. + const lockFile = path.join(dir, BUILD_LOCK); + await writeFile( + lockFile, + JSON.stringify({ pid: 999_999, time: new Date().toISOString() }), + ); + const taken = await acquireDirLock(dir, BUILD_LOCK, { alive: () => false }); + assert.equal(JSON.parse(await readFile(lockFile, 'utf8')).pid, process.pid); + await taken.release(); + + // A live process's lock not refreshed for longer than staleMs. + await writeFile( + lockFile, + JSON.stringify({ pid: 4242, time: '2026-01-01T00:00:00.000Z' }), + ); + const old = await acquireDirLock(dir, BUILD_LOCK, { alive: () => true }); + await old.release(); + + // A live, fresh one is respected; release never removes another's lock. + await writeFile( + lockFile, + JSON.stringify({ pid: 4242, time: new Date().toISOString() }), + ); + await assert.rejects( + acquireDirLock(dir, BUILD_LOCK, { alive: () => true }), + /pid 4242/, + ); + await old.release(); + assert.equal(JSON.parse(await readFile(lockFile, 'utf8')).pid, 4242); +}); + +test('the command line names its feeds or says --all, and --help needs nothing else', () => { + assert.throws(() => parseBuildArgs([]), /--feed .*--all/); + assert.equal(parseBuildArgs(['--all']).all, true); + assert.deepEqual(parseBuildArgs(['--help']), { help: true }); + assert.deepEqual(parseBuildArgs(['--feed', 'x', '-h']), { help: true }); + assert.throws(() => parseBuildArgs(['--all', '--feed', 'x']), /not both/); + assert.throws( + () => parseBuildArgs(['--feed', 'x', '--bogus']), + /Unknown argument --bogus/, + ); + const named = parseBuildArgs(['--feed', 'a', '--feed', 'b', '--force']); + assert.deepEqual(named.feeds, ['a', 'b']); + assert.equal(named.force, true); + assert.equal(parseBuildArgs(['--feed', 'a']).force, false); + // Only registered feeds are built: there is no flag for any other. + assert.throws( + () => + parseBuildArgs([ + '--extra-feed', + 'test-extra=https://example.test/gtfs.zip', + ]), + /Unknown argument --extra-feed/, + ); +}); + +test('--out may not be src/ or public/ or inside them, but may sit beside them', () => { + const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + ); + for (const inside of [ + 'src', + 'public', + path.join('src', 'data', 'packs'), + // A name that only starts with two dots is still inside. + path.join('src', '..cache'), + path.join('public', '..x', 'y'), + ]) + assert.throws( + () => parseBuildArgs(['--feed', 'a', '--out', path.join(root, inside)]), + /--out must not be inside/, + inside, + ); + for (const outside of [ + path.join('.cache', 'rail-paths'), + 'src-packs', + path.join('src', '..', 'packs'), + ]) + assert.equal( + parseBuildArgs(['--feed', 'a', '--out', path.join(root, outside)]).out, + path.resolve(root, outside), + ); + assert.equal(isInside(root, root), true); + assert.equal(isInside(path.join(root, 'src'), root), false); +}); + +test('a build in which any feed fails exits with code 2', () => { + assert.equal(FEED_FAILED_EXIT_CODE, 2); + assert.equal(buildExitCode(0), 0); + // One failure among feeds that built is still a failed build. + assert.equal(buildExitCode(1), 2); + assert.equal(buildExitCode(12), 2); +}); diff --git a/src/tooling/railCatalogPacks.test.mjs b/src/tooling/railCatalogPacks.test.mjs new file mode 100644 index 000000000..ad364da27 --- /dev/null +++ b/src/tooling/railCatalogPacks.test.mjs @@ -0,0 +1,235 @@ +/** + * Schedule packs are opt-in build output: the catalog lists a live feed + * whose trains are named only through its pack (`routesFrom: 'schedule'`) + * once the pack is built (as `hasSchedulePack`, the pack version or the + * pack reader tells), every other feed always, and a failing probe never + * hides a feed. Without any of them no such feed is listed. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile, mkdir, utimes } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + SCHEDULE_PACK_PROBE_TTL_MS, + createRailService, +} from '../sources/railService.js'; +import { + getRegisteredRailFeed, + publicRailCatalog, + railFeedNeedsSchedulePack, +} from '../data/railFeeds.js'; +import { + createFsSchedulePackProbe, + createFsSchedulePackVersion, +} from '../../server/providers/rail.js'; + +const request = (path) => ({ + url: `https://example.test${path}`, + method: 'GET', +}); + +async function catalogIds(service) { + const response = await service.handle(request('/api/rail/feeds')); + assert.equal(response.status, 200); + const { feeds } = await response.json(); + return new Set(feeds.map((feed) => feed.id)); +} + +test('which feeds need their schedule pack', () => { + // Live GTFS-RT whose vehicles carry no route. + for (const id of ['renfe-larga-distancia', 'ktmb']) + assert.equal(railFeedNeedsSchedulePack(getRegisteredRailFeed(id)), true); + // Live, with routes of its own. + assert.equal( + railFeedNeedsSchedulePack(getRegisteredRailFeed('mbta-rail')), + false, + ); + // Irish Rail's train codes classify its trains without a pack. + assert.equal( + railFeedNeedsSchedulePack(getRegisteredRailFeed('irish-rail')), + false, + ); +}); + +test('pack-routed feeds wait for their pack; other feeds stay', async (t) => { + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + hasSchedulePack: async (feedId) => feedId === 'ktmb', + }); + t.after(() => service.close()); + const ids = await catalogIds(service); + assert.ok(ids.has('ktmb'), 'a built pack-routed feed is listed'); + assert.ok(!ids.has('renfe-larga-distancia'), 'an unbuilt one waits'); + assert.ok(ids.has('mbta-rail'), 'live feed is listed'); + assert.ok(ids.has('irish-rail'), 'Irish Rail is listed'); +}); + +test('with no way to tell, no feed that needs a pack is listed', async (t) => { + let calls = 0; + const service = createRailService({ + fetchImpl: async () => { + calls += 1; + throw new Error('no network in tests'); + }, + }); + t.after(() => service.close()); + const ids = await catalogIds(service); + assert.ok(!ids.has('ktmb'), 'a pack-routed feed waits for its pack'); + assert.ok(!ids.has('renfe-larga-distancia'), 'all of them'); + assert.ok(ids.has('mbta-rail'), 'live feed is listed'); + const packless = publicRailCatalog().filter( + (feed) => !railFeedNeedsSchedulePack(getRegisteredRailFeed(feed.id)), + ); + assert.equal(ids.size, packless.length); + // Nor are its trains or trip updates served, and the operator is not + // contacted for them. + for (const path of [ + '/api/rail/trains/renfe-larga-distancia', + '/api/rail/trains/ktmb', + '/api/rail/updates/renfe-larga-distancia', + ]) { + const response = await service.handle(request(path)); + if (response.status === 404) continue; // no trip updates route + assert.equal(response.status, 503, path); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('X-Rail-Backoff'), 'schedule'); + assert.ok(Number(response.headers.get('Retry-After')) > 0); + assert.equal((await response.json()).error, 'Rail schedule unavailable'); + } + assert.equal(calls, 0); +}); + +test('a pack reader alone tells what is built, its answers kept for the TTL', async (t) => { + let now = 1_000_000; + const reads = []; + let built = false; + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + now: () => now, + readSchedulePack: async (feedId) => { + reads.push(feedId); + if (feedId === 'ktmb') return '{}'; + if (feedId === 'renfe-larga-distancia' && built) { + const error = new Error('pack is 30000000 bytes'); + error.code = 'PACK_TOO_LARGE'; + throw error; + } + return null; + }, + }); + t.after(() => service.close()); + let ids = await catalogIds(service); + assert.ok(ids.has('ktmb'), 'a pack the reader returns is built'); + assert.ok( + !ids.has('renfe-larga-distancia'), + 'a reader answering null: not built', + ); + assert.ok(ids.has('mbta-rail')); + const firstReads = reads.length; + built = true; + ids = await catalogIds(service); + assert.ok(!ids.has('renfe-larga-distancia'), 'kept for the TTL'); + assert.equal(reads.length, firstReads); + now += SCHEDULE_PACK_PROBE_TTL_MS; + ids = await catalogIds(service); + assert.ok( + ids.has('renfe-larga-distancia'), + 'an oversized pack is built, and refused', + ); +}); + +test('a pack version alone tells what is built, without reading packs', async (t) => { + let reads = 0; + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + readSchedulePack: async () => { + reads += 1; + return '{}'; + }, + schedulePackVersion: async (feedId) => (feedId === 'ktmb' ? 'v1' : null), + }); + t.after(() => service.close()); + const ids = await catalogIds(service); + assert.ok(ids.has('ktmb')); + assert.ok(!ids.has('renfe-larga-distancia')); + assert.equal(reads, 0); +}); + +test('a failing probe lists the feed; answers are kept for the TTL', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + let now = 1_000_000; + let calls = 0; + let built = false; + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + now: () => now, + hasSchedulePack: async (feedId) => { + if (feedId === 'renfe-larga-distancia') { + calls += 1; + return built; + } + if (feedId === 'ktmb') throw new Error('EACCES'); + return false; + }, + }); + t.after(() => service.close()); + let ids = await catalogIds(service); + assert.ok(ids.has('ktmb'), 'probe failure shows the feed'); + assert.ok(!ids.has('renfe-larga-distancia')); + // Built meanwhile: still hidden within the TTL, listed after it. + built = true; + ids = await catalogIds(service); + assert.ok(!ids.has('renfe-larga-distancia')); + assert.equal(calls, 1); + now += SCHEDULE_PACK_PROBE_TTL_MS; + ids = await catalogIds(service); + assert.ok(ids.has('renfe-larga-distancia')); + assert.equal(calls, 2); +}); + +test('the fs probe sees only a schedule pack file', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'rail-probe-')); + t.after(() => rm(dir, { recursive: true, force: true })); + await writeFile(path.join(dir, 'ktmb.schedule.json'), '{}'); + await writeFile(path.join(dir, 'mbta-rail.json'), '{}'); + await mkdir(path.join(dir, 'mta-lirr.schedule.json')); + const probe = createFsSchedulePackProbe(dir); + assert.equal(await probe('ktmb'), true); + assert.equal( + await probe('mbta-rail'), + false, + 'a path pack alone is not built', + ); + assert.equal(await probe('mta-lirr'), false, 'a directory is not a pack'); + assert.equal(await probe('../ktmb'), false, 'ids never leave the dir'); +}); + +test('the fs pack version changes when the pack is rebuilt', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'rail-version-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const version = createFsSchedulePackVersion(dir); + assert.equal(await version('ktmb'), null, 'no pack, no version'); + const file = path.join(dir, 'ktmb.schedule.json'); + await writeFile(file, '{}'); + await utimes(file, 1_000, 1_000); + const first = await version('ktmb'); + assert.equal(typeof first, 'string'); + assert.equal(await version('ktmb'), first, 'stable while unchanged'); + await writeFile(file, '{"trips":{}}'); + await utimes(file, 2_000, 2_000); + assert.notEqual(await version('ktmb'), first); + assert.equal(await version('../ktmb'), null, 'ids never leave the dir'); +}); diff --git a/src/tooling/railService.test.mjs b/src/tooling/railService.test.mjs new file mode 100644 index 000000000..19290a67a --- /dev/null +++ b/src/tooling/railService.test.mjs @@ -0,0 +1,877 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Writable } from 'node:stream'; +import { createRailService } from '../sources/railService.js'; +import { railEnabledFeeds } from '../data/railFeeds.js'; + +import { + RAIL_PATH_PACK_MAX_BYTES, + railAdmissionGlobalMax, + railNaturalRequestsPerMinute, +} from '../data/railProxy.js'; +import { + createFsPathPackReader, + createFsSchedulePackReader, + createRailPackCache, + sendRailResponse, +} from '../../server/providers/rail.js'; + +const request = (path, method = 'GET') => ({ + url: `https://example.test${path}`, + method, +}); + +/** A full-dataset FeedMessage with one commuter train and one bus. */ +function mbtaBytes() { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(Date.now() / 1000)); + writer.writeBytesField(1, header.finish()); + for (const [id, route] of [ + ['cr', 'CR-Providence'], + ['bus', '1'], + ]) { + const entity = new PbfWriter(); + entity.writeStringField(1, id); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(5, route); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 42.3); + position.writeFloatField(2, -71.1); + vehicle.writeBytesField(2, position.finish()); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, id); + vehicle.writeBytesField(8, descriptor.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('the service confines requests to registered feeds and GET', async (t) => { + let calls = 0; + const service = createRailService({ + fetchImpl: async () => { + calls++; + throw new Error('unexpected'); + }, + }); + t.after(service.close); + for (const path of [ + '/other', + '/api/rail/trains/unknown', + '/api/rail/trains/vre', + '/api/rail/trains/https%3A%2F%2Fexample.test', + '/api/rail/vehicles/mbta-rail', + ]) { + assert.equal((await service.handle(request(path))).status, 404, path); + } + for (const method of ['POST', 'HEAD']) { + const refused = await service.handle(request('/api/rail/feeds', method)); + assert.equal(refused.status, 405); + // A refusal is never kept by a shared cache. + assert.equal(refused.headers.get('cache-control'), 'no-store'); + assert.equal(refused.headers.get('allow'), 'GET'); + } + const response = await service.handle(request('/api/rail/feeds')); + assert.equal(response.status, 200); + const { feeds } = await response.json(); + assert.ok(feeds.some((feed) => feed.id === 'mbta-rail')); + assert.ok(feeds.every((feed) => feed.url === undefined)); + assert.equal(calls, 0); + service.close(); + assert.equal((await service.handle(request('/api/rail/feeds'))).status, 503); +}); + +test('a snapshot carries trains only and is served from cache while fresh', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url, init) => { + urls.push(url); + assert.equal(init.redirect, 'manual'); + assert.match(init.headers['User-Agent'], /rail-proxy/); + return new Response(mbtaBytes(), { status: 200 }); + }, + }); + t.after(service.close); + const first = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(first.status, 200); + assert.equal(first.headers.get('X-GEV-Cache'), 'MISS'); + const body = await first.json(); + assert.equal(body.count, 1); + assert.equal(body.dropped, 1); + assert.equal(body.trains[0].category, 'local'); + const second = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(second.headers.get('X-GEV-Cache'), 'HIT'); + assert.deepEqual(urls, ['https://cdn.mbta.com/realtime/VehiclePositions.pb']); +}); + +test('a redirect off the feed origin is refused before it is followed', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url) => { + urls.push(url); + return new Response(null, { + status: 307, + headers: { location: 'https://elsewhere.test/feed.pb' }, + }); + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(response.status, 504); + assert.equal((await response.json()).feedId, 'mbta-rail'); + assert.ok(Number(response.headers.get('Retry-After')) >= 1); + assert.deepEqual(urls, ['https://cdn.mbta.com/realtime/VehiclePositions.pb']); + // The cooldown answers the next request without contacting the operator. + const again = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(again.status, 503); + assert.equal(again.headers.get('X-Rail-Backoff'), 'cooldown'); + assert.equal(urls.length, 1); +}); + +test('one document for positions and trip updates is fetched once per window', async (t) => { + let now = 1_800_000_000_000; + const urls = []; + const service = createRailService({ + now: () => now, + fetchImpl: async (url) => { + urls.push(url); + return new Response(mbtaBytes(), { status: 200 }); + }, + }); + t.after(service.close); + const trains = '/api/rail/trains/mta-lirr'; + const updates = '/api/rail/updates/mta-lirr'; + // Both at once share one request; each route keeps its own snapshot. + const [a, b] = await Promise.all([ + service.handle(request(trains)), + service.handle(request(updates)), + ]); + assert.equal(a.status, 200); + assert.equal(b.status, 200); + assert.equal(urls.length, 1); + // Positions come round again (15 s); trip updates (30 s) read that body. + now += 16_000; + await service.handle(request(trains)); + assert.equal(urls.length, 2); + now += 15_000; + assert.equal((await service.handle(request(updates))).status, 200); + assert.equal(urls.length, 2, 'the body positions fetched 15 s ago'); + // With nobody reading positions, trip updates fetch on their own window. + now += 31_000; + await service.handle(request(updates)); + assert.equal(urls.length, 3); + assert.equal(new Set(urls).size, 1, 'the same URL each time'); +}); + +test('an upstream HTTP error answers 502 with a retry hint', async (t) => { + const service = createRailService({ + fetchImpl: async () => new Response('nope', { status: 500 }), + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mta-lirr')); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error, 'Rail feed unavailable'); + assert.ok(body.retryInSec >= 1); +}); + +test('path packs are served from the injected reader for enabled feeds', async (t) => { + const asked = []; + const pack = JSON.stringify({ version: 1, feedId: 'mta-lirr' }); + const service = createRailService({ + fetchImpl: async () => { + throw new Error('a path request never reaches an operator'); + }, + readPathPack: async (feedId, options) => { + asked.push([feedId, options.maxBytes]); + return feedId === 'mta-lirr' ? pack : null; + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/paths/mta-lirr')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-cache'); + assert.match(response.headers.get('content-type'), /application\/json/); + assert.equal(await response.text(), pack); + assert.deepEqual(asked, [['mta-lirr', RAIL_PATH_PACK_MAX_BYTES]]); + const none = await service.handle(request('/api/rail/paths/mbta-rail')); + assert.equal(none.status, 404); + assert.equal(none.headers.get('cache-control'), 'no-store'); + for (const blocked of [ + '/api/rail/paths/vre', + '/api/rail/paths/unknown', + '/api/rail/paths/..%2Fsecret', + ]) + assert.equal((await service.handle(request(blocked))).status, 404, blocked); + assert.equal(asked.length, 2, 'unregistered ids never reach the reader'); + assert.equal( + (await service.handle(request('/api/rail/paths/mta-lirr', 'POST'))).status, + 405, + ); +}); + +test('a missing reader or pack is a 404; an unreadable or oversized pack is a 503', async (t) => { + const bare = createRailService(); + t.after(bare.close); + assert.equal( + (await bare.handle(request('/api/rail/paths/mta-lirr'))).status, + 404, + ); + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const failing = createRailService({ + readPathPack: async () => { + throw new Error('disk on fire'); + }, + }); + t.after(failing.close); + // A reader that fails (EMFILE, EACCES, EIO) is not "no pack": asked again. + for (const [kind, error] of [ + ['paths', 'Rail paths unreadable'], + ['schedule', 'Rail schedule unreadable'], + ]) { + const unreadable = createRailService({ + [kind === 'paths' ? 'readPathPack' : 'readSchedulePack']: async () => { + const fault = new Error('too many open files'); + fault.code = 'EMFILE'; + throw fault; + }, + }); + t.after(unreadable.close); + const response = await unreadable.handle( + request(`/api/rail/${kind}/mta-lirr`), + ); + assert.equal(response.status, 503, kind); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.ok(Number(response.headers.get('Retry-After')) > 0); + assert.equal((await response.json()).error, error); + } + assert.equal( + (await failing.handle(request('/api/rail/paths/mta-lirr'))).status, + 503, + ); + const none = createRailService({ readPathPack: async () => null }); + t.after(none.close); + assert.equal( + (await none.handle(request('/api/rail/paths/mta-lirr'))).status, + 404, + ); + // Over the cap is a build fault, not an unbuilt pack: its own answer, + // whether the service measures it or the reader refuses it unread. + for (const readPathPack of [ + async () => new Uint8Array(RAIL_PATH_PACK_MAX_BYTES + 1), + async () => { + const error = new Error('pack is 30000000 bytes'); + error.code = 'PACK_TOO_LARGE'; + throw error; + }, + ]) { + const huge = createRailService({ readPathPack }); + t.after(huge.close); + const response = await huge.handle(request('/api/rail/paths/mta-lirr')); + assert.equal(response.status, 503); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal((await response.json()).error, 'Rail paths too large'); + } + // At the cap is served, as bytes. + const full = createRailService({ + readPathPack: async () => new Uint8Array(RAIL_PATH_PACK_MAX_BYTES), + }); + t.after(full.close); + const served = await full.handle(request('/api/rail/paths/mta-lirr')); + assert.equal(served.status, 200); + assert.equal( + (await served.arrayBuffer()).byteLength, + RAIL_PATH_PACK_MAX_BYTES, + ); +}); + +test('the fs reader reads .json from its directory and nothing else', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-paths-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + await writeFile(path.join(dir, 'mta-lirr.json'), '{"version":1}'); + await writeFile(path.join(dir, 'grt-ion.json'), 'x'.repeat(64)); + const read = createFsPathPackReader(dir); + const pack = await read('mta-lirr'); + assert.ok(pack instanceof Uint8Array, 'bytes, never decoded to text'); + assert.equal(Buffer.from(pack).toString('utf8'), '{"version":1}'); + assert.equal(await read('mbta-rail'), null, 'no file'); + assert.equal(await read('../mta-lirr'), null, 'not an id'); + assert.equal(await read('MTA-LIRR'), null); + await assert.rejects(read('grt-ion', { maxBytes: 10 }), (error) => { + assert.match(error.message, /64 bytes/); + assert.equal(error.code, 'PACK_TOO_LARGE'); + return true; + }); + assert.equal((await read('grt-ion', { maxBytes: 64 })).length, 64); +}); + +test('the fs pack cache reads an unchanged pack once and a rebuilt one again', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-cache-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'mta-lirr.json'); + await writeFile(file, '{"version":1}'); + const cache = createRailPackCache(40); + const read = createFsPathPackReader(dir, { cache }); + const [first, again] = await Promise.all([ + read('mta-lirr'), + read('mta-lirr'), + ]); + assert.equal(first, again, 'one read shared by two at once'); + assert.equal(await read('mta-lirr'), first, 'kept while unchanged'); + assert.equal(cache.bytes(), 13); + // A rebuild changes the size or the time: read again, the old copy gone. + await writeFile(file, '{"version":22}'); + await utimes(file, new Date(), new Date(Date.now() + 5_000)); + const rebuilt = await read('mta-lirr'); + assert.notEqual(rebuilt, first); + assert.equal(Buffer.from(rebuilt).toString('utf8'), '{"version":22}'); + assert.equal(cache.bytes(), 14); + // The byte budget holds: the least recently read pack makes room. + await writeFile(path.join(dir, 'mbta-rail.json'), 'x'.repeat(30)); + await read('mbta-rail'); + assert.equal(cache.bytes(), 30); + // A pack over the whole byte budget is served but never kept. + await writeFile(path.join(dir, 'grt-ion.json'), 'y'.repeat(41)); + assert.equal((await read('grt-ion')).length, 41); + assert.equal(cache.bytes(), 30); +}); + +test('a served pack is streamed to the Node response as bytes, unchanged', async () => { + const bytes = new TextEncoder().encode('{"paths":"é"}'); + const service = createRailService({ readPathPack: async () => bytes }); + try { + const response = await service.handle(request('/api/rail/paths/mta-lirr')); + const chunks = []; + const head = {}; + const res = new Writable({ + write(chunk, encoding, done) { + chunks.push(chunk); + done(); + }, + }); + res.writeHead = (status, headers) => + Object.assign(head, { status, headers }); + await sendRailResponse(response, res); + assert.equal(head.status, 200); + assert.equal(head.headers['cache-control'], 'no-cache'); + const out = Buffer.concat(chunks); + assert.deepEqual(new Uint8Array(out), bytes); + // The reader's own buffer goes out: no decode, no copy. + assert.equal(chunks.length, 1); + assert.equal(chunks[0].buffer, bytes.buffer); + } finally { + service.close(); + } +}); + +/** A TripUpdates FeedMessage with one commuter-rail trip and one bus trip. */ +function tripUpdateBytes() { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(Date.now() / 1000)); + writer.writeBytesField(1, header.finish()); + for (const [id, route] of [ + ['cr-trip', 'CR-Providence'], + ['bus-trip', '1'], + ]) { + const entity = new PbfWriter(); + entity.writeStringField(1, id); + const update = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, id); + trip.writeStringField(5, route); + update.writeBytesField(1, trip.finish()); + const stop = new PbfWriter(); + stop.writeStringField(4, 'place-sstat'); + const arrival = new PbfWriter(); + arrival.writeVarintField(1, 240); + stop.writeBytesField(2, arrival.finish()); + update.writeBytesField(2, stop.finish()); + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('trip updates come from their own URL, cache and filter', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url) => { + urls.push(url); + return new Response( + url.endsWith('TripUpdates.pb') ? tripUpdateBytes() : mbtaBytes(), + { status: 200 }, + ); + }, + }); + t.after(service.close); + const updates = await service.handle(request('/api/rail/updates/mbta-rail')); + assert.equal(updates.status, 200); + assert.equal(updates.headers.get('X-GEV-Cache'), 'MISS'); + assert.equal(updates.headers.get('cache-control'), 'public, max-age=30'); + const body = await updates.json(); + assert.equal(body.count, 1); + assert.equal(body.dropped, 1, 'the bus trip never leaves the server'); + assert.deepEqual( + { + tripId: body.trips[0].tripId, + routeId: body.trips[0].routeId, + delaySec: body.trips[0].delaySec, + stop: body.trips[0].stops[0], + }, + { + tripId: 'cr-trip', + routeId: 'CR-Providence', + delaySec: 240, + stop: { + stopId: 'place-sstat', + stopSequence: null, + arrival: { time: null, delay: 240 }, + departure: null, + }, + }, + ); + // Positions are a separate cache entry: this is a MISS, not the updates. + const trains = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(trains.headers.get('X-GEV-Cache'), 'MISS'); + assert.equal((await trains.json()).trains[0].id, 'cr'); + const again = await service.handle(request('/api/rail/updates/mbta-rail')); + assert.equal(again.headers.get('X-GEV-Cache'), 'HIT'); + assert.deepEqual(urls, [ + 'https://cdn.mbta.com/realtime/TripUpdates.pb', + 'https://cdn.mbta.com/realtime/VehiclePositions.pb', + ]); + assert.equal( + (await service.handle(request('/api/rail/updates/capmetro-rail'))).status, + 404, + 'a feed without trip updates has no /updates route', + ); +}); + +test('every train answer comes from the operator, never from a timetable', async (t) => { + // A feed once answered `/trains` with an empty list for the timetable to + // fill; every feed now reports its trains, so every answer is fetched. + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const asked = []; + const service = createRailService({ + fetchImpl: async (url) => { + asked.push(url); + return new Response('', { status: 503 }); + }, + hasSchedulePack: async () => true, + }); + t.after(service.close); + const feeds = railEnabledFeeds(); + for (const feed of feeds) { + const response = await service.handle( + request(`/api/rail/trains/${feed.id}`), + ); + assert.equal(response.status, 502, feed.id); + assert.equal(response.headers.get('X-GEV-Cache'), 'NONE', feed.id); + } + // Feeds that share one positions document are asked once per window. + assert.equal( + new Set(asked).size, + new Set(feeds.map((feed) => feed.url)).size, + ); +}); + +test('Irish Rail is fetched as XML and served as event-positioned trains', async (t) => { + const xml = ` + + + R + 53.35 + -6.25 + A216 + 06 Nov 2026 + A216\\n14:00 - Dublin Heuston to Cork (9 mins late)\\nDeparted Thurles next stop Limerick Junction + To Cork + +`; + const seen = []; + const service = createRailService({ + fetchImpl: async (url, init) => { + seen.push([url, init.headers.Accept]); + return new Response(xml, { + status: 200, + headers: { 'content-type': 'text/xml; charset=utf-8' }, + }); + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/irish-rail')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'public, max-age=30'); + const body = await response.json(); + assert.equal(body.positions, 'event'); + assert.equal(body.trains[0].id, 'A216'); + assert.equal(body.trains[0].category, 'national'); + assert.equal(body.trains[0].positionSource, 'event'); + // The message's next stop decides the status; it does not leave the server. + assert.equal(body.trains[0].status, 'IN_TRANSIT_TO'); + assert.equal(body.trains[0].nextStop, undefined); + // No schedule pack here: the train code alone classifies the train. + assert.equal(body.count, 1); + assert.deepEqual(seen, [ + [ + 'https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML', + 'text/xml, application/xml;q=0.9', + ], + ]); +}); + +test('an unreadable Irish Rail document is a 502 with backoff, not a crash', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const service = createRailService({ + fetchImpl: async () => new Response('busy', { status: 200 }), + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/irish-rail')); + assert.equal(response.status, 502); + const again = await service.handle(request('/api/rail/trains/irish-rail')); + assert.equal(again.headers.get('X-Rail-Backoff'), 'cooldown'); +}); + +test('an answer names its upstream host, and a failed fetch is a logged 504', async (t) => { + const warnings = []; + const warn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + t.after(() => { + console.warn = warn; + }); + const service = createRailService({ + now: () => 1_800_000_000_000, + fetchImpl: async () => new Response(mbtaBytes(), { status: 200 }), + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('X-Rail-Upstream'), 'cdn.mbta.com'); + assert.equal(response.headers.get('X-Rail-Contact'), '1800000000000'); + assert.equal(response.headers.get('cache-control'), 'public, max-age=15'); + const failing = createRailService({ + fetchImpl: async (url) => { + throw new TypeError(`fetch failed for ${url}`); + }, + }); + t.after(failing.close); + const failed = await failing.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(failed.status, 504); + assert.equal((await failed.json()).error, 'Rail feed unavailable'); + assert.ok( + warnings.some((line) => line.includes('trains:mbta-rail unavailable')), + ); +}); + +test('a redirect on the feed origin is followed, one hop at a time', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url, init) => { + urls.push(url); + assert.equal(init.redirect, 'manual'); + if (urls.length === 1) + return new Response(null, { + status: 302, + headers: { location: '/realtime/VehiclePositions.pb?v=2' }, + }); + return new Response(mbtaBytes(), { status: 200 }); + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(response.status, 200); + assert.deepEqual(urls, [ + 'https://cdn.mbta.com/realtime/VehiclePositions.pb', + 'https://cdn.mbta.com/realtime/VehiclePositions.pb?v=2', + ]); + assert.equal(response.headers.get('X-Rail-Upstream'), 'cdn.mbta.com'); +}); + +test('schedule packs are served from the injected reader', async (t) => { + const asked = []; + const pack = JSON.stringify({ version: 1, feedId: 'ktmb' }); + const service = createRailService({ + fetchImpl: async () => { + throw new Error('a schedule request never reaches an operator'); + }, + readSchedulePack: async (feedId, options) => { + asked.push([feedId, options.maxBytes]); + return feedId === 'mbta-rail' ? null : pack; + }, + }); + t.after(service.close); + for (const id of ['ktmb', 'irish-rail']) { + const response = await service.handle(request(`/api/rail/schedule/${id}`)); + assert.equal(response.status, 200, id); + assert.equal(response.headers.get('cache-control'), 'no-cache'); + assert.equal(await response.text(), pack); + } + const none = await service.handle(request('/api/rail/schedule/mbta-rail')); + assert.equal(none.status, 404); + assert.equal((await none.json()).error, 'No rail schedule'); + for (const blocked of [ + '/api/rail/schedule/vre', + '/api/rail/schedule/unknown', + '/api/rail/schedule/..%2Fktmb', + ]) + assert.equal((await service.handle(request(blocked))).status, 404, blocked); + assert.deepEqual(asked, [ + ['ktmb', RAIL_PATH_PACK_MAX_BYTES], + ['irish-rail', RAIL_PATH_PACK_MAX_BYTES], + ['mbta-rail', RAIL_PATH_PACK_MAX_BYTES], + ]); + // No reader: 404; an oversized pack: 503, as for path packs. + const bare = createRailService(); + t.after(bare.close); + assert.equal( + (await bare.handle(request('/api/rail/schedule/ktmb'))).status, + 404, + ); + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const huge = createRailService({ + readSchedulePack: async () => new Uint8Array(RAIL_PATH_PACK_MAX_BYTES + 1), + }); + t.after(huge.close); + const refused = await huge.handle(request('/api/rail/schedule/ktmb')); + assert.equal(refused.status, 503); + assert.equal((await refused.json()).error, 'Rail schedule too large'); + // The path reader is not asked for a schedule, nor the reverse. + const paths = createRailService({ readPathPack: async () => pack }); + t.after(paths.close); + assert.equal( + (await paths.handle(request('/api/rail/schedule/ktmb'))).status, + 404, + ); +}); + +test('the fs schedule reader reads .schedule.json beside the path packs', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-schedule-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + await writeFile(path.join(dir, 'ktmb.schedule.json'), '{"version":1}'); + await writeFile(path.join(dir, 'ktmb.json'), '{"paths":true}'); + const read = createFsSchedulePackReader(dir); + const text = async (pending) => Buffer.from(await pending).toString('utf8'); + assert.equal(await text(read('ktmb')), '{"version":1}'); + assert.equal(await read('irish-rail'), null); + assert.equal(await read('../ktmb'), null); + await assert.rejects(read('ktmb', { maxBytes: 3 }), /13 bytes/); + assert.equal( + await text(createFsPathPackReader(dir)('ktmb')), + '{"paths":true}', + ); +}); + +test('a viewer of every region at once is not refused by the global cap', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const service = createRailService({ + now: () => 1_800_000_000_000, + fetchImpl: async () => new Response(mbtaBytes(), { status: 200 }), + }); + t.after(service.close); + const { feeds } = await ( + await service.handle(request('/api/rail/feeds')) + ).json(); + const backoffs = []; + let requests = 0; + for (const feed of feeds) { + for (const route of feed.tripUpdates ? ['trains', 'updates'] : ['trains']) { + requests += 1; + const response = await service.handle( + request(`/api/rail/${route}/${feed.id}`), + ); + backoffs.push(response.headers.get('X-Rail-Backoff')); + } + } + assert.ok(requests > 40, `${requests} resources, more than the old cap`); + assert.ok(!backoffs.includes('rate-limited'), 'the global cap held back'); + assert.ok(railAdmissionGlobalMax() >= railNaturalRequestsPerMinute()); +}); + +/** Positions with a trip id but no route, as Renfe's long-distance feed sends. */ +function routelessBytes(trips) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(Date.now() / 1000) - 3600); + writer.writeBytesField(1, header.finish()); + for (const tripId of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 40.4); + position.writeFloatField(2, -3.7); + vehicle.writeBytesField(2, position.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('routes missing from a feed are read from its schedule pack, once an hour', async (t) => { + let now = 1_800_000_000_000; + const reads = []; + const service = createRailService({ + now: () => now, + fetchImpl: async () => + new Response(routelessBytes(['0419212026-11-06', '9999912026-11-06']), { + status: 200, + }), + readSchedulePack: async (feedId, options) => { + reads.push([feedId, options.maxBytes]); + return JSON.stringify({ + version: 1, + feedId: 'renfe-larga-distancia', + trips: { + '0419212026-11-06': { route: 'ALVIA~X~1700037606GL023' }, + other: { route: 'MD~S~1' }, + }, + }); + }, + hasSchedulePack: async () => true, + }); + t.after(service.close); + const path = '/api/rail/trains/renfe-larga-distancia'; + const body = await (await service.handle(request(path))).json(); + assert.equal(body.count, 1); + assert.equal(body.dropped, 1, 'a trip the pack does not know'); + assert.equal(body.trains[0].category, 'national'); + assert.equal(body.trains[0].timestampSource, 'fetch'); + now += 20_000; + await service.handle(request(path)); + assert.deepEqual(reads, [ + ['renfe-larga-distancia', RAIL_PATH_PACK_MAX_BYTES], + ]); + now += 61 * 60_000; + await service.handle(request(path)); + assert.equal(reads.length, 2, 're-read after an hour'); +}); + +test('a rebuilt schedule pack replaces the lookups within a minute', async (t) => { + let now = 1_800_000_000_000; + let version = 'built-1'; + let route = 'ALVIA~X~1'; + let reads = 0; + let versionAsks = 0; + const service = createRailService({ + now: () => now, + fetchImpl: async () => + new Response(routelessBytes(['0419212026-11-06']), { status: 200 }), + readSchedulePack: async () => { + reads += 1; + return JSON.stringify({ + version: 1, + feedId: 'renfe-larga-distancia', + trips: { '0419212026-11-06': { route } }, + }); + }, + hasSchedulePack: async () => true, + schedulePackVersion: async () => { + versionAsks += 1; + return version; + }, + }); + t.after(service.close); + const path = '/api/rail/trains/renfe-larga-distancia'; + const routeNow = async () => + (await (await service.handle(request(path))).json()).trains[0].routeId; + assert.equal(await routeNow(), 'ALVIA~X~1'); + assert.equal(reads, 1); + // Unchanged: kept, and the version is not asked on every poll. + now += 61_000; + assert.equal(await routeNow(), 'ALVIA~X~1'); + assert.equal(reads, 1); + const asks = versionAsks; + now += 20_000; + await routeNow(); + assert.equal(versionAsks, asks); + // Rebuilt: the next check after the minute reads the new pack. + version = 'built-2'; + route = 'AVE~X~2'; + now += 61_000; + await routeNow(); + now += 16_000; + assert.equal(await routeNow(), 'AVE~X~2'); + assert.equal(reads, 2, 'read once more, well inside the hour'); +}); + +test('without a schedule pack, a feed that needs it is unavailable and the pack retried later', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + let now = 1_800_000_000_000; + let reads = 0; + const service = createRailService({ + now: () => now, + fetchImpl: async () => + new Response(routelessBytes(['0419212026-11-06']), { status: 200 }), + readSchedulePack: async () => { + reads += 1; + return null; + }, + // Listed (the probe says built), but the pack is gone when read. + hasSchedulePack: async () => true, + }); + t.after(service.close); + const path = '/api/rail/trains/renfe-larga-distancia'; + // Every train needs the pack for its route: not a healthy empty feed. + const response = await service.handle(request(path)); + assert.equal(response.status, 503); + assert.equal(response.headers.get('X-Rail-Backoff'), 'schedule'); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal((await response.json()).error, 'Rail schedule unavailable'); + now += 20_000; + await service.handle(request(path)); + assert.equal(reads, 1, 'not re-read on every poll'); + now += 6 * 60_000; + await service.handle(request(path)); + assert.equal(reads, 2, 'retried after five minutes'); + // A feed that carries its own routes never asks for the pack. + let asked = 0; + const own = createRailService({ + fetchImpl: async () => new Response(mbtaBytes(), { status: 200 }), + readSchedulePack: async () => { + asked += 1; + return null; + }, + }); + t.after(own.close); + await own.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(asked, 0); +}); diff --git a/src/tooling/railServiceFaults.test.mjs b/src/tooling/railServiceFaults.test.mjs new file mode 100644 index 000000000..b5eb46a0b --- /dev/null +++ b/src/tooling/railServiceFaults.test.mjs @@ -0,0 +1,214 @@ +/** + * The rail service under upstream faults and missing build output: an empty + * or non-protobuf 200, a pack-routed feed without its pack, and a + * downstream cache near the end of a window. + + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { createRailService } from '../sources/railService.js'; +import { RAIL_FEED_REGISTRY, getRailFeed } from '../data/railFeeds.js'; + +const request = (path) => ({ + url: `https://example.test${path}`, + method: 'GET', +}); + +function quietWarnings(t) { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); +} + +/** A full-dataset VehiclePositions message: one MBTA commuter train. */ +function positionsBytes() { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, 1_800_000_000); + writer.writeBytesField(1, header.finish()); + const entity = new PbfWriter(); + entity.writeStringField(1, 'cr'); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(5, 'CR-Providence'); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 42.3); + position.writeFloatField(2, -71.1); + vehicle.writeBytesField(2, position.finish()); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, 'cr'); + vehicle.writeBytesField(8, descriptor.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + return writer.finish(); +} + +/** A full-dataset TripUpdates message with the given trip ids. */ +function tripUpdatesBytes(tripIds) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, 1_800_000_000); + writer.writeBytesField(1, header.finish()); + for (const tripId of tripIds) { + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, tripId); + descriptor.writeStringField(3, '20270115'); + const update = new PbfWriter(); + update.writeBytesField(1, descriptor.finish()); + update.writeVarintField(5, 60); + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('an empty 200 or 204 never replaces the last good snapshot', async (t) => { + quietWarnings(t); + let now = 1_800_000_000_000; + let answer = () => new Response(positionsBytes(), { status: 200 }); + let calls = 0; + const service = createRailService({ + now: () => now, + fetchImpl: async () => { + calls += 1; + return answer(); + }, + }); + t.after(service.close); + const path = '/api/rail/trains/mbta-rail'; + const good = await service.handle(request(path)); + assert.equal(good.headers.get('X-GEV-Cache'), 'MISS'); + const goodBody = await good.text(); + for (const empty of [ + () => new Response(new Uint8Array(0), { status: 200 }), + () => new Response(null, { status: 204 }), + ]) { + answer = empty; + now += 16_000; + const stale = await service.handle(request(path)); + assert.equal(stale.status, 200); + assert.equal(stale.headers.get('X-GEV-Cache'), 'STALE-ERROR'); + assert.equal(await stale.text(), goodBody, 'the good snapshot, kept'); + // The failure is on the ladder: the next ask does not reach upstream. + const before = calls; + const held = await service.handle(request(path)); + assert.equal(held.headers.get('X-GEV-Cache'), 'STALE-ERROR'); + assert.equal(calls, before); + } + // A healthy answer takes over again. + answer = () => new Response(positionsBytes(), { status: 200 }); + now += 61_000; + const back = await service.handle(request(path)); + assert.equal(back.headers.get('X-GEV-Cache'), 'MISS'); + assert.equal((await back.json()).count, 1); +}); + +test('an error page served as 200 is an upstream fault (502), not a timeout', async (t) => { + quietWarnings(t); + for (const [route, body] of [ + ['trains', 'Service Unavailable'], + ['trains', '{"error":"quota exceeded"}'], + ['updates', 'maintenance'], + ['updates', ''], + ]) { + const service = createRailService({ + fetchImpl: async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + }); + const response = await service.handle( + request(`/api/rail/${route}/mbta-rail`), + ); + service.close(); + assert.equal(response.status, 502, `${route}: ${body}`); + assert.equal((await response.json()).error, 'Rail feed unavailable'); + } +}); + +test('a pack-routed feed names its trip updates from the pack, and is unavailable without it', async (t) => { + quietWarnings(t); + // Every pack-routed feed with trip updates (Renfe long distance): its + // updates carry no route, so they are classified by the pack's trips. + const feeds = RAIL_FEED_REGISTRY.filter( + (feed) => + feed.routesFrom === 'schedule' && typeof feed.tripUpdatesUrl === 'string', + ); + assert.ok(feeds.length >= 1, 'Renfe long distance at least'); + for (const registered of feeds) { + const id = registered.id; + if (!getRailFeed(id)) continue; + const service = createRailService({ + fetchImpl: async () => + new Response(tripUpdatesBytes(['in-pack', 'elsewhere']), { + status: 200, + }), + readSchedulePack: async (feedId) => + feedId === id + ? JSON.stringify({ + version: 1, + feedId: id, + trips: { 'in-pack': { route: 'ALVIA~X~1700037606GL023' } }, + }) + : null, + hasSchedulePack: async () => true, + }); + try { + const response = await service.handle(request(`/api/rail/updates/${id}`)); + assert.equal(response.status, 200, id); + const body = await response.json(); + assert.deepEqual( + body.trips.map((trip) => [trip.tripId, trip.routeId]), + [['in-pack', 'ALVIA~X~1700037606GL023']], + id, + ); + assert.equal(body.dropped, 1, `${id}: the trip the pack does not know`); + assert.equal(body.entityCount, 2, id); + assert.equal(body.skipped, undefined, 'every trip is decoded'); + } finally { + service.close(); + } + // Listed, but the pack is gone when read: unavailable, not empty. + const bare = createRailService({ + fetchImpl: async () => + new Response(tripUpdatesBytes(['in-pack', 'elsewhere']), { + status: 200, + }), + readSchedulePack: async () => null, + hasSchedulePack: async () => true, + }); + try { + const response = await bare.handle(request(`/api/rail/updates/${id}`)); + assert.equal(response.status, 503, id); + assert.equal((await response.json()).error, 'Rail schedule unavailable'); + } finally { + bare.close(); + } + } +}); + +test('a HIT is cached downstream only for what is left of its window', async (t) => { + let now = 1_800_000_000_000; + const service = createRailService({ + now: () => now, + fetchImpl: async () => new Response(positionsBytes(), { status: 200 }), + }); + t.after(service.close); + const path = '/api/rail/trains/mbta-rail'; + const maxAge = async () => + (await service.handle(request(path))).headers.get('cache-control'); + assert.equal(await maxAge(), 'public, max-age=15'); + now += 10_000; + assert.equal(await maxAge(), 'public, max-age=5'); + now += 4_500; + assert.equal(await maxAge(), 'public, max-age=1'); +}); diff --git a/src/tooling/railServicePacks.test.mjs b/src/tooling/railServicePacks.test.mjs new file mode 100644 index 000000000..de8d964de --- /dev/null +++ b/src/tooling/railServicePacks.test.mjs @@ -0,0 +1,337 @@ +// The schedule pack a feed's live trains are read with, and how packs are +// served: a pack of another format or feed, or whose window has ended, is +// refused; a pack-routed feed whose every record is dropped answers 503, +// not an empty list; a train named by its number keeps its run while its +// report says it is late; packs go out no-cache, with their version as +// their ETag. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Writable } from 'node:stream'; +import { PbfWriter } from 'pbf'; +import { createRailService } from '../sources/railService.js'; +import { railProxy } from '../../server/providers/rail.js'; + +/** 2027-01-15T08:00:00Z. */ +const NOW = 1_800_000_000_000; +const MIN = 60_000; +const LIVE_TRIP = '0419212026-11-06'; + +const request = (path, headers = undefined) => ({ + url: `https://example.test${path}`, + method: 'GET', + ...(headers ? { headers } : {}), +}); + +function quietWarnings(t) { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); +} + +/** Positions with a trip id but no route, as Renfe's long-distance feed sends. */ +function routelessBytes(trips) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(NOW / 1000)); + writer.writeBytesField(1, header.finish()); + for (const tripId of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 40.4); + position.writeFloatField(2, -3.7); + vehicle.writeBytesField(2, position.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +/** + * A Renfe long-distance schedule pack whose trips run on 15 January 2027 + * (Madrid, UTC+1 then) from `startSec` local for `durationSec`. + */ +function renfePack({ + version = 1, + feedId = 'renfe-larga-distancia', + trips = [LIVE_TRIP], + startSec = 9 * 3600, + durationSec = 3600, +} = {}) { + return JSON.stringify({ + version, + feedId, + timezone: 'Europe/Madrid', + window: { from: '2027-01-14', to: '2027-01-15' }, + profiles: [[0, 0, durationSec, durationSec]], + services: { S: ['2027-01-15'] }, + trips: Object.fromEntries( + trips.map((tripId) => [ + tripId, + { + route: 'ALVIA~X~1700037606GL023', + pattern: 0, + profile: 0, + startSec, + service: 'S', + }, + ]), + ), + }); +} + +function renfeService(t, readSchedulePack, clock = () => NOW) { + const service = createRailService({ + now: clock, + fetchImpl: async () => + new Response(routelessBytes([LIVE_TRIP]), { status: 200 }), + readSchedulePack, + hasSchedulePack: async () => true, + }); + t.after(service.close); + return service; +} + +const TRAINS = '/api/rail/trains/renfe-larga-distancia'; + +async function assertScheduleUnavailable(response, label) { + assert.equal(response.status, 503, label); + assert.equal(response.headers.get('X-Rail-Backoff'), 'schedule', label); + assert.equal(response.headers.get('cache-control'), 'no-store', label); + assert.equal( + (await response.json()).error, + 'Rail schedule unavailable', + label, + ); +} + +test('a pack that names none of the live trips is unavailable, not an empty feed', async (t) => { + quietWarnings(t); + // A pack built for other days: Renfe's trip ids carry their date. + const service = renfeService(t, async () => + renfePack({ trips: ['0419212026-11-05'] }), + ); + await assertScheduleUnavailable(await service.handle(request(TRAINS))); +}); + +test('a pack of another format, another feed or a window that has ended is refused', async (t) => { + quietWarnings(t); + // The same live train, the same trip in each pack: only the pack differs. + const good = renfeService(t, async () => renfePack()); + const served = await (await good.handle(request(TRAINS))).json(); + assert.equal(served.count, 1); + assert.equal(served.trains[0].routeId, 'ALVIA~X~1700037606GL023'); + for (const [label, pack] of [ + ['format version 2', renfePack({ version: 2 })], + ['another feed', renfePack({ feedId: 'ktmb' })], + // 04:00 to 05:00 UTC: over three hours before now. + ['window ended', renfePack({ startSec: 5 * 3600 })], + ]) { + const service = renfeService(t, async () => pack); + await assertScheduleUnavailable( + await service.handle(request(TRAINS)), + label, + ); + } +}); + +test('a kept pack is refused once its window ends, and read again later', async (t) => { + quietWarnings(t); + let now = NOW; + let reads = 0; + // Its one run: 07:30 to 08:20 UTC. + const service = renfeService( + t, + async () => { + reads += 1; + return renfePack({ startSec: 8.5 * 3600, durationSec: 50 * 60 }); + }, + () => now, + ); + assert.equal((await service.handle(request(TRAINS))).status, 200); + now += 21 * MIN; + await assertScheduleUnavailable(await service.handle(request(TRAINS))); + assert.equal(reads, 1, 'refused as kept, not read again'); + now += 6 * MIN; + await service.handle(request(TRAINS)); + assert.equal(reads, 2, 'read again after five minutes'); +}); + +/** Irish Rail's running trains, one train with its stated delay. */ +function irishXml(minutesLate) { + return ` + + + R52.6 + -6.4A610 + 15 Jan 2027 + A610\\n07:00 - Dublin Connolly to Rosslare Europort (${minutesLate} mins late)\\nDeparted Enniscorthy next stop Wexford + Southbound + +`; +} + +/** A610's run ends 07:45 UTC (Dublin is on UTC in January); A700 runs later. */ +const IRISH_PACK = JSON.stringify({ + version: 1, + feedId: 'irish-rail', + timezone: 'Europe/Dublin', + window: { from: '2027-01-14', to: '2027-01-15' }, + profiles: [[0, 0, 2700, 2700]], + services: { S: ['2027-01-15'] }, + trips: { + '6024_8435': { + name: 'A610', + route: 'A~X~DUB-EUROPORT-I', + pattern: 0, + profile: 0, + startSec: 7 * 3600, + service: 'S', + }, + '6024_9000': { + name: 'A700', + route: 'A~X~DUB-EUROPORT-I', + pattern: 0, + profile: 0, + startSec: 10 * 3600, + service: 'S', + }, + }, +}); + +test('a late Irish Rail train keeps its run past its timetabled end', async (t) => { + // Now is 15 minutes after the run's timetabled end, past the 10 minutes + // a run is looked for around its times. + const tripOf = async (minutesLate) => { + const service = createRailService({ + now: () => NOW, + fetchImpl: async () => + new Response(irishXml(minutesLate), { status: 200 }), + readSchedulePack: async () => IRISH_PACK, + }); + try { + const body = await ( + await service.handle(request('/api/rail/trains/irish-rail')) + ).json(); + return body.trains[0]; + } finally { + service.close(); + } + }; + const late = await tripOf(16); + assert.equal(late.tripId, '6024_8435', '16 minutes late: still on its run'); + assert.equal(late.trainNumber, 'A610'); + assert.equal(late.delaySec, 960); + const onTime = await tripOf(0); + assert.equal(onTime.tripId, 'A610', 'on time: that run is over'); + assert.equal(onTime.trainNumber, undefined); +}); + +test('packs are served no-cache with their version as ETag, and 304 when unchanged', async (t) => { + let pathVersion = '1731340000000.5:120'; + let reads = 0; + const service = createRailService({ + readPathPack: async () => { + reads += 1; + return '{"paths":1}'; + }, + pathPackVersion: async () => pathVersion, + readSchedulePack: async () => '{"schedule":1}', + schedulePackVersion: async () => 'built-7', + }); + t.after(service.close); + const path = '/api/rail/paths/mta-lirr'; + const first = await service.handle(request(path)); + assert.equal(first.status, 200); + assert.equal(first.headers.get('cache-control'), 'no-cache'); + const etag = first.headers.get('etag'); + assert.equal(etag, `"${encodeURIComponent(pathVersion)}"`); + assert.equal(await first.text(), '{"paths":1}'); + // Asked again with its tag: 304, and the pack is not read. + for (const headers of [ + { 'if-none-match': etag }, + new Headers({ 'If-None-Match': `W/${etag}, "other"` }), + ]) { + const again = await service.handle(request(path, headers)); + assert.equal(again.status, 304); + assert.equal(again.headers.get('etag'), etag); + assert.equal(again.headers.get('cache-control'), 'no-cache'); + assert.equal(await again.text(), ''); + } + assert.equal(reads, 1); + // Rebuilt: the old tag gets the new pack. + pathVersion = '1731340099000:140'; + const rebuilt = await service.handle( + request(path, { 'if-none-match': etag }), + ); + assert.equal(rebuilt.status, 200); + assert.notEqual(rebuilt.headers.get('etag'), etag); + assert.equal(reads, 2); + // The schedule pack carries its own version. + const schedule = await service.handle(request('/api/rail/schedule/mta-lirr')); + assert.equal(schedule.headers.get('etag'), '"built-7"'); + assert.equal( + ( + await service.handle( + request('/api/rail/schedule/mta-lirr', { + 'if-none-match': '"built-7"', + }), + ) + ).status, + 304, + ); + // Without a version there is no tag: no-cache, read each time. + const bare = createRailService({ readPathPack: async () => '{}' }); + t.after(bare.close); + const plain = await bare.handle(request(path, { 'if-none-match': '*' })); + assert.equal(plain.status, 200); + assert.equal(plain.headers.get('etag'), null); + assert.equal(plain.headers.get('cache-control'), 'no-cache'); +}); + +test('the development server passes If-None-Match to the pack routes', async (t) => { + const plugin = railProxy({ + readPathPack: async () => '{"paths":1}', + pathPackVersion: async () => 'v1', + }); + let handler = null; + const server = { + middlewares: { + use(prefix, fn) { + assert.equal(prefix, '/api/rail'); + handler = fn; + }, + }, + }; + plugin.configureServer(server); + t.after(() => plugin.closeBundle()); + const answer = async (headers) => { + const head = {}; + const chunks = []; + const res = new Writable({ + write(chunk, encoding, done) { + chunks.push(chunk); + done(); + }, + }); + res.writeHead = (status, sent) => Object.assign(head, { status, sent }); + const ended = new Promise((resolve) => res.on('finish', resolve)); + await handler({ url: '/paths/mta-lirr', method: 'GET', headers }, res); + await ended; + return { ...head, body: Buffer.concat(chunks).toString('utf8') }; + }; + const first = await answer({}); + assert.equal(first.status, 200); + assert.equal(first.sent.etag, '"v1"'); + assert.equal(first.body, '{"paths":1}'); + const again = await answer({ 'if-none-match': '"v1"' }); + assert.equal(again.status, 304); + assert.equal(again.body, ''); +}); diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 202e21870..cd01e9810 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -673,12 +673,15 @@ test('public transit documentation uses courtesy attribution and the approved gr new URL('../../DATA_SOURCES.md', import.meta.url), 'utf8', ); + // The Transit layer's own rows; the Rail layer's rows for the same + // operators quote their licences. const rows = data .split('\n') - .filter((line) => - /^\| \*\*(MBTA|CapMetro|Metro Transit|OVapi|Entur|TransLink|HSL)/.test( - line, - ), + .filter( + (line) => + /^\| \*\*(MBTA|CapMetro|Metro Transit|OVapi|Entur|TransLink|HSL)/.test( + line, + ) && /^\| [^|]+\| Transit layer /.test(line), ); assert.equal(rows.length, 7); for (const row of rows) { diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 20573cc91..640d5ad74 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -32,6 +32,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'local-adsb': msg('Local ADS-B'), 'osm-places': msg('OSM Places'), transit: msg('Transit'), + rail: msg('Rail'), 'recent-imagery': msg('Recent Imagery'), 'fire-perimeters': msg('Fire Perimeters'), 'gnss-integrity': msg('GNSS Integrity'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 896627bdc..c78a17645 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -53,6 +53,7 @@ const PANEL_GROUPS = [ 'ais-live-vessels', 'traffic', 'transit', + 'rail', 'bikeshare', ], }, diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 7ac40db2d..6288d32aa 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; -test('panel presentation places Transit between Street Traffic and Bike Share in Movement', () => { +test('panel presentation places Transit and Rail between Street Traffic and Bike Share in Movement', () => { const source = readFileSync( new URL('./layerPanel.js', import.meta.url), 'utf8', @@ -28,10 +28,12 @@ test('panel presentation places Transit between Street Traffic and Bike Share in 'ais-live-vessels', 'traffic', 'transit', + 'rail', 'bikeshare', ], ); assert.equal(order.filter(({ id }) => id === 'transit').length, 1); + assert.equal(order.filter(({ id }) => id === 'rail').length, 1); }); test('Wildlife follows Events and precedes Weather', () => { diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index da277293f..23fd127db 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -462,6 +462,7 @@ export const VOICE_OFF_LAYERS = Object.freeze({ news: 'browsed in the News panel; voice tools are a later step', 'wildlife-sightings': 'community observation layer; no room in the prompt budget', + rail: 'train layer; no room in the prompt budget, and "trains" still names transit', }); const TYPE_NAMES = Object.freeze({ diff --git a/src/worldFocus.js b/src/worldFocus.js index 6ed3a3b32..30d39369e 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -10,6 +10,9 @@ export const WORLD_FOCUS_FRAMING = Object.freeze({ vessel: Object.freeze({ radiusM: 150, rangeM: 1200, pitchDeg: -30 }), fire: Object.freeze({ radiusM: 400, rangeM: 3000, pitchDeg: -35 }), sighting: Object.freeze({ radiusM: 250, rangeM: 2500, pitchDeg: -40 }), + // A stopped train's one fly-to, and the frame a moving train is followed + // in (src/layers/rail/follow.js): the train and the track either side. + train: Object.freeze({ radiusM: 200, rangeM: 2500, pitchDeg: -35 }), 'sighting-area': Object.freeze({ radiusM: 11000, rangeM: 60000, diff --git a/src/worldFocus.test.mjs b/src/worldFocus.test.mjs index 3a10b75eb..8e2438cb1 100644 --- a/src/worldFocus.test.mjs +++ b/src/worldFocus.test.mjs @@ -209,6 +209,7 @@ test('every framing is a real oblique standoff, not a nadir or an inside-out sph 'fire', 'sighting', 'sighting-area', + 'train', 'vessel', ]); for (const kind of kinds) { @@ -244,6 +245,10 @@ test('every framing is a real oblique standoff, not a nadir or an inside-out sph { ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }, ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.train }, + { radiusM: 200, rangeM: 2500, pitchDeg: -35 }, + ); assert.deepEqual( { ...WORLD_FOCUS_FRAMING.sighting }, { radiusM: 250, rangeM: 2500, pitchDeg: -40 }, From d793406aefced2c622bba37970fb0a1556f98b1e Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 11 Nov 2026 16:08:24 +0100 Subject: [PATCH 2/4] docs(changelog): add the #1675 fragment --- changelog.d/{0-rail-layer.md => 1675-rail-layer.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0-rail-layer.md => 1675-rail-layer.md} (97%) diff --git a/changelog.d/0-rail-layer.md b/changelog.d/1675-rail-layer.md similarity index 97% rename from changelog.d/0-rail-layer.md rename to changelog.d/1675-rail-layer.md index 14eac616f..044dd41c5 100644 --- a/changelog.d/0-rail-layer.md +++ b/changelog.d/1675-rail-layer.md @@ -21,4 +21,4 @@ flown to once; the selected train and its line stay on the map from orbit. No train is placed in Ukraine. Feeds that publish no licence are used on the basis each one's entry records, and their credit never presents that - as a licence (Denis Groshev, @ZloyS, #0). + as a licence (Denis Groshev, @ZloyS, #1675). From ace21600023665673101e087675eae7a62b6a670 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 11 Nov 2026 14:54:01 -0800 Subject: [PATCH 3/4] fix(rail): Montpellier tram speeds are converted from km/h TaM publishes km/h where GTFS-Realtime asks m/s, and the Transit layer's entry for the same feed already sets speedUnit: 'km/h'. The Rail entry did not, so the proxy passed the readings through and the card multiplied them by 3.6 again: a tram at 30 km/h read about 108 km/h. A registry test now requires a Rail feed that Transit also reads to declare the same unit. --- src/data/railFeeds.js | 9 ++++++++- src/data/railFeeds.test.mjs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/data/railFeeds.js b/src/data/railFeeds.js index f1c1eec1d..6b7215c3c 100644 --- a/src/data/railFeeds.js +++ b/src/data/railFeeds.js @@ -476,7 +476,9 @@ const OWNER_AUTHORIZED_NO_TERMS = * 64 MiB: railMaxBodyBytes; the feeds that set one publish small documents * and set a lower cap), and `headerClock: 'untrusted'` marks a feed whose * header time is wrong and whose vehicles carry none: every train is then - * stamped with the fetch time. + * stamped with the fetch time. `speedUnit: 'km/h'` marks a feed that + * publishes km/h where the spec requires m/s; the proxy converts it, as for + * Transit (src/data/transitFeeds.js). * `tripUpdatesUrl` is the operator's GTFS-RT TripUpdates feed, served as * `/api/rail/updates/`. * @@ -509,6 +511,7 @@ const OWNER_AUTHORIZED_NO_TERMS = * url: string, * positions?: 'gps'|'event', * headers?: Record, headerClock?: 'untrusted', + * speedUnit?: 'km/h', * pollIntervalMs?: number, tripUpdatesUrl?: string, * tripUpdatesIntervalMs?: number, maxBodyBytes?: number, * limits?: {quote: string, note?: string}, @@ -1307,6 +1310,10 @@ export const RAIL_FEED_REGISTRY = Object.freeze([ format: 'gtfs-rt', // Every TaM vehicle (about 15 KB); the tram routes are kept. url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb', + // Speeds are km/h where the spec asks m/s (the Transit layer's entry for + // this feed measured readings at 3.3 times the movement); the proxy + // converts them. + speedUnit: 'km/h', tripUpdatesUrl: 'https://gtfsproxy.e-tam.fr/COMMON/TripUpdate.pb', tripUpdatesIntervalMs: 60_000, staticGtfsUrl: 'https://gtfsproxy.e-tam.fr/COMMON/GTFS.zip', diff --git a/src/data/railFeeds.test.mjs b/src/data/railFeeds.test.mjs index 5304680a9..f6fb8db1e 100644 --- a/src/data/railFeeds.test.mjs +++ b/src/data/railFeeds.test.mjs @@ -66,6 +66,7 @@ const REGISTRY_FIELDS = new Set([ 'positions', 'headers', 'headerClock', + 'speedUnit', 'pollIntervalMs', 'tripUpdatesUrl', 'tripUpdatesIntervalMs', @@ -472,3 +473,19 @@ test('feeds that read routes from their schedule pack can build one', () => { })) assert.ok(!/@/.test(value), feed.id); }); + +test('a feed Transit also reads declares the same speed unit', async () => { + const { TRANSIT_FEED_REGISTRY } = await import('./transitFeeds.js'); + const transitByUrl = new Map( + TRANSIT_FEED_REGISTRY.map((feed) => [feed.url, feed]), + ); + let shared = 0; + for (const feed of RAIL_FEED_REGISTRY) { + const transit = transitByUrl.get(feed.url); + if (!transit) continue; + shared += 1; + assert.equal(feed.speedUnit, transit.speedUnit, feed.id); + } + assert.ok(shared > 0); + assert.equal(getRegisteredRailFeed('tam-montpellier').speedUnit, 'km/h'); +}); From 283890cee0239b2e7408ce8ec124ec129c89b762 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 11 Nov 2026 14:54:01 -0800 Subject: [PATCH 4/4] fix(rail): trip-update delays are as old as the server's read The browser stamped every trip-updates answer as just read. While an operator's feed fails, the rail proxy replays its last good copy for up to ten minutes, and a cached copy can be a TTL old, so those delays never reached RAIL_UPDATES_MAX_AGE_MS and a selected train's card showed them as current. Delays now take their age from the snapshot's fetchedAt (capped at now), as positions do in ingestion.js, so they stop applying 75 s after the server last read the operator. --- src/layers/rail/schedules.js | 10 +++++- src/layers/rail/schedules.test.mjs | 49 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/layers/rail/schedules.js b/src/layers/rail/schedules.js index 8e6a5e94c..90892f752 100644 --- a/src/layers/rail/schedules.js +++ b/src/layers/rail/schedules.js @@ -287,8 +287,16 @@ export function createRailSchedules({ if (!response.ok) throw new Error(`rail updates HTTP ${response.status}`); const body = await response.json(); if (slot.controller !== controller) return; + // Delays are as old as the server's read of the operator (`fetchedAt`), + // not this answer: a cached copy, or the last good one replayed while + // the operator fails, stops applying RAIL_UPDATES_MAX_AGE_MS after it, + // as positions do (ingestion.js applySnapshot). + const at = now(); slot.delays = delaysFromUpdates(body); - slot.at = now(); + slot.at = Math.min( + Number.isFinite(body?.fetchedAt) ? body.fetchedAt : at, + at, + ); onChange(feedId); } catch (error) { if (error?.name !== 'AbortError') diff --git a/src/layers/rail/schedules.test.mjs b/src/layers/rail/schedules.test.mjs index 0db2cdca9..6d618bc06 100644 --- a/src/layers/rail/schedules.test.mjs +++ b/src/layers/rail/schedules.test.mjs @@ -372,6 +372,55 @@ test('delays are polled every 30 s only while a train on the feed is selected', assert.equal(h.timers.size, 0); }); +test('delays are as old as the server’s read, even when it replays them', async () => { + let readAt = null; + const h = harness({ + updates: () => + reply({ + fetchedAt: readAt ?? h.now, + trips: [ + { + tripId: 'T1', + delaySec: 240, + stops: [{ stopId: 'B', arrival: { delay: 240 }, departure: null }], + }, + ], + }), + }); + const train = liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.05, + }); + h.provider.onSelect(train); + await flush(); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240); + + // The operator fails: each poll is answered with the server's last copy, + // still stamped with the time it was read. + readAt = h.now; + const poll = [...h.timers.values()][0].fn; + for (let i = 0; i < 3; i++) { + h.now += RAIL_UPDATES_POLL_MS; + poll(); + await flush(); + } + assert.equal(h.calls.updates, 4); + assert.ok(3 * RAIL_UPDATES_POLL_MS > RAIL_UPDATES_MAX_AGE_MS); + assert.equal(h.provider.delaysFor(FEED), null); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 0); + + // A fresh read applies again; a server clock ahead counts as now. + readAt = h.now + 5_000; + poll(); + await flush(); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240); + h.now += RAIL_UPDATES_MAX_AGE_MS; + assert.notEqual(h.provider.delaysFor(FEED), null); +}); + test('a selected train’s delays are not polled when its feed has no timetable', async () => { const h = harness({ schedule: () => reply({ error: 'No rail schedule' }, 404),