mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a788db78 | ||
|
|
a105e819e0 | ||
|
|
202cb8c3dc | ||
|
|
2b1784d7ed | ||
|
|
beeb29f0fa | ||
|
|
b7015f8819 | ||
|
|
283890cee0 | ||
|
|
ace2160002 | ||
|
|
0f18c03041 | ||
|
|
9b3ca481ce | ||
|
|
632da1cebd | ||
|
|
99a1e06b61 | ||
|
|
c19495644d | ||
|
|
ca58e113c0 | ||
|
|
94ff78be3b | ||
|
|
82e2b716bc | ||
|
|
a29611ca17 | ||
|
|
72c24a86d1 | ||
|
|
13818d0b9f | ||
|
|
44d2c0695c | ||
|
|
aa95c2c25f | ||
|
|
1c08482fa8 | ||
|
|
98b642869e | ||
|
|
d793406aef | ||
|
|
830c080803 | ||
|
|
71797427d6 | ||
|
|
0ebc09079d | ||
|
|
ae66d07575 | ||
|
|
43893697aa | ||
|
|
4fd36c44e0 | ||
|
|
45a33b54df | ||
|
|
f612fc2180 | ||
|
|
a8970c1f18 | ||
|
|
8334262109 | ||
|
|
e0fedc13dd | ||
|
|
5b7e917f94 | ||
|
|
f878efe22d | ||
|
|
c76832ca69 | ||
|
|
e34b5103c6 | ||
|
|
f3996da0af | ||
|
|
9c73939425 | ||
|
|
5370b8a268 | ||
|
|
409dbf1660 | ||
|
|
fdd153b73d | ||
|
|
f7b0367d4b | ||
|
|
fc6944951b | ||
|
|
a42b38c4b3 | ||
|
|
7efcf37c62 | ||
|
|
b7531e655d | ||
|
|
efaeece117 | ||
|
|
e5aee68e1d | ||
|
|
3dc65aca64 | ||
|
|
a073c58e50 | ||
|
|
489156366a | ||
|
|
4e3f6bf505 | ||
|
|
ce72e12743 | ||
|
|
78773d2ccb | ||
|
|
30e6cd39c7 | ||
|
|
14f7326dbf | ||
|
|
451f3e41cf | ||
|
|
c0a74be7f6 | ||
|
|
f08a7a2d72 | ||
|
|
ceff3eb6c9 | ||
|
|
5c45469dca | ||
|
|
bf1c44a9d3 | ||
|
|
ffbbb970f9 | ||
|
|
bb4a7050d3 | ||
|
|
3f55077c2c | ||
|
|
52aea49faa | ||
|
|
483807530b | ||
|
|
77ac2894d4 | ||
|
|
3b9aebe9f1 | ||
|
|
a4bd028bd2 | ||
|
|
9d27366b3e | ||
|
|
fed397a764 | ||
|
|
34f694c6f2 | ||
|
|
e5ed90ff17 | ||
|
|
7c1d5ff576 | ||
|
|
7703090d90 | ||
|
|
0ec57057fa | ||
|
|
8ce01cf48e | ||
|
|
fcf240e789 | ||
|
|
ed2703d345 | ||
|
|
98de41c94a | ||
|
|
562a2ea544 | ||
|
|
fa2e1a9ec7 | ||
|
|
55d5896cb3 | ||
|
|
1bff1961b4 | ||
|
|
536f371dde | ||
|
|
f8795ff426 |
No files matched your search
@@ -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 <id> ... 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=
|
||||
@@ -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.
|
||||
|
||||
+33
-1
@@ -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<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **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/<feedId>` returns one feed's train snapshot, `/api/rail/updates/<feedId>` its trip updates, and `/api/rail/paths/<feedId>` and `/api/rail/schedule/<feedId>` 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 <id>` (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, anLine truncated
|
||||
- **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
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
|
||||
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
|
||||
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients.
|
||||
|
||||
Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
Thirty-five tools (thirty-four unless an Overpass server is set), four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
|
||||
**🎥 Direct it** — drone-operator camera verbs:
|
||||
|
||||
@@ -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 <id>` 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 |
|
||||
|
||||
@@ -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/<id>` 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/<id>`, `/api/rail/updates/<id>`, `/api/rail/paths/<id>` and `/api/rail/schedule/<id>` 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.
|
||||
@@ -132,6 +133,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`
|
||||
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
|
||||
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
|
||||
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
|
||||
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; freshness is judged only by the result's `issued_at`, so a result older than five minutes only arms, whether the panel was mounted again (reopening a conversation) or got it later on the same mount, while one re-sent or remounted within five minutes of issue opens the photo again; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it, or when the screen centre cannot be picked at all (no ground picking, or a globe with no size yet); with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
|
||||
|
||||
## Scope & expectations
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
- "Show me X on Street View" (or on Mapillary, KartaView or Panoramax): a
|
||||
`show_street_level` voice and typed action flies to the place, switches to
|
||||
Google 3D when the source needs it, lights that source in Street Level,
|
||||
turns the layer on and opens the nearest photo, saying which step failed.
|
||||
With no place named it looks under the screen centre, as OPEN does. Views
|
||||
and share links carry the same request (`street_level`, `sl=`). A plain
|
||||
`sl=google` link never loads a billed panorama by itself: it arms Street
|
||||
View and leaves the load to OPEN GOOGLE STREET VIEW; embedded views open it,
|
||||
and so does the God's Eye View panel for a result issued in the last five
|
||||
minutes; a link whose source has no working key says so. A request stops
|
||||
when the user navigates, or enters cockpit, clean view, recording or scene
|
||||
playback, before its photo loads (collapsing the panel does not stop it: the
|
||||
photo reveals it); a new photo replaces the open one in place, so EXPAND
|
||||
stays. When nothing is found and a 360°, flat or SINCE filter is on, the
|
||||
answer names the filter. Failure cards are in the interface language. An
|
||||
unavailable map stack now reports the
|
||||
controller's own reason instead of always asking for a Cesium ion token
|
||||
(Said Turksever, @asturksever, #1113).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Stop screen annotations sampling surface height for marks that are hidden,
|
||||
behind the camera or over the horizon. Visible marks still waiting for
|
||||
terrain share 32 samples per projection pass and retry once a second, so a
|
||||
large board no longer stalls the frame (asturksever, #1277).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Routing outages report "Routing service unavailable" in voice route planning
|
||||
and "Routing unavailable" in Directions instead of "no route found"
|
||||
(quanticsoul4772, #1509).
|
||||
@@ -0,0 +1,5 @@
|
||||
- FIRMS fire ages round to whole hours before choosing a unit, so an age of
|
||||
47.5–48 hours reads "2d", as exactly 48 hours does, not "48h". Detections
|
||||
are filtered to the trailing 24 hours, so this shows mostly in the
|
||||
`STALE · cached {age}` stats line after about two days without a successful
|
||||
refresh (@quanticsoul4772, #1531).
|
||||
@@ -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 <id>` (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, #1675).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The News layer reads its time from the world clock, so a host that follows
|
||||
the clock for News can show a past time's reports with their ages, window and
|
||||
counts as they were then. Nothing changes while the timeline is live
|
||||
(Denis Groshev, @ZloyS, #1677).
|
||||
+394
-16
@@ -145,16 +145,6 @@ voice follow-ups match on them and bikeshare uses the raw station name as its
|
||||
ID. The text is still untrusted: normalization does not prevent prompt
|
||||
injection.
|
||||
|
||||
Screen annotations skip hidden records and, before surface sampling, reject an
|
||||
unsettled anchor only when its whole accepted height range (-430 to 9000 m) is
|
||||
behind the camera or below the globe horizon; a settled height is tested as the
|
||||
actual point. Anchors outside the conservative frustum still project at their
|
||||
cached or zero height, so a partly visible arrow keeps its connector, but do
|
||||
not sample. Unresolved visible heights share a 32-sample projection-pass budget,
|
||||
served oldest-refused first, retry at most once per second per anchor on
|
||||
rendered frames, and retain no extra timer or listener. Validated heights remain
|
||||
settled in the bounded cache.
|
||||
|
||||
Terrain height requests accept at most 64 points, matching the browser service's
|
||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
|
||||
@@ -1147,7 +1137,7 @@ Voice controls bind to a protocol-independent session factory. The default WebRT
|
||||
|
||||
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 35 tools using separate description-only metadata; a session is offered 34 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
|
||||
|
||||
@@ -3063,6 +3053,7 @@ to a set time, naming its end as `at` (epoch ms): the row then reads
|
||||
`window_end`. The dev proxy never sets `at`.
|
||||
|
||||
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||
FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line.
|
||||
The `get_active_fires` tool reports acquisition times as UTC ISO timestamps,
|
||||
including zero-padded hours and minutes; missing or invalid dates and times
|
||||
remain `null`, including impossible calendar dates from a custom source.
|
||||
@@ -4921,6 +4912,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 |
|
||||
@@ -5267,6 +5259,111 @@ core's lookups alone. The MCP globe panel allows the meta-catalogue and the
|
||||
three biggest instances; a picture on another instance shows only in the
|
||||
app.
|
||||
|
||||
**"Show me X on Street View."** `show_street_level` (voice and typed;
|
||||
`src/voice/gevActions.js`) is one call whose stages fail separately
|
||||
(`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`):
|
||||
|
||||
- **Key first.** A keyless source explains itself and changes nothing, as a
|
||||
chip does.
|
||||
- **Place.** A name, coordinates, a pointed spot or a numbered item: it flies
|
||||
there at street framing and waits for arrival. With no place it looks at the
|
||||
ground under the screen centre, as OPEN does (`aim: 'screen-centre'`,
|
||||
`armed: false`: sky refuses, and a link's armed point is not used and is kept
|
||||
until a photo opens). Coordinates off the globe are refused.
|
||||
- **Map and source.** It switches to the map the source needs (Google Street
|
||||
View: Google 3D), lights the source through `setLayerParams`, one at a time
|
||||
and kept like a chip click (origin `voice`; a link runs the action with
|
||||
origin `programmatic`, so it never writes the recipient's saved layer state),
|
||||
turns the layer on and calls `openNearest`. `provider` is `google` for
|
||||
"Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit
|
||||
source.
|
||||
- **Hidden panel.** Cockpit, clean view, recording or scene playback end it at
|
||||
`view`, before the flight and after every await; embed mode's own clean view
|
||||
is exempt, since the photo is lifted over the globe.
|
||||
- **Superseded.** After every await (flight, map switch, layer on, lookup) a
|
||||
newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where
|
||||
its own flight lands) or a press or wheel on the globe stops it
|
||||
(`superseded: true`). The same checks run before a found photo loads
|
||||
(`proceed`), so nothing billed loads for a request that was given up. A
|
||||
lookup given up leaves the panel as it was when the request kept the photo's
|
||||
source; one that had switched the source off closes that source's photo with
|
||||
it. A photo that opened after either is closed again.
|
||||
- **Replacing a photo.** An open photo stays up while the new request looks
|
||||
(`openNearest` `replace: true`; the viewer is replaced, not closed, so EXPAND
|
||||
stays, also across sources through `holdViewer`, which holds only the action's
|
||||
own switch-off of the photo's source: a source switched by hand meanwhile
|
||||
still stops the load) and closes if nothing opens, so a place with no imagery
|
||||
shows its own error; a link that only arms closes it at once. A replacement
|
||||
from another source drops the old photo's details, and the request's error or
|
||||
armed OPEN outlives the old viewer. A replacement stops a playing Mapillary
|
||||
sequence.
|
||||
- **Outcome.** `noImagery` marks an empty result, `imageryError` a source or
|
||||
viewer failure and `aim: 'sky'` a sky centre (from `street.nearestOutcome`);
|
||||
an empty result names a narrowing 360°/flat or SINCE filter the source
|
||||
honours, as `filter`. Every photo the action opens reveals the panel, also
|
||||
one the user had collapsed.
|
||||
- **Search.** A source without a coverage layer searches up to 250 m
|
||||
(`NEAREST_RADIUS_MAX_M`; Google honours it); clicks keep 50 m. Mapillary
|
||||
asks for 100 photos within 50 m, its API's most (the API returns them
|
||||
unordered and cuts at the limit), and gives up after 12 s, body included
|
||||
(`NEAREST_TIMEOUT_MS`).
|
||||
- **Phone.** The panel sits in the Cameras tab sheet: a photo that opens, or a
|
||||
link that arms, opens the sheet and scrolls the panel into view
|
||||
(`revealPanel`, which does nothing above 720px).
|
||||
|
||||
The layer also exposes `requiredMapStack(id)` and `providerStatus(id)` for
|
||||
callers that decide before the panel lists a source, and `holdViewer()`. A
|
||||
caller's abort is honoured through the viewer's mount and
|
||||
load, and only the lookup that still owns the panel changes its spinner or
|
||||
error.
|
||||
|
||||
Views carry the request as `street_level` (`src/view/index.js`): a source and
|
||||
a point, which defaults to where the camera looks (below 20 km, at a pitch
|
||||
steeper than -5° and within 10 km ahead; else the camera's own point); half a
|
||||
point is not mixed with the camera's: both coordinates come from that default. A `google` source brings `map: photoreal` and refuses
|
||||
another map; `street_level` does not combine with `follow`.
|
||||
`show_in_gods_eye_view`, and the embedded app for a posted view with no
|
||||
point, measure the view's altitude from the terrain under the camera
|
||||
(`groundedLookAt` with the terrain heights service) before taking the look-at
|
||||
point, and keep the view's own estimate without a terrain answer (the embedded
|
||||
app waits 2 s for one). Links carry
|
||||
`sl=<source>,<lat>,<lon>`; the globe panel applies views through the action,
|
||||
and a plain link runs it once restored (one shot: the rewritten hash drops
|
||||
`sl`). A link that follows an entity ignores `sl`.
|
||||
|
||||
- **Billing gate.** A plain link never loads a photo from a source that bills
|
||||
every load (`billedPerImage`; Google): the action runs with
|
||||
`autoOpen: false` and arms the layer (`armNearest`: the link's point and
|
||||
radius, for the next OPEN press only, and only while the ground under the
|
||||
screen centre is within 1 km of it (`ARMED_REACH_M`) or cannot be picked; a
|
||||
sky centre refuses; any other lookup, open, close, disable, the source
|
||||
switched off or its map leaving drops it), opens the panel and shows a toast
|
||||
pointing at OPEN GOOGLE STREET VIEW. A link whose source has no working
|
||||
key here changes nothing and says so in a toast (not in a frame).
|
||||
- **Who may open.** Posted views, and the panel page's own link (inline embed)
|
||||
for a tool result issued in the last 5 minutes, open the photo:
|
||||
`show_in_gods_eye_view` stamps a street-level result `issued_at`, every
|
||||
`gev:view` the panel posts carries `fresh`, and the inline app applies a
|
||||
view without it with `autoOpen: false` (a framing page's posted views still
|
||||
open). `?embed=1` on a published link only arms, without the panel nudge or
|
||||
toast.
|
||||
- **Who owns the camera.** A link's photo step is skipped when a newer link
|
||||
superseded its restore, when its restore flight never landed
|
||||
(`share.camera !== 'applied'`: the visitor dragged or searched), or after
|
||||
any navigation since the generation that flight owned (`navigationToken` on
|
||||
the settled result), also while its layers still load. A link pasted into
|
||||
the open tab runs its step after its restore settles
|
||||
(`gev:share-navigation-restore-settled`).
|
||||
- **Embed.** The rail is hidden, so an open photo moves to an overlay on the
|
||||
globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in
|
||||
full screen, Esc leaves full screen first). EXPAND stays under embed mode's
|
||||
clean view; cockpit, recording and scene playback hide the overlay. Loading
|
||||
errors render inside the overlay, and inside the viewer while EXPAND is on.
|
||||
A posted view whose photo step fails before the imagery stage closes the
|
||||
earlier photo before its camera flies; one the user overtook (a click on the
|
||||
map, a newer request) leaves their photo and camera alone; one that found a
|
||||
photo it could not load keeps that error.
|
||||
|
||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||
@@ -5351,7 +5448,14 @@ service's usage policy states; past a bounded queue the answer is a 429 with a
|
||||
`Retry-After` rather than a queue that grows until everything times out. The
|
||||
upstream host is pinned and a redirect is refused rather than followed, and a
|
||||
rate limit from the routing service is reported as one instead of as "no route
|
||||
found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit /
|
||||
found". Upstream failure statuses except 400 and 429, network failures, and
|
||||
unreadable, oversized or non-JSON bodies return `502 routing service unavailable`.
|
||||
Voice hears `Routing service unavailable (HTTP 502)`; Directions shows
|
||||
`Routing unavailable (HTTP 502)`. OSRM 400 responses or successful responses
|
||||
without a usable route return "no route found". The 12 s upstream budget includes
|
||||
queue time: expiry before dispatch returns 429 "routing busy" when the queue slot
|
||||
opens; expiry during the upstream request returns 502.
|
||||
`src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit /
|
||||
road name into one plain-English sentence per decision, folding
|
||||
`exit roundabout` steps into the roundabout they leave, and caps one route at
|
||||
200 maneuvers — a route with more is reported as cut off, in the row summary
|
||||
@@ -5373,7 +5477,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,
|
||||
@@ -5598,6 +5702,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 `<id>.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/<feedId>` one feed's snapshot,
|
||||
`GET /api/rail/updates/<feedId>` its trip updates, and
|
||||
`GET /api/rail/paths/<feedId>` and `GET /api/rail/schedule/<feedId>` 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 (`<id>.json`: the
|
||||
operator's track shapes) and a schedule pack (`<id>.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 <id>`, 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 <id>=<zip>` 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, `<trip_id>@<HH:MM:SS>`, 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.
|
||||
@@ -6322,7 +6699,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
|
||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
|
||||
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
|
||||
- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
|
||||
- **Thirty-five tools** (thirty-four in a session without `OVERPASS_UPSTREAMS`, which adds `osm_query`; argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `resolve_area`, `find_imagery`, `osm_query`, `next_iss_pass`, and `next_satellite_pass`.
|
||||
|
||||
> **Reading `npm test` totals:** the count depends on the Node major. The two
|
||||
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
|
||||
@@ -6595,7 +6972,8 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
### News (November 2026)
|
||||
|
||||
- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. A selected report's card reads "Source: host ↗" and opens the article.
|
||||
- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. An app built on Core may draw the picture cards itself: `createApplicationCatalog({ newsOptions: { pictureCards } })` calls `pictureCards(cards, { selectedId })` on each redraw with one card per event on the map that has a picture, single source or not, at every height (zoomed out, where a hotspot stands as one beacon, its reports' pictures still come), most severe then newest first (`{ id, title, lat, lon, publishedAt, severity, color, image, video, credit }`, at the marker), and the selected report's id or null (its own card still stands at its marker); with no cards when the layer turns off; the layer then paints none of its own, and a click on one is `setParams({ reportId, focus: true })`. A selected report's card reads "Source: host ↗" and opens the article.
|
||||
- **Time.** The layer reads its time from the world clock (`worldClock.timeFor('news')`, passed as `now` in `src/app/constructCatalog.js`): report ages, the 24-hour window, "today", fading and the pressing and card rules all count from it. Core does not follow the clock for the layer, so this is the wall clock and nothing changes. A host whose news source can answer a past time may follow it for `news`; the layer then shows that time's news as it was then, with ages counted from the timeline.
|
||||
- **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report.
|
||||
- **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working.
|
||||
- **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN` when it is set.
|
||||
@@ -6761,7 +7139,7 @@ apply to `public/` assets or built-preview serving.
|
||||
- `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution.
|
||||
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
||||
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
|
||||
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
|
||||
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors; routing outages return 502.
|
||||
- `/api/flights`, `/api/military`, and `/api/route` answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET`, before rate limiting and any upstream call.
|
||||
- `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding.
|
||||
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail).
|
||||
|
||||
@@ -68,6 +68,16 @@ cannot name still makes a place view. Opening a vessel thing view with a
|
||||
world time waits for the vessel layer's snapshot at that time and selects the
|
||||
vessel once it has it.
|
||||
|
||||
## Street-level photos (`sl`)
|
||||
|
||||
Views built by `show_in_gods_eye_view` or `src/view/index.js` can add
|
||||
`sl=<source>,<lat>,<lon>` to the long form. Once the link has restored, the
|
||||
app opens the nearest photo there, or, for a source that bills every load,
|
||||
arms the panel so OPEN loads it (see `docs/TOOLS.md`). The app's own links do
|
||||
not carry it: the address bar's long form drops `sl` when the camera next
|
||||
moves, and the short form has no field for it. A reload or the app's Copy
|
||||
Link keeps the lit source, the layer and the map, but not the photo request.
|
||||
|
||||
## Payload
|
||||
|
||||
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
|
||||
|
||||
+23
-5
@@ -102,11 +102,29 @@ action draws (pins, highlights, areas, arrows, routes and labels at a place
|
||||
name or coordinates). Links carry them in the `an` parameter, and the app
|
||||
draws them once the link has been restored.
|
||||
|
||||
A view can open street-level photos too: `street_level` names a source
|
||||
(`google` for Street View, `mapillary`, `kartaview`, `panoramax`, or none for
|
||||
the lit one) and a point, by default the spot the camera looks at. Street View
|
||||
shows only on the Google 3D map, so a `google` source sets `map` to
|
||||
`photoreal` and refuses another; a view cannot both follow an entity and open
|
||||
photos. Links carry it as `sl=<source>,<lat>,<lon>`: once restored, the app
|
||||
runs `show_street_level`, which flies there and opens the nearest photo. A
|
||||
plain link with a `google` source only arms the panel for OPEN GOOGLE STREET
|
||||
VIEW; a view posted into the embedded app opens it, and so does the panel
|
||||
page's own opening link, but only for a tool result issued in the last five
|
||||
minutes (an older one, such as a host remounting the panel, only arms);
|
||||
`?embed=1` on a published link does not. Mapillary,
|
||||
KartaView and Panoramax, which do not bill per image, open at once. A link
|
||||
that follows an entity ignores `sl`.
|
||||
|
||||
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
|
||||
`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
|
||||
tilted camera over an area looks at its center from behind; camera fields
|
||||
given with an area override its framing. `resolveViewArguments` turns them
|
||||
into a view.
|
||||
`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and
|
||||
`street_level`. A tilted camera over an area looks at its center from behind;
|
||||
camera fields given with an area override its framing. A passed-through
|
||||
view's `street_level` point moves with a new `area` or camera position (a
|
||||
turned camera keeps it). `resolveViewArguments` turns them into a view. The
|
||||
app's own links (the address bar, Copy Link, `/share/<code>`) do not carry
|
||||
`sl`; see `docs/SHARE-LINK-FORMAT.md`.
|
||||
|
||||
### The God's Eye View panel
|
||||
|
||||
@@ -299,5 +317,5 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
|
||||
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
|
||||
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
|
||||
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
|
||||
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks, something to follow, or street-level photos (Street View or Mapillary) at a place |
|
||||
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -181,6 +181,18 @@ export function answerMapillaryRequest({ method, url, images }) {
|
||||
: [];
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('lat') && q.has('lng')) {
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
||||
const limit = Number(q.get('limit')) || 10;
|
||||
// In no particular order, as the real API answers.
|
||||
const data = photos
|
||||
.filter((image) => metresApart(at, image) <= radius)
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((image) => withFields(imageRecord(image), fields));
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||
return {
|
||||
...json(400, { error: 'unsupported images query' }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2192,6 +2192,7 @@
|
||||
"scripts/qa-street-level.mjs",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/i18n/locales/ar.json",
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
@@ -3249,6 +3250,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 +4094,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 +5133,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": [
|
||||
|
||||
@@ -46,6 +46,15 @@ const VIEWPORT = { width: 1440, height: 900 };
|
||||
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
||||
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
|
||||
const DAY_MS = 86_400_000;
|
||||
/** A Sacramento street with Street View, for the sl=google link step. */
|
||||
const STREET_VIEW_STREET = Object.freeze({ lon: -121.49372, lat: 38.57986 });
|
||||
/**
|
||||
* What a Street View lookup and load cost: the Maps JS API's
|
||||
* SingleImageSearch (the nearest lookup), GetMetadata and the panorama tiles.
|
||||
* Script loads are not counted.
|
||||
*/
|
||||
const STREET_VIEW_REQUEST =
|
||||
/MapsJsInternalService\/(SingleImageSearch|GetMetadata)|GeoPhotoService|streetviewpixels-pa\.googleapis\.com/;
|
||||
|
||||
/**
|
||||
* Coverage for any street-zoom tile: a grid of 360°/flat, recent/old
|
||||
@@ -234,6 +243,28 @@ async function boot(page, url) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a share link as a fresh document. Changing only the hash of a live
|
||||
* page and reloading races the app's share-link writer, which can put its own
|
||||
* state back in the hash before the reload reads it. The page's fixtures (a
|
||||
* CDP session on the page) carry over.
|
||||
*/
|
||||
async function bootLink(page, link) {
|
||||
await page.goto('about:blank');
|
||||
await boot(page, link);
|
||||
}
|
||||
|
||||
/** The camera's position in degrees and metres. */
|
||||
const cameraAt = (page) =>
|
||||
page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return {
|
||||
lon: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
height: c.height,
|
||||
};
|
||||
});
|
||||
|
||||
/** Run `test(uiState, arg)` in the page until it is true. */
|
||||
const uiUntil = (page, test, arg = null, timeout = 30_000) =>
|
||||
page.waitForFunction(
|
||||
@@ -780,6 +811,219 @@ async function main() {
|
||||
await uiUntil(page, litAlone, 'mapillary');
|
||||
await park(page);
|
||||
|
||||
const base = url.split('#')[0];
|
||||
await fixtureStep(
|
||||
'a link to street-level photos (sl=) opens the photo at its point once the link restores',
|
||||
async () => {
|
||||
const photo = fixture.images[0];
|
||||
// The camera sits ~330 m north of the photo: the lookup must use
|
||||
// the link's point, not the view centre.
|
||||
const cameraLat = Number((photo.lat + 0.003).toFixed(6));
|
||||
await bootLink(
|
||||
page,
|
||||
`${base}#v=2&lat=${cameraLat}&lon=${photo.lon}&alt=600&heading=0&pitch=-60&roll=0&sl=mapillary,${photo.lat},${photo.lon}`,
|
||||
);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const { street } = await getUI(page);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
assert.equal(street.providerId, 'mapillary');
|
||||
assert.equal(
|
||||
street.imageId,
|
||||
photo.id,
|
||||
`the photo at the link's point, not the one nearest the camera: ${street.imageId}`,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => location.hash.includes('sl=')),
|
||||
false,
|
||||
'the rewritten link no longer carries the one-shot request',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await fixtureStep(
|
||||
'the show_street_level action opens the nearest photo at a point without a pointer',
|
||||
async () => {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.closeViewer(),
|
||||
);
|
||||
const photo = fixture.images[0];
|
||||
const result = await page.evaluate(
|
||||
(at) =>
|
||||
window.__godsEyeView.voiceCommands.runner('show_street_level', {
|
||||
latitude: at.lat,
|
||||
longitude: at.lon,
|
||||
provider: 'mapillary',
|
||||
fly: false,
|
||||
}),
|
||||
{ lat: photo.lat, lon: photo.lon },
|
||||
);
|
||||
assert.equal(
|
||||
result.ok,
|
||||
true,
|
||||
`action failed: ${result.stage} ${result.error}`,
|
||||
);
|
||||
assert.equal(result.provider, 'mapillary');
|
||||
assert.ok(
|
||||
fixture.images.some((image) => image.id === result.imageId),
|
||||
`a fixture photo opened: ${result.imageId}`,
|
||||
);
|
||||
assert.match(result.say || '', /^Mapillary at /);
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'an sl=google link never loads a photo by itself: keyless it stops at the key, keyed it arms and waits for OPEN',
|
||||
async ({ skipped }) => {
|
||||
const point = STREET_VIEW_STREET;
|
||||
// ~330 m north of the street: a flight would move the camera, and a
|
||||
// press that ignored the armed point would open something else.
|
||||
const cameraLat = Number((point.lat + 0.003).toFixed(6));
|
||||
const googleRequests = [];
|
||||
const onRequest = (request) => {
|
||||
if (STREET_VIEW_REQUEST.test(request.url()))
|
||||
googleRequests.push(request.url());
|
||||
};
|
||||
page.on('request', onRequest);
|
||||
try {
|
||||
await bootLink(
|
||||
page,
|
||||
`${base}#v=2&lat=${cameraLat}&lon=${point.lon}&alt=600&heading=0&pitch=-60&roll=0&map=osm&sl=google,${point.lat},${point.lon}`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(want) => {
|
||||
const c =
|
||||
window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return Math.abs((c.latitude * 180) / Math.PI - want) < 0.0005;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
cameraLat,
|
||||
);
|
||||
const googleKey = await page.evaluate(() =>
|
||||
Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||
);
|
||||
if (!googleKey) {
|
||||
// Without a key the action stops at the key, before any flight,
|
||||
// map switch or source change, and the direct call says why.
|
||||
const direct = await page.evaluate(
|
||||
(at) =>
|
||||
window.__godsEyeView.voiceCommands.runner(
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: at.lat,
|
||||
longitude: at.lon,
|
||||
provider: 'google',
|
||||
fly: false,
|
||||
autoOpen: false,
|
||||
},
|
||||
),
|
||||
point,
|
||||
);
|
||||
assert.equal(direct.ok, false);
|
||||
assert.equal(
|
||||
direct.stage,
|
||||
'key',
|
||||
`${direct.stage} ${direct.error}`,
|
||||
);
|
||||
const after = await page.evaluate(() => {
|
||||
const m =
|
||||
window.__godsEyeView.dataManager.layers.get(
|
||||
'street-level',
|
||||
).module;
|
||||
const u = m.getUIState();
|
||||
return {
|
||||
stack: window.__godsEyeView.mapStackController.getActiveId(),
|
||||
google: m.getParams().google === true,
|
||||
armed: u.street.armed,
|
||||
open: u.street.open,
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
after,
|
||||
{ stack: 'osm', google: false, armed: null, open: false },
|
||||
'a keyless Street View link changes nothing',
|
||||
);
|
||||
const still = await cameraAt(page);
|
||||
assert.ok(
|
||||
Math.abs(still.lat - cameraLat) < 0.0005,
|
||||
`the camera did not fly: ${still.lat}`,
|
||||
);
|
||||
assert.deepEqual(googleRequests, [], 'no Street View request');
|
||||
return;
|
||||
}
|
||||
const photoreal = await page.evaluate(() =>
|
||||
window.__godsEyeView.mapStackController.isStackAvailable(
|
||||
'photoreal',
|
||||
),
|
||||
);
|
||||
if (!photoreal) return skipped('no Google 3D');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) =>
|
||||
u.street.armed !== null &&
|
||||
u.providers.some((p) => p.id === 'google' && p.on),
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const beforePress = googleRequests.length;
|
||||
const waiting = await getUI(page);
|
||||
assert.equal(
|
||||
waiting.street.open,
|
||||
false,
|
||||
'nothing open without a click',
|
||||
);
|
||||
assert.equal(waiting.street.imageId, null);
|
||||
assert.deepEqual(
|
||||
waiting.street.armed.point,
|
||||
{ lat: point.lat, lon: point.lon },
|
||||
'armed at the link point',
|
||||
);
|
||||
assert.equal(
|
||||
beforePress,
|
||||
0,
|
||||
'no Street View request before the press',
|
||||
);
|
||||
await expandPanel(page);
|
||||
await press(page, '#sl-nearest-btn');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||
null,
|
||||
90_000,
|
||||
);
|
||||
const { street } = await getUI(page);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
assert.equal(street.providerId, 'google');
|
||||
assert.ok(
|
||||
googleRequests.length >= 1,
|
||||
'the press made the Street View request',
|
||||
);
|
||||
const metres = Math.hypot(
|
||||
(street.position.lat - point.lat) * 111_320,
|
||||
(street.position.lon - point.lon) *
|
||||
111_320 *
|
||||
Math.cos((point.lat * Math.PI) / 180),
|
||||
);
|
||||
assert.ok(
|
||||
metres <= 250,
|
||||
`within 250 m of the link point: ${Math.round(metres)} m`,
|
||||
);
|
||||
// Back to Mapillary: the keyless page restores the saved layer
|
||||
// and expects its KEY REQUIRED.
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await uiUntil(page, litAlone, 'mapillary');
|
||||
} finally {
|
||||
page.off('request', onRequest);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await fixtureStep(
|
||||
// The fixture bundle carries a token, so this covers a keyless
|
||||
// server behind a keyed bundle; the keyless default is checked above.
|
||||
|
||||
@@ -904,6 +904,7 @@ const SLOW_TOOLS = new Set([
|
||||
'annotate_map',
|
||||
'fly_to_location',
|
||||
'select_nearest_aircraft',
|
||||
'show_street_level',
|
||||
'analyst_query',
|
||||
'fly_route',
|
||||
'frame_overhead',
|
||||
|
||||
@@ -35,6 +35,14 @@ export const CORE_PHRASES = Object.freeze([
|
||||
{ phrase: 'Show me the whole earth', expect: 'zoom_to_globe' },
|
||||
{ phrase: 'Frame the aircraft near us from overhead', expect: 'frame_overhead', typedArgs: [where('frame_overhead', (a) => a.target === 'flights')] },
|
||||
|
||||
// — street-level photos: one composite action, never fly + basemap —
|
||||
{ phrase: 'Show me the Eiffel Tower on Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && /eiffel/i.test(a.query || ''))] },
|
||||
{ phrase: 'Street view of 10th Street in Sacramento', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google')] },
|
||||
{ phrase: 'Show me Times Square on Mapillary', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'mapillary')] },
|
||||
{ phrase: 'Switch to Google Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && !a.query)] },
|
||||
{ phrase: 'Show me the Eiffel Tower', expect: 'fly_to_location' },
|
||||
{ phrase: 'Turn on street level', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'street-level' && a.enabled === true)] },
|
||||
|
||||
// — the satellites trap: data layer, never basemap —
|
||||
{ phrase: 'Show me the satellites', expect: { oneOf: ['set_layer_visibility', 'frame_overhead'] } },
|
||||
{ phrase: 'Turn off the satellites', expect: 'set_layer_visibility', args: { layerId: 'satellites' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'satellites' && a.enabled === false)] },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -38,7 +38,7 @@ function realtimeDirectives(
|
||||
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
|
||||
'Do not mention disabled layers or stale selections.',
|
||||
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, show_street_level, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.',
|
||||
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers, approximate outlines and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
|
||||
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
|
||||
@@ -65,6 +65,7 @@ function realtimeDirectives(
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
|
||||
'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
|
||||
'STREET LEVEL: "X on Street View/Mapillary/KartaView/Panoramax", "street view of X" → show_street_level once, not fly_to_location; no place = view centre. Street View is never a basemap.',
|
||||
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
|
||||
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
|
||||
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
|
||||
|
||||
@@ -65,6 +65,28 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
show_street_level: {
|
||||
description:
|
||||
'Fly to a place and open its nearest street-level photo; Street View switches to Google 3D (billed). No query: view centre.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
query: {
|
||||
description: 'Place or address; "pointer" = under the cursor.',
|
||||
$position: 1,
|
||||
},
|
||||
provider: {
|
||||
description:
|
||||
'google = Street View; others by name; omit: the lit source.',
|
||||
$position: 1,
|
||||
},
|
||||
referent: {
|
||||
description: 'Numbered item n from the last result list.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
adjust_camera_zoom: {
|
||||
description:
|
||||
'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
|
||||
|
||||
@@ -72,6 +72,14 @@ class UpstreamRateLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the routing service failed or sent no JSON; answered as 502. */
|
||||
class UpstreamUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('routing service unavailable');
|
||||
this.name = 'UpstreamUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one outbound request no sooner than the shared gate allows.
|
||||
* @template T
|
||||
@@ -167,18 +175,26 @@ async function fetchRoute({
|
||||
`${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` +
|
||||
`?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`;
|
||||
const controller = new AbortController();
|
||||
// The 12 s budget includes queue wait, so Directions (15 s) hears back.
|
||||
const timer = setTimeout(() => controller.abort(), 12000);
|
||||
let osrm;
|
||||
try {
|
||||
const upstreamRes = await throughUpstreamGate(() =>
|
||||
fetchImpl(upstream, {
|
||||
const upstreamRes = await throughUpstreamGate(async () => {
|
||||
// Expiry before dispatch reflects our backlog, not an upstream outage.
|
||||
if (controller.signal.aborted)
|
||||
throw new RouteBusyError(_upstreamIntervalMs);
|
||||
try {
|
||||
return await fetchImpl(upstream, {
|
||||
signal: controller.signal,
|
||||
// The endpoint is configured above; a redirect is the one way out of
|
||||
// it, so it is refused rather than followed.
|
||||
redirect: 'error',
|
||||
headers: { 'User-Agent': 'gods-eye-view/dev (local)' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
throw new UpstreamUnavailableError();
|
||||
}
|
||||
});
|
||||
if (upstreamRes.status === 429) {
|
||||
await cancelBody(upstreamRes);
|
||||
const retryAfter = Number(upstreamRes.headers.get('retry-after'));
|
||||
@@ -186,20 +202,28 @@ async function fetchRoute({
|
||||
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5,
|
||||
);
|
||||
}
|
||||
if (!upstreamRes.ok) {
|
||||
// OSRM answers 400 for a route it cannot find; other failures are outages.
|
||||
if (upstreamRes.status === 400) {
|
||||
await cancelBody(upstreamRes);
|
||||
return { payload: null, error: 'no route found' };
|
||||
}
|
||||
if (!upstreamRes.ok) {
|
||||
await cancelBody(upstreamRes);
|
||||
throw new UpstreamUnavailableError();
|
||||
}
|
||||
const ctype = upstreamRes.headers.get('content-type') || '';
|
||||
if (!ctype.includes('json')) {
|
||||
await cancelBody(upstreamRes);
|
||||
return { payload: null, error: 'no route found' };
|
||||
throw new UpstreamUnavailableError();
|
||||
}
|
||||
const text = await readResponseTextCapped(
|
||||
upstreamRes,
|
||||
ROUTE_MAX_RESPONSE_BYTES,
|
||||
// Read and parse failures are outages; later ones stay "route proxy error".
|
||||
try {
|
||||
osrm = JSON.parse(
|
||||
await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES),
|
||||
);
|
||||
osrm = JSON.parse(text);
|
||||
} catch {
|
||||
throw new UpstreamUnavailableError();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -355,6 +379,11 @@ export function installRouteMiddleware(
|
||||
upstreamError.retryAfterSec,
|
||||
);
|
||||
}
|
||||
if (upstreamError instanceof UpstreamUnavailableError) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: upstreamError.message }));
|
||||
return;
|
||||
}
|
||||
throw upstreamError;
|
||||
}
|
||||
if (error || !payload) return fail(error || 'no route found');
|
||||
|
||||
@@ -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<Buffer>) => Promise<Buffer>, bytes: () => number}}
|
||||
*/
|
||||
export function createRailPackCache(maxBytes = RAIL_PACK_CACHE_MAX_BYTES) {
|
||||
/** @type {Map<string, {mtimeMs: number, size: number, bytes: Buffer}>} */
|
||||
const entries = new Map();
|
||||
/** @type {Map<string, Promise<Buffer>>} */
|
||||
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 `<dir>/<feedId><suffix>`.
|
||||
* 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<typeof createRailPackCache>|null} [cache]
|
||||
* @returns {(feedId: string, options?: {maxBytes?: number}) =>
|
||||
* Promise<Buffer|null>}
|
||||
*/
|
||||
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 `<dir>/<feedId>.json`.
|
||||
* @param {string} dir
|
||||
* @param {{cache?: ReturnType<typeof createRailPackCache>|null}} [options]
|
||||
*/
|
||||
export function createFsPathPackReader(dir, { cache = null } = {}) {
|
||||
return createFsPackReader(dir, '.json', cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `readSchedulePack` that reads `<dir>/<feedId>.schedule.json`.
|
||||
* @param {string} dir
|
||||
* @param {{cache?: ReturnType<typeof createRailPackCache>|null}} [options]
|
||||
*/
|
||||
export function createFsSchedulePackReader(dir, { cache = null } = {}) {
|
||||
return createFsPackReader(dir, '.schedule.json', cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `hasSchedulePack` for the rail service: whether
|
||||
* `<dir>/<feedId>.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<boolean>}
|
||||
*/
|
||||
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
|
||||
* `<dir>/<feedId><suffix>`, 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<string|null>}
|
||||
*/
|
||||
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 `<dir>/<feedId>.schedule.json`, or null when there is no such
|
||||
* file.
|
||||
* @param {string} dir
|
||||
* @returns {(feedId: string) => Promise<string|null>}
|
||||
*/
|
||||
export function createFsSchedulePackVersion(dir) {
|
||||
return createFsPackVersion(dir, '.schedule.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* A `pathPackVersion` for the rail service: the same for
|
||||
* `<dir>/<feedId>.json`.
|
||||
* @param {string} dir
|
||||
* @returns {(feedId: string) => Promise<string|null>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -100,20 +100,6 @@ export function createScreenAnnotationRenderer(
|
||||
const heightCache = new Map();
|
||||
const HEIGHT_CACHE_SOFT = 600; // target size; over this we evict keys not used this frame
|
||||
const HEIGHT_CACHE_HARD = 8000; // absolute ceiling (one big ring + others) — never exceeded
|
||||
// Accepted surface-height range; samples outside it are treated as misses.
|
||||
const MIN_SURFACE_HEIGHT = -430;
|
||||
const MAX_SURFACE_HEIGHT = 9000;
|
||||
const HEIGHT_SAMPLE_BUDGET = 32; // clampToHeight calls per projection pass
|
||||
const HEIGHT_RETRY_MS = 1000; // minimum interval between misses per anchor
|
||||
let cullingVolume = null;
|
||||
const heightBounds = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 9500);
|
||||
// Fair sampling: keys refused by an exhausted budget queue in arrival order,
|
||||
// and each pass reserves budget for the oldest queued keys first, so a fixed
|
||||
// iteration order cannot starve anchors beyond the first budget's worth.
|
||||
let heightQueue = new Set(); // keys refused in earlier passes, oldest first
|
||||
let heightReserved = new Set(); // queued keys this pass serves first
|
||||
let heightRefused = new Set(); // keys refused during this pass
|
||||
let heightBudget = HEIGHT_SAMPLE_BUDGET; // unreserved slots left this pass
|
||||
let projGen = 0; // bumped each projection frame; entries used this frame are "hot"
|
||||
function trimHeightCache() {
|
||||
if (heightCache.size <= HEIGHT_CACHE_SOFT) return;
|
||||
@@ -141,20 +127,11 @@ export function createScreenAnnotationRenderer(
|
||||
return PALETTE[anno.color] || PALETTE.primary;
|
||||
}
|
||||
|
||||
function clearHeights() {
|
||||
heightCache.clear();
|
||||
heightQueue.clear();
|
||||
}
|
||||
|
||||
function heightKey(lon, lat) {
|
||||
return `${lon.toFixed(5)},${lat.toFixed(5)}`;
|
||||
}
|
||||
|
||||
// Best-effort surface height under a coordinate (clamps onto the 3D tiles).
|
||||
// Returns 0 until the tile loads, then caches the validated height.
|
||||
// canSample=false returns the cached height (or 0) without surface sampling.
|
||||
// `key` and `cached` are the caller's cache lookup for this coordinate.
|
||||
function groundHeight(lon, lat, key, cached, canSample = true) {
|
||||
function groundHeight(lon, lat) {
|
||||
const key = `${lon.toFixed(5)},${lat.toFixed(5)}`;
|
||||
const cached = heightCache.get(key);
|
||||
if (cached && cached.settled) {
|
||||
// Mark hot for this frame + bump to most-recently-used so heights for live
|
||||
// marks survive eviction while places we've navigated away from age out.
|
||||
@@ -163,20 +140,6 @@ export function createScreenAnnotationRenderer(
|
||||
heightCache.set(key, cached);
|
||||
return cached.h;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (cached && (!canSample || now < cached.nextAttempt)) {
|
||||
cached.gen = projGen;
|
||||
return cached.h;
|
||||
}
|
||||
if (!canSample) return 0;
|
||||
if (heightReserved.has(key)) {
|
||||
heightReserved.delete(key);
|
||||
} else if (heightBudget > 0) {
|
||||
heightBudget--;
|
||||
} else {
|
||||
heightRefused.add(key);
|
||||
return cached?.h || 0;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
@@ -187,11 +150,7 @@ export function createScreenAnnotationRenderer(
|
||||
);
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (
|
||||
Number.isFinite(h) &&
|
||||
h > MIN_SURFACE_HEIGHT &&
|
||||
h < MAX_SURFACE_HEIGHT
|
||||
) {
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||
heightCache.set(key, { h, settled: true, gen: projGen });
|
||||
trimHeightCache();
|
||||
return h;
|
||||
@@ -202,12 +161,7 @@ export function createScreenAnnotationRenderer(
|
||||
/* tiles not ready */
|
||||
}
|
||||
const fallback = cached ? cached.h : 0;
|
||||
heightCache.set(key, {
|
||||
h: fallback,
|
||||
settled: false,
|
||||
gen: projGen,
|
||||
nextAttempt: now + HEIGHT_RETRY_MS,
|
||||
});
|
||||
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
|
||||
trimHeightCache();
|
||||
return fallback;
|
||||
}
|
||||
@@ -353,7 +307,7 @@ export function createScreenAnnotationRenderer(
|
||||
} catch {
|
||||
/* the board is unreachable */
|
||||
}
|
||||
if (records.size === 0) clearHeights();
|
||||
if (records.size === 0) heightCache.clear();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -409,32 +363,11 @@ export function createScreenAnnotationRenderer(
|
||||
// Anchor at the real surface height (cached + validated), so marks sit on
|
||||
// the ground/building instead of at sea level. The settle-once cache keeps
|
||||
// it stable (no per-frame jitter that earlier broke nearby projections).
|
||||
// A settled height is tested as the actual point below. An unknown height
|
||||
// is rejected before GPU-backed surface sampling only when the whole
|
||||
// accepted height range is proven hidden by one constraint.
|
||||
const key = heightKey(lon, lat);
|
||||
const cached = heightCache.get(key);
|
||||
let height;
|
||||
if (cached?.settled) {
|
||||
height = groundHeight(lon, lat, key, cached);
|
||||
} else {
|
||||
if (heightRangeHidden(lon, lat)) return null;
|
||||
// The frustum restricts sampling only; an endpoint outside it still
|
||||
// projects (at its cached or zero height) so a partly visible arrow,
|
||||
// route or ring keeps its on-screen connector and label.
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
const world = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
0,
|
||||
undefined,
|
||||
heightBounds.center,
|
||||
groundHeight(lon, lat),
|
||||
);
|
||||
const inFrustum =
|
||||
cullingVolume?.computeVisibility(heightBounds) !==
|
||||
Cesium.Intersect.OUTSIDE;
|
||||
height = groundHeight(lon, lat, key, cached, inFrustum);
|
||||
}
|
||||
const world = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
||||
@@ -458,21 +391,6 @@ export function createScreenAnnotationRenderer(
|
||||
return { x: win.x, y: win.y };
|
||||
}
|
||||
|
||||
// Points along one surface normal are linear in height, and each rejection
|
||||
// region (behind the camera plane; the occluder's cone beyond the horizon
|
||||
// plane) is convex. So when both height-range endpoints fail the SAME test,
|
||||
// every intermediate height fails it too. Mixed failures prove nothing.
|
||||
function heightRangeHidden(lon, lat) {
|
||||
const low = Cesium.Cartesian3.fromDegrees(lon, lat, MIN_SURFACE_HEIGHT);
|
||||
const high = Cesium.Cartesian3.fromDegrees(lon, lat, MAX_SURFACE_HEIGHT);
|
||||
const behind = (point) => {
|
||||
Cesium.Cartesian3.subtract(point, scene.camera.positionWC, scratchDir);
|
||||
return Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0;
|
||||
};
|
||||
if (behind(low) && behind(high)) return true;
|
||||
return !occluder.isPointVisible(low) && !occluder.isPointVisible(high);
|
||||
}
|
||||
|
||||
// Tracked-entity z-order: the tracked aircraft is a Cesium billboard in the
|
||||
// CANVAS, which sits BELOW this SVG overlay (z-90, HTML over canvas). So a screen mark
|
||||
// could hide it. After layout, any mark whose ACTUAL projected bounding box (rings,
|
||||
@@ -557,20 +475,6 @@ export function createScreenAnnotationRenderer(
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
// Serve the oldest refused keys first; the rest of the budget is open.
|
||||
heightReserved = new Set();
|
||||
for (const key of heightQueue) {
|
||||
if (heightReserved.size >= HEIGHT_SAMPLE_BUDGET) break;
|
||||
heightReserved.add(key);
|
||||
}
|
||||
heightBudget = HEIGHT_SAMPLE_BUDGET - heightReserved.size;
|
||||
heightRefused = new Set();
|
||||
cullingVolume =
|
||||
scene.camera.frustum?.computeCullingVolume?.(
|
||||
scene.camera.positionWC,
|
||||
scene.camera.directionWC,
|
||||
scene.camera.upWC,
|
||||
) || null;
|
||||
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||
const trackedRect = trackedEntityRect();
|
||||
occluder.cameraPosition = scene.camera.positionWC;
|
||||
@@ -728,13 +632,6 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Still-refused queued keys keep their place; new refusals join the back.
|
||||
// Keys no longer requesting a sample (settled, culled, removed) drop out.
|
||||
const nextQueue = new Set();
|
||||
for (const key of heightQueue)
|
||||
if (heightRefused.has(key)) nextQueue.add(key);
|
||||
for (const key of heightRefused) nextQueue.add(key);
|
||||
heightQueue = nextQueue;
|
||||
|
||||
// Keep clustered callouts readable.
|
||||
const placed = [];
|
||||
@@ -914,7 +811,7 @@ export function createScreenAnnotationRenderer(
|
||||
records.delete(anno.id);
|
||||
publish();
|
||||
// Board emptied → drop accumulated height samples (natural reset point).
|
||||
if (records.size === 0) clearHeights();
|
||||
if (records.size === 0) heightCache.clear();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
@@ -937,7 +834,7 @@ export function createScreenAnnotationRenderer(
|
||||
records.clear();
|
||||
leaving.clear();
|
||||
byKey.clear();
|
||||
clearHeights();
|
||||
heightCache.clear();
|
||||
}
|
||||
|
||||
// Claim the board; from here on only this renderer's own board changes.
|
||||
|
||||
@@ -166,17 +166,10 @@ test('an arrow defines its arrowhead and drops the dash once drawn on', async (t
|
||||
test('outline upgrade preserves the existing SVG group identity', async (t) => {
|
||||
const { store, svg, groups } = board(t);
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.5, 31.2, 1000000),
|
||||
directionWC: Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(
|
||||
Cesium.Cartesian3.fromDegrees(-97.5, 31.2),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
positionWC: Cesium.Cartesian3.ZERO,
|
||||
directionWC: Cesium.Cartesian3.ZERO,
|
||||
positionCartographic: { height: 1000 },
|
||||
};
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, y: 100 }));
|
||||
const sampledAnchors = [];
|
||||
const scene = sceneFor(camera, {
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
@@ -582,280 +575,3 @@ test('a mark hidden from Draw is not drawn or placed, and comes back when shown'
|
||||
await settle();
|
||||
assert.equal(drawn(), true, 'shown again, it is drawn');
|
||||
});
|
||||
|
||||
test('hidden and back-facing annotations do not sample; visible unresolved heights have bounded retries', (t) => {
|
||||
const store = samplingBoard(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 });
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({
|
||||
x: 100,
|
||||
y: 100,
|
||||
}));
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100000),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
positionCartographic: { height: 100000 },
|
||||
moveEnd: new Cesium.Event(),
|
||||
};
|
||||
let samples = 0;
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 600 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight() {
|
||||
samples++;
|
||||
},
|
||||
requestRender() {},
|
||||
};
|
||||
const renderer = samplingRenderer(store, scene);
|
||||
const mark = (id, lon) => ({
|
||||
id,
|
||||
type: 'label',
|
||||
anchor: { lon, lat: 0 },
|
||||
label: id,
|
||||
});
|
||||
renderer.add(mark('far-side', 180));
|
||||
for (let i = 0; i < 100; i++) postRender.raiseEvent();
|
||||
assert.equal(samples, 0);
|
||||
renderer.add(mark('near-side', 0));
|
||||
for (let i = 0; i < 100; i++) postRender.raiseEvent();
|
||||
assert.equal(samples, 1);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
}
|
||||
assert.equal(samples, 11);
|
||||
camera.moveEnd.raiseEvent();
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'camera events cannot bypass the retry interval');
|
||||
renderer.update(mark('near-side', 0), { empty: true });
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'hidden records do not sample');
|
||||
camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
|
||||
renderer.update(mark('near-side', 0));
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'behind-camera anchors do not sample');
|
||||
camera.directionWC = new Cesium.Cartesian3(-1, 0, 0);
|
||||
camera.frustum = {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.OUTSIDE,
|
||||
}),
|
||||
};
|
||||
postRender.raiseEvent();
|
||||
assert.equal(samples, 11, 'outside-frustum anchors do not sample');
|
||||
camera.frustum = null;
|
||||
const before = samples;
|
||||
renderer.add({
|
||||
id: 'large-area',
|
||||
type: 'area',
|
||||
label: 'Area',
|
||||
ring: Array.from({ length: 100 }, (_, i) => [0.001 + i / 10000, 0]),
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
});
|
||||
assert.equal(
|
||||
samples - before,
|
||||
32,
|
||||
'a projection pass caps synchronous surface work',
|
||||
);
|
||||
renderer.destroy();
|
||||
assert.equal(camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(postRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
function sampledLon(world) {
|
||||
const carto = Cesium.Cartographic.fromCartesian(world);
|
||||
return Number(Cesium.Math.toDegrees(carto.longitude).toFixed(5));
|
||||
}
|
||||
|
||||
/**
|
||||
* A board with no view mounted, for tests that count surface samples: the
|
||||
* renderer projects on `add` and on each post-render event, and the store
|
||||
* holds what it would draw. Animation frames run at once.
|
||||
*/
|
||||
function samplingBoard(t) {
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (fn) => {
|
||||
fn();
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
});
|
||||
return useScreenAnnotationsStore(createPinia());
|
||||
}
|
||||
|
||||
/** The renderer under test, on the given board and scene. */
|
||||
function samplingRenderer(store, scene) {
|
||||
return createScreenAnnotationRenderer(
|
||||
{ scene, camera: scene.camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
}
|
||||
|
||||
function squareFrustum() {
|
||||
const frustum = new Cesium.PerspectiveFrustum();
|
||||
frustum.fov = Cesium.Math.toRadians(60);
|
||||
frustum.aspectRatio = 1;
|
||||
frustum.near = 1;
|
||||
frustum.far = 1e8;
|
||||
return frustum;
|
||||
}
|
||||
|
||||
test('an arrow with one endpoint outside a real frustum keeps its visible connector', (t) => {
|
||||
const store = samplingBoard(t);
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', (_scene, world) =>
|
||||
sampledLon(world) > 0.1 ? { x: 1100, y: 400 } : { x: 400, y: 400 },
|
||||
);
|
||||
// 5 km straight down over (0, 0): east is +Y, north is +Z in ECEF here.
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 5000),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
positionCartographic: { height: 5000 },
|
||||
frustum: squareFrustum(),
|
||||
};
|
||||
const sampled = [];
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 800 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
sampled.push(sampledLon(world));
|
||||
return world;
|
||||
},
|
||||
};
|
||||
// The 0.2-degree endpoint (~22 km east) is well beyond the side plane plus
|
||||
// the 9.5 km height-bound radius, yet in front of the camera and the horizon.
|
||||
const far = Cesium.Cartesian3.fromDegrees(0.2, 0, 0);
|
||||
const volume = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
assert.equal(
|
||||
volume.computeVisibility(new Cesium.BoundingSphere(far, 9500)),
|
||||
Cesium.Intersect.OUTSIDE,
|
||||
);
|
||||
const renderer = samplingRenderer(store, scene);
|
||||
renderer.add({
|
||||
id: 'partial-arrow',
|
||||
type: 'arrow',
|
||||
label: 'Ridge',
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
to: { lon: 0.2, lat: 0 },
|
||||
});
|
||||
postRender.raiseEvent();
|
||||
const [mark] = store.board.marks;
|
||||
assert.equal(mark.display, '', 'the arrow stays drawn');
|
||||
const path = mark.children.find((child) => child.attrs.class?.includes('gev-anno-arrow'));
|
||||
assert.match(path.attrs.d, /^M 400\.0 400\.0 Q .* 1100\.0 400\.0$/);
|
||||
assert.deepEqual([...new Set(sampled)], [0], 'only the in-frustum endpoint samples');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a low-angle cached roof stays visible when range endpoints fail different tests', (t) => {
|
||||
const store = samplingBoard(t);
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 380 }));
|
||||
// Camera 1 m above the ellipsoid, heading east, pitched 25 degrees down.
|
||||
const pitch = Cesium.Math.toRadians(25);
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1),
|
||||
directionWC: new Cesium.Cartesian3(-Math.sin(pitch), Math.cos(pitch), 0),
|
||||
upWC: new Cesium.Cartesian3(Math.cos(pitch), Math.sin(pitch), 0),
|
||||
positionCartographic: { height: 1 },
|
||||
frustum: squareFrustum(),
|
||||
};
|
||||
const roofLon = 4000 / 111319.49;
|
||||
let samples = 0;
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 800 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight() {
|
||||
samples++;
|
||||
return Cesium.Cartesian3.fromDegrees(roofLon, 0, 20);
|
||||
},
|
||||
};
|
||||
// Sanity: height 0 is beyond the horizon and 9000 m is behind the camera,
|
||||
// while the 20 m roof itself is in front and above the horizon.
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.positionWC);
|
||||
const ahead = (h) => {
|
||||
const p = Cesium.Cartesian3.fromDegrees(roofLon, 0, h);
|
||||
const v = Cesium.Cartesian3.subtract(p, camera.positionWC, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.dot(v, camera.directionWC) > 0;
|
||||
};
|
||||
assert.equal(occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(roofLon, 0, 0)), false);
|
||||
assert.equal(ahead(9000), false);
|
||||
assert.equal(ahead(20), true);
|
||||
assert.equal(occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(roofLon, 0, 20)), true);
|
||||
|
||||
const renderer = samplingRenderer(store, scene);
|
||||
renderer.add({ id: 'roof', type: 'label', label: 'Roof', anchor: { lon: roofLon, lat: 0 } });
|
||||
assert.equal(samples, 1, 'the unknown height is sampled, not culled');
|
||||
assert.equal(store.board.marks[0].display, '');
|
||||
for (let i = 0; i < 5; i++) postRender.raiseEvent();
|
||||
assert.equal(samples, 1, 'the settled height is reused');
|
||||
assert.equal(store.board.marks[0].display, '', 'the cached roof point is tested and shown');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('height retries beyond the first budget are served under sparse passes', (t) => {
|
||||
const store = samplingBoard(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1000 });
|
||||
t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 100, y: 100 }));
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100000),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
positionCartographic: { height: 100000 },
|
||||
};
|
||||
const lons = Array.from({ length: 33 }, (_, i) => Number(((i + 1) / 1000).toFixed(3)));
|
||||
const last = lons.at(-1);
|
||||
let tilesReady = false;
|
||||
let pass = [];
|
||||
const postRender = new Cesium.Event();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { width: 800, height: 600 },
|
||||
postRender,
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
const lon = sampledLon(world);
|
||||
pass.push(lon);
|
||||
return tilesReady && lon === last ? Cesium.Cartesian3.fromDegrees(lon, 0, 12) : undefined;
|
||||
},
|
||||
};
|
||||
const renderer = samplingRenderer(store, scene);
|
||||
for (const lon of lons) {
|
||||
renderer.add({ id: `p${lon}`, type: 'label', label: 'P', anchor: { lon, lat: 0 } });
|
||||
}
|
||||
assert.ok(pass.includes(last), 'every point gets its initial miss');
|
||||
tilesReady = true; // tiles under the final point arrive after its miss
|
||||
const lastSamples = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
t.mock.timers.tick(1000);
|
||||
pass = [];
|
||||
postRender.raiseEvent();
|
||||
assert.ok(pass.length <= 32, `pass ${i} keeps the 32-sample budget`);
|
||||
lastSamples.push(pass.filter((lon) => lon === last).length);
|
||||
}
|
||||
assert.ok(lastSamples.slice(0, 2).includes(1), 'the 33rd point is served within two passes');
|
||||
assert.equal(lastSamples.at(-1), 0, 'and then stays settled');
|
||||
// Points refused in one pass are served first in the next: every point is
|
||||
// retried within any two consecutive sparse passes.
|
||||
pass = [];
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
const firstPass = pass;
|
||||
pass = [];
|
||||
t.mock.timers.tick(1000);
|
||||
postRender.raiseEvent();
|
||||
const served = new Set([...firstPass, ...pass]);
|
||||
for (const lon of lons.slice(0, -1)) assert.ok(served.has(lon), `${lon} retried`);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { NEWS_LAYER_ID } from '../layers/news/model.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
@@ -18,6 +19,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';
|
||||
@@ -68,6 +70,7 @@ export function createApplicationCatalog({
|
||||
signal,
|
||||
metadata = APPLICATION_LAYER_METADATA,
|
||||
vesselOptions,
|
||||
newsOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
streetLevelProviders,
|
||||
@@ -142,7 +145,13 @@ export function createApplicationCatalog({
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
createApplicationNews({ source: sources.news }),
|
||||
// Ages, the window and the counts read the world clock: a host that
|
||||
// follows it for the layer shows the news at the time on the timeline.
|
||||
createApplicationNews({
|
||||
now: worldClock.timeFor(NEWS_LAYER_ID),
|
||||
...newsOptions,
|
||||
source: sources.news,
|
||||
}),
|
||||
createApplicationWildlifeSightings({
|
||||
source: sources['wildlife-sightings'],
|
||||
groundFloor: surface.groundFloor,
|
||||
@@ -191,6 +200,7 @@ export function createApplicationCatalog({
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationRail({ surface, source: sources.rail }),
|
||||
createApplicationFirms({
|
||||
surface,
|
||||
id: 'local-firms',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+263
-15
@@ -12,13 +12,25 @@
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
import {
|
||||
announceNavigationAuthority,
|
||||
watchCameraOwner,
|
||||
} from '../navigationPolicy.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js';
|
||||
import {
|
||||
annotationsFromParams,
|
||||
createView,
|
||||
followFromParams,
|
||||
groundedLookAt,
|
||||
streetLevelFromParams,
|
||||
} from '../view/index.js';
|
||||
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const STREET_LEVEL_LAYER = 'street-level';
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
military_aircraft: 'military',
|
||||
@@ -27,6 +39,8 @@ const FOLLOW_LAYERS = {
|
||||
const FOLLOW_ATTEMPTS = 20;
|
||||
const FOLLOW_RETRY_MS = 1000;
|
||||
const FLIGHT_SECONDS = 2;
|
||||
/** How long a posted view waits for the terrain under its camera. */
|
||||
const TERRAIN_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Whether this page shows the app embedded: `?embed=1` when another page
|
||||
@@ -45,6 +59,14 @@ export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the panel page started the app for a tool result issued just now,
|
||||
* not one its host handed back on mounting the panel again.
|
||||
*/
|
||||
function isFreshPanelResult() {
|
||||
return isEmbeddedInline() && globalThis.GEV_EMBED_FRESH === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
|
||||
* may report it hidden while it is on screen (see keepPanelRendering), so an
|
||||
@@ -165,6 +187,40 @@ function flyTo(viewer, camera) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `show_street_level` arguments for a view's street-level photos. A plain
|
||||
* link (`autoOpen: false`) only arms a source that bills every load; a view
|
||||
* posted into the embedded app is the client's own request and opens.
|
||||
*/
|
||||
function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
|
||||
return {
|
||||
latitude: streetLevel.lat,
|
||||
longitude: streetLevel.lon,
|
||||
...(streetLevel.provider ? { provider: streetLevel.provider } : {}),
|
||||
fly: true,
|
||||
...(autoOpen ? {} : { autoOpen: false }),
|
||||
};
|
||||
}
|
||||
|
||||
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
|
||||
/** Dispatched on the window when a link opened after start-up has restored. */
|
||||
const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled';
|
||||
|
||||
/** The armed link's toast, naming the OPEN button as the panel shows it. */
|
||||
function armedToast() {
|
||||
return t('Street View is ready: press {open} to load it', {
|
||||
open: t('OPEN {labels}', { labels: translateKnown(GOOGLE_LABEL) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Why a link's photo stays shut: its source has no working key here. */
|
||||
function keyToast(result) {
|
||||
return t(
|
||||
"{source} cannot open this link's street-level photo without a working key",
|
||||
{ source: translateKnown(result.providerName || 'Street Level') },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app show a view: style and map, exactly the view's layers,
|
||||
* annotations, the camera, and the followed entity. Each step runs through the
|
||||
@@ -173,13 +229,20 @@ function flyTo(viewer, camera) {
|
||||
*/
|
||||
export async function applyView(
|
||||
view,
|
||||
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
|
||||
{
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen = true,
|
||||
retryMs = FOLLOW_RETRY_MS,
|
||||
},
|
||||
) {
|
||||
const steps = [];
|
||||
const act = async (name, args) => {
|
||||
const act = async (name, args, options) => {
|
||||
let result = null;
|
||||
try {
|
||||
result = await run(name, args);
|
||||
result = await run(name, args, options);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error?.message || String(error) };
|
||||
}
|
||||
@@ -198,6 +261,8 @@ export async function applyView(
|
||||
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
|
||||
}
|
||||
for (const layerId of view.layers) {
|
||||
// The street-level action lights its source first, then the layer.
|
||||
if (view.street_level && layerId === STREET_LEVEL_LAYER) continue;
|
||||
if (!dataManager.isEnabled(layerId))
|
||||
await act('set_layer_visibility', { layerId, enabled: true });
|
||||
}
|
||||
@@ -207,6 +272,29 @@ export async function applyView(
|
||||
await act('clear_annotations', {});
|
||||
if (view.annotations.length)
|
||||
await act('annotate_map', { annotations: [...view.annotations] });
|
||||
if (view.street_level) {
|
||||
// The photos frame the camera; when the action fails before flying, the
|
||||
// view's own camera still applies.
|
||||
// A posted view replays someone else's view, as a link does: the source
|
||||
// it lights is not saved as the visitor's own choice.
|
||||
const result = await act(
|
||||
'show_street_level',
|
||||
streetLevelArgs(view.street_level, { autoOpen }),
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
if (result?.ok !== true) {
|
||||
// Overtaken (a click on the map, a newer request): hands off.
|
||||
if (result?.cancelled || result?.superseded) return steps;
|
||||
// An earlier view's photo must not stay over a place this view left.
|
||||
// From the imagery stage on the action has settled the panel itself
|
||||
// (a photo it could not load keeps its error).
|
||||
if (result?.stage !== 'imagery')
|
||||
dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.();
|
||||
if (!result?.location)
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
if (!view.follow) {
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
return steps;
|
||||
@@ -250,18 +338,115 @@ export function installViews({
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
terrain = null,
|
||||
location = globalThis.location,
|
||||
windowRef = globalThis.window,
|
||||
}) {
|
||||
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
|
||||
const linked = annotationsFromParams(
|
||||
const hashParams = new URLSearchParams(
|
||||
String(location?.hash || '').replace(/^#/, ''),
|
||||
);
|
||||
// The link's own steps run first, one at a time, and posted views queue
|
||||
// behind them; one failing step does not take the next with it. A passive
|
||||
// link never flies over someone who took the camera: its steps are skipped
|
||||
// when a newer link superseded it, when its flight never landed (the
|
||||
// recipient dragged or searched), or after any navigation since the
|
||||
// generation its restore flight owned.
|
||||
const cameraLanded = (outcome) => outcome?.share?.camera === 'applied';
|
||||
const restoreGeneration = (outcome) =>
|
||||
Number.isInteger(outcome?.navigationToken)
|
||||
? outcome.navigationToken
|
||||
: shell._navigationGeneration;
|
||||
let generationAtRestore = null;
|
||||
let startSuperseded = false;
|
||||
let queue = ready.then((outcome) => {
|
||||
generationAtRestore = restoreGeneration(outcome);
|
||||
startSuperseded =
|
||||
outcome?.status === 'superseded' ||
|
||||
(Boolean(outcome?.share) && !cameraLanded(outcome));
|
||||
});
|
||||
const navigatedSince = (generation) =>
|
||||
Number.isFinite(generation) && shell._navigationGeneration !== generation;
|
||||
const linkStep = (work) =>
|
||||
queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
await work();
|
||||
} catch {
|
||||
/* reported by the action itself */
|
||||
}
|
||||
});
|
||||
const linked = annotationsFromParams(hashParams);
|
||||
if (linked.length)
|
||||
queue = linkStep(() => run('annotate_map', { annotations: linked }));
|
||||
// A link's street-level photos open once it has restored. A billed source
|
||||
// only arms, except for the panel page's own link to a result issued just
|
||||
// now. A link that follows an entity keeps the camera on it.
|
||||
const linkedStreetLevel = (params) =>
|
||||
followFromParams(params) ? null : streetLevelFromParams(params);
|
||||
const openLinkedStreetLevel = async (
|
||||
streetLevel,
|
||||
{ generation, autoOpen = false },
|
||||
) => {
|
||||
if (navigatedSince(generation)) return;
|
||||
// The link replays the sender's view: nothing it switches becomes the
|
||||
// recipient's own preference.
|
||||
const result = await run(
|
||||
'show_street_level',
|
||||
streetLevelArgs(streetLevel, { autoOpen }),
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
// A source without a working key changes nothing: say why, or the link
|
||||
// looks broken. In a frame the host reads the result instead.
|
||||
if (result?.stage === 'key' && !isEmbedded(location)) {
|
||||
shell._showToast?.(keyToast(result));
|
||||
return;
|
||||
}
|
||||
// OPEN must be in view, but embed mode hides the rail that holds it: in
|
||||
// a frame a posted view is the way to open a photo.
|
||||
if (result?.armed === true && !isEmbedded(location)) {
|
||||
// Opened for this link only: the recipient's saved layout stays theirs.
|
||||
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
|
||||
explicit: true,
|
||||
persist: false,
|
||||
});
|
||||
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
|
||||
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
|
||||
shell._showToast?.(armedToast());
|
||||
}
|
||||
};
|
||||
const startStreetLevel = linkedStreetLevel(hashParams);
|
||||
if (startStreetLevel)
|
||||
queue = linkStep(() =>
|
||||
startSuperseded
|
||||
? undefined
|
||||
: openLinkedStreetLevel(startStreetLevel, {
|
||||
generation: generationAtRestore,
|
||||
autoOpen: isFreshPanelResult(),
|
||||
}),
|
||||
);
|
||||
// A link pasted into the open tab: its photos follow once its restore
|
||||
// settles, under the same rules.
|
||||
const onNavigatedRestore = (event) => {
|
||||
if (event?.detail?.status !== 'settled' || !cameraLanded(event.detail))
|
||||
return;
|
||||
const streetLevel = linkedStreetLevel(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
);
|
||||
if (linked.length)
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
|
||||
});
|
||||
if (!isEmbedded(location)) return () => {};
|
||||
if (!streetLevel) return;
|
||||
const generation = restoreGeneration(event.detail);
|
||||
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
|
||||
};
|
||||
windowRef?.addEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
const stopNavigatedRestores = () =>
|
||||
windowRef?.removeEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
if (!isEmbedded(location)) return stopNavigatedRestores;
|
||||
|
||||
windowRef.document.body.classList.add('ui-embed');
|
||||
shell.setCleanView?.(true);
|
||||
@@ -278,7 +463,6 @@ export function installViews({
|
||||
peer.postMessage(message, origin);
|
||||
};
|
||||
// Views apply one at a time, in the order they arrive.
|
||||
let queue = ready;
|
||||
const onMessage = (event) => {
|
||||
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
|
||||
return;
|
||||
@@ -302,9 +486,60 @@ export function installViews({
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue = queue.then(async () => {
|
||||
// A panel's host can hand back old results on a remount: only one issued
|
||||
// just now may open a billed photo. A framing page's view is its own.
|
||||
const autoOpen = !isEmbeddedInline() || event.data.fresh === true;
|
||||
// Photos asked for without a point open where the camera looks, measured
|
||||
// from the ground under it, as show_in_gods_eye_view places them.
|
||||
const asked = event.data.view?.street_level;
|
||||
const unplaced =
|
||||
asked &&
|
||||
typeof asked === 'object' &&
|
||||
asked.lat == null &&
|
||||
asked.lon == null;
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
||||
if (unplaced && view.street_level) {
|
||||
// A terrain service that does not answer soon leaves the view's
|
||||
// own estimate.
|
||||
const wait = AbortSignal.timeout(TERRAIN_WAIT_MS);
|
||||
const owner = watchCameraOwner(viewer, shell);
|
||||
const point = await groundedLookAt(
|
||||
view.camera,
|
||||
terrain,
|
||||
signal ? AbortSignal.any([signal, wait]) : wait,
|
||||
)
|
||||
.catch(() => null)
|
||||
.finally(() => owner.stop());
|
||||
if (signal?.aborted) return;
|
||||
// Whoever takes the camera while the terrain answers (a drag, a
|
||||
// search, the assistant) keeps it: the view stands down.
|
||||
if (owner.taken()) {
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
steps: [{ step: 'street_level', ok: false, superseded: true }],
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (point)
|
||||
view = createView({
|
||||
...event.data.view,
|
||||
street_level: { ...asked, ...point },
|
||||
});
|
||||
}
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen,
|
||||
});
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
@@ -314,7 +549,19 @@ export function installViews({
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
});
|
||||
})
|
||||
// A view that fails is answered, and the views behind it still apply.
|
||||
.catch((error) =>
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
error: error?.message || String(error),
|
||||
},
|
||||
replyOrigin,
|
||||
),
|
||||
);
|
||||
};
|
||||
windowRef.addEventListener('message', onMessage);
|
||||
void ready.then(() => {
|
||||
@@ -322,6 +569,7 @@ export function installViews({
|
||||
});
|
||||
return () => {
|
||||
stopRendering();
|
||||
stopNavigatedRestores();
|
||||
windowRef.removeEventListener('message', onMessage);
|
||||
};
|
||||
}
|
||||
+901
-3
@@ -8,7 +8,7 @@ import {
|
||||
panelAwareVisibility,
|
||||
} from './embed.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { createView } from '../view/index.js';
|
||||
import { createView, viewToParams } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
|
||||
});
|
||||
const recorder = (answers = {}) => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
const options = [];
|
||||
const run = async (name, args, runOptions) => {
|
||||
calls.push([name, args]);
|
||||
options.push([name, runOptions]);
|
||||
const answer = answers[name];
|
||||
return typeof answer === 'function'
|
||||
? answer(calls)
|
||||
: (answer ?? { ok: true });
|
||||
};
|
||||
return { calls, run };
|
||||
return { calls, run, options };
|
||||
};
|
||||
|
||||
test('embed mode is opt-in by query parameter', () => {
|
||||
@@ -83,6 +85,19 @@ test('a view is applied through the app actions in order', async () => {
|
||||
assert.ok(steps.some((step) => step.step === 'camera'));
|
||||
});
|
||||
|
||||
test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => {
|
||||
const { options, run } = recorder();
|
||||
await applyView(
|
||||
createView({
|
||||
camera: { lat: 48.86, lon: 2.29 },
|
||||
street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 },
|
||||
}),
|
||||
{ viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run },
|
||||
);
|
||||
const [, runOptions] = options.find(([name]) => name === 'show_street_level');
|
||||
assert.equal(runOptions?.origin, 'programmatic');
|
||||
});
|
||||
|
||||
test('a followed entity is retried until its layer has it', async () => {
|
||||
let tries = 0;
|
||||
const { run } = recorder({
|
||||
@@ -102,6 +117,65 @@ test('a followed entity is retried until its layer has it', async () => {
|
||||
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
|
||||
});
|
||||
|
||||
test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => {
|
||||
const parent = { postMessage() {} };
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = parent;
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} };
|
||||
const { calls, run } = recorder();
|
||||
const asked = [];
|
||||
// Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m.
|
||||
const terrain = {
|
||||
getHeights: async (points) => {
|
||||
asked.push(points);
|
||||
return points.map(() => ({ ellipsoid: 1590 }));
|
||||
},
|
||||
};
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
terrain,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: parent,
|
||||
origin: 'https://host.example',
|
||||
data: {
|
||||
type: 'gev:view',
|
||||
id: 1,
|
||||
view: {
|
||||
camera: {
|
||||
lat: 39.74,
|
||||
lon: -104.99,
|
||||
altitude_m: 1909,
|
||||
pitch_deg: -30,
|
||||
heading_deg: 0,
|
||||
},
|
||||
street_level: { provider: 'mapillary' },
|
||||
},
|
||||
},
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
remove();
|
||||
const [, args] = calls.find(([name]) => name === 'show_street_level');
|
||||
// 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the
|
||||
// ellipsoid altitude would give.
|
||||
assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]);
|
||||
assert.ok(
|
||||
args.latitude > 39.744 && args.latitude < 39.746,
|
||||
String(args.latitude),
|
||||
);
|
||||
assert.equal(args.longitude, -104.99);
|
||||
});
|
||||
|
||||
test('an embedded page takes views only from its parent and answers it', async () => {
|
||||
const posted = [];
|
||||
const targets = [];
|
||||
@@ -186,6 +260,59 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
remove();
|
||||
});
|
||||
|
||||
test('an inline panel opens a billed photo only for a view whose result is fresh', async () => {
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = windowRef;
|
||||
windowRef.postMessage = () => {};
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
Object.assign(windowRef, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const { calls, run } = recorder();
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
const remove = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
const view = {
|
||||
camera: { lat: 48.8584, lon: 2.2945 },
|
||||
map: 'photoreal',
|
||||
street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 },
|
||||
};
|
||||
for (const [id, fresh] of [
|
||||
[1, true],
|
||||
[2, false],
|
||||
[3, undefined],
|
||||
]) {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: windowRef,
|
||||
data: { type: 'gev:view', id, view, fresh },
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.deepEqual(
|
||||
calls
|
||||
.filter(([name]) => name === 'show_street_level')
|
||||
.map(([, args]) => args.autoOpen ?? true),
|
||||
[true, false, false],
|
||||
);
|
||||
remove();
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
}
|
||||
});
|
||||
|
||||
test('an app loaded inline into a panel page talks through its own window', async () => {
|
||||
const posted = [];
|
||||
const windowRef = new EventTarget();
|
||||
@@ -393,3 +520,774 @@ test('a followed aircraft owns the camera, and cockpit view is entered after it'
|
||||
/no cockpit view/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a view with street-level photos lets the action fly and open them', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder();
|
||||
const view = createView({
|
||||
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.map(([name]) => name),
|
||||
[
|
||||
'set_map_stack',
|
||||
'stop_tracking',
|
||||
'clear_annotations',
|
||||
'show_street_level',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(calls.at(-1)[1], {
|
||||
latitude: 38.5799,
|
||||
longitude: -121.4937,
|
||||
provider: 'google',
|
||||
fly: true,
|
||||
});
|
||||
assert.equal(viewer.flights.length, 0, 'the action frames the camera');
|
||||
assert.ok(steps.every((step) => step.ok));
|
||||
assert.equal(steps.at(-1).step, 'show_street_level');
|
||||
});
|
||||
|
||||
test('a link to street-level photos opens them after it restores', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const toasts = [];
|
||||
const panels = [];
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (...args) => panels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: 38.58,
|
||||
longitude: -121.49,
|
||||
provider: 'mapillary',
|
||||
fly: true,
|
||||
autoOpen: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[toasts, panels],
|
||||
[[], []],
|
||||
'an opened photo needs no toast or panel nudge',
|
||||
);
|
||||
// A failure (no key) is not an armed state either: it says why instead.
|
||||
const failed = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (...args) => panels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: failed.run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
[toasts, panels],
|
||||
[
|
||||
[
|
||||
"Street Level cannot open this link's street-level photo without a working key",
|
||||
],
|
||||
[],
|
||||
],
|
||||
'it says why, and nothing to press opens',
|
||||
);
|
||||
});
|
||||
|
||||
test('a link that follows an entity keeps the camera: its photos are not opened', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const params = viewToParams(
|
||||
createView({
|
||||
camera: { lat: 1, lon: 2 },
|
||||
follow: { kind: 'satellite', id: '25544' },
|
||||
}),
|
||||
);
|
||||
params.set('sl', 'mapillary,1.00000,2.00000');
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: `#${params}` },
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a failing link step does not take the next one with it', async () => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
calls.push(name);
|
||||
if (name === 'annotate_map') throw new Error('marks failed');
|
||||
return { ok: true, armed: false, ...args };
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['annotate_map', 'show_street_level']);
|
||||
});
|
||||
|
||||
test('a link whose source has no working key says why instead of doing nothing; a frame stays quiet', async () => {
|
||||
for (const embed of [false, true]) {
|
||||
const toasts = [];
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
provider: 'google',
|
||||
providerName: 'Google Street View',
|
||||
error: 'Google Street View needs its key',
|
||||
}),
|
||||
});
|
||||
const windowRef = new EventTarget();
|
||||
if (embed) {
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
windowRef.parent = { postMessage() {} };
|
||||
}
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed() {
|
||||
throw new Error('nothing to arm');
|
||||
},
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: embed ? '?embed=1' : '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls[0][0], 'show_street_level');
|
||||
assert.deepEqual(
|
||||
toasts,
|
||||
embed
|
||||
? []
|
||||
: [
|
||||
"Google Street View cannot open this link's street-level photo without a working key",
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => {
|
||||
const toasts = [];
|
||||
const panels = [];
|
||||
const revealed = [];
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({ ok: true, armed: true }),
|
||||
});
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_showToast: (text) => toasts.push(text),
|
||||
setPanelCollapsed: (id, collapsed, options) =>
|
||||
panels.push([id, collapsed, options]),
|
||||
revealPanel: (id) => revealed.push(id),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls[0][1].autoOpen, false);
|
||||
assert.deepEqual(toasts, [
|
||||
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
panels,
|
||||
[['street-level-panel', false, { explicit: true, persist: false }]],
|
||||
"the panel opens so OPEN is in view, without saving the layout as the recipient's",
|
||||
);
|
||||
assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too');
|
||||
// Adding ?embed=1 to a published link does not lift the gate: the page is
|
||||
// still a plain top-level load.
|
||||
const framedToasts = [];
|
||||
const framedPanels = [];
|
||||
const framed = recorder({
|
||||
show_street_level: () => ({ ok: true, armed: true }),
|
||||
});
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
windowRef.parent = { postMessage() {} };
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
_showToast: (text) => framedToasts.push(text),
|
||||
setPanelCollapsed: (...args) => framedPanels.push(args),
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: framed.run,
|
||||
location: {
|
||||
search: '?embed=1',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(framed.calls[0][1].autoOpen, false);
|
||||
// In a frame the rail that holds OPEN is hidden: no nudge, no toast.
|
||||
assert.deepEqual([framedToasts, framedPanels], [[], []]);
|
||||
// Only the panel page's own link for a result issued just now is the
|
||||
// client's request: a host that mounts the panel again hands back an old
|
||||
// result, and that one only arms, like any link.
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
for (const fresh of [true, false]) {
|
||||
globalThis.GEV_EMBED_FRESH = fresh;
|
||||
const inline = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
const inlineWindow = new EventTarget();
|
||||
inlineWindow.document = { body: { classList: { add() {} } } };
|
||||
inlineWindow.postMessage = () => {};
|
||||
// The inline panel keeps rendering from a timer; give it inert clocks.
|
||||
Object.assign(inlineWindow, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const stop = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: inline.run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef: inlineWindow,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false);
|
||||
else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms');
|
||||
stop();
|
||||
}
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
delete globalThis.GEV_EMBED_FRESH;
|
||||
}
|
||||
// applyView (a posted view) opens too.
|
||||
const posted = recorder();
|
||||
await applyView(
|
||||
createView({
|
||||
camera: { lat: 1, lon: 2 },
|
||||
street_level: { provider: 'google' },
|
||||
}),
|
||||
{
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: posted.run,
|
||||
},
|
||||
);
|
||||
assert.equal('autoOpen' in posted.calls.at(-1)[1], false);
|
||||
});
|
||||
|
||||
test('a view whose photos fail before any flight still gets its camera', async () => {
|
||||
const viewer = fakeViewer();
|
||||
const { calls, run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
});
|
||||
assert.equal(viewer.flights.length, 1, 'the view flies itself');
|
||||
assert.deepEqual(
|
||||
steps.map((step) => [step.step, step.ok]),
|
||||
[
|
||||
['set_map_stack', true],
|
||||
['stop_tracking', true],
|
||||
['clear_annotations', true],
|
||||
['show_street_level', false],
|
||||
['camera', true],
|
||||
],
|
||||
);
|
||||
assert.ok(calls.some(([name]) => name === 'show_street_level'));
|
||||
// A flight that happened (the action reports where) is not repeated.
|
||||
const flown = fakeViewer();
|
||||
const { run: flownRun } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
location: 'Somewhere',
|
||||
error: 'no imagery',
|
||||
}),
|
||||
});
|
||||
await applyView(view, {
|
||||
viewer: flown,
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: flownRun,
|
||||
});
|
||||
assert.equal(flown.flights.length, 0);
|
||||
});
|
||||
|
||||
test('a link runs its marks and photos in order, and posted views wait behind them', async () => {
|
||||
const log = [];
|
||||
let releaseStreetLevel = null;
|
||||
let releaseMarks = null;
|
||||
const run = async (name, args) => {
|
||||
log.push(`start:${name}`);
|
||||
if (name === 'annotate_map')
|
||||
await new Promise((resolve) => {
|
||||
releaseMarks = resolve;
|
||||
});
|
||||
if (name === 'show_street_level')
|
||||
await new Promise((resolve) => {
|
||||
releaseStreetLevel = resolve;
|
||||
});
|
||||
log.push(`end:${name}`);
|
||||
return {
|
||||
ok: true,
|
||||
...(name === 'show_street_level' ? { location: 'x' } : {}),
|
||||
};
|
||||
};
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.document = { body: { classList: { add() {} } } };
|
||||
// A framing page: answers go to it, not to the app's own window.
|
||||
windowRef.parent = {
|
||||
postMessage: (message) => log.push(`post:${message.type}`),
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '?embed=1',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// A view arrives while the link's photos are still opening.
|
||||
windowRef.dispatchEvent(
|
||||
Object.assign(new Event('message'), {
|
||||
source: windowRef.parent,
|
||||
origin: 'null',
|
||||
data: {
|
||||
type: 'gev:view',
|
||||
id: 'v1',
|
||||
view: { camera: { lat: 3, lon: 4 } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const actions = () => log.filter((entry) => !entry.startsWith('post:'));
|
||||
assert.deepEqual(
|
||||
actions(),
|
||||
['start:annotate_map'],
|
||||
'the photos wait for the marks',
|
||||
);
|
||||
releaseMarks();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(actions().slice(0, 3), [
|
||||
'start:annotate_map',
|
||||
'end:annotate_map',
|
||||
'start:show_street_level',
|
||||
]);
|
||||
assert.ok(
|
||||
!log.includes('start:stop_tracking'),
|
||||
'the posted view waits for the link',
|
||||
);
|
||||
releaseStreetLevel();
|
||||
for (
|
||||
let tick = 0;
|
||||
tick < 20 && !log.includes('post:gev:view-applied');
|
||||
tick++
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'),
|
||||
true,
|
||||
);
|
||||
assert.ok(log.includes('post:gev:view-applied'), log.join(' '));
|
||||
});
|
||||
|
||||
test('an aborted install never runs the link photos', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
signal: controller.signal,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a passive link stands down when someone navigated while it waited behind its marks', async () => {
|
||||
const calls = [];
|
||||
let releaseMarks = null;
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 7,
|
||||
};
|
||||
const run = async (name) => {
|
||||
calls.push(name);
|
||||
if (name === 'annotate_map')
|
||||
await new Promise((resolve) => {
|
||||
releaseMarks = resolve;
|
||||
});
|
||||
return { ok: true };
|
||||
};
|
||||
const an = encodeURIComponent(
|
||||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||||
);
|
||||
installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['annotate_map']);
|
||||
shell._navigationGeneration = 8; // the recipient searched, or the assistant flew
|
||||
releaseMarks();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
['annotate_map'],
|
||||
'the link did not fly over the newer navigation',
|
||||
);
|
||||
|
||||
// Unchanged navigation: the photos open as before.
|
||||
const quiet = [];
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 3,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: async (name) => {
|
||||
quiet.push(name);
|
||||
return { ok: true };
|
||||
},
|
||||
location: {
|
||||
search: '',
|
||||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
|
||||
});
|
||||
|
||||
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(options, [
|
||||
['show_street_level', { origin: 'programmatic' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a start link whose restore a newer link superseded opens nothing', async () => {
|
||||
const { calls, run } = recorder();
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
|
||||
_navigationGeneration: 4,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
|
||||
});
|
||||
|
||||
test('a start link whose restore flight the recipient interrupted leaves the camera with them', async () => {
|
||||
for (const [camera, expected] of [
|
||||
['cancelled', 0],
|
||||
['skipped', 0],
|
||||
['applied', 1],
|
||||
]) {
|
||||
const { calls, run } = recorder();
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve({
|
||||
status: 'settled',
|
||||
share: { camera },
|
||||
}),
|
||||
_navigationGeneration: 4,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, expected, camera);
|
||||
}
|
||||
});
|
||||
|
||||
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
const windowRef = new EventTarget();
|
||||
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 1,
|
||||
};
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location,
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'the start link has no photos');
|
||||
const settled = (status, camera = 'applied') =>
|
||||
windowRef.dispatchEvent(
|
||||
Object.assign(new Event('gev:share-navigation-restore-settled'), {
|
||||
detail: { status, share: { camera } },
|
||||
}),
|
||||
);
|
||||
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
|
||||
shell._navigationGeneration = 2; // the restore's own flight
|
||||
settled('failed');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'a failed restore opens nothing');
|
||||
// The recipient dragged or searched during the restore flight: the camera
|
||||
// is theirs, and the link does not pull it back.
|
||||
shell._navigationGeneration = 3;
|
||||
for (const camera of ['cancelled', 'superseded']) {
|
||||
settled('settled', camera);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
[],
|
||||
`an interrupted restore (${camera}) opens nothing`,
|
||||
);
|
||||
}
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: 48.8874,
|
||||
longitude: 2.3495,
|
||||
provider: 'mapillary',
|
||||
fly: true,
|
||||
autoOpen: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(options.at(-1), [
|
||||
'show_street_level',
|
||||
{ origin: 'programmatic' },
|
||||
]);
|
||||
remove();
|
||||
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1, 'removed with the install');
|
||||
});
|
||||
|
||||
test('a view whose photo step the user overtook leaves their photo and camera alone', async () => {
|
||||
// Photo B, opened by a click while the posted view looked up its photo.
|
||||
const street = { open: false, imageId: null };
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
[
|
||||
'street-level',
|
||||
{
|
||||
module: {
|
||||
closeViewer: () =>
|
||||
Object.assign(street, { open: false, imageId: null }),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => {
|
||||
Object.assign(street, { open: true, imageId: 'b' });
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
cancelled: true,
|
||||
superseded: true,
|
||||
error: 'The camera moved on before the photo opened',
|
||||
};
|
||||
},
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'mapillary' },
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const steps = await applyView(view, { viewer, dataManager, run });
|
||||
assert.deepEqual(street, { open: true, imageId: 'b' }, 'photo B stays');
|
||||
assert.equal(viewer.flights.length, 0, 'the camera stays with the user');
|
||||
assert.equal(steps.at(-1).ok, false, 'reported as not applied');
|
||||
});
|
||||
|
||||
test('a view whose photo was found but could not load keeps its error in view', async () => {
|
||||
const closed = [];
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
imageryError: true,
|
||||
location: '1.00000, 2.00000',
|
||||
error: 'Image could not be opened',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'mapillary' },
|
||||
});
|
||||
await applyView(view, { viewer: fakeViewer(), dataManager, run });
|
||||
assert.equal(closed.length, 0, 'the viewer keeps the new photo’s error');
|
||||
});
|
||||
|
||||
test('a view whose photos fail closes the photo an earlier view opened', async () => {
|
||||
const closed = [];
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
await applyView(view, { viewer, dataManager, run });
|
||||
assert.equal(closed.length, 1, 'closed before the view flies away');
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const opened = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
await applyView(view, { viewer, dataManager, run: opened.run });
|
||||
assert.equal(closed.length, 1, 'an opened photo stays');
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installViews } from './embed.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { createAnnotationEngine } from '../annotations/annotationEngine.js';
|
||||
import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
// A camera taken while a posted view waits on terrain stays with whoever took
|
||||
// it: the stale view stands down whole, so it neither stops a newer follow nor
|
||||
// frames its own marks. Offline: real installViews, action runner and
|
||||
// annotation engine; the viewer, renderer, flights data and terrain are fixtures.
|
||||
globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); };
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function scenario(t, effect) {
|
||||
document.body.className = '';
|
||||
const entered = deferred();
|
||||
const terrain = deferred();
|
||||
const viewer = fakeCesiumViewer();
|
||||
viewer.clock = { onTick: new Cesium.Event() };
|
||||
viewer.canvas = viewer.scene.canvas;
|
||||
// A real perspective frustum looking down at 0,0; the old view's mark
|
||||
// at 48N,2E is outside it. No override of the engine's visibility logic.
|
||||
Object.assign(viewer.camera, {
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 1000),
|
||||
direction: new Cesium.Cartesian3(-1, 0, 0),
|
||||
up: new Cesium.Cartesian3(0, 0, 1),
|
||||
heading: 0,
|
||||
frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }),
|
||||
});
|
||||
const entity = { id: 'fixture-aircraft' };
|
||||
const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 };
|
||||
let stopCalls = 0;
|
||||
const flights = {
|
||||
findByQuery: () => aircraft,
|
||||
trackById: () => { viewer.trackedEntity = entity; return true; },
|
||||
getTrackedInfo: () => viewer.trackedEntity ? aircraft : null,
|
||||
stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; },
|
||||
};
|
||||
const dataManager = {
|
||||
layers: new Map([['flights', { module: flights }]]),
|
||||
getAll: () => [{ id: 'flights', enabled: true }],
|
||||
isEnabled: id => id === 'flights',
|
||||
setLayerParams: () => true,
|
||||
};
|
||||
const shell = {
|
||||
_navigationGeneration: 1,
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
setCleanView() {},
|
||||
runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); },
|
||||
};
|
||||
const annotations = createAnnotationEngine({ viewer,
|
||||
renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} },
|
||||
});
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations });
|
||||
const calls = [];
|
||||
const run = async (name, args, options) => {
|
||||
calls.push(name);
|
||||
return runner(name, args, options);
|
||||
};
|
||||
const replies = [];
|
||||
const peer = { postMessage: message => replies.push(message) };
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = peer;
|
||||
windowRef.document = document;
|
||||
const remove = installViews({ shell, viewer, dataManager, run, windowRef,
|
||||
location: { search: '?embed=1', hash: '' },
|
||||
terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } },
|
||||
});
|
||||
t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; });
|
||||
await tick();
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source: peer, origin: 'https://host.example', data: {
|
||||
type: 'gev:view', id: 1, view: {
|
||||
camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 },
|
||||
layers: ['flights'],
|
||||
street_level: { provider: 'mapillary' },
|
||||
annotations: effect === 'annotations'
|
||||
? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [],
|
||||
},
|
||||
} });
|
||||
windowRef.dispatchEvent(event);
|
||||
await entered.promise;
|
||||
if (effect === 'follow') {
|
||||
const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' });
|
||||
assert.equal(follow.ok, true, 'new follow starts through the real runner');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
} else {
|
||||
viewer.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
}
|
||||
terrain.resolve([{ ellipsoid: 200 }]);
|
||||
for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick();
|
||||
const reply = replies.find(reply => reply.id === 1);
|
||||
t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity,
|
||||
annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply }));
|
||||
assert.ok(reply, 'host receives completion');
|
||||
assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied');
|
||||
assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler');
|
||||
if (effect === 'follow') {
|
||||
assert.equal(stopCalls, 0, 'stale view must not stop the newer follow');
|
||||
assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected');
|
||||
} else {
|
||||
assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera');
|
||||
}
|
||||
}
|
||||
|
||||
for (const effect of ['follow', 'annotations']) {
|
||||
test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect));
|
||||
}
|
||||
@@ -462,8 +462,11 @@ export function panelRuntime(config) {
|
||||
* Load God's Eye View into this page from the app's panel build, in
|
||||
* inline embed mode, starting at the first view as its link would.
|
||||
*/
|
||||
async function startApp(url, view) {
|
||||
async function startApp(url, view, fresh = false) {
|
||||
window.GEV_EMBED_INLINE = true;
|
||||
// Whether this result was just issued, not handed back by a host that
|
||||
// mounted the panel again: only then may its link open a billed photo.
|
||||
window.GEV_EMBED_FRESH = fresh;
|
||||
window.GEV_APP_BASE_URL = config.appBaseUrl;
|
||||
try {
|
||||
history.replaceState(
|
||||
@@ -551,13 +554,21 @@ export function panelRuntime(config) {
|
||||
);
|
||||
}
|
||||
|
||||
// Tool results: the first view starts the app, later ones move it.
|
||||
// Tool results: the first view starts the app, later ones move it. A
|
||||
// result issued longer ago than this was handed back on a remount.
|
||||
const FRESH_RESULT_MS = 5 * 60 * 1000;
|
||||
let started = false;
|
||||
let ready = false;
|
||||
let queued = null;
|
||||
let queuedFresh = false;
|
||||
let currentUrl = null;
|
||||
const postView = (view) =>
|
||||
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
|
||||
// Every view carries whether its result was issued just now: only those may
|
||||
// open a billed photo, whichever result on this mount they came in.
|
||||
const postView = (view, fresh) =>
|
||||
window.postMessage(
|
||||
{ type: 'gev:view', id: nextId++, view, fresh: fresh === true },
|
||||
'*',
|
||||
);
|
||||
function show(result) {
|
||||
const data = result?.structuredContent;
|
||||
const view = data?.view;
|
||||
@@ -565,13 +576,18 @@ export function panelRuntime(config) {
|
||||
if (!view || !url) return;
|
||||
currentUrl = url;
|
||||
open.hidden = false;
|
||||
if (ready) return postView(view);
|
||||
const issuedAt = Number(data.issued_at);
|
||||
const fresh =
|
||||
Number.isFinite(issuedAt) &&
|
||||
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
|
||||
if (ready) return postView(view, fresh);
|
||||
queued = view;
|
||||
queuedFresh = fresh;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
startApp(url, view, fresh).catch((error) =>
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
@@ -592,7 +608,7 @@ export function panelRuntime(config) {
|
||||
if (message?.type === 'gev:ready') {
|
||||
ready = true;
|
||||
status.remove();
|
||||
if (queued) postView(queued);
|
||||
if (queued) postView(queued, queuedFresh);
|
||||
queued = null;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { panelRuntime } from './globePanelRuntime.js';
|
||||
|
||||
const element = () => ({
|
||||
isConnected: true,
|
||||
hidden: true,
|
||||
textContent: '',
|
||||
addEventListener() {},
|
||||
remove() {
|
||||
this.isConnected = false;
|
||||
},
|
||||
});
|
||||
|
||||
test('every view the panel posts says whether its result was issued just now', async () => {
|
||||
const savedWindow = globalThis.window;
|
||||
const savedDocument = globalThis.document;
|
||||
const views = [];
|
||||
const parent = { postMessage() {} };
|
||||
const windowRef = new EventTarget();
|
||||
Object.assign(windowRef, {
|
||||
parent,
|
||||
postMessage: (message) => {
|
||||
if (message?.type === 'gev:view') views.push(message);
|
||||
},
|
||||
});
|
||||
const elements = { status: element(), open: element(), expand: element() };
|
||||
globalThis.window = windowRef;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => elements[id] ?? null,
|
||||
addEventListener() {},
|
||||
baseURI: 'https://host.example/panel',
|
||||
body: { clientWidth: 600 },
|
||||
};
|
||||
const deliver = (source, data) => {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source, data });
|
||||
windowRef.dispatchEvent(event);
|
||||
};
|
||||
const result = (issuedAt) => ({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: {
|
||||
structuredContent: {
|
||||
url: 'http://localhost:4173/#sl=google,48.85840,2.29450',
|
||||
issued_at: issuedAt,
|
||||
view: { camera: { lat: 48.8584, lon: 2.2945 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
panelRuntime({
|
||||
appBaseUrl: 'http://localhost:4173/',
|
||||
panelBase: '/panel/',
|
||||
protocolVersion: '2025-06-18',
|
||||
panelHeight: 480,
|
||||
});
|
||||
deliver(windowRef, { type: 'gev:ready' });
|
||||
deliver(parent, result(Date.now()));
|
||||
// A host replaying an old result on the same mount.
|
||||
deliver(parent, result(Date.now() - 60 * 60 * 1000));
|
||||
deliver(parent, result(undefined));
|
||||
} finally {
|
||||
globalThis.window = savedWindow;
|
||||
globalThis.document = savedDocument;
|
||||
}
|
||||
assert.deepEqual(
|
||||
views.map((message) => message.fresh),
|
||||
[true, false, false],
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+3
-1
@@ -246,8 +246,10 @@ export function createApplicationTools({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
run: (name, args, options = {}) =>
|
||||
voiceCommands.runner(name, args, { ...options, signal }),
|
||||
signal,
|
||||
terrain: operations.requests.terrain,
|
||||
}),
|
||||
);
|
||||
// An application's own tool takes the pointer as Draw does, and its own
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 `<objTrainPositions>` 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 `<name>...</name>` 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(`</${name}>`, 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<Record<string, string|null>>, 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 (/<!DOCTYPE|<!ENTITY/i.test(text))
|
||||
throw new IrishRailFormatError('document declares a DTD');
|
||||
if (!text.includes('<ArrayOfObjTrainPositions'))
|
||||
throw new IrishRailFormatError('not a running-trains document');
|
||||
const rows = [];
|
||||
let truncated = false;
|
||||
const OPEN = '<objTrainPositions>';
|
||||
const CLOSE = '</objTrainPositions>';
|
||||
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 };
|
||||
}
|
||||
@@ -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 `<objTrainPositions>` 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 ` <objTrainPositions>
|
||||
<TrainStatus>${status}</TrainStatus>
|
||||
<TrainLatitude>${lat}</TrainLatitude>
|
||||
<TrainLongitude>${lon}</TrainLongitude>
|
||||
<TrainCode>${code}</TrainCode>
|
||||
<TrainDate>${date}</TrainDate>
|
||||
<PublicMessage>${message}</PublicMessage>
|
||||
<Direction>${direction}</Direction>
|
||||
</objTrainPositions>`;
|
||||
}
|
||||
|
||||
function document(...trains) {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://api.irishrail.ie/realtime/">
|
||||
${trains.join('\n')}
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
}
|
||||
|
||||
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(
|
||||
`<!DOCTYPE x [<!ENTITY a "aaaa">]>${document(train())}`,
|
||||
),
|
||||
IrishRailFormatError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseIrishRailTrainsXml('<html>error</html>'),
|
||||
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) }), '<objTrainPositions>'),
|
||||
);
|
||||
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(
|
||||
'<objTrainPositions><TrainCode>E1</TrainCode><Direction /></objTrainPositions>',
|
||||
),
|
||||
);
|
||||
assert.equal(empty.rows[0].Direction, '');
|
||||
assert.equal(empty.rows[0].TrainStatus, null);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
["military-installations", "i"],
|
||||
["news", "00"],
|
||||
["radio", "r"],
|
||||
["rail", "02"],
|
||||
["recent-imagery", "1"],
|
||||
["rocket-launches", "x"],
|
||||
["s2-ghostmaps", "5"],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,491 @@
|
||||
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',
|
||||
'speedUnit',
|
||||
'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);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns="http://api.irishrail.ie/realtime/">
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus><TrainLatitude>53.1442</TrainLatitude>
|
||||
<TrainLongitude>-6.06085</TrainLongitude><TrainCode>A610</TrainCode>
|
||||
<TrainDate>09 Nov 2026</TrainDate>
|
||||
<PublicMessage>A610\\n18:37 - Dublin Connolly to Rosslare Europort (3 mins late)\\nDeparted Greystones next stop Kilcoole</PublicMessage>
|
||||
<Direction>Southbound</Direction>
|
||||
</objTrainPositions>
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus><TrainLatitude>53.35</TrainLatitude>
|
||||
<TrainLongitude>-6.25</TrainLongitude><TrainCode>E999</TrainCode>
|
||||
<TrainDate>09 Nov 2026</TrainDate>
|
||||
<PublicMessage>E999\\n19:00 - Bray to Howth (0 mins late)\\nDeparted Connolly next stop Clontarf Road</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
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');
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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]) => `
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus>
|
||||
<TrainLatitude>${lat}</TrainLatitude>
|
||||
<TrainLongitude>${lon}</TrainLongitude>
|
||||
<TrainCode>${code}</TrainCode>
|
||||
<TrainDate>06 Nov 2026</TrainDate>
|
||||
<PublicMessage>${code}</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>`;
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns="http://api.irishrail.ie/realtime/">${position('A101', AMSTERDAM)}${position('A102', LVIV)}
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
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);
|
||||
});
|
||||
@@ -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/<feedId>`. 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<number>} coords `[lat, lon, lat, lon, ...]`, at least two points.
|
||||
* @param {ArrayLike<number>} [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/<feedId>`.
|
||||
* @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)
|
||||
: [];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/<feedId>`, `/paths/<feedId>`, `/schedule/<feedId>` or
|
||||
* `/updates/<feedId>` (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/<feedId>` 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<string, string>}
|
||||
*/
|
||||
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<object>} [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<object>} [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<string, string>}
|
||||
*/
|
||||
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)) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ArrayOfObjTrainPositions xmlns="http://api.irishrail.ie/realtime/">
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus>
|
||||
<TrainLatitude>54.0</TrainLatitude>
|
||||
<TrainLongitude>-6.4</TrainLongitude>
|
||||
<TrainCode>A118</TrainCode>
|
||||
<TrainDate>06 Nov 2026</TrainDate>
|
||||
<PublicMessage>A118\\n14:50 - Dublin Connolly to Belfast (0 mins late)\\nDeparted Dundalk next stop Newry</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>
|
||||
<objTrainPositions>
|
||||
<TrainStatus>R</TrainStatus>
|
||||
<TrainLatitude>53.29</TrainLatitude>
|
||||
<TrainLongitude>-6.13</TrainLongitude>
|
||||
<TrainCode>E931</TrainCode>
|
||||
<TrainDate>06 Nov 2026</TrainDate>
|
||||
<PublicMessage>E931\\n15:03 - Greystones to Howth (16 mins late)\\nDeparted Sutton next stop Howth</PublicMessage>
|
||||
<Direction>Northbound</Direction>
|
||||
</objTrainPositions>
|
||||
</ArrayOfObjTrainPositions>`;
|
||||
|
||||
/** 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('<!DOCTYPE html><html></html>')],
|
||||
['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('<html>Service Unavailable</html>'),
|
||||
),
|
||||
(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');
|
||||
});
|
||||
@@ -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 (`<feedId>.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 `<feedId>.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 `<feedId>.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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @module railScheduleIndex
|
||||
* @description Server-side lookup over a schedule pack (the
|
||||
* `<feedId>.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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
* `<VehicleActivity>` 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';
|
||||
}
|
||||
}
|
||||
|
||||
/** `<Name>` or `<prefix:Name>` (no attributes kept), up to its close. */
|
||||
function readField(block, name) {
|
||||
const match = new RegExp(
|
||||
`<(?:[A-Za-z_][\\w.-]*:)?${name}(?:\\s[^>]*)?>([^<]*)</(?:[A-Za-z_][\\w.-]*:)?${name}>`,
|
||||
).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<Record<string, string|null>>, 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 (/<!DOCTYPE|<!ENTITY/i.test(text))
|
||||
throw new SiriVmFormatError('document declares a DTD');
|
||||
if (!/<(?:[A-Za-z_][\w.-]*:)?VehicleMonitoringDelivery[\s>]/.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 `<codespace>:Line:<n>` 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()],
|
||||
};
|
||||
}
|
||||
@@ -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 `<VehicleActivity>`, 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 `<VehicleActivity><RecordedAtTime>${recorded}</RecordedAtTime><ValidUntilTime>${validUntil}</ValidUntilTime><MonitoredVehicleJourney><LineRef>${line}</LineRef><DataSource>SJN</DataSource>${extra}<VehicleLocation><Longitude>${lon}</Longitude><Latitude>${lat}</Latitude></VehicleLocation><Delay>${delay}</Delay><VehicleJourneyRef>${journey}</VehicleJourneyRef><VehicleRef>${vehicle}</VehicleRef></MonitoredVehicleJourney></VehicleActivity>`;
|
||||
}
|
||||
|
||||
function document(...activities) {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Siri xmlns="http://www.siri.org.uk/siri" version="2.0"><ServiceDelivery><ResponseTimestamp>2026-11-09T17:26:46.11584667+01:00</ResponseTimestamp><ProducerRef>ENT</ProducerRef><VehicleMonitoringDelivery version="2.0"><ResponseTimestamp>2026-11-09T17:26:46.115848853+01:00</ResponseTimestamp>${activities.join('')}</VehicleMonitoringDelivery></ServiceDelivery></Siri>`;
|
||||
}
|
||||
|
||||
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: '<VehicleMode>bus</VehicleMode>' }),
|
||||
activity({ vehicle: 'rail', extra: '<VehicleMode>rail</VehicleMode>' }),
|
||||
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>[^<]*<\/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: '<Bearing>91.5</Bearing>' }).replace(
|
||||
/<(\/?)(VehicleActivity|LineRef|Latitude|Longitude)>/g,
|
||||
'<$1siri:$2>',
|
||||
),
|
||||
).replace(
|
||||
'<VehicleJourneyRef>',
|
||||
'<PublishedLineName>F6</PublishedLineName><VehicleJourneyRef>',
|
||||
);
|
||||
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('<html><body>Bad gateway</body></html>'),
|
||||
SiriVmFormatError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseSiriVmXml(`<!DOCTYPE x [<!ENTITY a "b">]>${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) =>
|
||||
`<VehicleActivity><VehicleRef>v${i}</VehicleRef></VehicleActivity>`,
|
||||
);
|
||||
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('<html/>'), 0),
|
||||
(error) => error.code === 'UPSTREAM_UNREADABLE',
|
||||
);
|
||||
});
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'transit-motion',
|
||||
'rail',
|
||||
'directions',
|
||||
'ais',
|
||||
'military',
|
||||
|
||||
@@ -982,6 +982,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
// show_street_level (street-level photos) is a later, separate tool.
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||
(tool) =>
|
||||
![
|
||||
@@ -989,6 +990,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'resolve_area',
|
||||
'find_imagery',
|
||||
'osm_query',
|
||||
'show_street_level',
|
||||
].includes(tool.name),
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "لم يُصفِّ KartaView هذه المنطقة في الوقت المحدد — حرّك الخريطة لإعادة الطلب",
|
||||
"None of KartaView’s photos here pass the imagery filter": "لا تجتاز أي من صور KartaView هنا مرشح الصور",
|
||||
"KartaView did not answer in time — try again": "لم يستجب KartaView في الوقت المحدد — أعد المحاولة",
|
||||
"Mapillary did not answer in time — try again": "لم يستجب Mapillary في الوقت المحدد — أعد المحاولة",
|
||||
"KartaView could not be reached — check the network": "تعذّر الوصول إلى KartaView — تحقّق من الشبكة",
|
||||
"KartaView sent an answer that could not be read": "أرسل KartaView استجابة تعذّرت قراءتها",
|
||||
"KartaView is rate-limiting requests": "يقيّد KartaView معدل الطلبات",
|
||||
@@ -3734,6 +3735,17 @@
|
||||
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.",
|
||||
"Press OPEN to load the photo": "اضغط «فتح» لتحميل الصورة",
|
||||
"Captured {month}": "التُقطت في {month}",
|
||||
"this spot": "هذا الموضع",
|
||||
"the view centre": "مركز العرض",
|
||||
"{source} at {place}": "{source} في {place}",
|
||||
"Street level at {place}": "مستوى الشارع في {place}",
|
||||
"Switching map": "جارٍ تبديل الخريطة",
|
||||
"Opening imagery": "جارٍ فتح الصور",
|
||||
"Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله",
|
||||
"{source} cannot open this link's street-level photo without a working key": "لا يمكن لـ {source} فتح صورة مستوى الشارع في هذا الرابط بدون مفتاح صالح",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا.",
|
||||
"STATIONS": "المحطات",
|
||||
"Crewed stations and the craft that visit them": "المحطات المأهولة والمركبات التي تزورها",
|
||||
"IMAGING": "التصوير",
|
||||
@@ -3776,6 +3788,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}",
|
||||
@@ -3788,5 +3815,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}"
|
||||
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°",
|
||||
"Street Level is unavailable": "مستوى الشارع غير متاح",
|
||||
"No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع",
|
||||
"Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع",
|
||||
"Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور",
|
||||
"Aim the view at a street": "وجّه العرض نحو شارع",
|
||||
"{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا",
|
||||
"{source} needs its key": "يحتاج {source} إلى مفتاحه",
|
||||
"{source} needs the {map} map, which is not available": "يحتاج {source} إلى خريطة {map}، وهي غير متاحة",
|
||||
"{source} could not be switched on": "تعذّر تفعيل {source}",
|
||||
"{source} is not available on this map": "{source} غير متاح على هذه الخريطة",
|
||||
"{source} imagery could not be opened": "تعذّر فتح صور {source}",
|
||||
"{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه",
|
||||
"No {source} imagery near {place}": "لا توجد صور {source} قرب {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})"
|
||||
}
|
||||
@@ -1791,6 +1791,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView hat dieses Gebiet nicht rechtzeitig gefiltert – Karte verschieben, um erneut anzufragen",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Keines der KartaView-Fotos hier passt zum Bildfilter",
|
||||
"KartaView did not answer in time — try again": "KartaView hat nicht rechtzeitig geantwortet – erneut versuchen",
|
||||
"Mapillary did not answer in time — try again": "Mapillary hat nicht rechtzeitig geantwortet – erneut versuchen",
|
||||
"KartaView could not be reached — check the network": "KartaView ist nicht erreichbar – Netzwerk prüfen",
|
||||
"KartaView sent an answer that could not be read": "KartaView hat eine unlesbare Antwort gesendet",
|
||||
"KartaView is rate-limiting requests": "KartaView begrenzt die Anfragen",
|
||||
@@ -3410,6 +3411,17 @@
|
||||
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
|
||||
"Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden",
|
||||
"Captured {month}": "Aufgenommen im {month}",
|
||||
"this spot": "diese Stelle",
|
||||
"the view centre": "die Bildmitte",
|
||||
"{source} at {place}": "{source} bei {place}",
|
||||
"Street level at {place}": "Straßenebene bei {place}",
|
||||
"Switching map": "Karte wird gewechselt",
|
||||
"Opening imagery": "Bilder werden geöffnet",
|
||||
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.",
|
||||
"STATIONS": "STATIONEN",
|
||||
"Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen",
|
||||
"IMAGING": "BILDGEBUNG",
|
||||
@@ -3452,6 +3464,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",
|
||||
@@ -3464,5 +3491,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "Schiffs-UUID: {id}"
|
||||
"Vessel UUID: {id}": "Schiffs-UUID: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen",
|
||||
"Street Level is unavailable": "Straßenebene ist nicht verfügbar",
|
||||
"No street-level imagery source is configured": "Keine Quelle für Bilder auf Straßenebene konfiguriert",
|
||||
"Street Level could not be enabled": "Straßenebene konnte nicht eingeschaltet werden",
|
||||
"Street Level changed while it looked for imagery": "Straßenebene hat sich während der Bildersuche geändert",
|
||||
"Aim the view at a street": "Die Ansicht auf eine Straße richten",
|
||||
"{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene",
|
||||
"{source} needs its key": "{source} braucht seinen Schlüssel",
|
||||
"{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist",
|
||||
"{source} could not be switched on": "{source} konnte nicht eingeschaltet werden",
|
||||
"{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar",
|
||||
"{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden",
|
||||
"{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann",
|
||||
"No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})"
|
||||
}
|
||||
@@ -2411,6 +2411,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes",
|
||||
"KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo",
|
||||
"Mapillary did not answer in time — try again": "Mapillary no respondió a tiempo — inténtalo de nuevo",
|
||||
"KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red",
|
||||
"KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer",
|
||||
"KartaView is rate-limiting requests": "KartaView está limitando las peticiones",
|
||||
@@ -3491,6 +3492,17 @@
|
||||
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
|
||||
"Press OPEN to load the photo": "Pulse ABRIR para cargar la foto",
|
||||
"Captured {month}": "Capturada en {month}",
|
||||
"this spot": "este punto",
|
||||
"the view centre": "el centro de la vista",
|
||||
"{source} at {place}": "{source} en {place}",
|
||||
"Street level at {place}": "A pie de calle en {place}",
|
||||
"Switching map": "Cambiando de mapa",
|
||||
"Opening imagery": "Abriendo imágenes",
|
||||
"Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} no puede abrir la foto a pie de calle de este enlace sin una clave válida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero.",
|
||||
"STATIONS": "ESTACIONES",
|
||||
"Crewed stations and the craft that visit them": "Estaciones tripuladas y las naves que las visitan",
|
||||
"IMAGING": "IMÁGENES",
|
||||
@@ -3533,6 +3545,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}",
|
||||
@@ -3545,5 +3572,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID del buque: {id}"
|
||||
"Vessel UUID: {id}": "UUID del buque: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitud debe estar dentro de ±90° y la longitud dentro de ±180°",
|
||||
"Street Level is unavailable": "A pie de calle no está disponible",
|
||||
"No street-level imagery source is configured": "No hay ninguna fuente de imágenes a pie de calle configurada",
|
||||
"Street Level could not be enabled": "No se pudo activar A pie de calle",
|
||||
"Street Level changed while it looked for imagery": "A pie de calle cambió mientras buscaba imágenes",
|
||||
"Aim the view at a street": "Apunta la vista a una calle",
|
||||
"{source} is not a street-level imagery source here": "{source} no es una fuente de imágenes a pie de calle aquí",
|
||||
"{source} needs its key": "{source} necesita su clave",
|
||||
"{source} needs the {map} map, which is not available": "{source} requiere el mapa {map}, que no está disponible",
|
||||
"{source} could not be switched on": "No se pudo activar {source}",
|
||||
"{source} is not available on this map": "{source} no está disponible en este mapa",
|
||||
"{source} imagery could not be opened": "No se pudieron abrir las imágenes de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes",
|
||||
"No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})"
|
||||
}
|
||||
@@ -1185,6 +1185,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView n'a pas filtré cette zone à temps — déplacez la carte pour relancer la demande",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Aucune photo KartaView de cet endroit ne passe le filtre d'images",
|
||||
"KartaView did not answer in time — try again": "KartaView n'a pas répondu à temps — réessayez",
|
||||
"Mapillary did not answer in time — try again": "Mapillary n'a pas répondu à temps — réessayez",
|
||||
"KartaView could not be reached — check the network": "KartaView est injoignable — vérifiez le réseau",
|
||||
"KartaView sent an answer that could not be read": "KartaView a envoyé une réponse illisible",
|
||||
"KartaView is rate-limiting requests": "KartaView limite les requêtes",
|
||||
@@ -3491,6 +3492,17 @@
|
||||
"Refresh map still": "Actualiser l'image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
|
||||
"Press OPEN to load the photo": "Appuyez sur OUVRIR pour charger la photo",
|
||||
"Captured {month}": "Prise en {month}",
|
||||
"this spot": "cet endroit",
|
||||
"the view centre": "le centre de la vue",
|
||||
"{source} at {place}": "{source} à {place}",
|
||||
"Street level at {place}": "Niveau rue à {place}",
|
||||
"Switching map": "Changement de carte",
|
||||
"Opening imagery": "Ouverture des images",
|
||||
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.",
|
||||
"STATIONS": "STATIONS",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
|
||||
"IMAGING": "IMAGERIE",
|
||||
@@ -3533,6 +3545,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}",
|
||||
@@ -3545,5 +3572,20 @@
|
||||
"ICAO: {id}": "ICAO : {id}",
|
||||
"NORAD: {id}": "NORAD : {id}",
|
||||
"MMSI: {id}": "MMSI : {id}",
|
||||
"Vessel UUID: {id}": "UUID du navire : {id}"
|
||||
"Vessel UUID: {id}": "UUID du navire : {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°",
|
||||
"Street Level is unavailable": "Niveau rue n'est pas disponible",
|
||||
"No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée",
|
||||
"Street Level could not be enabled": "Impossible d'activer Niveau rue",
|
||||
"Street Level changed while it looked for imagery": "Niveau rue a changé pendant la recherche d'images",
|
||||
"Aim the view at a street": "Orientez la vue vers une rue",
|
||||
"{source} is not a street-level imagery source here": "{source} n'est pas une source d'images au niveau de la rue ici",
|
||||
"{source} needs its key": "{source} a besoin de sa clé",
|
||||
"{source} needs the {map} map, which is not available": "{source} nécessite la carte {map}, qui n'est pas disponible",
|
||||
"{source} could not be switched on": "Impossible d'activer {source}",
|
||||
"{source} is not available on this map": "{source} n'est pas disponible sur cette carte",
|
||||
"{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images",
|
||||
"No {source} imagery near {place}": "Aucune image {source} près de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})"
|
||||
}
|
||||
@@ -1051,6 +1051,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView לא סינן את האזור בזמן — הזז את המפה כדי לשאול שוב",
|
||||
"None of KartaView’s photos here pass the imagery filter": "אף תמונת KartaView כאן לא עוברת את מסנן הצילומים",
|
||||
"KartaView did not answer in time — try again": "KartaView לא ענה בזמן — נסה שוב",
|
||||
"Mapillary did not answer in time — try again": "Mapillary לא ענה בזמן — נסה שוב",
|
||||
"KartaView could not be reached — check the network": "KartaView לא נגיש — בדוק את הרשת",
|
||||
"KartaView sent an answer that could not be read": "KartaView שלח תשובה שלא ניתן לקרוא",
|
||||
"KartaView is rate-limiting requests": "KartaView מגביל את קצב הבקשות",
|
||||
@@ -3292,6 +3293,17 @@
|
||||
"Refresh map still": "רענון תמונת המפה",
|
||||
"The map still uses a separate request and may show a different moment.": "תמונת המפה נשלפת בבקשה נפרדת ועשויה להראות רגע שונה.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "הווידאו זורם מ-Québec 511, שרואה את כתובת ה-IP שלך.",
|
||||
"Press OPEN to load the photo": "לחץ על פתח כדי לטעון את התמונה",
|
||||
"Captured {month}": "צולם ב-{month}",
|
||||
"this spot": "המקום הזה",
|
||||
"the view centre": "מרכז התצוגה",
|
||||
"{source} at {place}": "{source} ב-{place}",
|
||||
"Street level at {place}": "רמת רחוב ב-{place}",
|
||||
"Switching map": "מחליף מפה",
|
||||
"Opening imagery": "פותח צילומים",
|
||||
"Street View is ready: press {open} to load it": "Street View מוכן: לחץ על {open} כדי לטעון אותו",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת רמת הרחוב של הקישור הזה בלי מפתח תקף",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.",
|
||||
"Ship bridge view": "תצוגת גשר האונייה",
|
||||
"Satellite orbital view": "התצוגה המסלולית של הלוויין",
|
||||
"Exit bridge view": "יציאה מתצוגת הגשר",
|
||||
@@ -3357,6 +3369,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": "יום/שנ׳",
|
||||
@@ -3545,5 +3572,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID של כלי השיט: {id}"
|
||||
"Vessel UUID: {id}": "UUID של כלי השיט: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°",
|
||||
"Street Level is unavailable": "Street Level אינו זמין",
|
||||
"No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב",
|
||||
"Street Level could not be enabled": "לא ניתן היה להפעיל את Street Level",
|
||||
"Street Level changed while it looked for imagery": "Street Level השתנה בזמן החיפוש אחר צילומים",
|
||||
"Aim the view at a street": "כוון את התצוגה אל רחוב",
|
||||
"{source} is not a street-level imagery source here": "{source} אינו מקור לצילומי רחוב כאן",
|
||||
"{source} needs its key": "{source} דורש את המפתח שלו",
|
||||
"{source} needs the {map} map, which is not available": "{source} דורש את מפת {map}, שאינה זמינה",
|
||||
"{source} could not be switched on": "לא ניתן היה להפעיל את {source}",
|
||||
"{source} is not available on this map": "{source} אינו זמין במפה הזו",
|
||||
"{source} imagery could not be opened": "לא ניתן היה לפתוח את הצילומים של {source}",
|
||||
"{place} has no position to look for imagery at": "ל-{place} אין מיקום לחיפוש צילומים",
|
||||
"No {source} imagery near {place}": "אין צילומים של {source} ליד {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})"
|
||||
}
|
||||
@@ -1185,6 +1185,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView non ha filtrato quest'area in tempo: sposta la mappa per riprovare",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Nessuna delle foto KartaView qui supera il filtro delle immagini",
|
||||
"KartaView did not answer in time — try again": "KartaView non ha risposto in tempo: riprova",
|
||||
"Mapillary did not answer in time — try again": "Mapillary non ha risposto in tempo: riprova",
|
||||
"KartaView could not be reached — check the network": "KartaView non è raggiungibile: controlla la rete",
|
||||
"KartaView sent an answer that could not be read": "KartaView ha inviato una risposta illeggibile",
|
||||
"KartaView is rate-limiting requests": "KartaView sta limitando le richieste",
|
||||
@@ -3491,6 +3492,17 @@
|
||||
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
|
||||
"Press OPEN to load the photo": "Premi APRI per caricare la foto",
|
||||
"Captured {month}": "Scattata a {month}",
|
||||
"this spot": "questo punto",
|
||||
"the view centre": "il centro della vista",
|
||||
"{source} at {place}": "{source} a {place}",
|
||||
"Street level at {place}": "Vista stradale a {place}",
|
||||
"Switching map": "Cambio mappa",
|
||||
"Opening imagery": "Apertura delle immagini",
|
||||
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} non può aprire la foto a livello stradale di questo link senza una chiave valida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista.",
|
||||
"STATIONS": "STAZIONI",
|
||||
"Crewed stations and the craft that visit them": "Stazioni con equipaggio e i veicoli che le visitano",
|
||||
"IMAGING": "IMMAGINI",
|
||||
@@ -3533,6 +3545,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}",
|
||||
@@ -3545,5 +3572,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID della nave: {id}"
|
||||
"Vessel UUID: {id}": "UUID della nave: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°",
|
||||
"Street Level is unavailable": "Vista stradale non è disponibile",
|
||||
"No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata",
|
||||
"Street Level could not be enabled": "Impossibile attivare Vista stradale",
|
||||
"Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini",
|
||||
"Aim the view at a street": "Punta la vista su una strada",
|
||||
"{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui",
|
||||
"{source} needs its key": "{source} richiede la sua chiave",
|
||||
"{source} needs the {map} map, which is not available": "{source} richiede la mappa {map}, che non è disponibile",
|
||||
"{source} could not be switched on": "Impossibile attivare {source}",
|
||||
"{source} is not available on this map": "{source} non è disponibile su questa mappa",
|
||||
"{source} imagery could not be opened": "Impossibile aprire le immagini di {source}",
|
||||
"{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini",
|
||||
"No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})"
|
||||
}
|
||||
@@ -1147,6 +1147,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaViewがこの範囲を時間内に絞り込めませんでした。地図を動かして再度リクエストしてください",
|
||||
"None of KartaView’s photos here pass the imagery filter": "この場所のKartaViewの写真はいずれも画像フィルターを通過しません",
|
||||
"KartaView did not answer in time — try again": "KartaViewから時間内に応答がありませんでした。もう一度お試しください",
|
||||
"Mapillary did not answer in time — try again": "Mapillaryから時間内に応答がありませんでした。もう一度お試しください",
|
||||
"KartaView could not be reached — check the network": "KartaViewに接続できません。ネットワークを確認してください",
|
||||
"KartaView sent an answer that could not be read": "KartaViewから読み取れない応答が返されました",
|
||||
"KartaView is rate-limiting requests": "KartaViewがリクエストをレート制限中です",
|
||||
@@ -3329,6 +3330,17 @@
|
||||
"Refresh map still": "地図上の静止画を更新",
|
||||
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
|
||||
"Press OPEN to load the photo": "「開く」を押して写真を読み込む",
|
||||
"Captured {month}": "{month}撮影",
|
||||
"this spot": "この地点",
|
||||
"the view centre": "表示中心",
|
||||
"{source} at {place}": "{place}の{source}",
|
||||
"Street level at {place}": "{place}のストリートレベル",
|
||||
"Switching map": "地図を切り替え中",
|
||||
"Opening imagery": "画像を開いています",
|
||||
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。",
|
||||
"STATIONS": "ステーション",
|
||||
"Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機",
|
||||
"IMAGING": "地球観測",
|
||||
@@ -3371,6 +3383,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}時点の位置を表示しています",
|
||||
@@ -3383,5 +3410,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "船舶UUID: {id}"
|
||||
"Vessel UUID: {id}": "船舶UUID: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "緯度は±90°、経度は±180°の範囲内で指定してください",
|
||||
"Street Level is unavailable": "ストリートレベルは利用できません",
|
||||
"No street-level imagery source is configured": "ストリートレベル画像のソースが設定されていません",
|
||||
"Street Level could not be enabled": "ストリートレベルをオンにできませんでした",
|
||||
"Street Level changed while it looked for imagery": "画像の検索中にストリートレベルが変更されました",
|
||||
"Aim the view at a street": "表示を通りに向けてください",
|
||||
"{source} is not a street-level imagery source here": "{source}はここではストリートレベル画像のソースではありません",
|
||||
"{source} needs its key": "{source}にはキーが必要です",
|
||||
"{source} needs the {map} map, which is not available": "{source}には{map}マップが必要ですが、利用できません",
|
||||
"{source} could not be switched on": "{source}をオンにできませんでした",
|
||||
"{source} is not available on this map": "{source}はこのマップでは利用できません",
|
||||
"{source} imagery could not be opened": "{source}の画像を開けませんでした",
|
||||
"{place} has no position to look for imagery at": "{place}には画像を探す位置がありません",
|
||||
"No {source} imagery near {place}": "{place}の近くに{source}の画像はありません",
|
||||
"{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})"
|
||||
}
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "O KartaView não filtrou esta área a tempo — mova o mapa para pedir de novo",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Nenhuma foto do KartaView aqui passa no filtro de imagens",
|
||||
"KartaView did not answer in time — try again": "O KartaView não respondeu a tempo — tente novamente",
|
||||
"Mapillary did not answer in time — try again": "O Mapillary não respondeu a tempo — tente novamente",
|
||||
"KartaView could not be reached — check the network": "Não foi possível acessar o KartaView — verifique a rede",
|
||||
"KartaView sent an answer that could not be read": "O KartaView enviou uma resposta que não pôde ser lida",
|
||||
"KartaView is rate-limiting requests": "O KartaView está limitando as requisições",
|
||||
@@ -3491,6 +3492,17 @@
|
||||
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.",
|
||||
"Press OPEN to load the photo": "Clique em ABRIR para carregar a foto",
|
||||
"Captured {month}": "Capturada em {month}",
|
||||
"this spot": "este ponto",
|
||||
"the view centre": "o centro da vista",
|
||||
"{source} at {place}": "{source} em {place}",
|
||||
"Street level at {place}": "Nível da rua em {place}",
|
||||
"Switching map": "Mudando de mapa",
|
||||
"Opening imagery": "Abrindo imagens",
|
||||
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.",
|
||||
"STATIONS": "ESTAÇÕES",
|
||||
"Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam",
|
||||
"IMAGING": "IMAGENS",
|
||||
@@ -3533,6 +3545,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}",
|
||||
@@ -3545,5 +3572,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID da embarcação: {id}"
|
||||
"Vessel UUID: {id}": "UUID da embarcação: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°",
|
||||
"Street Level is unavailable": "Nível da rua não está disponível",
|
||||
"No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada",
|
||||
"Street Level could not be enabled": "Não foi possível ativar Nível da rua",
|
||||
"Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens",
|
||||
"Aim the view at a street": "Aponte a vista para uma rua",
|
||||
"{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui",
|
||||
"{source} needs its key": "{source} precisa da sua chave",
|
||||
"{source} needs the {map} map, which is not available": "{source} requer o mapa {map}, que não está disponível",
|
||||
"{source} could not be switched on": "Não foi possível ativar {source}",
|
||||
"{source} is not available on this map": "{source} não está disponível neste mapa",
|
||||
"{source} imagery could not be opened": "Não foi possível abrir as imagens de {source}",
|
||||
"{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens",
|
||||
"No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})"
|
||||
}
|
||||
@@ -2452,6 +2452,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков",
|
||||
"KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова",
|
||||
"Mapillary did not answer in time — try again": "Mapillary не ответил вовремя — попробуйте снова",
|
||||
"KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть",
|
||||
"KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать",
|
||||
"KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов",
|
||||
@@ -3572,6 +3573,17 @@
|
||||
"Refresh map still": "Обновить кадр на карте",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.",
|
||||
"Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок",
|
||||
"Captured {month}": "Снято: {month}",
|
||||
"this spot": "эта точка",
|
||||
"the view centre": "центр вида",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Уличные снимки: {place}",
|
||||
"Switching map": "Смена карты",
|
||||
"Opening imagery": "Открытие снимков",
|
||||
"Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} не может открыть уличное фото по этой ссылке без рабочего ключа",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него.",
|
||||
"STATIONS": "СТАНЦИИ",
|
||||
"Crewed stations and the craft that visit them": "Пилотируемые станции и прилетающие к ним корабли",
|
||||
"IMAGING": "СЪЁМКА",
|
||||
@@ -3614,6 +3626,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}",
|
||||
@@ -3626,5 +3653,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}"
|
||||
"Vessel UUID: {id}": "UUID судна: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°",
|
||||
"Street Level is unavailable": "Уличные снимки недоступны",
|
||||
"No street-level imagery source is configured": "Источник уличных снимков не настроен",
|
||||
"Street Level could not be enabled": "Не удалось включить уличные снимки",
|
||||
"Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска",
|
||||
"Aim the view at a street": "Наведите вид на улицу",
|
||||
"{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков",
|
||||
"{source} needs its key": "{source}: нужен ключ",
|
||||
"{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна",
|
||||
"{source} could not be switched on": "Не удалось включить {source}",
|
||||
"{source} is not available on this map": "{source}: недоступно на этой карте",
|
||||
"{source} imagery could not be opened": "Не удалось открыть снимки {source}",
|
||||
"{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков",
|
||||
"No {source} imagery near {place}": "Нет снимков {source} рядом с {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})"
|
||||
}
|
||||
@@ -1713,6 +1713,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView hann inte filtrera området – flytta kartan för att fråga igen",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Inga av KartaViews foton här klarar bildfiltret",
|
||||
"KartaView did not answer in time — try again": "KartaView svarade inte i tid – försök igen",
|
||||
"Mapillary did not answer in time — try again": "Mapillary svarade inte i tid – försök igen",
|
||||
"KartaView could not be reached — check the network": "KartaView kunde inte nås – kontrollera nätverket",
|
||||
"KartaView sent an answer that could not be read": "KartaView skickade ett svar som inte kunde läsas",
|
||||
"KartaView is rate-limiting requests": "KartaView begränsar antalet förfrågningar",
|
||||
@@ -3327,6 +3328,17 @@
|
||||
"Refresh map still": "Uppdatera stillbilden på kartan",
|
||||
"The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.",
|
||||
"Press OPEN to load the photo": "Tryck på ÖPPNA för att läsa in bilden",
|
||||
"Captured {month}": "Tagen {month}",
|
||||
"this spot": "den här platsen",
|
||||
"the view centre": "vyns mitt",
|
||||
"{source} at {place}": "{source} vid {place}",
|
||||
"Street level at {place}": "Gatunivå vid {place}",
|
||||
"Switching map": "Byter karta",
|
||||
"Opening imagery": "Öppnar bilder",
|
||||
"Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att läsa in den",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} kan inte öppna länkens bild på gatunivå utan en fungerande nyckel",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Bilder på gatunivå är dolda i den aktuella vyn. Lämna den först.",
|
||||
"STATIONS": "STATIONER",
|
||||
"Crewed stations and the craft that visit them": "Bemannade rymdstationer och farkosterna som besöker dem",
|
||||
"IMAGING": "AVBILDNING",
|
||||
@@ -3444,6 +3456,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}",
|
||||
@@ -3457,6 +3484,21 @@
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "Fartygs-UUID: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Latituden måste ligga inom ±90° och longituden inom ±180°",
|
||||
"Street Level is unavailable": "”Gatunivå” är inte tillgängligt",
|
||||
"No street-level imagery source is configured": "Ingen källa för bilder på gatunivå är konfigurerad",
|
||||
"Street Level could not be enabled": "”Gatunivå” kunde inte aktiveras",
|
||||
"Street Level changed while it looked for imagery": "”Gatunivå” ändrades medan bilder söktes",
|
||||
"Aim the view at a street": "Rikta vyn mot en gata",
|
||||
"{source} is not a street-level imagery source here": "{source} är inte en källa för bilder på gatunivå här",
|
||||
"{source} needs its key": "{source} behöver sin nyckel",
|
||||
"{source} needs the {map} map, which is not available": "{source} behöver kartan {map}, som inte är tillgänglig",
|
||||
"{source} could not be switched on": "{source} kunde inte slås på",
|
||||
"{source} is not available on this map": "{source} är inte tillgänglig på den här kartan",
|
||||
"{source} imagery could not be opened": "Bilder från {source} kunde inte öppnas",
|
||||
"{place} has no position to look for imagery at": "{place} har ingen position att söka efter bilder vid",
|
||||
"No {source} imagery near {place}": "Inga bilder från {source} nära {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (aktiva filter: {filters})",
|
||||
"1 h/s": "1 h/s",
|
||||
"1 d/s": "1 d/s",
|
||||
"1 wk/s": "1 v/s",
|
||||
|
||||
@@ -1203,6 +1203,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView не встиг відфільтрувати цю область — зсуньте карту, щоб запитати знову",
|
||||
"None of KartaView’s photos here pass the imagery filter": "Жодне фото KartaView тут не проходить фільтр знімків",
|
||||
"KartaView did not answer in time — try again": "KartaView не відповів вчасно — спробуйте знову",
|
||||
"Mapillary did not answer in time — try again": "Mapillary не відповів вчасно — спробуйте знову",
|
||||
"KartaView could not be reached — check the network": "Не вдалося звʼязатися з KartaView — перевірте мережу",
|
||||
"KartaView sent an answer that could not be read": "KartaView надіслав відповідь, яку не вдалося прочитати",
|
||||
"KartaView is rate-limiting requests": "KartaView обмежує частоту запитів",
|
||||
@@ -3572,6 +3573,17 @@
|
||||
"Refresh map still": "Оновити кадр на карті",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.",
|
||||
"Press OPEN to load the photo": "Натисніть «ВІДКРИТИ», щоб завантажити фото",
|
||||
"Captured {month}": "Знято: {month}",
|
||||
"this spot": "це місце",
|
||||
"the view centre": "центр карти",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Вуличні знімки: {place}",
|
||||
"Switching map": "Перемикання карти",
|
||||
"Opening imagery": "Відкриття знімків",
|
||||
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
|
||||
"{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.",
|
||||
"STATIONS": "СТАНЦІЇ",
|
||||
"Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають",
|
||||
"IMAGING": "ЗЙОМКА",
|
||||
@@ -3614,6 +3626,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}",
|
||||
@@ -3626,5 +3653,20 @@
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}"
|
||||
"Vessel UUID: {id}": "UUID судна: {id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°",
|
||||
"Street Level is unavailable": "Вуличні знімки недоступні",
|
||||
"No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано",
|
||||
"Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки",
|
||||
"Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку",
|
||||
"Aim the view at a street": "Наведіть вигляд на вулицю",
|
||||
"{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків",
|
||||
"{source} needs its key": "{source}: потрібен ключ",
|
||||
"{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна",
|
||||
"{source} could not be switched on": "Не вдалося увімкнути {source}",
|
||||
"{source} is not available on this map": "{source}: недоступно на цій карті",
|
||||
"{source} imagery could not be opened": "Не вдалося відкрити знімки {source}",
|
||||
"{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків",
|
||||
"No {source} imagery near {place}": "Немає знімків {source} поблизу {place}",
|
||||
"{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})"
|
||||
}
|
||||
@@ -1124,6 +1124,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及时筛选此区域——移动地图以重新请求",
|
||||
"None of KartaView’s photos here pass the imagery filter": "此处没有 KartaView 照片通过影像筛选",
|
||||
"KartaView did not answer in time — try again": "KartaView 未及时响应——请重试",
|
||||
"Mapillary did not answer in time — try again": "Mapillary 未及时响应——请重试",
|
||||
"KartaView could not be reached — check the network": "无法连接 KartaView——请检查网络",
|
||||
"KartaView sent an answer that could not be read": "KartaView 返回的响应无法读取",
|
||||
"KartaView is rate-limiting requests": "KartaView 已被限流",
|
||||
@@ -3329,6 +3330,17 @@
|
||||
"Refresh map still": "刷新地图静止画面",
|
||||
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。",
|
||||
"Press OPEN to load the photo": "点击“打开”加载照片",
|
||||
"Captured {month}": "拍摄于 {month}",
|
||||
"this spot": "此处",
|
||||
"the view centre": "视图中心",
|
||||
"{source} at {place}": "{place} 的 {source}",
|
||||
"Street level at {place}": "{place} 的街景",
|
||||
"Switching map": "正在切换地图",
|
||||
"Opening imagery": "正在打开影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
|
||||
"{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。",
|
||||
"STATIONS": "空间站",
|
||||
"Crewed stations and the craft that visit them": "载人空间站及来访飞行器",
|
||||
"IMAGING": "成像",
|
||||
@@ -3371,6 +3383,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} 的位置",
|
||||
@@ -3383,5 +3410,20 @@
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}"
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "纬度须在 ±90° 以内,经度须在 ±180° 以内",
|
||||
"Street Level is unavailable": "街景不可用",
|
||||
"No street-level imagery source is configured": "未配置街景影像来源",
|
||||
"Street Level could not be enabled": "无法开启街景",
|
||||
"Street Level changed while it looked for imagery": "查找影像时街景已更改",
|
||||
"Aim the view at a street": "请将视野对准街道",
|
||||
"{source} is not a street-level imagery source here": "{source} 在此处不是街景影像来源",
|
||||
"{source} needs its key": "{source} 需要其密钥",
|
||||
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地图,但该地图不可用",
|
||||
"{source} could not be switched on": "无法开启 {source}",
|
||||
"{source} is not available on this map": "{source} 在此地图上不可用",
|
||||
"{source} imagery could not be opened": "无法打开 {source} 影像",
|
||||
"{place} has no position to look for imagery at": "{place} 没有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近没有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})"
|
||||
}
|
||||
@@ -1119,6 +1119,7 @@
|
||||
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求",
|
||||
"None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選",
|
||||
"KartaView did not answer in time — try again": "KartaView 未及時響應——請重試",
|
||||
"Mapillary did not answer in time — try again": "Mapillary 未及時響應——請重試",
|
||||
"KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡",
|
||||
"KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取",
|
||||
"KartaView is rate-limiting requests": "KartaView 已被限流",
|
||||
@@ -3329,6 +3330,17 @@
|
||||
"Refresh map still": "重新整理地圖靜態畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
|
||||
"Press OPEN to load the photo": "點擊“打開”加載照片",
|
||||
"Captured {month}": "拍攝於 {month}",
|
||||
"this spot": "此處",
|
||||
"the view centre": "視圖中心",
|
||||
"{source} at {place}": "{place} 的 {source}",
|
||||
"Street level at {place}": "{place} 的街景",
|
||||
"Switching map": "正在切換地圖",
|
||||
"Opening imagery": "正在打開影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載",
|
||||
"{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。",
|
||||
"STATIONS": "太空站",
|
||||
"Crewed stations and the craft that visit them": "載人太空站及來訪飛行器",
|
||||
"IMAGING": "成像",
|
||||
@@ -3371,6 +3383,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} 的位置",
|
||||
@@ -3383,5 +3410,20 @@
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}"
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}",
|
||||
"Latitude must be within ±90° and longitude within ±180°": "緯度須在 ±90° 以內,經度須在 ±180° 以內",
|
||||
"Street Level is unavailable": "街景不可用",
|
||||
"No street-level imagery source is configured": "未配置街景影像來源",
|
||||
"Street Level could not be enabled": "無法開啟街景",
|
||||
"Street Level changed while it looked for imagery": "查找影像時街景已更改",
|
||||
"Aim the view at a street": "請將視野對準街道",
|
||||
"{source} is not a street-level imagery source here": "{source} 在此處不是街景影像來源",
|
||||
"{source} needs its key": "{source} 需要其密鑰",
|
||||
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地圖,但該地圖不可用",
|
||||
"{source} could not be switched on": "無法開啟 {source}",
|
||||
"{source} is not available on this map": "{source} 在此地圖上不可用",
|
||||
"{source} imagery could not be opened": "無法打開 {source} 影像",
|
||||
"{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置",
|
||||
"No {source} imagery near {place}": "{place} 附近沒有 {source} 影像",
|
||||
"{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})"
|
||||
}
|
||||
@@ -21,7 +21,13 @@ export function createDirectionsSource({
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json();
|
||||
// Proxy error pages may not be JSON.
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
if (response.ok) throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
// Failures keep their English message and carry the HTTP status and the
|
||||
// server's refusal text, so the layer can show them in the language.
|
||||
@@ -39,7 +45,8 @@ export function createDirectionsSource({
|
||||
{ status: 429, refusal },
|
||||
);
|
||||
}
|
||||
if (!response.ok && !payload?.error)
|
||||
// A 5xx body may mislabel an outage as a route refusal.
|
||||
if (response.status >= 500 || (!response.ok && !payload?.error))
|
||||
throw Object.assign(
|
||||
new Error(`Routing unavailable (HTTP ${response.status})`),
|
||||
{ status: response.status },
|
||||
|
||||
@@ -41,3 +41,15 @@ test('route failures are surfaced without fallback requests', async () => {
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('a routing outage stays an outage when its body carries an error', async () => {
|
||||
const source = createDirectionsSource({ fetchImpl: async () =>
|
||||
Response.json({ ok: false, error: 'routing service unavailable' }, { status: 502 }) });
|
||||
await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' });
|
||||
});
|
||||
|
||||
test('a routing outage page that is not JSON still reads as an outage', async () => {
|
||||
const source = createDirectionsSource({ fetchImpl: async () =>
|
||||
new Response('<html>Bad Gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) });
|
||||
await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' });
|
||||
});
|
||||
+23
-11
@@ -535,14 +535,26 @@ export function createModel({
|
||||
return `${isolate(latPart)} ${isolate(lonPart)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Millisecond delta → `{ unit: '<1h' | 'h' | 'd', count }`, or null for
|
||||
* invalid input. Both age formatters read it, so they pick the same unit.
|
||||
*/
|
||||
|
||||
function ageParts(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return { unit: '<1h', count: 0 };
|
||||
const wholeHours = Math.round(hours);
|
||||
if (wholeHours < 48) return { unit: 'h', count: wholeHours };
|
||||
return { unit: 'd', count: Math.round(hours / 24) };
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||
|
||||
function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return '<1h';
|
||||
if (hours < 48) return `${Math.round(hours)}h`;
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
const age = ageParts(deltaMs);
|
||||
if (!age) return '';
|
||||
return age.unit === '<1h' ? '<1h' : `${age.count}${age.unit}`;
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
|
||||
@@ -573,12 +585,12 @@ export function createModel({
|
||||
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
|
||||
|
||||
function formatAgeLabel(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return t('<1h', { context: 'age' });
|
||||
if (hours < 48)
|
||||
return t('{count}h', { context: 'age', count: Math.round(hours) });
|
||||
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
|
||||
const age = ageParts(deltaMs);
|
||||
if (!age) return '';
|
||||
if (age.unit === '<1h') return t('<1h', { context: 'age' });
|
||||
return age.unit === 'h'
|
||||
? t('{count}h', { context: 'age', count: age.count })
|
||||
: t('{count}d', { context: 'age', count: age.count });
|
||||
}
|
||||
|
||||
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getLocale, setLocale } from '../../i18n/index.js';
|
||||
import { createModel } from './model.js';
|
||||
|
||||
const HOUR_MS = 3600000;
|
||||
|
||||
test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => {
|
||||
const previousLocale = getLocale();
|
||||
const { formatAge, formatAgeLabel } = createModel({
|
||||
services: { anchors: {} },
|
||||
});
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(formatAge(47.4 * HOUR_MS), '47h');
|
||||
assert.equal(formatAge(47.5 * HOUR_MS), '2d');
|
||||
assert.equal(formatAge(47.6 * HOUR_MS), '2d');
|
||||
assert.equal(formatAge(48 * HOUR_MS), '2d');
|
||||
assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h');
|
||||
assert.equal(formatAgeLabel(47.5 * HOUR_MS), '2d');
|
||||
assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS));
|
||||
assert.equal(formatAgeLabel(48 * HOUR_MS), '2d');
|
||||
} finally {
|
||||
setLocale(previousLocale);
|
||||
}
|
||||
});
|
||||
|
||||
test('both fire age formatters agree on invalid, sub-hour and multi-day ages', () => {
|
||||
const previousLocale = getLocale();
|
||||
const { formatAge, formatAgeLabel } = createModel({
|
||||
services: { anchors: {} },
|
||||
});
|
||||
try {
|
||||
setLocale('en');
|
||||
for (const format of [formatAge, formatAgeLabel]) {
|
||||
assert.equal(format(-1), '');
|
||||
assert.equal(format(Number.NaN), '');
|
||||
assert.equal(format(0.5 * HOUR_MS), '<1h');
|
||||
assert.equal(format(1 * HOUR_MS), '1h');
|
||||
assert.equal(format(72 * HOUR_MS), '3d');
|
||||
}
|
||||
} finally {
|
||||
setLocale(previousLocale);
|
||||
}
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
countryGroups,
|
||||
countryOnly,
|
||||
disputedLabel,
|
||||
eventPicture,
|
||||
feedCardView,
|
||||
feedQueryFor,
|
||||
hotspotLabel,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
normalizeFilters,
|
||||
passesFilters,
|
||||
reportMarkerPosition,
|
||||
reportSeverity,
|
||||
reportsInView,
|
||||
severityStyle,
|
||||
shortTitle,
|
||||
@@ -167,6 +169,14 @@ const emptyInView = () => ({
|
||||
* chosen: outlines (disputed areas dotted) and the geocoded reports as
|
||||
* markers, the big events as picture cards. The News panel renders the feed
|
||||
* this layer publishes through getRowControls().feed.
|
||||
*
|
||||
* `pictureCards(cards, { selectedId })`, when given, draws the picture cards
|
||||
* instead of the layer: it is called on every render with one card per
|
||||
* pictured event on the map, at every height (zoomed out too, where the
|
||||
* hotspots stand as beacons), in rank order (`{ id, title, lat, lon,
|
||||
* publishedAt, severity, color, image, video, credit }`), and the selected
|
||||
* report's id or null, whose own card the layer still draws at its marker; it
|
||||
* is called with no cards when the layer is turned off.
|
||||
*/
|
||||
export function createNewsLayer({
|
||||
source,
|
||||
@@ -178,6 +188,7 @@ export function createNewsLayer({
|
||||
openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
|
||||
getViewCentre = viewCentre,
|
||||
registerCredit = registerDynamicCredit,
|
||||
pictureCards: drawPictureCards = null,
|
||||
} = {}) {
|
||||
if (
|
||||
typeof source?.getHotspots !== 'function' ||
|
||||
@@ -185,6 +196,8 @@ export function createNewsLayer({
|
||||
)
|
||||
throw new TypeError('News requires a news source');
|
||||
if (!overlayHost) throw new TypeError('News requires an overlay host');
|
||||
if (drawPictureCards !== null && typeof drawPictureCards !== 'function')
|
||||
throw new TypeError('News picture cards must be a function');
|
||||
|
||||
let viewer = null,
|
||||
dataSource = null,
|
||||
@@ -679,6 +692,53 @@ export function createNewsLayer({
|
||||
}
|
||||
}
|
||||
|
||||
// What a host's picture cards are chosen from when the hotspots stand as
|
||||
// beacons: every report the close-up view would mark, and the selection.
|
||||
function cardable() {
|
||||
const current = selected();
|
||||
const all = localMode() ? localReports(NEWS_MAP_LIMIT) : mapReports;
|
||||
return current && !all.some((r) => r.id === current.id)
|
||||
? [...all, current]
|
||||
: all;
|
||||
}
|
||||
|
||||
// With a `pictureCards` function the host draws the picture cards: one per
|
||||
// event on the map with a picture (its most severe, then newest report),
|
||||
// most severe then newest first, at its marker. The host chooses which
|
||||
// stand; a click on one calls setParams({ reportId, focus: true }).
|
||||
function hostCards(drawn) {
|
||||
const events = new Set();
|
||||
return drawn
|
||||
.filter((r) => r.pins.length && onMap(r) && eventPicture(r))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
reportSeverity(b) - reportSeverity(a) ||
|
||||
(b.event?.lastAt || b.publishedAt || '').localeCompare(
|
||||
a.event?.lastAt || a.publishedAt || '',
|
||||
),
|
||||
)
|
||||
.filter((report) => {
|
||||
const event = report.event?.id || report.id;
|
||||
if (events.has(event)) return false;
|
||||
events.add(event);
|
||||
return true;
|
||||
})
|
||||
.map((report) => {
|
||||
const picture = eventPicture(report);
|
||||
return {
|
||||
id: report.id,
|
||||
title: report.titleEn || report.title,
|
||||
...reportMarkerPosition(report, 0),
|
||||
publishedAt: report.event?.lastAt || report.publishedAt || null,
|
||||
severity: reportSeverity(report),
|
||||
color: severityStyle(report).color,
|
||||
image: picture.url,
|
||||
video: picture.video || null,
|
||||
credit: picture.credit,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// The selected report's card: what, where, how attested, the link to the
|
||||
// original article ("Source: host ↗"), which a click on it opens, and the
|
||||
// service it came through.
|
||||
@@ -775,7 +835,13 @@ export function createNewsLayer({
|
||||
.filter((target) => target.position);
|
||||
horizon.update();
|
||||
updatePulse();
|
||||
pictureCards(drawn, current, labels);
|
||||
if (drawPictureCards)
|
||||
// At every height: zoomed out, a hotspot's reports give way to its
|
||||
// beacon, but their pictures still stand (the host keeps them apart).
|
||||
drawPictureCards(enabled ? hostCards(detail ? drawn : cardable()) : [], {
|
||||
selectedId: enabled ? selectedId : null,
|
||||
});
|
||||
else pictureCards(drawn, current, labels);
|
||||
selectedCard(current, labels);
|
||||
if (enabled)
|
||||
overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false });
|
||||
@@ -1442,6 +1508,7 @@ export function createNewsLayer({
|
||||
}
|
||||
overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false);
|
||||
drawPictureCards?.([], { selectedId: null });
|
||||
requestRender();
|
||||
notify();
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ function fixture(
|
||||
centre = () => ({ lat: 49, lon: 36 }),
|
||||
credits = [],
|
||||
now = () => NOW,
|
||||
pictureCards,
|
||||
} = {},
|
||||
) {
|
||||
const calls = { feed: [], hotspots: 0, outline: 0 };
|
||||
@@ -118,6 +119,7 @@ function fixture(
|
||||
credits.push(credit.key);
|
||||
return true;
|
||||
},
|
||||
...(pictureCards ? { pictureCards } : {}),
|
||||
});
|
||||
// The context store announces selections on `window`, which Node lacks.
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -227,6 +229,113 @@ test('the severity floor changes what is shown, not what is read', async (t) =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a host that draws the picture cards is handed one card per pictured event on the map, most severe first', async (t) => {
|
||||
const picture = (url, extra = {}) => ({
|
||||
size: 1,
|
||||
lastAt: '2026-10-28T09:00:00.000Z',
|
||||
sides: [],
|
||||
image: { url, publisher: 'Example', ...extra },
|
||||
});
|
||||
const reports = [
|
||||
report('single', {
|
||||
severity: 1,
|
||||
event: picture('https://example.com/single.jpg', {
|
||||
video: 'https://example.com/single.mp4',
|
||||
}),
|
||||
}),
|
||||
report('major', {
|
||||
severity: 3,
|
||||
event: {
|
||||
...picture('https://example.com/major.jpg'),
|
||||
id: 'event-major',
|
||||
size: 3,
|
||||
},
|
||||
}),
|
||||
// Another report of the same event: its picture stands once.
|
||||
report('repeat', {
|
||||
severity: 2,
|
||||
event: {
|
||||
...picture('https://example.com/major.jpg'),
|
||||
id: 'event-major',
|
||||
size: 3,
|
||||
},
|
||||
}),
|
||||
report('bare', { severity: 2 }),
|
||||
];
|
||||
const handed = [];
|
||||
const { layer, entries } = fixture(t, {
|
||||
height: 500_000,
|
||||
source: { getFeed: async () => ({ reports, nextBefore: null }) },
|
||||
pictureCards: (cards, state) => handed.push({ cards, ...state }),
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ filters: { minSeverity: 0 } });
|
||||
const { cards, selectedId } = handed.at(-1);
|
||||
assert.equal(selectedId, null);
|
||||
// A single source is enough; a report without a picture has none.
|
||||
assert.deepEqual(
|
||||
cards.map((card) => card.id),
|
||||
['major', 'single'],
|
||||
);
|
||||
assert.equal(cards[1].image, 'https://example.com/single.jpg');
|
||||
assert.equal(cards[1].video, 'https://example.com/single.mp4');
|
||||
assert.equal(cards[0].video, null);
|
||||
assert.equal(cards[0].credit, 'Example');
|
||||
assert.ok(
|
||||
Math.abs(cards[0].lat - 50) < 0.5 && Math.abs(cards[0].lon - 36) < 0.5,
|
||||
);
|
||||
// The layer paints none of its own.
|
||||
assert.ok(
|
||||
entries.flat().every((entry) => !entry.id.startsWith('event-card:')),
|
||||
);
|
||||
// The selected report is named, pictured or not.
|
||||
layer.setParams({ reportId: 'bare' });
|
||||
assert.equal(handed.at(-1).selectedId, 'bare');
|
||||
layer.disable();
|
||||
assert.deepEqual(handed.at(-1), { cards: [], selectedId: null });
|
||||
});
|
||||
|
||||
test("a host's picture cards stay at every height, beacons or markers", async (t) => {
|
||||
const reports = [
|
||||
report('major', {
|
||||
severity: 3,
|
||||
event: {
|
||||
size: 1,
|
||||
lastAt: '2026-10-28T09:00:00.000Z',
|
||||
sides: [],
|
||||
image: { url: 'https://example.com/major.jpg', publisher: 'Example' },
|
||||
},
|
||||
}),
|
||||
];
|
||||
const handed = [];
|
||||
const { layer } = fixture(t, {
|
||||
// The whole globe: the hotspot stands as one beacon, its reports unmarked.
|
||||
height: 20_000_000,
|
||||
source: { getFeed: async () => ({ reports, nextBefore: null }) },
|
||||
pictureCards: (cards) => handed.push(cards),
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ filters: { minSeverity: 0 } });
|
||||
assert.deepEqual(
|
||||
handed.at(-1).map((card) => card.id),
|
||||
['major'],
|
||||
);
|
||||
});
|
||||
|
||||
test('picture cards must be drawn by a function', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createNewsLayer({
|
||||
source: { getHotspots() {}, getFeed() {} },
|
||||
overlayHost: {},
|
||||
pictureCards: 'yes',
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('Local reads every report once, lists those near the view and takes the map over', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let centre = { lat: 49, lon: 36 };
|
||||
|
||||
Vendored
+202
@@ -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 };
|
||||
}
|
||||
@@ -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<string, string>} [previous] copy key → key drawn last time.
|
||||
* @returns {{winners: Map<string, string>, hidden: Set<string>}}
|
||||
* `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 }),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<object>} */
|
||||
_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';
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user