mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dfa7ca32d | ||
|
|
ef6c30d4c3 |
No files matched your search
+6
-309
@@ -175,312 +175,6 @@ How to read this:
|
||||
| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) |
|
||||
| **Le Havre LiA** (opendata.transports-lia.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); and this is its own GTFS-RT vehicle feed from that record; route ids carry the network prefix `8-`. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) |
|
||||
| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine |
|
||||
| **Agde Cap’Bus** (zenbus.net) | Transit layer live vehicles, Agde, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-capbus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (6 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Cap’Bus – Communauté d’agglomération Hérault-Méditerranée |
|
||||
| **Agen Tempo** (api.pysae.com) | Transit layer live vehicles, Agen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/agen-gtfs-urbain). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (33 vehicles on 2026-11-13). | Tempo Bus – Agglomération d’Agen |
|
||||
| **Alès Alesy** (alesy.plateforme-2cloud.com) | Transit layer live vehicles, Alès, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-des-lignes-urbaines-et-interurbaines-sur-le-reseau-alesy-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (62 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | Alesy – Syndicat mixte transports publics du bassin d’Alès |
|
||||
| **Aléop coaches** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Pays de la Loire, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/arrets-horaires-et-circuits-des-lignes-de-transports-aleop-1). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (103 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Route `YEU` is the Île d’Yeu boat. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Aléop - Pays de la Loire – Pays de la Loire |
|
||||
| **Amiens Ametis** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Amiens, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/ametis). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (95 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Ametis – Communauté d’agglomération Amiens Métropole |
|
||||
| **Angers Irigo** (ara-api.enroute.mobi) | Transit layer live vehicles, Angers, France | ODbL (https://transport.data.gouv.fr/datasets/angers-loire-metropole-reseau-irigo-gtfs-gtfs-rt-siri). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (214 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Lines A, B and C are the tramway. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Irigo – Communauté urbaine Angers Loire Métropole |
|
||||
| **Arles Envia** (accm.plateforme-2cloud.com) | Transit layer live vehicles, Arles, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reels-reseau-envia). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (33 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Envia – Communauté d’agglomération d’Arles-Crau-Camargue-Montagnette |
|
||||
| **Arras Artis** (api.pysae.com) | Transit layer live vehicles, Arras, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-artis). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Artis – Communauté urbaine d’Arras |
|
||||
| **Aubenas Tout’enbus** (gtfs-rt.infra-hubup.fr) | Transit layer live vehicles, Aubenas, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Tout’enbus – Région Auvergne-Rhône-Alpes (CC du Bassin d’Aubenas) |
|
||||
| **Auch Alliance** (zenbus.net) | Transit layer live vehicles, Auch, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-reseau-alliance-bus-grand-auch). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (7 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Alliance – Communauté d’agglomération Grand Auch Coeur de Gascogne |
|
||||
| **Auray Glazgo** (api.pysae.com) | Transit layer live vehicles, Auray, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-glazgo). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (4 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Glazgo – Communauté de communes Auray Quiberon Terre Atlantique |
|
||||
| **Avignon Orizo** (api.maas-fr.cityway.fr) | Transit layer live vehicles, Avignon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-orizo-grand-avignon). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (90 vehicles on 2026-11-13). Line T1 is the tramway. | Orizo – Communauté d’agglomération du Grand Avignon (COGA) |
|
||||
| **Barentin MOCA** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Barentin, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/moca-communaute-de-communes-caux-austreberthe). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Published through the Atoumod (Normandy) hub; its positions stalled for about ten minutes once on 2026-11-13. | MOCA – Communauté de communes Caux - Austreberthe |
|
||||
| **Beaune Côte&Bus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Beaune, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/offre-de-transport-de-la-c-a-beaune-cote-sud-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Côte&Bus – Communauté d’agglomération Beaune, Côte et Sud - Communauté Beaune-Chagny-Nolay |
|
||||
| **Beauvais Corolis** (api.oisemob.cityway.fr) | Transit layer live vehicles, Beauvais, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-corolis-urbain-communaute-dagglomeration-du-beauvaisis). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (23 vehicles on 2026-11-13). | Corolis – Communauté d’agglomération du Beauvaisis |
|
||||
| **Beauvaisis Corolis interurbain** (api.oisemob.cityway.fr) | Transit layer live vehicles, Beauvais, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-corolis-interurbain-communaute-dagglomeration-du-beauvaisis). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (13 vehicles on 2026-11-13). | Corolis – Communauté d’agglomération du Beauvaisis |
|
||||
| **Bernay l’Bus** (zenbus.net) | Transit layer live vehicles, Bernay, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-transport-urbain-de-bernay). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (1 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | l’Bus – Communauté de communes Intercom Bernay Terres de Normandie |
|
||||
| **Blois Azalys** (app.mecatran.com) | Transit layer live vehicles, Blois, France | ODbL (https://transport.data.gouv.fr/datasets/agglopolys-offre-theorique-mobilite-reseau-urbain-azalys-de-blois). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (55 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Speeds are km/h: readings ran 6.63 times the measured movement on 2026-11-13, so the proxy converts them. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Azalys – Communauté d’agglomération de Blois "Agglopolys" |
|
||||
| **Bourg-en-Bresse Rubis** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Bourg-en-Bresse, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/rubis-grand-bourg-mobilites-offre-de-transport-theorique-et-temps-reel). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (34 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Speeds are km/h: readings ran 4.99 times the measured movement on 2026-11-13, so the proxy converts them. The feed carries no route ids. | Rubis – Communauté d’agglomération du Bassin de Bourg-en-Bresse |
|
||||
| **Bourgoin-Jallieu Ruban** (app.pysae.com) | Transit layer live vehicles, Bourgoin-Jallieu, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-ruban). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (28 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | Ruban – Communauté d’agglomération Porte de l’Isère (CAPI) |
|
||||
| **Brest Bibus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Brest, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-des-bus-et-tramways-circulant-sur-le-territoire-de-brest-metropole). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (113 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Lines A and B are the tramway; line C is the cable car, which has no mode of its own here and draws as a bus. Vehicles carry no timestamp of their own, so the feed header’s is used. | Bibus – Brest Métropole |
|
||||
| **Béziers beeMob** (feed-beemob-beziers.ratpdev.com) | Transit layer live vehicles, Béziers, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-de-transports-beemob-beziers-mediterranee). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (40 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | beeMob – Communauté d’agglomération de Béziers-Méditerranée |
|
||||
| **Cannes Palm Bus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Cannes, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-gtfs-gtfs-rt-du-reseau-palmbus-cannes-pays-de-lerins). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (86 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Palmbus – Communauté d’agglomération Cannes Pays de Lérins |
|
||||
| **Carcassonne RTCA** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Carcassonne, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-rtca). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (23 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | RTCA – Communauté d’agglomération Carcassonne Agglo |
|
||||
| **Caux Seine Rezo’Bus** (app.pysae.com) | Transit layer live vehicles, Lillebonne, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-caux-seine-mobilite-rezobus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | REZO’BUS – Communauté d’agglomération Caux Seine Agglo |
|
||||
| **Cavaillon C’mon bus** (app.pysae.com) | Transit layer live vehicles, Cavaillon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-reseau-cmonbus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (11 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Cmonbus – Communauté d’agglomération Luberon Monts de Vaucluse |
|
||||
| **Cayenne Agglo’bus** (cacl.plateforme-2cloud.com) | Transit layer live vehicles, Cayenne, French Guiana | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/agence-agglobus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (44 vehicles on 2026-11-13). | Agglobus – Communauté d’agglomération du Centre Littoral |
|
||||
| **Chamonix buses** (api.pysae.com) | Transit layer live vehicles, Chamonix-Mont-Blanc, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-reseau-chamonix-mobilite). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (11 vehicles on 2026-11-13). | Communauté de communes de la Vallée de Chamonix-Mont-Blanc |
|
||||
| **Chantilly Aire’MOB** (api.oisemob.cityway.fr) | Transit layer live vehicles, Chantilly, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-airemob-communaute-de-communes-de-laire-cantilienne). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). | Aire’MOB – Communauté de communes de l’Aire Cantilienne |
|
||||
| **Cherbourg Cap Cotentin** (app.pysae.com) | Transit layer live vehicles, Cherbourg-en-Cotentin, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-en-temps-reel-des-bus-et-autocars-circulant-sur-le-reseau-cap-cotentin). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (53 vehicles on 2026-11-13). | Cap Cotentin – Communauté d’agglomération du Cotentin |
|
||||
| **Chinon CVL Mobilité** (cvlmobilite.plateforme-2cloud.com) | Transit layer live vehicles, Chinon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-cvlmobilite-plan-de-transport-theorique-ligne-a-format-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (7 vehicles on 2026-11-13). | CVL Mobilité – Communauté de communes Chinon, Vienne et Loire |
|
||||
| **Clermont (Oise) Le Bus** (api.oisemob.cityway.fr) | Transit layer live vehicles, Clermont, Oise, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-le-bus-communaute-de-communes-du-clermontois). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (4 vehicles on 2026-11-13). | Le Bus – Communauté de communes du Clermontois |
|
||||
| **Compiègne TIC** (api.oisemob.cityway.fr) | Transit layer live vehicles, Compiègne, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-tic-urbain-communaute-dagglomeration-de-la-region-de-compiegne-et-de-la-basse-automne). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (19 vehicles on 2026-11-13). | TIC – Communauté d’agglomération de la Région de Compiègne et de la Basse Automne |
|
||||
| **Compiègne TIC interurbain** (api.oisemob.cityway.fr) | Transit layer live vehicles, Compiègne, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-tic-interurbain-communaute-dagglomeration-de-la-region-de-compiegne-et-de-la-basse-automne). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (21 vehicles on 2026-11-13). | TIC – Communauté d’agglomération de la Région de Compiègne et de la Basse Automne |
|
||||
| **Cosne-sur-Loire Papillon** (api.pysae.com) | Transit layer live vehicles, Cosne-Cours-sur-Loire, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-du-reseau-de-bus-papillon-a-cosne-cours-sur-loire). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Papillon – Communauté de communes Coeur de Loire |
|
||||
| **Creil AXO** (api.oisemob.cityway.fr) | Transit layer live vehicles, Creil, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-axo-communaute-dagglomeration-creil-sud-oise). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (28 vehicles on 2026-11-13). | AXO – Communauté d’agglomération Creil Sud Oise |
|
||||
| **Crépy-en-Valois Mobi** (api.oisemob.cityway.fr) | Transit layer live vehicles, Crépy-en-Valois, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-mobi-communaute-de-communes-du-pays-de-valois). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). | Mobi – Communauté de communes du Pays de Valois |
|
||||
| **Cœur du Var Le TACO** (api.pysae.com) | Transit layer live vehicles, Le Luc, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/le-taco-1). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (1 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Le TACO – Région Provence-Alpes-Côte d’Azur (CC Coeur du Var) |
|
||||
| **Dieppe DeepMob** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Dieppe, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/deepmob-dieppe-maritime). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (12 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Published through the Atoumod (Normandy) hub; its positions stalled for about ten minutes once on 2026-11-13. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | DeepMob – Communauté d’agglomération de la Région Dieppoise |
|
||||
| **Dijon Divia** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Dijon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-diviamobilites). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (149 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Speeds are km/h: readings ran 4.42 times the measured movement on 2026-11-13, so the proxy converts them. Lines T1 and T2 are the tramway. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | DiviaMobilités – Dijon Métropole |
|
||||
| **Dole Grandole Mobilités** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Dole, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-du-grand-dole). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (18 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Grandole Mobilités – Communauté d’agglomération du Grand Dole |
|
||||
| **Draguignan TEDbus** (pysae.com) | Transit layer live vehicles, Draguignan, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-tedbus-lignes-regulieres-dracenie-provence-verdon-agglomeration). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (15 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | TEDbus – Dracénie Provence Verdon Agglomération |
|
||||
| **Figeac Le Bus** (zenbus.net) | Transit layer live vehicles, Figeac, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-du-reseau-figeac-le-bus-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (3 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Figeac - Le Bus – Région Occitanie (CC Grand-Figeac) |
|
||||
| **Flers Némus** (flers-agglo.plateforme-2cloud.com) | Transit layer live vehicles, Flers, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-du-reseau-urbain-de-flers-agglo-au-format-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Nemus – Communauté d’agglomération Flers Agglo |
|
||||
| **Fougères SURF** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Fougères, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-surf). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (8 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | SURF – Communauté d’agglomération Fougères Agglomération |
|
||||
| **Fréjus–Saint-Raphaël Le Bus** (api.pysae.com) | Transit layer live vehicles, Fréjus, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-transport-urbain-le-bus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (31 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Le Bus – Estérel Côte d’Azur Agglomération |
|
||||
| **Gap L’Agglo en Bus** (gtfs-rt.infra-hubup.fr) | Transit layer live vehicles, Gap, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reels-du-reseau-de-transports-lagglo-en-bus-communaute-dagglomeration-gap-tallard-durance). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (11 vehicles on 2026-11-13). | L’Agglo en Bus – Communauté d’agglomération Gap-Tallard-Durance |
|
||||
| **Guingamp-Paimpol Mobilité** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Guingamp, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-scolaire-guingamp-paimpol-mobilite). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (13 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Served by the national access point’s own proxy of the operator feed. | Guingamp-Paimpol Mobilité – Communauté d’agglomération Guingamp-Paimpol Agglomération de l’Armor à l’Argoat |
|
||||
| **Guérande Lila Presqu’île** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Guérande, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-scolaire-lila). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (20 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Lila Presqu’ile – Communauté d’agglomération de la Presqu’île de Guérande Atlantique (CAP ATLANTIQUE) |
|
||||
| **Haguenau Ritmo** (zenbus.net) | Transit layer live vehicles, Haguenau, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-ritmo-haguenau-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (20 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Ritmo – Communauté d’agglomération de Haguenau |
|
||||
| **Hazebrouck Hop Bus** (gtfs-rt.infra-hubup.fr) | Transit layer live vehicles, Hazebrouck, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/hop-bus-le-reseau-100-gratuit-du-coeur-de-flandre). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (8 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Hop Bus – Communauté d’agglomération Coeur de Flandre |
|
||||
| **Honfleur Hobus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Honfleur, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/hobus-honfleur). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Published through the Atoumod (Normandy) hub; its positions stalled for about ten minutes once on 2026-11-13. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Hobus – Communauté de communes du Pays de Honfleur-Beuzeville |
|
||||
| **Issoire Cherpa** (zenbus.net) | Transit layer live vehicles, Issoire, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/cherpa-horaires-lignes-regulieres). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (1 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Cherpa – Agglo Pays d’Issoire |
|
||||
| **Lavaur Lisatis** (zenbus.net) | Transit layer live vehicles, Lavaur, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-lisatis). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (3 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | LISATIS – Région Occitanie (CC Tarn-Agout) |
|
||||
| **Le Mans SETRAM** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Le Mans, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-du-reseau-des-transports-bus-et-tramway-setram-circulant-sur-le-territoire-le-mans-metropole). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (102 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Speeds are km/h: readings ran 3.67 times the measured movement on 2026-11-13, so the proxy converts them. Lines T1 and T2 are the tramway. | SETRAM – Communauté urbaine Le Mans Métropole |
|
||||
| **Le Tampon CarSud** (zenbus.net) | Transit layer live vehicles, Le Tampon, La Réunion, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/referentiel-topologique-reseau-carsud). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (22 vehicles on 2026-11-13). | CarSud – Communauté d’agglomération du Sud |
|
||||
| **Liancourt Le Libellule** (api.oisemob.cityway.fr) | Transit layer live vehicles, Liancourt, France | ODbL (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-le-libellule-communaute-du-liancourtois-la-vallee-doree). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (1 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | Libellule – Communauté de communes du Liancourtois |
|
||||
| **Lisieux Astrobus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Lisieux, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/astrobus-lisieux-normandie). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (6 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Published through the Atoumod (Normandy) hub; its positions stalled for about ten minutes once on 2026-11-13. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Astrobus – Communauté d’agglomération Lisieux Normandie |
|
||||
| **Longwy TGL** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Longwy, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-du-reseau-tgl). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (21 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | TGL – Syndicat mixte intercommunal des transports de l’agglomération de Longwy |
|
||||
| **Lunel Olé** (ole.plateforme-2cloud.com) | Transit layer live vehicles, Lunel, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/offre-de-transport-du-reseau-ole-lunel-agglo-gtfs-et-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). | Olé – Communauté d’agglomération Lunel Agglo |
|
||||
| **Lunéville Lunéo** (pysae.com) | Transit layer live vehicles, Lunéville, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-lulu-en-bus-ex-luneo-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Lunéo – PETR du Pays du Lunévillois |
|
||||
| **Martinique Centre (CACEM)** (ext.saemartinique.ovh:4435) | Transit layer live vehicles, Fort-de-France, Martinique, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-urbain-de-la-zone-centre). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (28 vehicles on 2026-11-13). | de la zone Centre (CACEM) – Martinique Transport |
|
||||
| **Martinique Sud (CAESM)** (api.pysae.com) | Transit layer live vehicles, Southern Martinique, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-urbain-de-la-zone-sud). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (42 vehicles on 2026-11-13). Routes 4001 to 4004 are the Vedettes boats. | de la zone Sud (CAESM) – Martinique Transport |
|
||||
| **Mayenne May’bus** (zenbus.net) | Transit layer live vehicles, Mayenne, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-maybus-mayenne-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | May’bus – Communauté de communes Mayenne Communauté |
|
||||
| **Mende TUM** (zenbus.net) | Transit layer live vehicles, Mende, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-max-mende-mobilites-bus-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (1 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | TUM – Région Occitanie (CC Coeur de Lozère) |
|
||||
| **Menton Zest** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Menton, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-reseau-zest). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (22 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Zest – Communauté d’agglomération de la Riviera Française |
|
||||
| **Metz Le Met’** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Metz, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fichiers-gtfs-eurometropole-de-metz). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (140 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Le Met’ – Metz Métropole |
|
||||
| **Montluçon Maelis** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Montluçon, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-reel-du-reseau-maelis-montlucon-communaute-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (15 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Served by the national access point’s own proxy of the operator feed. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-13, so the proxy converts them. The feed carries no route ids. | Maelis – Communauté d’agglomération Montluçon Communauté |
|
||||
| **Montélimar Montélibus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Montélimar, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-montelibus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (20 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Montelibus – Communauté d’agglomération Montélimar Agglomération |
|
||||
| **Moulins Aléo** (api.pysae.com) | Transit layer live vehicles, Moulins, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-du-reseau-de-transport-aleo). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (16 vehicles on 2026-11-13). | Aléo – Communauté d’agglomération Moulins Communauté |
|
||||
| **Narbonne Citibus** (feed-citibus-narbonne.ratpdev.com) | Transit layer live vehicles, Narbonne, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/offre-de-transport-communaute-dagglomeration-le-grand-narbonne). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (54 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Citibus – Communauté d’agglomération Le Grand Narbonne |
|
||||
| **Nice Lignes d’Azur** (ara-api.enroute.mobi) | Transit layer live vehicles, Nice, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-dynamiques-du-reseau-de-transport-lignes-dazur). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (241 vehicles on 2026-11-13). Lines L1, L2 and L3 are the tramway. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Lignes d’Azur – Métropole Nice Côte d’Azur |
|
||||
| **Nogent-le-Rotrou Nosbus** (api.pysae.com) | Transit layer live vehicles, Nogent-le-Rotrou, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-nosbus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Nosbus – Région Centre-Val de Loire (CC du Perche) |
|
||||
| **Noyon Lib’Bus** (api.oisemob.cityway.fr) | Transit layer live vehicles, Noyon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-libbus-communaute-de-communes-du-pays-noyonnais). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). | Lib’Bus – Communauté de communes du Pays Noyonnais |
|
||||
| **Oloron-Sainte-Marie La Navette** (zenbus.net) | Transit layer live vehicles, Oloron-Sainte-Marie, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-oloron-sainte-marie-la-navette-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | La Navette Oloron Sainte-Marie – Communauté de communes du Haut Béarn |
|
||||
| **Orange TCVO** (tcvo.2cloud.app) | Transit layer live vehicles, Orange, France | ODbL (https://transport.data.gouv.fr/datasets/transport-en-commun-de-la-ville-dorange-ccpop-1). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | TCVO – Pays d’Orange en Provence |
|
||||
| **Pays de l’Or Transp’Or** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Mauguio, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/offre-transport-en-commun-du-reseau-transpor-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (6 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Transp’Or – Communauté d’agglomération du Pays de l’Or |
|
||||
| **Pays des Écrins buses** (app.pysae.com) | Transit layer live vehicles, L’Argentière-la-Bessée, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/transports-en-commun-pays-des-ecrins). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). | Communauté de communes du Pays des Ecrins |
|
||||
| **Perpignan Sankéo** (zenbus.net) | Transit layer live vehicles, Perpignan, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-sankeo). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (50 vehicles on 2026-11-13). | Sankéo – Communauté urbaine Perpignan Méditerranée Métropole |
|
||||
| **Pont-Sainte-Maxence TOHM** (api.oisemob.cityway.fr) | Transit layer live vehicles, Pont-Sainte-Maxence, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-theoriques-et-temps-reel-du-reseau-tohm-communaute-de-communes-des-pays-doise-et-dhalatte). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). | TOHM – Communauté de communes des Pays d’Oise et d’Halatte |
|
||||
| **Pontivy PondiBUS** (pysae.com) | Transit layer live vehicles, Pontivy, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-pondibus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | PondiBUS – Communauté de communes Pontivy Communauté |
|
||||
| **Porto-Vecchio A Citadina** (pysae.com) | Transit layer live vehicles, Porto-Vecchio, Corsica, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-a-citadina-porto-vecchio-horaires-theoriques-et-temps-reel). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | A Citadina – Communauté de communes du Sud Corse |
|
||||
| **Quimper QUB** (feed-qub-quimper.ratpdev.com) | Transit layer live vehicles, Quimper, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-du-reseau-de-transports-qub-transports-urbains-de-quimper-bretagne-occidentale). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (49 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Qub – Communauté d’agglomération Quimper Bretagne Occidentale |
|
||||
| **Riom RLV Mobilités** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Riom, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-rlv-mobilites). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (7 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | RLV Mobilités – Communauté d’agglomération Riom Limagne et Volcans |
|
||||
| **Rosny-sous-Bois Titus** (zenbus.net) | Transit layer live vehicles, Rosny-sous-Bois, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-titus-rosny-sous-bois-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Titus – Île-de-France Mobilités |
|
||||
| **Rumilly J’ybus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Rumilly, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/offre-de-transports-jybus-a-rumilly). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (6 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Jybus – Communauté de communes Rumilly Terre de Savoie |
|
||||
| **Rémi coaches** (api.okina.fr) | Transit layer live vehicles, Centre-Val de Loire, France | ODbL (https://transport.data.gouv.fr/datasets/remi-offre-theorique-mobilite-reseau-interurbain-regional). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (100 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Rémi – Centre-Val de Loire |
|
||||
| **Réunion Car Jaune** (pysae.com) | Transit layer live vehicles, La Réunion, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-au-format-gtfs-et-horaires-temps-reel-au-format-gtfs-rt-du-reseau-car-jaune-a-la-reunion). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (59 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Car Jaune – La Réunion |
|
||||
| **Sablé-sur-Sarthe Réso** (gtfs-rt.infra-hubup.fr) | Transit layer live vehicles, Sablé-sur-Sarthe, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-reso). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Réso – Communauté de communes du Pays Sabolien |
|
||||
| **Saint-Brevin Brévibus** (pysae.com) | Transit layer live vehicles, Saint-Brevin-les-Pins, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-brevibus). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (1 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Brévibus – Communauté de communes du Sud Estuaire |
|
||||
| **Saint-Malo MAT** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Saint-Malo, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-urbain-mat). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (30 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | MAT – Communauté d’agglomération du Pays de Saint Malo Agglomération |
|
||||
| **Saint-Omer Mouvéo** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Saint-Omer, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/transports-en-commun-audomarois-1). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (32 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Mouvéo – Communauté d’agglomération du Pays de Saint-Omer |
|
||||
| **Saint-Pierre Alternéo** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Saint-Pierre, La Réunion, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-du-reseau-alterneo). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (17 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Alternéo – Communauté d’agglomération CIVIS (Communauté Intercommunale des Villes Solidaires) |
|
||||
| **Saint-Sulpice-la-Pointe buses** (zenbus.net) | Transit layer live vehicles, Saint-Sulpice-la-Pointe, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-ville-de-st-sulpice-la-pointe). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (2 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | ST SULPICE LA POINTE – Région Occitanie (CC Tarn-Agout) |
|
||||
| **Saintes Buss** (zenbus.net) | Transit layer live vehicles, Saintes, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-buss-saintes-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (19 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Buss – Communauté d’agglomération de Saintes |
|
||||
| **Sarlat Bus** (zenbus.net) | Transit layer live vehicles, Sarlat-la-Canéda, France | ODbL (https://transport.data.gouv.fr/datasets/horaires-theoriques-et-temps-reel-du-reseau-sarlatbus-gtfs-gtfs-rt). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (1 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Sarlat Bus – Communauté de communes Sarlat-Périgord Noir |
|
||||
| **Sarthe Illygo** (pysae.com) | Transit layer live vehicles, Sarthe, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-illygo). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (5 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Illygo – Pôle Métropolitain Mobilités Le Mans – Sarthe |
|
||||
| **Sète Agglopôle Mobilité** (sete.ceccli.com) | Transit layer live vehicles, Sète, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-bus-sete-agglopole-mobilite). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (48 vehicles on 2026-11-13). | Sète Agglopole Mobilité – Communauté d’agglomération Sète Agglopôle Méditerranée |
|
||||
| **Tarbes–Lourdes TLP Mobilités** (zenbus.net) | Transit layer live vehicles, Tarbes, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-du-reseau-tarbes-lourdes-tlp-mobilites-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (24 vehicles on 2026-11-13). | TLP – Communauté d’agglomération Tarbes-Lourdes-Pyrénées |
|
||||
| **Troyes TCAT** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Troyes, France | ODbL (https://transport.data.gouv.fr/datasets/donnees-tcat-troyes-champagne-metropole-1). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (52 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Served by the national access point’s own proxy of the operator feed. | TCAT – Communauté d’agglomération Troyes Champagne Métropole |
|
||||
| **Valence–Romans Citéa** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Valence, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/ce-jeu-de-donnees-contient-la-liste-des-arrets-des-horaires-et-des-parcours-theoriques-du-reseau-de-transport-urbain-et-interurbain-de-vrm). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (130 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Citéa – Valence-Romans déplacements |
|
||||
| **Valenciennes Transvilles** (ara-api.enroute.mobi) | Transit layer live vehicles, Valenciennes, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/simouv-horaires-theoriques-format-gtfs-et-temps-reel-format-siri-transvilles-valenciennes). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (87 vehicles on 2026-11-13). Lines T1 and T2 (route ids 190 and 191) are the tramway. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Transvilles – Syndicat intercommunal de mobilité et d’organisation urbaine du Valenciennois |
|
||||
| **Vannes Kicéo** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Vannes, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-urbain-kiceo). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (63 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Kicéo – Communauté d’agglomération Golfe du Morbihan - Vannes Agglomération |
|
||||
| **Vendôme Move** (pysae.com) | Transit layer live vehicles, Vendôme, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/gtfs-move-vendome). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (7 vehicles on 2026-11-13). Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Move – Communauté d’agglomération Territoires Vendômois |
|
||||
| **Vernon SNgo!** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Vernon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/sngo-vernon-les-andelys-seine-normandie-agglo). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (7 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Published through the Atoumod (Normandy) hub; its positions stalled for about ten minutes once on 2026-11-13. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | SNgo! – Communauté d’agglomération Seine Normandie Agglomération |
|
||||
| **Vesoul Moova** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Vesoul, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/horaires-theoriques-du-reseau-moova-communaute-dagglomeration-de-vesoul-gtfs). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (7 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | moova – Communauté d’agglomération de Vesoul |
|
||||
| **Vichy Mobivie** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Vichy, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/mobivie-reseau-urbain-vichy-communaute). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (11 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. | Mobivie – Communauté d’agglomération Vichy Communauté |
|
||||
| **Vienne L’va** (feed-vienne-mobi.ratpdev.com) | Transit layer live vehicles, Vienne, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-lva). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (44 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | L’va – Communauté d’agglomération Vienne Condrieu |
|
||||
| **Vierzon Le Vib’** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Vierzon, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-urbain-le-vib). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (4 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. Served by the national access point’s own proxy of the operator feed. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Le Vib’ – Région Centre-Val de Loire (CC Vierzon-Sologne-Berry) |
|
||||
| **Yvetot Vikibus** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Yvetot, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/vikibus-yvetot-normandie). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (3 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Published through the Atoumod (Normandy) hub; its positions stalled for about ten minutes once on 2026-11-13. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Vikibus – Communauté de communes Yvetot Normandie |
|
||||
| **liO Occitanie coaches** (lio.2cloud.app) | Transit layer live vehicles, Occitanie, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-lio-occitanie). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (243 vehicles on 2026-11-13). ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database. | liO Occitanie – Occitanie |
|
||||
| **Évreux Transurbain** (proxy.transport.data.gouv.fr) | Transit layer live vehicles, Évreux, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/transurbain-evreux-portes-de-normandie). Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (24 vehicles on 2026-11-13). Served by the national access point’s own proxy of the operator feed. Published through the Atoumod (Normandy) hub; its positions stalled for about ten minutes once on 2026-11-13. Where a route id is not the line name, the name comes from `routes.txt` in the same record’s GTFS. | Transurbain – Communauté d’agglomération Evreux Portes de Normandie |
|
||||
| **Metro Bilbao** (ctb-gtfs-rt.s3.eu-south-2.amazonaws.com) | Transit layer live vehicles, Bilbao, Spain | CC BY 4.0 (https://data.ctb.eus/en/pages/legal-notice). Legal notice read 2026-11-13. Reuse is allowed for commercial and non-commercial purposes on condition that the source and the date of the last update are mentioned, hence the dated credit. The consortium reserves the right to set other conditions. 15 vehicles on 2026-11-13. | Consorcio de Transportes de Bizkaia (data.ctb.eus) |
|
||||
| **PKS Rzeszów** (www.mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3428). Same server as the MPK Rzeszów feed; its open-data page was read 2026-11-13. Keyless feed on www.mpkrzeszow.pl, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 71 vehicles on 2026-11-13. The feed carries no route ids. | PKS w Rzeszowie S.A. |
|
||||
| **Metro St. Louis** (gtfs.metrostlouis.org) | Transit layer live vehicles, St. Louis, MO | Metro Transit developer terms of use (https://www.metrostlouis.org/developer-resources/). Developer resources page read 2026-11-13. Acceptance is by use. Agency trademarks may not be used with the data unless approved, so the credit is the name as text. 127 vehicles on 2026-11-13. | Metro Transit – St. Louis |
|
||||
| **Community Transit** (s3.amazonaws.com) | Transit layer live vehicles, Snohomish County, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1448). Community Transit’s open data page (https://www.communitytransit.org/OpenData, read 2026-11-13) lists this feed for developers, with disclaimers only. Keyless feed on s3.amazonaws.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 137 vehicles on 2026-11-13. | Community Transit |
|
||||
| **Beach Cities Transit** (redondobeachbct.com) | Transit layer live vehicles, Redondo Beach, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1566). The City of Redondo Beach transit pages were read 2026-11-13. Keyless feed on redondobeachbct.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 9 vehicles on 2026-11-13. The feed carries no route ids. | Beach Cities Transit |
|
||||
| **Madison Metro Transit** (metromap.cityofmadison.com) | Transit layer live vehicles, Madison, WI | City of Madison developer licence agreement (https://www.cityofmadison.com/metro/business/information-for-developers/developer-license-agreement-and-terms-of-use). Agreement read 2026-11-13. Acceptance is by use; the licence is limited to uses that assist transit riders or promote public transport, which a live vehicle map is. The credit is the wording the agreement offers. 101 vehicles on 2026-11-13. | Data provided under license granted by City of Madison, WI, Metro Transit |
|
||||
| **MVgo Mountain View** (mtma.tripshot.com) | Transit layer live vehicles, Mountain View, CA | ODC-BY 1.0 (https://mvgo.org/developers/). Developers page read 2026-11-13. ODC-BY asks for attribution on public use. 7 vehicles on 2026-11-13. The feed carries no route ids. | MVgo – Mountain View Transportation Management Association |
|
||||
| **Emery Go-Round** (emerygoround.tripshot.com) | Transit layer live vehicles, Emeryville, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2227). The Emery Go-Round site was read 2026-11-13. Keyless feed on emerygoround.tripshot.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 13 vehicles on 2026-11-13. The feed carries no route ids. | Emery Go-Round |
|
||||
| **Valley Regional Transit** (s3.amazonaws.com) | Transit layer live vehicles, Boise, ID | CC BY 3.0 (https://www.valleyregionaltransit.org/about-us/resources/). Resources page read 2026-11-13; it lists this realtime file among the works. 29 vehicles on 2026-11-13. The feed carries no route ids. | Valley Regional Transit |
|
||||
| **Greater Portland Metro Bus** (gtfsrt.gptd.cadavl.com) | Transit layer live vehicles, Portland, ME | Greater Portland Metro developer terms (https://www.gpmetro.org/about-us/projects-initiatives/developers/). Developers page read 2026-11-13. No credit wording or registration is asked for. 30 vehicles on 2026-11-13. The feed carries no route ids. | Greater Portland Metro |
|
||||
| **Greater Cleveland RTA** (gtfs-rt.gcrta.vontascloud.com) | Transit layer live vehicles, Cleveland, OH | GCRTA Developer’s License Agreement (https://www.riderta.com/developers). Agreement read 2026-11-13. It asks licensees to “Clearly acknowledge GCRTA as the provider of the Data”, forbids implying GCRTA commissioned the product and forbids its logos, so the credit is text. Acceptance is by use. 294 vehicles on 2026-11-13. | Greater Cleveland Regional Transit Authority (GCRTA) |
|
||||
| **Fairfax Connector** (www.fairfaxcounty.gov) | Transit layer live vehicles, Fairfax County, VA | Fairfax County transportation data licence (https://www.fairfaxcounty.gov/connector/bustracker/data). Terms read 2026-11-13. They require the statement “Visit fairfaxconnector.com for more information” with a link, which the credit carries, and limit use to assisting transit users. 94 vehicles on 2026-11-13. | Fairfax Connector. Visit fairfaxconnector.com for more information |
|
||||
| **Butler County RTA** (www.buztrakr.com) | Transit layer live vehicles, Wayne Township, OH | Public realtime vehicle feed (https://gtfsrt.io/). BCRTA’s GTFS page (https://www.butlercountyrta.com/gtfs-download/, read 2026-11-13) links this host. Keyless feed on www.buztrakr.com, listed in the gtfsrt.io directory as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 18 vehicles on 2026-11-13. The feed carries no route ids. | Butler County RTA |
|
||||
| **Société de transport collectif de Pierre-De Saurel** (zenbus.net) | Transit layer live vehicles, Sorel-Tracy, QC, Canada | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2380). Keyless feed on zenbus.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 5 vehicles on 2026-11-13. | Société de transport collectif de Pierre-De Saurel |
|
||||
| **Regional Municipality of Wood Buffalo Transit** (rmwb-gtfsrt.civilia.ca) | Transit layer live vehicles, Fort McMurray, AB, Canada | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4691-vp). Keyless feed on rmwb-gtfsrt.civilia.ca, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 9 vehicles on 2026-11-13. | Regional Municipality of Wood Buffalo Transit |
|
||||
| **Bus Mont-Tremblant** (zenbus.net) | Transit layer live vehicles, Mont-Tremblant, QC, Canada | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5619-vp). Keyless feed on zenbus.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 1 vehicles on 2026-11-13. | Bus Mont-Tremblant |
|
||||
| **Saint Jean Sur Richelieu** (zenbus.net) | Transit layer live vehicles, Saint-Jean-sur-Richelieu, QC, Canada | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5631-vp). Keyless feed on zenbus.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 9 vehicles on 2026-11-13. | Saint Jean Sur Richelieu |
|
||||
| **L'Inter des Laurentides** (zenbus.net) | Transit layer live vehicles, Saint-Sauveur, QC, Canada | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5723-vp). Keyless feed on zenbus.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 1 vehicles on 2026-11-13. | L'Inter des Laurentides |
|
||||
| **MRC du Haut-Saint-Laurent** (gtfs-rt.infra-hubup.fr) | Transit layer live vehicles, Salaberry-de-Valleyfield, QC, Canada | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6693-tu_vp). Keyless feed on gtfs-rt.infra-hubup.fr, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 3 vehicles on 2026-11-13. | MRC du Haut-Saint-Laurent |
|
||||
| **Empresa Municipal de Transportes de Fuenlabrada (EMTF)** (api.control.optibus.co) | Transit layer live vehicles, Fuenlabrada, Spain | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2402). Keyless feed on api.control.optibus.co, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 26 vehicles on 2026-11-13. | Empresa Municipal de Transportes de Fuenlabrada (EMTF) |
|
||||
| **Orosháza local bus** (gtfs.gpspositions.net) | Transit layer live vehicles, Orosháza, Hungary | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3484). Keyless feed on gtfs.gpspositions.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 2 vehicles on 2026-11-13. | FlexCom Kommunikációs Kft. |
|
||||
| **TEP PARMA** (s3.eu-south-1.amazonaws.com) | Transit layer live vehicles, Parma, Italy | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3452). Keyless feed on s3.eu-south-1.amazonaws.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 62 vehicles on 2026-11-13. | TEP PARMA |
|
||||
| **Chișinău RTEC and PUA** (chisinau-rt-feed.gtfs.ro) | Transit layer live vehicles, Chișinău, Moldova | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2342). Keyless feed on chisinau-rt-feed.gtfs.ro, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 409 vehicles on 2026-11-13. | Regia Transport Electric Chișinău, Î.M. Parcul Urban de Autobuze |
|
||||
| **Transbus Buzău** (gtfsagent.transportpublic.ro) | Transit layer live vehicles, Buzău, Romania | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2113). Keyless feed on gtfsagent.transportpublic.ro, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 2 vehicles on 2026-11-13. | Transbus Buzău |
|
||||
| **Merced County Transit (The Bus)** (thebuslive.com) | Transit layer live vehicles, Merced, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1506). Keyless feed on thebuslive.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 27 vehicles on 2026-11-13. The feed carries no route ids. | Merced County Transit (The Bus) |
|
||||
| **Intercity Transit** (its.rideralerts.com) | Transit layer live vehicles, Olympia, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1537). Keyless feed on its.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 63 vehicles on 2026-11-13. | Intercity Transit |
|
||||
| **Stanislaus Regional Transit Authority (StanRTA)** (max.availtec.com) | Transit layer live vehicles, Modesto, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1614). Keyless feed on max.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 54 vehicles on 2026-11-13. | Stanislaus Regional Transit Authority (StanRTA) |
|
||||
| **Tompkins Consolidated Area Transit (TCAT)** (realtimetcatbus.availtec.com) | Transit layer live vehicles, Ithaca, NY | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1649). Keyless feed on realtimetcatbus.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 26 vehicles on 2026-11-13. | Tompkins Consolidated Area Transit (TCAT) |
|
||||
| **Riverside Transit Agency** (rtabus.com) | Transit layer live vehicles, Riverside, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1665). Keyless feed on rtabus.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 148 vehicles on 2026-11-13. | Riverside Transit Agency |
|
||||
| **Towson Loop** (passio3.com) | Transit layer live vehicles, Towson, MD | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1787). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 7 vehicles on 2026-11-13. | Towson Loop |
|
||||
| **Commerce Municipal Bus Lines** (citycommbus.com) | Transit layer live vehicles, Commerce, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1794). Keyless feed on citycommbus.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 9 vehicles on 2026-11-13. The feed carries no route ids. | Commerce Municipal Bus Lines |
|
||||
| **Mountain Line Transit** (mountainline.syncromatics.com) | Transit layer live vehicles, Flagstaff, AZ | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1980). Keyless feed on mountainline.syncromatics.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 21 vehicles on 2026-11-13. The feed carries no route ids. | Mountain Line Transit |
|
||||
| **Ben Franklin Transit** (myride.bft.org) | Transit layer live vehicles, Pasco, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2125). Keyless feed on myride.bft.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 55 vehicles on 2026-11-13. | Ben Franklin Transit |
|
||||
| **Montebello Bus Lines** (mbl.rideralerts.com) | Transit layer live vehicles, Montebello, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2218). Keyless feed on mbl.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 36 vehicles on 2026-11-13. | Montebello Bus Lines |
|
||||
| **Spirit Bus** (passio3.com) | Transit layer live vehicles, Monterey Park, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2221). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 3 vehicles on 2026-11-13. | Spirit Bus |
|
||||
| **Middletown Area Transit** (passio3.com) | Transit layer live vehicles, Portland, CT | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2271). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 8 vehicles on 2026-11-13. | Middletown Area Transit |
|
||||
| **Via Mobility** (passio3.com) | Transit layer live vehicles, Boulder, CO | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2398). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 9 vehicles on 2026-11-13. | Via Mobility |
|
||||
| **Butte Regional Transit (B-Line)** (planmyride.blinetransit.com) | Transit layer live vehicles, Chico, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2428). Keyless feed on planmyride.blinetransit.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 20 vehicles on 2026-11-13. | Butte Regional Transit (B-Line) |
|
||||
| **University of Georgia** (passio3.com) | Transit layer live vehicles, Athens, GA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2619). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 33 vehicles on 2026-11-13. | University of Georgia |
|
||||
| **Transit Authority of River City (TARC)** (tarc.rideralerts.com) | Transit layer live vehicles, Louisville, KY | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2665). Keyless feed on tarc.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 70 vehicles on 2026-11-13. | Transit Authority of River City (TARC) |
|
||||
| **South Metro Area Regional Transit (SMART)** (mysmartbus.com) | Transit layer live vehicles, Wilsonville, OR | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2707). Keyless feed on mysmartbus.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 13 vehicles on 2026-11-13. The feed carries no route ids. | South Metro Area Regional Transit (SMART) |
|
||||
| **ClackCo Connects (Clackamas County)** (passio3.com) | Transit layer live vehicles, Clackamas County, OR | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2744). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 5 vehicles on 2026-11-13. | ClackCo Connects (Clackamas County) |
|
||||
| **Sandy Area Metro** (passio3.com) | Transit layer live vehicles, Sandy, OR | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2747). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 6 vehicles on 2026-11-13. | Sandy Area Metro |
|
||||
| **Champaign-Urbana Mass Transit District** (gtfs-rt.mtd.org) | Transit layer live vehicles, Champaign, IL | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2889). Keyless feed on gtfs-rt.mtd.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 72 vehicles on 2026-11-13. | Champaign-Urbana Mass Transit District |
|
||||
| **Clovis Transit** (passio3.com) | Transit layer live vehicles, Clovis, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2897). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 14 vehicles on 2026-11-13. | Clovis Transit |
|
||||
| **Frederick County TransIT** (passio3.com) | Transit layer live vehicles, Frederick, MD | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3070). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 14 vehicles on 2026-11-13. | Frederick County TransIT |
|
||||
| **South Bend Public Transportation Corporation** (passio3.com) | Transit layer live vehicles, South Bend, IN | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3077). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 23 vehicles on 2026-11-13. | South Bend Public Transportation Corporation |
|
||||
| **Cecil Transit** (passio3.com) | Transit layer live vehicles, Elkton, MD | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3080). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 4 vehicles on 2026-11-13. | Cecil Transit |
|
||||
| **NY Waterway** (nywaterway.connexionz.net) | Transit layer live vehicles, New York, NY | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3199). Keyless feed on nywaterway.connexionz.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 3 vehicles on 2026-11-13. The feed carries no route ids. | NY Waterway |
|
||||
| **El Paso Transportation Authority** (data.peaktransit.com) | Transit layer live vehicles, El Paso County, TX | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3204). Keyless feed on data.peaktransit.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 6 vehicles on 2026-11-13. | El Paso Transportation Authority |
|
||||
| **Monroe County Transportation Authority (MCTA)** (ponytracker.availtec.com) | Transit layer live vehicles, Pocono Township, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3436). Keyless feed on ponytracker.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 7 vehicles on 2026-11-13. | Monroe County Transportation Authority (MCTA) |
|
||||
| **Shore Transit** (myride.shoretransit.org) | Transit layer live vehicles, Salisbury, MD | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3443). Keyless feed on myride.shoretransit.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 3 vehicles on 2026-11-13. | Shore Transit |
|
||||
| **Rogue Valley Transportation District** (gtfsrt.rvtd.cadavl.com) | Transit layer live vehicles, Medford, OR | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3458). Keyless feed on gtfsrt.rvtd.cadavl.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 13 vehicles on 2026-11-13. The feed carries no route ids. | Rogue Valley Transportation District |
|
||||
| **Broward County Transit** (myride2.broward.org) | Transit layer live vehicles, Broward County, FL | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3475). Keyless feed on myride2.broward.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 149 vehicles on 2026-11-13. | Broward County Transit |
|
||||
| **University of Minnesota** (data.peaktransit.com) | Transit layer live vehicles, Minneapolis, MN | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-3528). Keyless feed on data.peaktransit.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 19 vehicles on 2026-11-13. | University of Minnesota |
|
||||
| **Greenlink** (gtfsrt.greenlink.cadavl.com) | Transit layer live vehicles, Greenville, SC | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-2266-vp_1). Keyless feed on gtfsrt.greenlink.cadavl.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 13 vehicles on 2026-11-13. | Greenlink |
|
||||
| **Sacramento Regional Transit** (bustime.sacrt.com) | Transit layer live vehicles, Sacramento, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-3391-vp). Keyless feed on bustime.sacrt.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 137 vehicles on 2026-11-13. | Sacramento Regional Transit |
|
||||
| **GoDurham** (godurham.rideralerts.com) | Transit layer live vehicles, Durham, NC | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4062-vp). Keyless feed on godurham.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 48 vehicles on 2026-11-13. | GoDurham |
|
||||
| **Cascades East Transit** (passio3.com) | Transit layer live vehicles, Bend, OR | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4067-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 18 vehicles on 2026-11-13. | Cascades East Transit |
|
||||
| **Capital Area Transit** (cat.rideralerts.com) | Transit layer live vehicles, Harrisburg, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4299-vp). Keyless feed on cat.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 25 vehicles on 2026-11-13. | Capital Area Transit |
|
||||
| **City of Santa Clarita Transit** (apps.santaclaritatransit.com) | Transit layer live vehicles, Santa Clarita, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4328-vp). Keyless feed on apps.santaclaritatransit.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 44 vehicles on 2026-11-13. The feed carries no route ids. | City of Santa Clarita Transit |
|
||||
| **Akron Metro Regional Transit Authority** (realtimemetro.availtec.com) | Transit layer live vehicles, Akron, OH | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4332-vp). Keyless feed on realtimemetro.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 69 vehicles on 2026-11-13. | Akron Metro Regional Transit Authority |
|
||||
| **Whatcom Transportation Authority** (bustracker.ridewta.com) | Transit layer live vehicles, Bellingham, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4358-vp). Keyless feed on bustracker.ridewta.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 47 vehicles on 2026-11-13. | Whatcom Transportation Authority |
|
||||
| **Santa Barbara Metropolitan Transit District** (bustracker.sbmtd.gov) | Transit layer live vehicles, Santa Barbara, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4360-vp). Keyless feed on bustracker.sbmtd.gov, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 60 vehicles on 2026-11-13. | Santa Barbara Metropolitan Transit District |
|
||||
| **Spokane Transit Authority** (gtfsbridge.spokanetransit.com) | Transit layer live vehicles, Spokane, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4363-vp). Keyless feed on gtfsbridge.spokanetransit.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 123 vehicles on 2026-11-13. | Spokane Transit Authority |
|
||||
| **Torrance Transit** (www.mybusinfo.com) | Transit layer live vehicles, Torrance, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4364-vp). Keyless feed on www.mybusinfo.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 47 vehicles on 2026-11-13. | Torrance Transit |
|
||||
| **Metropolitan Tulsa Transit Authority** (tulsa.rideralerts.com) | Transit layer live vehicles, Tulsa, OK | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4374-vp). Keyless feed on tulsa.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 40 vehicles on 2026-11-13. | Metropolitan Tulsa Transit Authority |
|
||||
| **MARC Train (Maryland Transit Administration)** (mdotmta-gtfs-rt.s3.amazonaws.com) | Transit layer live vehicles, Baltimore, MD | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4451-vp). Keyless feed on mdotmta-gtfs-rt.s3.amazonaws.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 2 vehicles on 2026-11-13. | MARC Train (Maryland Transit Administration) |
|
||||
| **Fresno Area Express** (gis4u.fresno.gov) | Transit layer live vehicles, Fresno, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4483-vp). Keyless feed on gis4u.fresno.gov, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 106 vehicles on 2026-11-13. | Fresno Area Express |
|
||||
| **Antelope Valley Transit Authority** (track-it.avta.com) | Transit layer live vehicles, Palmdale, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4485-vp). Keyless feed on track-it.avta.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 36 vehicles on 2026-11-13. | Antelope Valley Transit Authority |
|
||||
| **River Valley Transit** (my.ridervt.com) | Transit layer live vehicles, Williamsport, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4549-vp). Keyless feed on my.ridervt.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 14 vehicles on 2026-11-13. | River Valley Transit |
|
||||
| **Area Transit Authority** (rideata.rideralerts.com) | Transit layer live vehicles, DuBois, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4552-vp). Keyless feed on rideata.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 13 vehicles on 2026-11-13. | Area Transit Authority |
|
||||
| **County of Lackawanna Transit System** (coltsivl.availtec.com) | Transit layer live vehicles, Scranton, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4554-vp). Keyless feed on coltsivl.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 19 vehicles on 2026-11-13. | County of Lackawanna Transit System |
|
||||
| **Luzerne County Transportation Authority** (realtimelctabus.availtec.com) | Transit layer live vehicles, Wilkes-Barre, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4556-vp). Keyless feed on realtimelctabus.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 22 vehicles on 2026-11-13. | Luzerne County Transportation Authority |
|
||||
| **Butler Transit Authority** (butlerivl.availtec.com) | Transit layer live vehicles, Butler, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4660-vp). Keyless feed on butlerivl.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 4 vehicles on 2026-11-13. | Butler Transit Authority |
|
||||
| **Indiana County Transit Authority** (indigobus.availtec.com) | Transit layer live vehicles, Indiana, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4662-vp). Keyless feed on indigobus.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 7 vehicles on 2026-11-13. | Indiana County Transit Authority |
|
||||
| **Shenango Valley Shuttle Service** (svss.availtec.com) | Transit layer live vehicles, Hermitage, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4664-vp). Keyless feed on svss.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 3 vehicles on 2026-11-13. | Shenango Valley Shuttle Service |
|
||||
| **Hazleton Public Transit** (realtimehpts.availtec.com) | Transit layer live vehicles, Hazleton, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4666-vp). Keyless feed on realtimehpts.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 8 vehicles on 2026-11-13. | Hazleton Public Transit |
|
||||
| **Altoona Metro Transit** (amtran.rideralerts.com) | Transit layer live vehicles, Altoona, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4670-vp). Keyless feed on amtran.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 12 vehicles on 2026-11-13. | Altoona Metro Transit |
|
||||
| **Cambria County Transit Authority** (live.camtranbus.com) | Transit layer live vehicles, Dale, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4672-vp). Keyless feed on live.camtranbus.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 21 vehicles on 2026-11-13. | Cambria County Transit Authority |
|
||||
| **Centre County Transit Authority** (realtime.catabus.com) | Transit layer live vehicles, State College, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4673-vp). Keyless feed on realtime.catabus.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 34 vehicles on 2026-11-13. | Centre County Transit Authority |
|
||||
| **Crawford Area Transportation Authority** (catabus.rideralerts.com) | Transit layer live vehicles, Troy Township, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4675-vp). Keyless feed on catabus.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 6 vehicles on 2026-11-13. | Crawford Area Transportation Authority |
|
||||
| **The Rapid** (connect.ridetherapid.org) | Transit layer live vehicles, Grand Rapids, MI | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4676-vp). Keyless feed on connect.ridetherapid.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 78 vehicles on 2026-11-13. | The Rapid |
|
||||
| **Kalamazoo Metro Transit** (trackmybus.kmetro.org) | Transit layer live vehicles, Kalamazoo, MI | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4677-vp). Keyless feed on trackmybus.kmetro.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 26 vehicles on 2026-11-13. | Kalamazoo Metro Transit |
|
||||
| **Schuylkill Transportation System** (sts.rideralerts.com) | Transit layer live vehicles, Pottsville, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4689-vp). Keyless feed on sts.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 7 vehicles on 2026-11-13. | Schuylkill Transportation System |
|
||||
| **Western Reserve Transit Authority** (myvalleystops.wrtaonline.com) | Transit layer live vehicles, Youngstown, OH | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4692-vp). Keyless feed on myvalleystops.wrtaonline.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 29 vehicles on 2026-11-13. | Western Reserve Transit Authority |
|
||||
| **Athens Clarke County Transit** (bustracker.accgov.com) | Transit layer live vehicles, Athens, GA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4783-vp). Keyless feed on bustracker.accgov.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 21 vehicles on 2026-11-13. | Athens Clarke County Transit |
|
||||
| **Manatee County Area Transit** (realtimemcat.availtec.com) | Transit layer live vehicles, Manatee County, FL | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4784-vp). Keyless feed on realtimemcat.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 24 vehicles on 2026-11-13. | Manatee County Area Transit |
|
||||
| **Lehigh and Northampton Transportation Authority** (realtimelanta.availtec.com) | Transit layer live vehicles, Allentown, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4785-vp). Keyless feed on realtimelanta.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 48 vehicles on 2026-11-13. | Lehigh and Northampton Transportation Authority |
|
||||
| **Norwalk Transit District** (mystop.norwalktransit.com) | Transit layer live vehicles, Norwalk, CT | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4790-vp). Keyless feed on mystop.norwalktransit.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 20 vehicles on 2026-11-13. | Norwalk Transit District |
|
||||
| **Valley Transit** (itransitnw.com) | Transit layer live vehicles, Walla Walla, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4793-vp). Keyless feed on itransitnw.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 10 vehicles on 2026-11-13. The feed carries no route ids. | Valley Transit |
|
||||
| **Muskegon Area Transit System** (muskegon.connexionz.net) | Transit layer live vehicles, Muskegon, MI | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-4832-vp). Keyless feed on muskegon.connexionz.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 8 vehicles on 2026-11-13. The feed carries no route ids. | Muskegon Area Transit System |
|
||||
| **Bustang** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). Served from RTD’s open-data host beside the Denver RTD feed, under the same click-through agreement (see `rtd-denver`). Same server and basis as that feed. 10 vehicles on 2026-11-13. | Bustang (CDOT), via RTD open data |
|
||||
| **Link Transit** (link.rideralerts.com) | Transit layer live vehicles, Wenatchee, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5502-vp). Keyless feed on link.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 33 vehicles on 2026-11-13. | Link Transit |
|
||||
| **Mid Mon Valley Transit Authority** (mmvta.rideralerts.com) | Transit layer live vehicles, Charleroi, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5504-vp). Keyless feed on mmvta.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 9 vehicles on 2026-11-13. | Mid Mon Valley Transit Authority |
|
||||
| **Lebanon Transit** (realtime.lebanontransit.org) | Transit layer live vehicles, Lebanon, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5532-vp). Keyless feed on realtime.lebanontransit.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 7 vehicles on 2026-11-13. | Lebanon Transit |
|
||||
| **rabbittransit** (realtime.rabbittransit.org) | Transit layer live vehicles, York, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5539-vp). Keyless feed on realtime.rabbittransit.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 21 vehicles on 2026-11-13. | rabbittransit |
|
||||
| **Norwalk Transit System** (nts.rideralerts.com) | Transit layer live vehicles, Norwalk, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5546-vp). Keyless feed on nts.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 24 vehicles on 2026-11-13. | Norwalk Transit System |
|
||||
| **Ride Gwinnett** (realtimegwinnett.availtec.com) | Transit layer live vehicles, Gwinnett County, GA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5550-vp). Keyless feed on realtimegwinnett.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 29 vehicles on 2026-11-13. | Ride Gwinnett |
|
||||
| **StarTran** (startran.connexionz.net) | Transit layer live vehicles, Lincoln, NE | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5554-vp). Keyless feed on startran.connexionz.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 23 vehicles on 2026-11-13. The feed carries no route ids. | StarTran |
|
||||
| **Yamhill County Transit Area** (ycta.connexionz.net) | Transit layer live vehicles, Yamhill County, OR | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5555-vp). Keyless feed on ycta.connexionz.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 6 vehicles on 2026-11-13. The feed carries no route ids. | Yamhill County Transit Area |
|
||||
| **Greater Peoria Mass Transit** (clk.rideralerts.com) | Transit layer live vehicles, West Peoria, IL | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5581-vp). Keyless feed on clk.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 24 vehicles on 2026-11-13. | Greater Peoria Mass Transit |
|
||||
| **Racine Transit** (racine.connexionz.net) | Transit layer live vehicles, Racine, WI | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5582-vp). Keyless feed on racine.connexionz.net, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 14 vehicles on 2026-11-13. The feed carries no route ids. | Racine Transit |
|
||||
| **Berks Area Regional Transportation Authority** (busfinder.bartabus.com) | Transit layer live vehicles, Reading, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5839-vp). Keyless feed on busfinder.bartabus.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 27 vehicles on 2026-11-13. | Berks Area Regional Transportation Authority |
|
||||
| **Red Rose Transit Authority** (busfinder.redrosetransit.com) | Transit layer live vehicles, Lancaster, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5840-vp). Keyless feed on busfinder.redrosetransit.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 22 vehicles on 2026-11-13. | Red Rose Transit Authority |
|
||||
| **Concho Valley Transit District** (passio3.com) | Transit layer live vehicles, San Angelo, TX | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5865-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 5 vehicles on 2026-11-13. | Concho Valley Transit District |
|
||||
| **Culver CityBus** (nextccbus.org) | Transit layer live vehicles, Culver City, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5892-vp). Keyless feed on nextccbus.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 40 vehicles on 2026-11-13. | Culver CityBus |
|
||||
| **Billings Metropolitan Transit** (passio3.com) | Transit layer live vehicles, Billings, MT | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5894-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 13 vehicles on 2026-11-13. | Billings Metropolitan Transit |
|
||||
| **Skagit Transit** (strweb.skagittransit.org) | Transit layer live vehicles, Burlington, WA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5896-vp). Keyless feed on strweb.skagittransit.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 21 vehicles on 2026-11-13. | Skagit Transit |
|
||||
| **Lakeland Area Mass Transit District** (www.ccbusinfo.com) | Transit layer live vehicles, Lakeland, FL | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5898-vp). Keyless feed on www.ccbusinfo.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 29 vehicles on 2026-11-13. | Lakeland Area Mass Transit District |
|
||||
| **Housatonic Area Regional Transit** (passio3.com) | Transit layer live vehicles, Danbury, CT | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-5903-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 12 vehicles on 2026-11-13. | Housatonic Area Regional Transit |
|
||||
| **Bis-Man Transit** (passio3.com) | Transit layer live vehicles, Bismarck, ND | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6096-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 7 vehicles on 2026-11-13. | Bis-Man Transit |
|
||||
| **Chapel Hill Transit** (mychtransit.org) | Transit layer live vehicles, Chapel Hill, NC | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6626-vp). Keyless feed on mychtransit.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 35 vehicles on 2026-11-13. The feed carries no route ids. | Chapel Hill Transit |
|
||||
| **Wichita Transit** (bus.wichitatransit.org) | Transit layer live vehicles, Wichita, KS | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6669-vp). Keyless feed on bus.wichitatransit.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 23 vehicles on 2026-11-13. | Wichita Transit |
|
||||
| **New River Transit Authority** (passio3.com) | Transit layer live vehicles, Beckley, WV | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6714-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 5 vehicles on 2026-11-13. | New River Transit Authority |
|
||||
| **City of Irvine** (passio3.com) | Transit layer live vehicles, Irvine, CA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6725-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 6 vehicles on 2026-11-13. | City of Irvine |
|
||||
| **North Central Regional Transit District** (bluebustracker.availtec.com) | Transit layer live vehicles, Rio Arriba County, NM | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6744-vp). Keyless feed on bluebustracker.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 17 vehicles on 2026-11-13. | North Central Regional Transit District |
|
||||
| **City of Mobile** (realtimemobile.availtec.com) | Transit layer live vehicles, Mobile, AL | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6774-vp). Keyless feed on realtimemobile.availtec.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 12 vehicles on 2026-11-13. | City of Mobile |
|
||||
| **Capital Area Transit System** (cats.rideralerts.com) | Transit layer live vehicles, Baton Rouge, LA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6784-vp). Keyless feed on cats.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 29 vehicles on 2026-11-13. | Capital Area Transit System |
|
||||
| **Westmoreland County Transit Authority** (wcta.rideralerts.com) | Transit layer live vehicles, Hempfield Township, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6786-vp). Keyless feed on wcta.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 13 vehicles on 2026-11-13. | Westmoreland County Transit Authority |
|
||||
| **City of Jacksonville** (passio3.com) | Transit layer live vehicles, Jacksonville, NC | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6820-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 5 vehicles on 2026-11-13. | City of Jacksonville |
|
||||
| **GoCary** (www.gocarylive.org) | Transit layer live vehicles, Cary, NC | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6821-vp). Keyless feed on www.gocarylive.org, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 16 vehicles on 2026-11-13. | GoCary |
|
||||
| **Bloomington Transit** (s3.amazonaws.com) | Transit layer live vehicles, Bloomington, IN | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6882-vp). Keyless feed on s3.amazonaws.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 27 vehicles on 2026-11-13. The feed carries no route ids. | Bloomington Transit |
|
||||
| **NCSU Wolfline** (passio3.com) | Transit layer live vehicles, Raleigh, NC | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-6896-vp). Keyless feed on passio3.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 21 vehicles on 2026-11-13. | NCSU Wolfline |
|
||||
| **Endless Mountains Transportation Authority** (best.rideralerts.com) | Transit layer live vehicles, Ulster Township, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-7022-vp). Keyless feed on best.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 7 vehicles on 2026-11-13. | Endless Mountains Transportation Authority |
|
||||
| **Lower Anthracite Transit System** (lats.rideralerts.com) | Transit layer live vehicles, Mount Carmel Township, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-7024-vp). Keyless feed on lats.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 2 vehicles on 2026-11-13. | Lower Anthracite Transit System |
|
||||
| **Pottstown Area Rapid Transit** (part.rideralerts.com) | Transit layer live vehicles, Pottstown, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-7026-vp). Keyless feed on part.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 3 vehicles on 2026-11-13. | Pottstown Area Rapid Transit |
|
||||
| **Fayette Area Coordinated Transportation** (fact.rideralerts.com) | Transit layer live vehicles, South Union Township, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-7028-vp). Keyless feed on fact.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 5 vehicles on 2026-11-13. | Fayette Area Coordinated Transportation |
|
||||
| **Freedom Transit** (freedom.rideralerts.com) | Transit layer live vehicles, Chartiers Township, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-7029-vp). Keyless feed on freedom.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 5 vehicles on 2026-11-13. | Freedom Transit |
|
||||
| **New Castle Area Transit Authority** (ncata.rideralerts.com) | Transit layer live vehicles, New Castle, PA | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-7031-vp). Keyless feed on ncata.rideralerts.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 12 vehicles on 2026-11-13. | New Castle Area Transit Authority |
|
||||
| **Lexington Transit Authority** (mystop.lextran.com) | Transit layer live vehicles, Lexington, KY | Public realtime vehicle feed (https://mobilitydatabase.org/feeds/gtfs_rt/tld-7044-vp). Keyless feed on mystop.lextran.com, listed in the Mobility Database as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 44 vehicles on 2026-11-13. | Lexington Transit Authority |
|
||||
| **MetroLINK Quad Cities** (bustracker.metroqc.com) | Transit layer live vehicles, Moline, IL | Public realtime vehicle feed (https://www.transit.land/feeds/f-9zrs-metrorockislandcountymetropolitanmasstransitdistrict~rt). Keyless feed on bustracker.metroqc.com, listed in the Transitland Atlas as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 33 vehicles on 2026-11-13. | MetroLINK Quad Cities |
|
||||
| **Streamline Bozeman** (ridestreamline.com) | Transit layer live vehicles, Bozeman, MT | Public realtime vehicle feed (https://www.transit.land/feeds/f-c815-streamlinetransit~mt~us~rt). Keyless feed on ridestreamline.com, listed in the Transitland Atlas as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 1 vehicles on 2026-11-13. The feed carries no route ids. | Streamline Bozeman |
|
||||
| **Coast RTA** (coastrta.transloc.com) | Transit layer live vehicles, Myrtle Beach, SC | Public realtime vehicle feed (https://www.transit.land/feeds/f-coast~rta~sc~us~rt). Keyless feed on coastrta.transloc.com, listed in the Transitland Atlas as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 9 vehicles on 2026-11-13. | Coast RTA |
|
||||
| **LAX airport shuttles** (laxtransportation.transloc.com) | Transit layer live vehicles, Los Angeles, CA | Public realtime vehicle feed (https://www.transit.land/feeds/f-lax~flyaway~rt). Keyless feed on laxtransportation.transloc.com, listed in the Transitland Atlas as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 6 vehicles on 2026-11-13. | LAX airport shuttles |
|
||||
| **Mission Viejo Shuttle** (mvshuttle.transloc.com) | Transit layer live vehicles, Mission Viejo, CA | Public realtime vehicle feed (https://www.transit.land/feeds/f-mission~viejo~shuttle~rt). Keyless feed on mvshuttle.transloc.com, listed in the Transitland Atlas as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 2 vehicles on 2026-11-13. | Mission Viejo Shuttle |
|
||||
| **LAX FlyAway** (laxflyaway.transloc.com) | Transit layer live vehicles, Los Angeles, CA | Public realtime vehicle feed (https://www.transit.land/feeds/f-laxflyaway~rt). Keyless feed on laxflyaway.transloc.com, listed in the Transitland Atlas as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 4 vehicles on 2026-11-13. | LAX FlyAway |
|
||||
| **Triton Transit (UC San Diego)** (api.us.sparelabs.com) | Transit layer live vehicles, San Diego, CA | Public realtime vehicle feed (https://www.transit.land/feeds/f-9muy-tritontransit~rt). Keyless feed on api.us.sparelabs.com, listed in the Transitland Atlas as this operator’s vehicle positions. Shipped on the same basis as Metro Transit and OVapi, and credited as a courtesy; switch it off if the operator objects. 11 vehicles on 2026-11-13. | Triton Transit (UC San Diego) |
|
||||
| **Zagreb ZET** (zet.hr) | Transit layer live vehicles, Zagreb, Croatia | Open Licence – Republic of Croatia (https://www.zet.hr/odredbe/datoteke-u-gtfs-formatu/669). ZET’s GTFS page, read 2026-11-13, places its GTFS and GTFS-realtime data under the Croatian open licence. The feed mixes trams and buses; route ids do not tell them apart, so the default is a guess. 345 vehicles on 2026-11-13. | Zagrebački električni tramvaj (ZET) |
|
||||
| **Toyama Chitetsu Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-chitetsu-chitetsubus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (26 vehicles). The centre is the middle of the vehicles seen then. | 富山地方鉄道 (Toyama Chitetsu Bus) |
|
||||
| **Imizu Kitokito Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-imizucity-imizushi-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (1 vehicle). The centre is the catalogue’s bounding-box centre for the network. | 射水市 (Imizu Kitokito Bus) |
|
||||
| **Kaetsunō Bus Himi loop** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kaetsunou-kaetsunouhimi-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (1 vehicle). The centre is the catalogue’s bounding-box centre for the network. | 加越能バス (Kaetsunō Bus Himi loop) |
|
||||
| **Kaetsunō Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kaetsunou-kaetsunouippan-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (13 vehicles). The centre is the middle of the vehicles seen then. | 加越能バス (Kaetsunō Bus) |
|
||||
| **Kaetsunō World Heritage Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kaetsunou-kaetsunousekaiisan-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (1 vehicle). The centre is the catalogue’s bounding-box centre for the network. | 加越能バス (Kaetsunō World Heritage Bus) |
|
||||
| **Kamiichi Town Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kamiichitown-kamiichi-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (1 vehicle). The centre is the catalogue’s bounding-box centre for the network. | 上市町 (Kamiichi Town Bus) |
|
||||
| **Kurobe City Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kurobecity-ishida-aimoto-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 黒部市 (Kurobe City Bus) |
|
||||
| **Kurobe Shinkansen–Ikuji Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kurobecity-shinkansenikujisen-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 黒部市 (Kurobe Shinkansen–Ikuji Bus) |
|
||||
| **Namerikawa Noru my car** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-namerikawacity-norumycar-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 滑川市 (Namerikawa Noru my car) |
|
||||
| **Nanto City Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-nantocity-nanbus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (2 vehicles). The centre is the catalogue’s bounding-box centre for the network. | 南砺市 (Nanto City Bus) |
|
||||
| **Nyūzen Noran my car** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-nyuzentown-noranmaicar-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 入善町 (Nyūzen Noran my car) |
|
||||
| **Oyabe City Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-oyabecity-oyabecitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 小矢部市 (Oyabe City Bus) |
|
||||
| **Takaoka City Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-takaokacity-koueibus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 高岡市 (Takaoka City Bus) |
|
||||
| **Tateyama Town Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-tateyamatown-tateyama-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 立山町 (Tateyama Town Bus) |
|
||||
| **Tokoname Gruun** (realtime.gtfs.info) | Transit layer live vehicles, Aichi Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-tokonamecity-Gruun-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (3 vehicles). The centre is the catalogue’s bounding-box centre for the network. | 常滑市 (Tokoname Gruun) |
|
||||
| **Tonami City Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-tonamicity-tonamishieibasu-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 砺波市 (Tonami City Bus) |
|
||||
| **Asahi Asahimachi Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyama-asahitown-asahimachibus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 富山県朝日町 (Asahi Asahimachi Bus) |
|
||||
| **Toyama Fuchū Community Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-fuchucommunitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Fuchū Community Bus) |
|
||||
| **Toyama Horikawa-Minami Community Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-horikawaminamicommunitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Horikawa-Minami Community Bus) |
|
||||
| **Toyama Jōjō Community Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-jojocommunitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Jōjō Community Bus) |
|
||||
| **Toyama Kureha Ikiiki Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-kurehaikiikibus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Kureha Ikiiki Bus) |
|
||||
| **Toyama Maidohaya Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-maidohayabus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Maidohaya Bus) |
|
||||
| **Toyama Mizuhashi Fureai Community Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-mizuhashifureaicommunitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Mizuhashi Fureai Community Bus) |
|
||||
| **Toyama Ōyama Community Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-oyamacommunitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (1 vehicle). The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Ōyama Community Bus) |
|
||||
| **Toyama Yamada Community Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-yamadacommunitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (2 vehicles). The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Yamada Community Bus) |
|
||||
| **Toyama Yatsuo Community Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-toyamacity-yatsuocommunitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (1 vehicle). The centre is the catalogue’s bounding-box centre for the network. | 富山市 (Toyama Yatsuo Community Bus) |
|
||||
| **Uozu Citizens’ Bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-uozucity-junkai-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (4 vehicles). The centre is the catalogue’s bounding-box centre for the network. | 魚津市 (Uozu Citizens’ Bus) |
|
||||
| **Uozu Omotenashi direct bus** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Toyama Prefecture, Japan | CC0 1.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-uozucity-omotenashi-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 魚津市 (Uozu Omotenashi direct bus) |
|
||||
| **Chita Aiai Bus** (realtime.gtfs.info) | Transit layer live vehicles, Aichi Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-chitacity-chita-aiaibus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (1 vehicle). The centre is the catalogue’s bounding-box centre for the network. | 知多市 (Chita Aiai Bus) |
|
||||
| **Ise Okage Bus** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-isecity-communitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (5 vehicles). The centre is the middle of the vehicles seen then. | 伊勢市 (Ise Okage Bus) |
|
||||
| **Keisei Bus Chiba West (Shiohama)** (s3-ajt-mobusta-gtfs.s3.ap-northeast-1.amazonaws.com) | Transit layer live vehicles, Chiba Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-keisei-transitbus-keiseitransitbus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (17 vehicles). The centre is the middle of the vehicles seen then. | 京成バス千葉ウエスト株式会社 (Keisei Bus Chiba West (Shiohama)) |
|
||||
| **Komono Kamoshika-gō** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-komonotown-communitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 菰野町 (Komono Kamoshika-gō) |
|
||||
| **Kumamoto Bus** (km.bus-vision.jp) | Transit layer live vehicles, Kumamoto Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kuma-bus-kuma-bus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-13 around midnight and again on 2026-11-14 at 08:00 Japan time (15 vehicles). The centre is the middle of the vehicles seen then. | 熊本バス (Kumamoto Bus) |
|
||||
| **Kumamoto Toshi Bus** (km.bus-vision.jp) | Transit layer live vehicles, Kumamoto Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kumamoto-toshibus-kumamoto-toshibus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-13 around midnight and again on 2026-11-14 at 08:00 Japan time (31 vehicles). The centre is the middle of the vehicles seen then. | 熊本都市バス (Kumamoto Toshi Bus) |
|
||||
| **Kumamoto Dentetsu Bus** (km.bus-vision.jp) | Transit layer live vehicles, Kumamoto Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-kumamotodentetsu-kumamotodentetsu-bus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-13 around midnight and again on 2026-11-14 at 08:00 Japan time (22 vehicles). The centre is the middle of the vehicles seen then. | 熊本電鉄 (Kumamoto Dentetsu Bus) |
|
||||
| **Mie Kōtsū Chūsei office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancochusei-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (31 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Chūsei office) |
|
||||
| **Mie Kōtsū Iga office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancoiga-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (11 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Iga office) |
|
||||
| **Mie Kōtsū Nanki office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancoinanki-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (6 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Nanki office) |
|
||||
| **Mie Kōtsū Ise office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancoise-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (13 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Ise office) |
|
||||
| **Mie Kōtsū Kuwana office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancokuwana-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (22 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Kuwana office) |
|
||||
| **Mie Kōtsū Matsusaka office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancomatsusaka-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (9 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Matsusaka office) |
|
||||
| **Mie Kōtsū Shima office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancoshima-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (8 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Shima office) |
|
||||
| **Mie Kōtsū Yokkaichi office** (bus-vision.jp) | Transit layer live vehicles, Mie Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-mie-kotsu-sancoyokkaichi-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (24 vehicles). The centre is the middle of the vehicles seen then. | 三重交通 (Mie Kōtsū Yokkaichi office) |
|
||||
| **Nagai Bus** (nagai.nolbe.net) | Transit layer live vehicles, Gunma Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-nagai-unyu-Nagaibus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (7 vehicles). The centre is the middle of the vehicles seen then. | 永井運輸株式会社 (Nagai Bus) |
|
||||
| **Nagano City Bus** (realtime.gtfs.info) | Transit layer live vehicles, Nagano Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-naganocity-shiei_noriaibus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 長野市 (Nagano City Bus) |
|
||||
| **Owariasahi Asapī-gō** (gtfs-rt-files.buscatch.jp) | Transit layer live vehicles, Aichi Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-owariasahicity-communitybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 尾張旭市 (Owariasahi Asapī-gō) |
|
||||
| **Sankō Bus** (km.bus-vision.jp) | Transit layer live vehicles, Kumamoto Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-sankobus-sankobus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-13 around midnight and again on 2026-11-14 at 08:00 Japan time (132 vehicles). The centre is the middle of the vehicles seen then. | 産交バス (Sankō Bus) |
|
||||
| **Takeya Kōtsū** (realtime.gtfs.info) | Transit layer live vehicles, Miyagi Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-takeyakoutu-takeyakotsu-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 株式会社タケヤ交通 (Takeya Kōtsū) |
|
||||
| **Tsuchiura Tsuchimaru Bus** (realtime.gtfs.info) | Transit layer live vehicles, Ibaraki Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-tsuchiuracity-tsuchimarubus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (3 vehicles). The centre is the catalogue’s bounding-box centre for the network. | 土浦市 (Tsuchiura Tsuchimaru Bus) |
|
||||
| **Yanbaru Express Bus** (gtfs.yanbaru-bus-navi.com) | Transit layer live vehicles, Okinawa Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/jbda-yanbaru-expressbus-yanbaru-express-bus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. The feed answered with a well-formed, empty message at 01:40 and again at 08:00 Japan time on Saturday 2026-11-14, so its positions have not been seen yet. The centre is the catalogue’s bounding-box centre for the network. | 合同会社やんばる急行バス (Yanbaru Express Bus) |
|
||||
| **Kenkō Hokubu Kōtsū** (api-public.odpt.org) | Transit layer live vehicles, Kōchi Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/odpt-KenkoHokubuKotsu-Localbus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (6 vehicles). The centre is the middle of the vehicles seen then. | 株式会社県交北部交通 (Kenkō Hokubu Kōtsū) |
|
||||
| **Kōchi Kōryō Kōtsū** (api-public.odpt.org) | Transit layer live vehicles, Kōchi Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/odpt-KochiKoryoKotsu-Localbus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (3 vehicles). The centre is the catalogue’s bounding-box centre for the network. | 高知高陵交通株式会社 (Kōchi Kōryō Kōtsū) |
|
||||
| **Kōchi Seinan Kōtsū** (api-public.odpt.org) | Transit layer live vehicles, Kōchi Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/odpt-KochiSeinanTraffic-Localbus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (11 vehicles). The centre is the middle of the vehicles seen then. | 高知西南交通株式会社 (Kōchi Seinan Kōtsū) |
|
||||
| **Susaki City Bus** (api-public.odpt.org) | Transit layer live vehicles, Kōchi Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/odpt-SusakiCity-Citybus-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-14 at 08:00 Japan time (2 vehicles). The centre is the catalogue’s bounding-box centre for the network. | 須崎市 (Susaki City Bus) |
|
||||
| **Aomori City community bus** (toshisei.aomoricitybus.com) | Transit layer live vehicles, Aomori Prefecture, Japan | CC BY 4.0 (https://mobilitydatabase.org/feeds/gtfs_rt/odpt-UrbanDevelopment_AomoriCity-AllLines-vp). The Mobility Database record gives this licence for the feed; the quote is the licence it links. Vehicle positions were seen on 2026-11-13 around midnight Japan time. The centre is the catalogue’s bounding-box centre for the network. The catalogue’s ODPT address redirects to this host, so the feed is registered at its own address. | 青森市 (Aomori City community bus) |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
@@ -574,7 +268,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<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 358 registered feeds are live-enabled; each can be disabled independently. Of those, 112 are French networks taken from the national access point, transport.data.gouv.fr: each is the GTFS-RT vehicle feed of a dataset record whose licence field is Licence Ouverte 2.0 or ODbL, and each returned live vehicle positions on 2026-11-13. Their rows live in `src/data/transitFeedsFrance.js`, with line names for the feeds that publish internal route keys in `src/data/transitRouteNamesFrance.js`, copied from each record’s own GTFS `routes.txt`. A network that was already here through its operator’s own endpoint (Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours) was not added a second time, and neither was the Atoumod aggregate, which repeats Normandy networks listed one by one. Feeds republished by bus-tracker.fr, feeds served over plain HTTP, and the three Fluo Grand Est feeds, whose positions ran seven to fifteen minutes old, are left out. A further 139 feeds come from the open GTFS-Realtime catalogues (Mobility Database, Transitland Atlas), most of them in the United States; their rows are in `src/data/transitFeedsCatalogue.js`. Ten are operators whose published terms were read on 2026-11-13 and permit display (Metro Bilbao, Metro St. Louis, Madison, MVgo, Valley Regional Transit, Greater Portland Metro, Greater Cleveland RTA, Fairfax Connector, Zagreb ZET, and Bustang under RTD’s agreement); each row quotes the grant. The other 129 are keyless endpoints the catalogue lists as the operator’s own, usually on the operator’s tracking vendor (Avail, Passio, Connexionz, TransLoc); each links its catalogue record, is credited as a courtesy, and ships on the same basis as Metro Transit and OVapi. Seven catalogue feeds answer keyless but are excluded because their terms do not allow this use: MTA New York City Bus (the terms require an API key and forbid serving data straight from MTA servers), Detroit DDOT (use only with the city’s permission), Everett Transit (registration required), Duluth Transit (display of arrival information needs written consent), Greater Dayton RTA (an unpublished developer agreement governs), CTtransit and Pittsburgh Regional Transit (their licences cover static GTFS only). Eight more are held until their terms can be read, because the page was unreachable or empty: Sofia, Lviv, Saint-Avold, Des Moines DART, Springfield SMTD, Knoxville KAT, Pioneer Valley PVTA and Lafayette CityBus. The BusOne proxy feeds for Italian and Austrian operators are a third party’s republication and are left out, as are five more data.gov.my feeds that would share the API’s four requests a minute with the two already here. Fifty-five Japanese bus networks, most of them in Toyama, Mie, Kumamoto and Kōchi prefectures, are in `src/data/transitFeedsJapan.js`; the Mobility Database records each under CC0 1.0 or CC BY 4.0. Vehicle positions have been seen for 34 of them (33 on 2026-11-14 at 08:00 Japan time, a Saturday, and Aomori the night before); the other 21, mostly municipal community buses, answered with an empty message then and overnight, and stay out of the coverage heat-map until their positions are seen. Each row says which case it is. 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 Line truncated
|
||||
- **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.
|
||||
|
||||
@@ -870,8 +564,11 @@ Global Forecast System (GFS). The `/api/wind` server-side proxy selects the
|
||||
latest available 0.25° cycle, reads its `.idx` inventory, byte-range fetches only
|
||||
the `UGRD`/`VGRD` 10 m GRIB2 messages, optionally adding `TMP` at 2 m or
|
||||
`PRMSL` at mean sea level from the same run and forecast time. It decodes the
|
||||
selected messages with ecCodes (WASM), resamples to a compact grid (1° by
|
||||
default), and serves a manifest plus Float32 U/V and optional scalar values. It is keyless, cached
|
||||
selected messages with ecCodes (WASM) and resamples them to a compact grid (1° by
|
||||
default) on a worker thread, so a refresh does not stall other API routes, and
|
||||
serves a manifest plus Float32 U/V and optional scalar values. The sampled wind
|
||||
of the current forecast file is kept, so choosing a color field fetches only
|
||||
that field. It is keyless, cached
|
||||
per cycle for an hour, and is a **forecast, not an observation**: the particles
|
||||
show model flow, not measured wind. The renderer bakes bounded curves through
|
||||
the sampled field and animates their phase on the GPU, with a canvas fallback.
|
||||
|
||||
@@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
||||
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
|
||||
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
|
||||
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
|
||||
- **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
|
||||
- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
|
||||
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
|
||||
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
|
||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- When the globe stops drawing after a render error or a lost WebGL context,
|
||||
an alert dialog says so instead of leaving a frozen or black globe behind
|
||||
working controls. Retry restarts drawing (Reload after a lost context), and
|
||||
Reset view reopens the app without the view restored from the address bar.
|
||||
Cesium's "Rendering has stopped" panel no longer appears in the app
|
||||
(German, @gekh, #1579).
|
||||
@@ -1,12 +0,0 @@
|
||||
- Let an application add its own Cockpit subjects through
|
||||
`services.cockpitSubjects`. A provider supplies the subject's position,
|
||||
heading and HUD (in the page's language, via `getLocale` and
|
||||
`onLocaleChange` from `./i18n`); the camera can follow it without the
|
||||
ground clamp, anchored exactly on it, and a generic HUD adds source rows, a
|
||||
banner, a veil (off under reduced motion) and a bottom progress line. The
|
||||
Rail and Transit layers expose read and hold APIs so a selected train or
|
||||
vehicle can be ridden, and `./layers/rail/paths` is exported; that export
|
||||
and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal
|
||||
provider. GEV itself registers none, and entry to the built-in aircraft,
|
||||
satellite and vessel views is unchanged; a provider's subject takes entry
|
||||
when it was selected after them (Denis Groshev, @ZloyS, #1686).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Draw a vessel whose feed sends no length at its ship type's typical size, so
|
||||
a cruise ship or container ship no longer shows as its type's smallest model
|
||||
(samehkhamis, #1715).
|
||||
@@ -1,26 +0,0 @@
|
||||
- Add 112 French networks to the Transit layer, from Nice, Dijon, Angers,
|
||||
Brest, Le Mans, Metz, Amiens, Avignon and Valenciennes to small-town
|
||||
networks, the liO, Aléop and Rémi regional coaches, Réunion, Martinique and
|
||||
French Guiana. Each is the keyless vehicle feed of a transport.data.gouv.fr
|
||||
record under Licence Ouverte 2.0 or ODbL, recorded in the registry and
|
||||
DATA_SOURCES.md. Trams and boats take their own mode, feeds that publish
|
||||
km/h (Dijon, Le Mans, Bourg-en-Bresse, Blois, Montluçon) are converted to
|
||||
m/s, and internal route keys read as the line on the vehicle. Networks
|
||||
already in the layer are not added a second time (#1716).
|
||||
- Add 139 more keyless feeds to the Transit layer from the open GTFS-Realtime
|
||||
catalogues, most of them United States agencies (Sacramento, Spokane,
|
||||
Cleveland, St. Louis, Madison, Louisville, Fresno, Broward County and many
|
||||
smaller systems), plus Zagreb, Bilbao's metro, Chișinău, Parma and a few
|
||||
Québec networks. Operators whose terms were read are credited as those
|
||||
terms ask, and every feed is recorded in DATA_SOURCES.md. Feeds whose
|
||||
terms need a key, registration or permission (New York City buses,
|
||||
Detroit, Pittsburgh and others) are left out (#1716).
|
||||
- Add 55 Japanese bus networks to the Transit layer, the first in Toyama,
|
||||
Mie, Kumamoto, Kōchi, Aichi, Okinawa and Aomori prefectures, each recorded
|
||||
under CC0 1.0 or CC BY 4.0 with the operator credited in Japanese (#1716).
|
||||
- Show where Transit has coverage. From orbit each feed is a soft green
|
||||
spot, hotter where networks cluster; at continental and regional zoom the
|
||||
spots become patches the size of each network's service area, drawn under
|
||||
the vehicles; close in they are off. Drawn from the feed registry, so no
|
||||
feed is polled for it, and only feeds whose live positions have been seen
|
||||
are shown (#1716).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The Wind server no longer blocks every other request while it decodes a
|
||||
forecast. Decoding runs on a worker thread (up to 400 ms of blocking down to
|
||||
about 4 ms), and choosing an overlay after wind has loaded fetches only the
|
||||
overlay's field (mazy1998, #1717).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Collapse the CCTV panel when the layer is switched off. It stayed open with
|
||||
an empty frame and a meta line that still described the last camera; the
|
||||
meta line of a disabled layer now reads the catalog count. Contacts, Space
|
||||
Missions and Clear selected data layers leave the panel as it is
|
||||
(asturksever, #1723).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Draw airport ground vehicles and ground stations as what they are instead of
|
||||
the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback
|
||||
tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m
|
||||
radio mast, and one with no altitude stands on the ground instead of at the
|
||||
10 km airborne default (samehkhamis, #1729).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Draw airport ground vehicles as real shapes instead of stacked boxes: the
|
||||
car is an operations SUV, the van a high-roof panel van, the fire truck an
|
||||
airport crash tender with turrets and the pushback a low tug with a glazed
|
||||
cab, all on the ground at their built size (samehkhamis, #1730).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Draw aircraft models in their own mesh colours instead of a 94% flat
|
||||
white (amber for military) over the model; the tracked and emergency
|
||||
colours now tint the mesh without hiding its paint (samehkhamis, #1731).
|
||||
@@ -1,4 +0,0 @@
|
||||
- An aircraft that reports no ground track (parked or taxiing, from adsb.lol
|
||||
or a local receiver) now faces its reported true heading instead of north,
|
||||
so one taxiing south no longer looks like it is moving backwards
|
||||
(samehkhamis, #1732).
|
||||
@@ -230,125 +230,3 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`,
|
||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||
markup. Node tests that import application modules register the component
|
||||
loader with `node --import=<core package>/build/register-vue`.
|
||||
|
||||
### Cockpit subjects
|
||||
|
||||
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
|
||||
selected AIS vessel. An application adds kinds of its own with
|
||||
`services.cockpitSubjects` on `createApplicationControls`: a list of
|
||||
providers, checked once when the controls are built
|
||||
(`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member
|
||||
that throws when read (a getter), is left out with a warning and the rest are
|
||||
kept. A provider's subject takes entry when it was selected after every
|
||||
built-in subject present (a tracked aircraft or satellite, a selected vessel),
|
||||
whose own fixed order is unchanged, and needs the provider's `layerId` on; the
|
||||
provider then holds its subject, hands the camera a frame and the generic HUD
|
||||
a view model on every update, and releases it on exit. Provider methods are
|
||||
synchronous and are called on the provider, so a class instance's methods keep
|
||||
their `this`; one that throws or returns a promise is treated as having
|
||||
failed. What a method returns may carry getters: one that throws when read
|
||||
makes a candidate none, a frame malformed (the view ends) and a HUD view keep
|
||||
the last; a worded member (labels, entry face, look) that throws keeps its
|
||||
last good words.
|
||||
|
||||
```js
|
||||
import { getLocale, onLocaleChange } from 'gods-eye-view/i18n';
|
||||
import { pointAtS } from 'gods-eye-view/layers/rail/paths';
|
||||
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: {
|
||||
view: 'Fahrtansicht',
|
||||
exit: 'Fahrtansicht verlassen',
|
||||
exitText: 'VERLASSEN',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ride the Rail layer's selected train; `rail` is its layer instance. */
|
||||
const ride = {
|
||||
kind: 'example-ride',
|
||||
layerId: 'rail',
|
||||
entryFace: { icon: 'train', label: 'RIDE' },
|
||||
// A function is read again when the page's language changes.
|
||||
labels: () => words[getLocale()] ?? words.en,
|
||||
candidate() {
|
||||
const train = rail.getSelectedTrain();
|
||||
return (
|
||||
train && {
|
||||
id: train.key,
|
||||
name: train.label,
|
||||
selectedAt: train.selectedAtMs,
|
||||
}
|
||||
);
|
||||
},
|
||||
// Only a train bound to its line can be ridden: read() needs its path.
|
||||
isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path),
|
||||
hold: (id) => rail.setCockpitSubject(id),
|
||||
read(nowMs, ctx) {
|
||||
const train = rail.getCockpitSubject();
|
||||
if (!train?.path) return null; // ends the view
|
||||
const point = pointAtS(train.path.line, train.path.s);
|
||||
return {
|
||||
id: train.key,
|
||||
latitude: point.lat,
|
||||
longitude: point.lon,
|
||||
heightM: train.position.railHeightM, // null stands it on the shared floor
|
||||
name: train.label,
|
||||
headingDeg:
|
||||
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
|
||||
speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps,
|
||||
// The layer already moves the train smoothly: sit on it.
|
||||
anchor: 'exact',
|
||||
eye: { upM: 3.5 },
|
||||
};
|
||||
},
|
||||
hud: (frame) => ({ title: frame.name }),
|
||||
release(id, { restore }) {
|
||||
rail.setCockpitSubject(null);
|
||||
if (restore) rail.focusTrain(id);
|
||||
},
|
||||
};
|
||||
|
||||
createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
|
||||
```
|
||||
|
||||
The `./i18n` entry gives `getLocale()` and `onLocaleChange()`:
|
||||
Core translates only its own strings, so a provider words its labels, entry
|
||||
face and HUD for the page's language (labels may be functions, as above; the
|
||||
HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the
|
||||
Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
|
||||
for a provider placing a train along its line. The frame, HUD view and
|
||||
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
|
||||
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
|
||||
`src/layers/transit/cockpitSubject.js`. A snapshot read again while nothing in
|
||||
it changed is the one read before, so `read` may take one on every update; a
|
||||
train's `path.line` is a frozen copy of its line, for `pointAtS` and
|
||||
`projectOnPath`. The `layers/rail/paths` export and the
|
||||
Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`,
|
||||
`getCockpitSubject` and the snapshots they return, `path.line` among them)
|
||||
are experimental and may change without notice.
|
||||
|
||||
A candidate's `selectedAt` is a token that changes each time the user selects
|
||||
the subject, even the same one again, so entry counts it as selected then; a
|
||||
new `id` under the same token is the same selection (a train's selection
|
||||
moving to another feed's copy of it). The Rail and Transit snapshots give one,
|
||||
`selectedAtMs`: when the user last selected the train or vehicle by a click or
|
||||
`focusTrain`/`focusVehicle`. It is null after a deselection and for one
|
||||
selected only by a hold, since a hold is not the user's selection.
|
||||
|
||||
A frame with `heightM: null` stands on the shared floor, or where it last
|
||||
stood while that floor is cold. If no height has been known since entry, the
|
||||
view waits, showing ACQUIRING SURFACE, for at most five seconds
|
||||
(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject.
|
||||
|
||||
`hold(id)` returns `true` once it holds the subject, or `false` to refuse
|
||||
entry; any other answer, `undefined` included, refuses entry too, with one
|
||||
warning. A refused hold, and one that throws or returns a promise, is followed
|
||||
by `release(id, { restore: false })`, again once a returned promise settles,
|
||||
so `release` must be harmless for a subject it does not hold and when called
|
||||
more than once.
|
||||
|
||||
Leaving the view does not track again an aircraft that was tracked before
|
||||
entry, as with the bridge and orbital views: when the user leaves by the exit
|
||||
control, the map goes back to the ridden subject through
|
||||
`release(id, { restore: true })`.
|
||||
+2
-137
@@ -2208,83 +2208,6 @@ the subject (tracking stopped, Satellites off) or tracking another target
|
||||
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
|
||||
covers entry, camera, zoom, look, HUD and exit.
|
||||
|
||||
### Application subjects
|
||||
|
||||
An application can add its own first-person subjects beside the aircraft,
|
||||
vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`,
|
||||
a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by
|
||||
the coordinator; a malformed one, or one with a member that throws when read,
|
||||
is left out with a warning and the rest are kept). A provider names
|
||||
its layer, an entry face and labels in the page's language, and supplies four
|
||||
operations: `candidate()` (what entry would ride now), `hold(id)`,
|
||||
`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD
|
||||
view model), with `release(id, {restore})` on exit. Entry needs the provider's
|
||||
layer on, no Context change in flight and the provider's own
|
||||
`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with
|
||||
the reason, while a provider's view is in use.
|
||||
|
||||
The built-in kinds keep their fixed entry order (a tracked aircraft, then the
|
||||
tracked satellite, then the selected vessel), so without providers entry is
|
||||
unchanged. A provider's candidate takes entry only when it was selected after
|
||||
every built-in subject present, by when the Cockpit first saw it or saw it
|
||||
selected again (its `selectedAt` changing): a train picked after an aircraft
|
||||
was tracked takes entry, and an aircraft tracked after it takes it back.
|
||||
Providers' candidates are ordered most recently selected first, then by
|
||||
priority; one first seen together with a built-in subject comes after it. The
|
||||
first in that order decides alone: when its gate is shut there is no entry
|
||||
rather than the next subject's.
|
||||
|
||||
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
|
||||
frame gives none, or where it last stood while that floor is cold; with
|
||||
neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for
|
||||
at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
|
||||
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
|
||||
turn off the floor clamps (`groundClamp: false`, for a subject inside a
|
||||
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
|
||||
layer that already moves it smoothly; a frame with no `speedMps` is always
|
||||
exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the
|
||||
camera every rendered frame. A frame that is null, malformed or for another
|
||||
subject ends the view (`restoreTracking: false`), as an aircraft tracked
|
||||
meanwhile and the provider's layer turning off do; the exit control asks the
|
||||
provider to restore its subject. The generic HUD shows the provider's kicker,
|
||||
title, meta line, speed with its unit and tag, readout cards, always-visible
|
||||
source rows, banner, route card and Live Signals rows, a veil that darkens the
|
||||
picture under the HUD (with passing light streaks, which stop under reduced
|
||||
motion), arrow-key look-around when the provider asks for it, and a progress
|
||||
line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
|
||||
labels never overlap). Core translates none of the provider's text: a host
|
||||
words it for the page's language (`gods-eye-view/i18n`:
|
||||
`getLocale`, `onLocaleChange`). Its entry face, labels and look may be
|
||||
functions, read again when they are shown and on a change of language, as the
|
||||
HUD view is.
|
||||
|
||||
The Rail layer offers a read API for such a view: `getSelectedTrain()` and
|
||||
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
|
||||
which way it runs (`pathBinding.js` learns it), its rail height, speed as
|
||||
reported or derived, position source, feed credit and stops, and
|
||||
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
|
||||
even again; null after a deselection and for a train only a hold selected),
|
||||
`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its
|
||||
feed held, clicks and Escape left to the view; the held copy stays the one
|
||||
drawn of its train while held, another feed's copy hidden, and it keeps
|
||||
moving, still selected, when its category is turned off) and
|
||||
`focusTrain(key)` (false for a train the layer does not draw); its pure path
|
||||
module is exported as `gods-eye-view/layers/rail/paths`. That export
|
||||
and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is
|
||||
the one read before, and its `path.line` is a frozen copy. Transit
|
||||
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
|
||||
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
|
||||
out of the camera's view, and its sprite, 3D model, card, trail and detection
|
||||
box are hidden; released, it is out of play until a visibility sweep finds it in
|
||||
view. Its snapshot's `position.groundHeightM` is the floor where playback draws
|
||||
it (the drawn marker's height while it is placed, else its segment's two
|
||||
reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the
|
||||
way the drawn train runs: once playback has placed it, a report that runs back
|
||||
turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
|
||||
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
|
||||
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
|
||||
cover them.
|
||||
|
||||
## Context coordination
|
||||
|
||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||
@@ -2306,17 +2229,6 @@ shown without a fade once it is decoded (or after one second if its decode never
|
||||
settles), so a new camera's preview is blank only while its frame loads. Disposal
|
||||
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
|
||||
|
||||
The panel opens when a camera becomes active and collapses when the layer is
|
||||
switched off, from its own switch or anywhere else. The panel chrome applies
|
||||
the collapse, so a panel the desktop accordion only folded has its saved and
|
||||
shared state closed too. Clear selected data layers and Context isolation
|
||||
(Contacts, Space Missions) switch the layer off without the user asking: the
|
||||
panel and its share preference stay as they are, and the layer comes back to an
|
||||
open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true
|
||||
while `_clearLayersOutsideContextMode` switches the layer off): the user
|
||||
switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its
|
||||
meta line reads the catalog count, not the last active camera.
|
||||
|
||||
## UI disposal
|
||||
|
||||
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
||||
@@ -2531,53 +2443,6 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
|
||||
detection is the backstop for when no probe context can be created. Applied
|
||||
during viewer construction, before any tile builds a draw command.
|
||||
|
||||
## Render failure notice
|
||||
|
||||
When the globe stops drawing, the scene chrome says so instead of leaving a
|
||||
black globe behind working controls. `installRenderFailureNotice()`
|
||||
(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas'
|
||||
`webglcontextlost`, logs the error, and publishes a notice that
|
||||
`RenderFailure.vue` shows as an alert dialog with focus on its first action.
|
||||
The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end
|
||||
"Rendering has stopped" panel no longer appears; `applicationViewerOptions()`
|
||||
keeps it by default for other callers.
|
||||
|
||||
- **Retry** restarts the render loop. The notice stays until a frame starts
|
||||
and ends without an error: Cesium raises `postRender` after a failed frame
|
||||
too.
|
||||
- **Reload**, after a lost context, reloads the page; the address keeps the
|
||||
view.
|
||||
- **Reset view** opens `appRootUrl()`, without the hash or `/share/<code>`
|
||||
path; the query, such as `?lang=`, is kept.
|
||||
|
||||
Pitfalls:
|
||||
|
||||
- An error thrown outside Scene's error handling (a frame listener, `onTick`,
|
||||
a resize) stops the loop without a `renderError`. The notice wraps the
|
||||
widget's `render()` and `resize()` and raises it as one, once even with
|
||||
`rethrowRenderErrors`.
|
||||
- A throwing listener leaves its Cesium event mid-raise, with listener changes
|
||||
only queued. The wrapper finishes those raises first (`finishRaise()` over
|
||||
`frameEvents()`), so a layer turned off or on meanwhile behaves.
|
||||
- A render error marks the loop stopped while its next frame is still queued.
|
||||
The wrapper keeps the loop marked running, so a Retry or a shown tab
|
||||
(`src/app/tools.js`) before that frame starts no second loop.
|
||||
- After a lost context, later render errors are only logged, so the notice
|
||||
keeps saying why. The listener neither prevents nor restores the loss.
|
||||
- When the globe draws again, focus goes back where it was, or to the globe's
|
||||
canvas, which the map's keys read as the map; the canvas is focusable only
|
||||
until focus leaves or the globe is pressed. Focus the user moved stays put.
|
||||
- The notice sits above floating panels such as the Québec camera viewer, and
|
||||
its buttons are 44 px tall on touch screens.
|
||||
|
||||
An inline conversation panel never reloads or navigates its host's page: it
|
||||
offers Retry after a render error only, and no notice after a lost context,
|
||||
where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`).
|
||||
Its timer (`keepPanelRendering()`) draws only while the render loop is on and
|
||||
logs nothing itself, so each render error is logged once, by the notice. An
|
||||
error a timer frame throws is caught there: it has already been raised as a
|
||||
render error.
|
||||
|
||||
## Map Source control ownership
|
||||
|
||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||
@@ -7429,9 +7294,9 @@ pins and share links already supply complete boxes.
|
||||
normal four-samples-per-250-ms budget. Retry timestamps more than the maximum
|
||||
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
||||
can retain a bounded retry delay of up to 30 seconds.
|
||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/121 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
|
||||
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
|
||||
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
|
||||
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
|
||||
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
||||
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
|
||||
|
||||
@@ -298,7 +298,6 @@
|
||||
},
|
||||
"./sources/rail-service": "./src/sources/railService.js",
|
||||
"./layers/rail/source": "./src/layers/rail/source.js",
|
||||
"./layers/rail/paths": "./src/data/railPaths.js",
|
||||
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
||||
"./server/providers/mapillary": {
|
||||
"node": "./server/providers/mapillary.js"
|
||||
|
||||
@@ -726,18 +726,18 @@
|
||||
"file": "other/car.glb",
|
||||
"min": [
|
||||
-2.31,
|
||||
-1.075,
|
||||
0.018
|
||||
-0.906,
|
||||
0.019
|
||||
],
|
||||
"max": [
|
||||
2.31,
|
||||
1.075,
|
||||
1.84
|
||||
2.3,
|
||||
0.906,
|
||||
1.948
|
||||
],
|
||||
"length": 4.62,
|
||||
"beam": 2.15,
|
||||
"length": 4.61,
|
||||
"beam": 1.812,
|
||||
"clearance": 0.0,
|
||||
"tris": 736,
|
||||
"tris": 206,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"car_carrier": {
|
||||
@@ -1124,19 +1124,19 @@
|
||||
"category": "other",
|
||||
"file": "other/fire_truck.glb",
|
||||
"min": [
|
||||
-6.408,
|
||||
-1.507,
|
||||
0.029
|
||||
-5.51,
|
||||
-1.5,
|
||||
0.027
|
||||
],
|
||||
"max": [
|
||||
5.51,
|
||||
1.507,
|
||||
3.64
|
||||
5.5,
|
||||
1.5,
|
||||
4.08
|
||||
],
|
||||
"length": 11.918,
|
||||
"beam": 3.014,
|
||||
"length": 11.01,
|
||||
"beam": 3.0,
|
||||
"clearance": 0.0,
|
||||
"tris": 1016,
|
||||
"tris": 314,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"fishing_boat": {
|
||||
@@ -1751,19 +1751,19 @@
|
||||
"category": "other",
|
||||
"file": "other/pushback.glb",
|
||||
"min": [
|
||||
-3.45,
|
||||
-1.398,
|
||||
0.025
|
||||
-3.0,
|
||||
-1.45,
|
||||
0.024
|
||||
],
|
||||
"max": [
|
||||
3.05,
|
||||
1.398,
|
||||
2.14
|
||||
3.0,
|
||||
1.45,
|
||||
2.1
|
||||
],
|
||||
"length": 6.5,
|
||||
"beam": 2.795,
|
||||
"length": 6.0,
|
||||
"beam": 2.9,
|
||||
"clearance": 0.0,
|
||||
"tris": 490,
|
||||
"tris": 176,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"r44": {
|
||||
@@ -1785,25 +1785,6 @@
|
||||
"tris": 1172,
|
||||
"icon": "heli"
|
||||
},
|
||||
"radio_mast": {
|
||||
"category": "other",
|
||||
"file": "other/radio_mast.glb",
|
||||
"min": [
|
||||
-0.7,
|
||||
-0.815,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
1.2,
|
||||
0.815,
|
||||
11.8
|
||||
],
|
||||
"length": 1.9,
|
||||
"beam": 1.63,
|
||||
"clearance": 0.0,
|
||||
"tris": 940,
|
||||
"icon": "vehicle"
|
||||
},
|
||||
"regional_dmu": {
|
||||
"category": "transit",
|
||||
"file": "transit/regional_dmu.glb",
|
||||
@@ -2303,18 +2284,18 @@
|
||||
"file": "other/van.glb",
|
||||
"min": [
|
||||
-2.76,
|
||||
-1.22,
|
||||
0.018
|
||||
-1.0,
|
||||
0.019
|
||||
],
|
||||
"max": [
|
||||
2.76,
|
||||
1.22,
|
||||
2.56
|
||||
2.75,
|
||||
1.0,
|
||||
2.438
|
||||
],
|
||||
"length": 5.52,
|
||||
"beam": 2.44,
|
||||
"length": 5.51,
|
||||
"beam": 2.0,
|
||||
"clearance": 0.0,
|
||||
"tris": 736,
|
||||
"tris": 206,
|
||||
"icon": "vehicle"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -983,14 +983,6 @@
|
||||
"SHIP": [
|
||||
"blimp",
|
||||
75.0
|
||||
],
|
||||
"ZVEH": [
|
||||
"car",
|
||||
4.61
|
||||
],
|
||||
"GRND": [
|
||||
"radio_mast",
|
||||
1.9
|
||||
]
|
||||
},
|
||||
"helicopters": {
|
||||
@@ -1151,41 +1143,12 @@
|
||||
5.0
|
||||
]
|
||||
},
|
||||
"aircraft_callsign": {
|
||||
"ZVEH": [
|
||||
{
|
||||
"prefixes": [
|
||||
"FIRE"
|
||||
],
|
||||
"model": "fire_truck",
|
||||
"length": 11.01
|
||||
},
|
||||
{
|
||||
"prefixes": [
|
||||
"PUSH",
|
||||
"TUG"
|
||||
],
|
||||
"model": "pushback",
|
||||
"length": 6.0
|
||||
},
|
||||
{
|
||||
"prefixes": [
|
||||
"DEICER",
|
||||
"SQUID",
|
||||
"SQB"
|
||||
],
|
||||
"model": "van",
|
||||
"length": 5.51
|
||||
}
|
||||
]
|
||||
},
|
||||
"vessels": [
|
||||
{
|
||||
"types": [
|
||||
70,
|
||||
79
|
||||
],
|
||||
"typical_len": 300,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1225,7 +1188,6 @@
|
||||
80,
|
||||
89
|
||||
],
|
||||
"typical_len": 250,
|
||||
"rules": [
|
||||
{
|
||||
"name_re": "\\bLNG\\b|\\bGAS\\b",
|
||||
@@ -1252,7 +1214,6 @@
|
||||
60,
|
||||
69
|
||||
],
|
||||
"typical_len": 140,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1282,7 +1243,6 @@
|
||||
37,
|
||||
37
|
||||
],
|
||||
"typical_len": 19,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 35,
|
||||
@@ -1313,7 +1273,6 @@
|
||||
30,
|
||||
30
|
||||
],
|
||||
"typical_len": 24,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 15,
|
||||
@@ -1384,7 +1343,6 @@
|
||||
55,
|
||||
55
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 25,
|
||||
@@ -1400,7 +1358,6 @@
|
||||
40,
|
||||
49
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 20,
|
||||
@@ -1416,7 +1373,6 @@
|
||||
33,
|
||||
33
|
||||
],
|
||||
"typical_len": 100,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 50,
|
||||
|
||||
@@ -557,13 +557,6 @@
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/frameRateMonitor.test.mjs",
|
||||
"src/ui/frameRateView.test.mjs",
|
||||
"src/ui/renderFailure.test.mjs",
|
||||
"src/ui/renderFailureView.test.mjs",
|
||||
"src/ui/renderFailureLoopErrors.test.mjs",
|
||||
"src/ui/renderFailureEventRetry.test.mjs",
|
||||
"src/app/renderFailureInlineRetry.test.mjs",
|
||||
"src/ui/renderFailureSecondLoop.test.mjs",
|
||||
"src/ui/renderLoopFixture.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
@@ -808,16 +801,6 @@
|
||||
"server/providers/transitHistory.js",
|
||||
"server/providers/transitHistory.test.mjs",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsJapan.test.mjs",
|
||||
"src/data/transitCoverage.js",
|
||||
"src/data/transitCoverage.test.mjs",
|
||||
"src/layers/transit/coverage.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitFeedsCatalogue.test.mjs",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsFrance.test.mjs",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitFeeds.test.mjs",
|
||||
"src/data/transitProxy.js",
|
||||
@@ -1446,18 +1429,8 @@
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitSubjectProviders.test.mjs",
|
||||
"src/cockpitProgress.js",
|
||||
"src/cockpitProgress.test.mjs",
|
||||
"src/ui/cockpitSubjectView.test.mjs",
|
||||
"src/ui/styles/cockpitSubject.css",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/cockpitSubject.test.mjs",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/data/fleetModels.test.mjs",
|
||||
"src/data/fleetModelInstances.test.mjs",
|
||||
"src/testSupport/fleetGlb.mjs",
|
||||
"src/testSupport/fleetModelFakes.mjs",
|
||||
"scripts/measure-fleet-trail-anchors.mjs"
|
||||
]
|
||||
@@ -22,9 +22,6 @@
|
||||
"src/ui/vueRuntime.js",
|
||||
"src/ui/views/index.js",
|
||||
"src/ui/views/CockpitView.vue",
|
||||
"src/ui/views/panels/CockpitProgress.vue",
|
||||
"src/cockpitProgress.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/ui/views/CommandDockView.vue",
|
||||
"src/ui/views/ContextView.vue",
|
||||
"src/ui/views/panels/RadioPanel.vue",
|
||||
@@ -166,7 +163,6 @@
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/views/panels/GlobeActions.vue",
|
||||
"src/ui/views/panels/RenderFailure.vue",
|
||||
"src/ui/views/panels/GlobeCompass.vue",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/styleParameters.js",
|
||||
@@ -1013,8 +1009,6 @@
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -1180,8 +1174,6 @@
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -1364,7 +1356,6 @@
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitSubject.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/layers.css",
|
||||
@@ -3124,8 +3115,6 @@
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitProgress.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
@@ -3143,7 +3132,6 @@
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/renderFailure.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
@@ -3314,7 +3302,6 @@
|
||||
"src/app/layers/rail.js",
|
||||
"src/data/railFeeds.js",
|
||||
"src/layers/rail/index.js",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/source.js",
|
||||
"src/layers/rail/policy.js",
|
||||
"src/layers/rail/ingestion.js",
|
||||
@@ -3336,16 +3323,11 @@
|
||||
"src/data/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -3355,8 +3337,6 @@
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/coverage.js",
|
||||
"src/data/transitCoverage.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
@@ -4310,7 +4290,6 @@
|
||||
"src/app/layers/rail.js",
|
||||
"src/data/railFeeds.js",
|
||||
"src/layers/rail/index.js",
|
||||
"src/layers/rail/cockpitSubject.js",
|
||||
"src/layers/rail/source.js",
|
||||
"src/layers/rail/policy.js",
|
||||
"src/layers/rail/ingestion.js",
|
||||
@@ -4331,16 +4310,11 @@
|
||||
"src/data/railTimeZones.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -4350,8 +4324,6 @@
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/coverage.js",
|
||||
"src/data/transitCoverage.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
@@ -4827,9 +4799,7 @@
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/fleetModels.js",
|
||||
"src/data/fleetTrailAnchors.js"
|
||||
"src/data/aircraftEmergency.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -5192,10 +5162,6 @@
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/httpBody.js",
|
||||
@@ -5213,10 +5179,6 @@
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/transitHistory.js"
|
||||
@@ -5258,17 +5220,12 @@
|
||||
"src/data/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/movement.js",
|
||||
@@ -5281,8 +5238,6 @@
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/coverage.js",
|
||||
"src/data/transitCoverage.js",
|
||||
"src/overlays/worldOverlay.js",
|
||||
"src/overlays/worldOverlayDraw.js",
|
||||
"src/overlays/worldOverlayTokens.js",
|
||||
@@ -5335,17 +5290,12 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/cockpitSubject.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
@@ -5355,8 +5305,6 @@
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/coverage.js",
|
||||
"src/data/transitCoverage.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
@@ -5414,10 +5362,6 @@
|
||||
"src/data/railNoPositions.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
@@ -5448,10 +5392,6 @@
|
||||
"src/data/railNoPositions.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
@@ -5468,10 +5408,6 @@
|
||||
"modules": [
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
@@ -5739,10 +5675,6 @@
|
||||
"src/layers/transit/policy.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/rioRouteNames.js",
|
||||
"src/data/transitFeedsFrance.js",
|
||||
"src/data/transitFeedsJapan.js",
|
||||
"src/data/transitFeedsCatalogue.js",
|
||||
"src/data/transitRouteNamesFrance.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
@@ -5986,10 +5918,5 @@
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"rail-paths": {
|
||||
"exports": ["./layers/rail/paths"],
|
||||
"modules": ["src/data/railPaths.js"],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -339,45 +339,6 @@ try {
|
||||
'#cctv-frame',
|
||||
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
|
||||
);
|
||||
// The address bar follows the share state after a short delay: wait for the
|
||||
// CCTV panel token (1 collapsed, 0 open) the step expects.
|
||||
const panelState = async (expectedToken) => {
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
(token) =>
|
||||
new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test(
|
||||
new URLSearchParams(location.hash.slice(1)).get('ui') || '',
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
expectedToken,
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
return page.evaluate(
|
||||
(shared) => ({
|
||||
collapsed: document
|
||||
.getElementById('cctv-panel')
|
||||
.classList.contains('collapsed'),
|
||||
shared,
|
||||
ui: new URLSearchParams(location.hash.slice(1)).get('ui'),
|
||||
}),
|
||||
settled ? expectedToken : null,
|
||||
);
|
||||
};
|
||||
const closedOnDisable = await panelState('1');
|
||||
check(
|
||||
'disable collapses the panel and its share preference',
|
||||
closedOnDisable.collapsed && closedOnDisable.shared === '1',
|
||||
JSON.stringify(closedOnDisable),
|
||||
);
|
||||
// The switch sits inside the collapsed panel: reopen it from the right rail.
|
||||
await page.click('[data-rail-item="cctv-panel"]');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.getElementById('cctv-panel').classList.contains('collapsed'),
|
||||
);
|
||||
await page.click('#cctv-enable-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
@@ -390,42 +351,6 @@ try {
|
||||
'disable clears the preview and re-enable reacquires the camera frame',
|
||||
disabledCleanly,
|
||||
);
|
||||
// Contacts switches CCTV off to isolate itself and restores it on the way
|
||||
// out: the open panel and its share preference survive the round trip.
|
||||
const cameraBeforeContacts = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._cctvControls.getState()
|
||||
?.activeCameraId,
|
||||
);
|
||||
const entered = await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setContextMode('flights'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
|
||||
);
|
||||
const duringContacts = await panelState('0');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setContextMode(null),
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('cctv'),
|
||||
);
|
||||
const afterContacts = await panelState('0');
|
||||
check(
|
||||
'Contacts isolation keeps the open CCTV panel and restores its camera',
|
||||
entered?.ok === true &&
|
||||
!duringContacts.collapsed &&
|
||||
duringContacts.shared === '0' &&
|
||||
!afterContacts.collapsed &&
|
||||
afterContacts.shared === '0' &&
|
||||
(await page.evaluate(
|
||||
(cameraId) =>
|
||||
window.__godsEyeView.styleManager._cctvControls.getState()
|
||||
?.activeCameraId === cameraId,
|
||||
cameraBeforeContacts,
|
||||
)),
|
||||
JSON.stringify({ entered, duringContacts, afterContacts }),
|
||||
);
|
||||
await page.$eval('#cctv-frame', (image) =>
|
||||
image.scrollIntoView({ block: 'nearest' }),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchGfsWind } from './wind/gfs.js';
|
||||
import { fetchIfsWind } from './wind/ifs.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
import { decodeWindGribInWorker } from './wind/decoder.js';
|
||||
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
|
||||
|
||||
// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact
|
||||
@@ -11,7 +11,7 @@ const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable';
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
decodeImpl = decodeWindGribInWorker,
|
||||
targetDx = 1,
|
||||
ttlMs = 3600_000,
|
||||
timeoutMs = 40_000,
|
||||
@@ -22,6 +22,8 @@ export function windProxy({
|
||||
const loadings = new Map();
|
||||
const grids = new Map();
|
||||
const attempts = new Map();
|
||||
// Sampled U/V of the last forecast object per model (see loadWeatherSnapshot).
|
||||
const windCache = new Map();
|
||||
const unavailable = (model, overlay) => ({
|
||||
manifest: {
|
||||
model,
|
||||
@@ -54,6 +56,7 @@ export function windProxy({
|
||||
targetDx,
|
||||
overlay,
|
||||
signal: controller.signal,
|
||||
windCache,
|
||||
});
|
||||
controller.signal.throwIfAborted();
|
||||
const { grid, cycle } = value;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Worker-thread entry for the wind provider, started by ./decoder.js. Node
|
||||
// loads this file from disk, so it may only import files that ship beside it.
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
import { serveWindDecodes } from './decode.js';
|
||||
|
||||
serveWindDecodes(parentPort);
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sampleWindField } from './grid.js';
|
||||
|
||||
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
|
||||
let eccodesPromise = null;
|
||||
function loadEccodes() {
|
||||
@@ -49,3 +51,18 @@ export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
|
||||
eccodes.writeFile(file, new Uint8Array(0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker side of ./decoder.js: decode each posted message, validate and sample
|
||||
* it to the display grid, and transfer back only the samples.
|
||||
*/
|
||||
export function serveWindDecodes(port, decodeImpl = decodeWindGribMessage) {
|
||||
port.on('message', async ({ id, bytes, targetDx }) => {
|
||||
try {
|
||||
const field = sampleWindField(await decodeImpl(bytes), targetDx);
|
||||
port.postMessage({ id, field }, [field.samples.buffer]);
|
||||
} catch (error) {
|
||||
port.postMessage({ id, error: String(error?.message ?? error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
const WORKER_URL = new URL('./decode-worker.js', import.meta.url);
|
||||
const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Decode GRIB messages on one lazily started worker thread.
|
||||
*
|
||||
* ecCodes decodes a 0.25° message synchronously (~1.04 M doubles), and
|
||||
* validating and resampling those values is synchronous too, so on the server
|
||||
* thread each refresh stalled every other API route. The worker does all three
|
||||
* and transfers back only the display-grid samples.
|
||||
*
|
||||
* The worker is referenced only while a job is pending, so it never keeps the
|
||||
* process alive. A crash, an exit or a job that outlives `jobTimeoutMs`
|
||||
* rejects every pending job and retires the worker; the next decode starts a
|
||||
* new one.
|
||||
*
|
||||
* @param {{workerUrl?: URL, jobTimeoutMs?: number}} [options]
|
||||
* @returns {{decode: Function, close: Function}}
|
||||
*/
|
||||
export function createWindDecoder({
|
||||
workerUrl = WORKER_URL,
|
||||
jobTimeoutMs = 30_000,
|
||||
} = {}) {
|
||||
let active = null;
|
||||
let nextId = 1;
|
||||
const retire = (state, error) => {
|
||||
if (active === state) active = null;
|
||||
const jobs = [...state.pending.values()];
|
||||
state.pending.clear();
|
||||
for (const job of jobs) job.reject(error);
|
||||
void state.worker.terminate();
|
||||
};
|
||||
const start = () => {
|
||||
const state = {
|
||||
worker: new Worker(workerUrl),
|
||||
pending: new Map(),
|
||||
};
|
||||
state.worker.unref();
|
||||
state.worker.on('message', ({ id, field, error }) => {
|
||||
const job = state.pending.get(id);
|
||||
if (!job) return; // Already rejected by an abort.
|
||||
if (error === undefined) job.resolve(field);
|
||||
else job.reject(new Error(error));
|
||||
});
|
||||
state.worker.on('error', (cause) =>
|
||||
retire(state, new Error('Wind decode worker failed', { cause })),
|
||||
);
|
||||
state.worker.on('exit', () =>
|
||||
retire(state, new Error('Wind decode worker exited')),
|
||||
);
|
||||
return state;
|
||||
};
|
||||
/**
|
||||
* Decode one message and sample it to `targetDx` (see `sampleWindField`).
|
||||
* An aborted `signal` rejects at once; the worker finishes the bounded job
|
||||
* and its result is dropped.
|
||||
*/
|
||||
const decode = (buffer, { targetDx = 1, signal } = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (buffer.byteLength > MAX_MESSAGE_BYTES)
|
||||
throw new Error('Wind GRIB exceeds byte budget');
|
||||
signal?.throwIfAborted();
|
||||
// A private copy: transferring the caller's (possibly pooled) Buffer
|
||||
// would detach memory it still owns.
|
||||
const bytes = new Uint8Array(buffer.byteLength);
|
||||
bytes.set(buffer);
|
||||
const state = (active ??= start());
|
||||
const id = nextId++;
|
||||
const settle = (finish) => (value) => {
|
||||
if (state.pending.get(id) === job) state.pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (state.pending.size === 0) state.worker.unref();
|
||||
finish(value);
|
||||
};
|
||||
const job = { resolve: settle(resolve), reject: settle(reject) };
|
||||
const abort = () => job.reject(signal.reason);
|
||||
const timer = setTimeout(
|
||||
() => retire(state, new Error('Wind decode timed out')),
|
||||
jobTimeoutMs,
|
||||
);
|
||||
timer.unref();
|
||||
state.pending.set(id, job);
|
||||
state.worker.ref();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
state.worker.postMessage({ id, bytes, targetDx }, [bytes.buffer]);
|
||||
});
|
||||
return {
|
||||
decode,
|
||||
/** Stop the worker; pending jobs reject and a later decode restarts it. */
|
||||
close() {
|
||||
if (active) retire(active, new Error('Wind decode worker closed'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let shared = null;
|
||||
|
||||
/**
|
||||
* Default decoder for the wind provider: one worker for the whole process,
|
||||
* started by the first wind request, however many `windProxy` instances exist.
|
||||
*/
|
||||
export function decodeWindGribInWorker(buffer, options) {
|
||||
shared ??= createWindDecoder();
|
||||
return shared.decode(buffer, options);
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export async function loadOptionalScalar({
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
targetDx = 1,
|
||||
timeoutMs = 12_000,
|
||||
}) {
|
||||
if (!range) return undefined;
|
||||
@@ -140,7 +141,13 @@ export async function loadOptionalScalar({
|
||||
fetchImpl,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const value = await decodeImpl(bytes);
|
||||
// Sampling here keeps an unusable companion on the same wind-only path as
|
||||
// an undecodable one. The decoder gets the signal so a timeout or a
|
||||
// cancelled refresh stops waiting for a decode that runs off this thread.
|
||||
const value = sampleWindField(
|
||||
await decodeImpl(bytes, { targetDx, signal: controller.signal }),
|
||||
targetDx,
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
return value;
|
||||
} catch {
|
||||
@@ -158,17 +165,80 @@ import {
|
||||
selectLatestGfsCycle,
|
||||
nearestGfsStep,
|
||||
} from './catalog.js';
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
|
||||
import { decodeWindGribInWorker } from './decoder.js';
|
||||
import {
|
||||
resampleWeatherSnapshot,
|
||||
sampleWindField,
|
||||
weatherScalarMetadata,
|
||||
} from './grid.js';
|
||||
|
||||
/**
|
||||
* Load U, V and the optional companion from one forecast object and resample
|
||||
* them to the display grid.
|
||||
*
|
||||
* `windCache` (a Map owned by one `windProxy`) retains the sampled U/V of the
|
||||
* last object loaded per model. A later request for the same object and byte
|
||||
* ranges, such as an overlay change or a refresh within the same forecast
|
||||
* step, then fetches and decodes only the companion. It holds two Float64
|
||||
* display grids per model (2 x 65,160 x 8 B = 1.04 MB at 1 degree) and is
|
||||
* written only after the pair has passed every wind check.
|
||||
*/
|
||||
export async function loadWeatherSnapshot({
|
||||
model,
|
||||
url,
|
||||
ranges,
|
||||
scalarRange,
|
||||
overlay,
|
||||
targetDx,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
windCache,
|
||||
}) {
|
||||
const key = [
|
||||
url,
|
||||
`${ranges.u.start}-${ranges.u.end}`,
|
||||
`${ranges.v.start}-${ranges.v.end}`,
|
||||
targetDx,
|
||||
].join('|');
|
||||
const kept = windCache?.get(model);
|
||||
const wind = kept?.key === key ? kept : null;
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
sampleWindField(
|
||||
await decodeImpl(await fetchRange({ url, ...range, fetchImpl, signal }), {
|
||||
targetDx,
|
||||
signal,
|
||||
}),
|
||||
targetDx,
|
||||
);
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
wind ? wind.u : load(ranges.u),
|
||||
wind ? wind.v : load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
targetDx,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
if (!wind) windCache?.set(model, { key, u, v });
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
|
||||
export async function fetchGfsWind({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
targetDx = 1,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
decodeImpl = decodeWindGribInWorker,
|
||||
signal,
|
||||
overlay = 'none',
|
||||
windCache,
|
||||
} = {}) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const nowMs = now();
|
||||
@@ -194,22 +264,18 @@ export async function fetchGfsWind({
|
||||
/* A missing optional field does not discard valid wind. */
|
||||
}
|
||||
}
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
decodeImpl(await fetchRange({ url: base, ...range, fetchImpl, signal }));
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
load(ranges.u),
|
||||
load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url: base,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
const fields = await loadWeatherSnapshot({
|
||||
model: 'gfs',
|
||||
url: base,
|
||||
ranges,
|
||||
scalarRange,
|
||||
overlay,
|
||||
targetDx,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
windCache,
|
||||
});
|
||||
const runIso = new Date(runMs).toISOString();
|
||||
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
||||
return {
|
||||
|
||||
+127
-35
@@ -48,38 +48,133 @@ export function resampleWindGrid({
|
||||
if (!Array.prototype.every.call(values, Number.isFinite))
|
||||
throw new Error('Invalid wind grid values');
|
||||
}
|
||||
const geometry = { ni, nj, lo1, la1, di, dj };
|
||||
const sampled = (values) => ({
|
||||
...geometry,
|
||||
dx,
|
||||
dy,
|
||||
samples: sampleValues(values, geometry, dx, dy),
|
||||
});
|
||||
return composeWindGrid({
|
||||
u: sampled(u),
|
||||
v: sampled(v),
|
||||
...(scalar === undefined ? {} : { scalar: sampled(scalar) }),
|
||||
scalarScale,
|
||||
scalarOffset,
|
||||
});
|
||||
}
|
||||
|
||||
/** Display-grid shape for a target spacing, under the point budget. */
|
||||
function displayShape(dx, dy) {
|
||||
const nx = Math.round(360 / dx);
|
||||
const ny = Math.round(180 / dy) + 1;
|
||||
if (nx * ny > 1_000_000)
|
||||
throw new Error('Resampled wind grid exceeds point budget');
|
||||
const outU = new Float32Array(nx * ny);
|
||||
const outV = new Float32Array(nx * ny);
|
||||
const outScalar = scalar === undefined ? null : new Float32Array(nx * ny);
|
||||
const sample = (source, lon, lat) => {
|
||||
const x = ((lon - lo1) / di + ni) % ni;
|
||||
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
|
||||
const x0 = Math.floor(x);
|
||||
const x1 = (x0 + 1) % ni;
|
||||
const y0 = Math.floor(y);
|
||||
const y1 = Math.min(nj - 1, y0 + 1);
|
||||
const fx = x - x0;
|
||||
const fy = y - y0;
|
||||
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
|
||||
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
|
||||
return top * (1 - fy) + bottom * fy;
|
||||
};
|
||||
return { nx, ny };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilinearly sample one validated source field onto the display grid. The
|
||||
* result stays in doubles so a caller can scale it before narrowing to
|
||||
* Float32, exactly as if it had sampled, scaled and stored in one step.
|
||||
*/
|
||||
function sampleValues(source, { ni, nj, lo1, la1, di, dj }, dx, dy) {
|
||||
const { nx, ny } = displayShape(dx, dy);
|
||||
const samples = new Float64Array(nx * ny);
|
||||
for (let y = 0; y < ny; y += 1) {
|
||||
const lat = 90 - y * dy;
|
||||
const sy = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
|
||||
const y0 = Math.floor(sy);
|
||||
const y1 = Math.min(nj - 1, y0 + 1);
|
||||
const fy = sy - y0;
|
||||
for (let x = 0; x < nx; x += 1) {
|
||||
const index = y * nx + x;
|
||||
const lon = x * dx;
|
||||
const lat = 90 - y * dy;
|
||||
outU[index] = sample(u, lon, lat);
|
||||
outV[index] = sample(v, lon, lat);
|
||||
if (outScalar)
|
||||
outScalar[index] =
|
||||
sample(scalar, lon, lat) * scalarScale + scalarOffset;
|
||||
const sx = ((lon - lo1) / di + ni) % ni;
|
||||
const x0 = Math.floor(sx);
|
||||
const x1 = (x0 + 1) % ni;
|
||||
const fx = sx - x0;
|
||||
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
|
||||
const bottom =
|
||||
source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
|
||||
samples[y * nx + x] = top * (1 - fy) + bottom * fy;
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one decoded GRIB field and reduce it to display-grid samples.
|
||||
*
|
||||
* This is the expensive per-message step after the decode (a 0.25° message is
|
||||
* ~1.04 M doubles to check), so the decode worker runs it and transfers only
|
||||
* the result. A field that already carries samples for `targetDx` passes
|
||||
* through, which lets the same call normalise worker output, injected decoders
|
||||
* and the retained wind of the current cycle.
|
||||
*
|
||||
* @param {object} field - `decodeWindGribMessage` output, or a sampled field.
|
||||
* @param {number} [targetDx] - Display-grid spacing in degrees.
|
||||
* @returns {{ni: number, nj: number, lo1: number, la1: number, di: number,
|
||||
* dj: number, units: (string|undefined), dx: number, dy: number,
|
||||
* samples: Float64Array}}
|
||||
*/
|
||||
export function sampleWindField(field, targetDx = 1) {
|
||||
if (!field) throw new Error('Mismatched weather grid geometry');
|
||||
if (
|
||||
field.samples instanceof Float64Array &&
|
||||
field.dx === targetDx &&
|
||||
field.dy === targetDx
|
||||
)
|
||||
return field;
|
||||
const { ni, nj, lo1, la1, di, dj, values, units } = field;
|
||||
if (
|
||||
!Number.isInteger(ni) ||
|
||||
!Number.isInteger(nj) ||
|
||||
ni < 1 ||
|
||||
nj < 1 ||
|
||||
ni * nj > 2_000_000 ||
|
||||
values?.length !== ni * nj ||
|
||||
![lo1, la1, di, dj, targetDx].every(Number.isFinite) ||
|
||||
di <= 0 ||
|
||||
dj <= 0 ||
|
||||
targetDx < 0.25 ||
|
||||
targetDx > 180
|
||||
)
|
||||
throw new Error('Invalid wind grid geometry');
|
||||
if (!Array.prototype.every.call(values, Number.isFinite))
|
||||
throw new Error('Invalid wind grid values');
|
||||
const geometry = { ni, nj, lo1, la1, di, dj };
|
||||
return {
|
||||
...geometry,
|
||||
units,
|
||||
dx: targetDx,
|
||||
dy: targetDx,
|
||||
samples: sampleValues(values, geometry, targetDx, targetDx),
|
||||
};
|
||||
}
|
||||
|
||||
/** Narrow sampled components that share one display grid to Float32. */
|
||||
function composeWindGrid({ u, v, scalar, scalarScale = 1, scalarOffset = 0 }) {
|
||||
const { dx, dy } = u;
|
||||
const { nx, ny } = displayShape(dx, dy);
|
||||
const components = [u, v, ...(scalar ? [scalar] : [])];
|
||||
if (
|
||||
components.some(
|
||||
(field) =>
|
||||
field.dx !== dx ||
|
||||
field.dy !== dy ||
|
||||
!(field.samples instanceof Float64Array) ||
|
||||
field.samples.length !== nx * ny,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid wind grid geometry');
|
||||
const outU = new Float32Array(u.samples);
|
||||
const outV = new Float32Array(v.samples);
|
||||
let outScalar = null;
|
||||
if (scalar) {
|
||||
outScalar = new Float32Array(nx * ny);
|
||||
for (let index = 0; index < outScalar.length; index += 1)
|
||||
outScalar[index] = scalar.samples[index] * scalarScale + scalarOffset;
|
||||
}
|
||||
for (const values of [outU, outV, ...(outScalar ? [outScalar] : [])]) {
|
||||
if (!values.every(Number.isFinite))
|
||||
throw new Error('Invalid resampled wind values');
|
||||
@@ -130,20 +225,17 @@ export function resampleWeatherFields({
|
||||
throw new Error('Mismatched weather grid geometry');
|
||||
if (metadata && scalar.units !== (overlay === 'temperature' ? 'K' : 'Pa'))
|
||||
throw new Error('Unexpected weather scalar units');
|
||||
const grid = resampleWindGrid({
|
||||
u: u.values,
|
||||
v: v.values,
|
||||
ni: u.ni,
|
||||
nj: u.nj,
|
||||
lo1: u.lo1,
|
||||
la1: u.la1,
|
||||
di: u.di,
|
||||
dj: u.dj,
|
||||
dx: targetDx,
|
||||
dy: targetDx,
|
||||
// Raw fields are validated and sampled here; worker output and the retained
|
||||
// wind of this cycle already are, so only the cheap narrowing is left.
|
||||
const [sampledU, sampledV, sampledScalar] = fields.map((field) =>
|
||||
sampleWindField(field, targetDx),
|
||||
);
|
||||
const grid = composeWindGrid({
|
||||
u: sampledU,
|
||||
v: sampledV,
|
||||
...(metadata
|
||||
? {
|
||||
scalar: scalar.values,
|
||||
scalar: sampledScalar,
|
||||
scalarScale: overlay === 'pressure' ? 0.01 : 1,
|
||||
scalarOffset: overlay === 'temperature' ? -273.15 : 0,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchText, fetchRange, loadOptionalScalar } from './gfs.js';
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
|
||||
import { fetchText, loadWeatherSnapshot } from './gfs.js';
|
||||
import { decodeWindGribInWorker } from './decoder.js';
|
||||
import { weatherScalarMetadata } from './grid.js';
|
||||
|
||||
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
|
||||
const IFS_BASE = 'https://data.ecmwf.int/forecasts';
|
||||
@@ -110,9 +110,10 @@ export async function fetchIfsWind({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
targetDx = 1,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
decodeImpl = decodeWindGribInWorker,
|
||||
signal,
|
||||
overlay = 'none',
|
||||
windCache,
|
||||
} = {}) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const nowMs = now();
|
||||
@@ -137,24 +138,18 @@ export async function fetchIfsWind({
|
||||
/* A missing optional field does not discard valid wind. */
|
||||
}
|
||||
}
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
decodeImpl(
|
||||
await fetchRange({ url: urls.grib, ...range, fetchImpl, signal }),
|
||||
);
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
load(ranges.u),
|
||||
load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url: urls.grib,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
const fields = await loadWeatherSnapshot({
|
||||
model: 'ifs',
|
||||
url: urls.grib,
|
||||
ranges,
|
||||
scalarRange,
|
||||
overlay,
|
||||
targetDx,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
windCache,
|
||||
});
|
||||
const runIso = new Date(runMs).toISOString();
|
||||
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
||||
return {
|
||||
|
||||
@@ -400,27 +400,6 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
||||
}
|
||||
});
|
||||
|
||||
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('area');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.27);
|
||||
|
||||
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe
|
||||
// draws again and the control that had focus is gone.
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes');
|
||||
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the live preview hangs at the height the click landed on', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
|
||||
Vendored
-2
@@ -11,8 +11,6 @@ import { t } from '../i18n/index.js';
|
||||
* whose address does not carry one. `localePicker: false` removes the language
|
||||
* picker, for a host that offers its own language choice (`switchLocale` from
|
||||
* the `./i18n` entry).
|
||||
* `services.cockpitSubjects` adds the application's own first-person subjects
|
||||
* to the Cockpit (src/cockpitSubjectProviders.js).
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
|
||||
+28
-11
@@ -94,6 +94,17 @@ export function panelAwareVisibility(doc = globalThis.document) {
|
||||
const PANEL_FRAME_MS = 33;
|
||||
const MISSED_FRAMES_MS = 250;
|
||||
|
||||
/** A render error's details, including the plain objects workers report. */
|
||||
function describeError(error) {
|
||||
if (error instanceof Error)
|
||||
return `${error.name}: ${error.message}\n${error.stack}`;
|
||||
try {
|
||||
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the globe drawing in an inline panel. Some hosts report a panel on
|
||||
* screen as hidden, which stops the browser's animation frames and with them
|
||||
@@ -111,27 +122,33 @@ export function keepPanelRendering(
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
};
|
||||
frameRequest = windowRef.requestAnimationFrame(onFrame);
|
||||
// Cesium stops its render loop after a render error, and the timer stops
|
||||
// with it; Retry restarts both. The render failure notice logs the error
|
||||
// (src/ui/renderFailure.js).
|
||||
// Cesium stops drawing after a render error, and so does the timer.
|
||||
// Report the error in full: workers report plain objects, which Cesium
|
||||
// prints as [object Object].
|
||||
let failed = false;
|
||||
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
|
||||
(_scene, error) => {
|
||||
failed = true;
|
||||
console.error(
|
||||
"[God's Eye View panel] render error:",
|
||||
describeError(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
const timer = windowRef.setInterval(() => {
|
||||
if (
|
||||
viewer.useDefaultRenderLoop === false ||
|
||||
failed ||
|
||||
now() - lastFrame < MISSED_FRAMES_MS ||
|
||||
viewer.isDestroyed?.()
|
||||
)
|
||||
return;
|
||||
try {
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
} catch {
|
||||
// Raised as a render error by now (src/ui/renderFailure.js), which
|
||||
// stops this timer; thrown on, it would only reach the host page.
|
||||
}
|
||||
viewer.resize();
|
||||
viewer.render();
|
||||
}, PANEL_FRAME_MS);
|
||||
return () => {
|
||||
windowRef.clearInterval(timer);
|
||||
windowRef.cancelAnimationFrame(frameRequest);
|
||||
removeErrorListener?.();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+27
-14
@@ -457,7 +457,8 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
|
||||
assert.equal(frame, null);
|
||||
});
|
||||
|
||||
test('a panel stops drawing while its render loop is stopped', () => {
|
||||
test('a panel reports a render error in full and stops drawing', () => {
|
||||
let listener = null;
|
||||
let tick = null;
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
@@ -466,19 +467,31 @@ test('a panel stops drawing while its render loop is stopped', () => {
|
||||
clearInterval() {},
|
||||
};
|
||||
const drawn = [];
|
||||
const viewer = { resize() {}, render: () => drawn.push('render') };
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
// Cesium stops its render loop after a render error.
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
// Retry restarts it, and the timer draws again.
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
tick();
|
||||
assert.deepEqual(drawn, ['render']);
|
||||
stop();
|
||||
const viewer = {
|
||||
resize() {},
|
||||
render: () => drawn.push('render'),
|
||||
scene: {
|
||||
renderError: {
|
||||
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||||
},
|
||||
},
|
||||
};
|
||||
const logged = [];
|
||||
const original = console.error;
|
||||
console.error = (...args) => logged.push(args.join(' '));
|
||||
try {
|
||||
let clock = 0;
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||||
clock = 1000;
|
||||
tick();
|
||||
assert.deepEqual(drawn, []);
|
||||
stop();
|
||||
assert.equal(listener, null);
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// Retry in an inline panel whose host stops animation frames, so the panel
|
||||
// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop
|
||||
// and useDefaultRenderLoop setter (CesiumWidget.prototype).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { keepPanelRendering } from './embed.js';
|
||||
import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
|
||||
|
||||
function setup() {
|
||||
let tick = null;
|
||||
let clock = 0;
|
||||
// The host reports the panel hidden: its own animation frames never arrive.
|
||||
const windowRef = {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: (fn) => ((tick = fn), 1),
|
||||
clearInterval() {},
|
||||
};
|
||||
const previousError = console.error;
|
||||
console.error = () => {};
|
||||
const state = { failure: null, drawn: 0 };
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
initializeFrame() {},
|
||||
// Scene.render: a failing primitive update raises renderError
|
||||
// (tryAndCatchError) and returns; a failing listener throws.
|
||||
render() {
|
||||
state.drawn += 1;
|
||||
this.preUpdate.raiseEvent(this);
|
||||
if (state.failure === 'caught')
|
||||
this.renderError.raiseEvent(this, new Error('boom'));
|
||||
if (state.failure === 'thrown') throw new Error('boom');
|
||||
this.postRender.raiseEvent(this);
|
||||
},
|
||||
};
|
||||
const { frames, widget, viewer, store, runFrames, restore } =
|
||||
renderLoopFixture(scene, {
|
||||
viewer: {
|
||||
isDestroyed: () => false,
|
||||
render: () => widget.render(),
|
||||
resize: () => widget.resize(),
|
||||
},
|
||||
notice: { allowReload: false },
|
||||
});
|
||||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
const timerFrame = () => {
|
||||
clock += 400;
|
||||
tick();
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
state,
|
||||
store,
|
||||
timerFrame,
|
||||
animationFrames: runFrames,
|
||||
restore: () => {
|
||||
stop();
|
||||
restore();
|
||||
console.error = previousError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const failure of ['caught', 'thrown']) {
|
||||
test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => {
|
||||
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||
setup();
|
||||
try {
|
||||
// The host stops animation frames with the loop's next one queued.
|
||||
assert.equal(frames.length, 1);
|
||||
state.failure = failure;
|
||||
timerFrame(); // an error thrown here does not reach the host page
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(typeof store.renderFailure.retry, 'function');
|
||||
state.failure = null;
|
||||
store.renderFailure.retry();
|
||||
const before = state.drawn;
|
||||
timerFrame();
|
||||
assert.ok(state.drawn > before, 'the panel draws again after Retry');
|
||||
assert.equal(
|
||||
store.renderFailure,
|
||||
null,
|
||||
'a clean frame clears the notice',
|
||||
);
|
||||
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||
// The host resumes frames: the globe draws once a frame.
|
||||
state.drawn = 0;
|
||||
animationFrames(3);
|
||||
assert.equal(state.drawn, 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => {
|
||||
const { frames, state, store, timerFrame, animationFrames, restore } =
|
||||
setup();
|
||||
try {
|
||||
state.failure = 'caught';
|
||||
animationFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(frames.length, 1);
|
||||
state.failure = null;
|
||||
store.renderFailure.retry();
|
||||
timerFrame();
|
||||
assert.equal(store.renderFailure, null);
|
||||
assert.equal(frames.length, 1, 'Retry starts no second loop');
|
||||
state.drawn = 0;
|
||||
animationFrames(3);
|
||||
assert.equal(state.drawn, 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { isEmbeddedInline } from './embed.js';
|
||||
import { installRenderFailureNotice } from '../ui/renderFailure.js';
|
||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||
import { followWorldTime } from '../time/viewerClock.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
@@ -57,17 +55,11 @@ export async function createApplicationScene({
|
||||
const viewer = createApplicationViewer({
|
||||
container: 'cesiumContainer',
|
||||
creditContainer,
|
||||
// The render failure notice below replaces Cesium's dead-end panel.
|
||||
showRenderLoopErrors: false,
|
||||
});
|
||||
defer(() => {
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
// An inline panel must not reload or navigate its host's page.
|
||||
defer(
|
||||
installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }),
|
||||
);
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(
|
||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||
|
||||
+4
-16
@@ -375,15 +375,8 @@ export function installGlobeInViewRecovery(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors`
|
||||
* false leaves render errors to the caller's own notice instead of Cesium's
|
||||
* panel.
|
||||
*/
|
||||
export function applicationViewerOptions(
|
||||
creditContainer,
|
||||
{ showRenderLoopErrors = true } = {},
|
||||
) {
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
timeline: false,
|
||||
animation: false,
|
||||
@@ -403,21 +396,16 @@ export function applicationViewerOptions(
|
||||
creditContainer,
|
||||
msaaSamples: 4,
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
showRenderLoopErrors,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({
|
||||
container,
|
||||
creditContainer,
|
||||
showRenderLoopErrors,
|
||||
}) {
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(
|
||||
container,
|
||||
applicationViewerOptions(creditContainer, { showRenderLoopErrors }),
|
||||
applicationViewerOptions(creditContainer),
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
|
||||
+2
-68
@@ -32,15 +32,8 @@ export function slewHeading(current, target, maxStepDeg) {
|
||||
* then uses this amount to converge on the layer's delayed display position.
|
||||
* Capping correction below forward speed prevents a late feed fix from
|
||||
* becoming a first-person surge or reversal while still removing drift.
|
||||
* @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an
|
||||
* application subject's own (cockpitSubjectStepSec).
|
||||
*/
|
||||
export function cockpitAnchorCorrectionStep(
|
||||
distanceM,
|
||||
speedMps,
|
||||
dtSec,
|
||||
maxDtSec = 0.1,
|
||||
) {
|
||||
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||
if (
|
||||
!Number.isFinite(distanceM) ||
|
||||
distanceM <= 0 ||
|
||||
@@ -48,36 +41,13 @@ export function cockpitAnchorCorrectionStep(
|
||||
dtSec <= 0
|
||||
)
|
||||
return 0;
|
||||
const dt = Math.min(
|
||||
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
|
||||
dtSec,
|
||||
);
|
||||
const dt = Math.min(0.1, dtSec);
|
||||
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
|
||||
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
|
||||
const correctionRateMps = Math.max(0.75, speed * 0.22);
|
||||
return Math.min(distanceM, eased, correctionRateMps * dt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The seconds an application subject's camera update advances its heading
|
||||
* slew, ground lift and anchor by: the time since the last update, at most
|
||||
* its update interval plus 0.1 s. A subject updated every 500 ms then turns
|
||||
* and eases at its configured rates, while a long stall (a hidden tab) still
|
||||
* moves it no further than one late update would.
|
||||
* @param {number} elapsedMs Time since the last camera update.
|
||||
* @param {number} intervalMs The subject's update interval (0: every frame).
|
||||
* @returns {number}
|
||||
*/
|
||||
export function cockpitSubjectStepSec(elapsedMs, intervalMs) {
|
||||
const elapsedSec = Number.isFinite(elapsedMs)
|
||||
? Math.max(0, elapsedMs) / 1000
|
||||
: 0;
|
||||
const intervalSec = Number.isFinite(intervalMs)
|
||||
? Math.max(0, intervalMs) / 1000
|
||||
: 0;
|
||||
return Math.min(intervalSec + 0.1, elapsedSec);
|
||||
}
|
||||
|
||||
/** Return whether a throttled cockpit presentation update is due. */
|
||||
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
|
||||
@@ -98,42 +68,6 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
|
||||
return nowMs - startedMs >= timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* How fast the clearance a ground clamp adds under an application subject's
|
||||
* eye comes and goes (m/s).
|
||||
*/
|
||||
export const COCKPIT_SUBJECT_LIFT_MPS = 1.5;
|
||||
|
||||
/**
|
||||
* The lift the ground clamp gives an application subject's eye this update.
|
||||
* The floor is read from coarse cells that refine as tiles load, so the lift
|
||||
* it asks for jumps; a ridden eye takes the clearance part of it at
|
||||
* `rateMps`, while what it needs to stay out of the floor itself (`hardM`)
|
||||
* applies at once.
|
||||
* @param {number|null} previousM The lift given last update; null on entry.
|
||||
* @param {number} targetM The lift the floor and clearance ask for (m).
|
||||
* @param {number} hardM The lift that keeps the eye out of the floor (m).
|
||||
* @param {number} dtSec Seconds since the last update.
|
||||
* @param {number} [rateMps]
|
||||
* @returns {number} At least 0, at least `hardM`.
|
||||
*/
|
||||
export function easeCockpitLift(
|
||||
previousM,
|
||||
targetM,
|
||||
hardM,
|
||||
dtSec,
|
||||
rateMps = COCKPIT_SUBJECT_LIFT_MPS,
|
||||
) {
|
||||
const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0;
|
||||
const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0;
|
||||
if (!Number.isFinite(previousM)) return Math.max(target, hard);
|
||||
const step =
|
||||
Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) *
|
||||
Math.max(0, Number.isFinite(dtSec) ? dtSec : 0);
|
||||
const eased = previousM + Math.max(-step, Math.min(step, target - previousM));
|
||||
return Math.max(0, hard, eased);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the cockpit camera above the shared rendered-surface floor.
|
||||
* Unknown floors preserve the proposed camera height.
|
||||
|
||||
@@ -8,11 +8,8 @@ import {
|
||||
cockpitAnchorCorrectionStep,
|
||||
cockpitAltitudeDisplayFt,
|
||||
cockpitGroundSafeHeight,
|
||||
cockpitSubjectStepSec,
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
COCKPIT_SUBJECT_LIFT_MPS,
|
||||
easeCockpitLift,
|
||||
compassDivisions,
|
||||
compassMarkerSlot,
|
||||
scrollingCompassDivisions,
|
||||
@@ -59,28 +56,6 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
|
||||
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
|
||||
});
|
||||
|
||||
test('an application subject on a slow cadence is corrected for its whole step', () => {
|
||||
// The built-in views' step stays capped at 0.1 s...
|
||||
const capped = cockpitAnchorCorrectionStep(100, 0, 0.5);
|
||||
assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1));
|
||||
// ...while a subject updated every 500 ms is corrected for all of it.
|
||||
const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5);
|
||||
assert.ok(Math.abs(whole / capped - 5) < 1e-9);
|
||||
assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped);
|
||||
});
|
||||
|
||||
test('an application subject steps by its interval, never by a long stall', () => {
|
||||
assert.equal(cockpitSubjectStepSec(16, 0), 0.016);
|
||||
assert.equal(cockpitSubjectStepSec(500, 500), 0.5);
|
||||
assert.equal(cockpitSubjectStepSec(520, 500), 0.52);
|
||||
// A hidden tab's minutes count as one late update.
|
||||
assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6);
|
||||
assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1);
|
||||
assert.equal(cockpitSubjectStepSec(-5, 500), 0);
|
||||
assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0);
|
||||
assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1);
|
||||
});
|
||||
|
||||
test('cockpit camera clearance never falls below the rendered ground floor', () => {
|
||||
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
|
||||
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
|
||||
@@ -460,30 +435,3 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', ()
|
||||
assert.equal(formatCockpitEmergency(null), null);
|
||||
assert.equal(formatCockpitEmergency({ label: '' }), null);
|
||||
});
|
||||
|
||||
test('an application subject’s ground lift eases its clearance, never the floor', () => {
|
||||
// On entry the lift asked for applies at once.
|
||||
assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5);
|
||||
// A floor cell that rises by a metre is met at the lift rate...
|
||||
let lift = 0;
|
||||
const frames = [];
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
lift = easeCockpitLift(lift, 1, 0, 0.016);
|
||||
frames.push(lift);
|
||||
}
|
||||
assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9);
|
||||
for (let i = 1; i < frames.length; i += 1)
|
||||
assert.ok(
|
||||
frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9,
|
||||
);
|
||||
assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second');
|
||||
// ...and given back the same way.
|
||||
assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9);
|
||||
// What keeps the eye out of the floor itself is never eased.
|
||||
assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200);
|
||||
assert.equal(
|
||||
easeCockpitLift(0.2, -3, -3, 0.016),
|
||||
0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016,
|
||||
);
|
||||
assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0);
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* @module cockpitProgress
|
||||
* @description Lay out a subject's progress line (cockpitSubjectProviders.js)
|
||||
* at a width in pixels: where each tick, label and the marker go.
|
||||
*
|
||||
* Ticks sit at fractions of the line, 0 at the left. Ticks closer than
|
||||
* `minTickPx` merge, so a long run still reads as a line: a labelled tick
|
||||
* keeps its place and takes in the bare ticks beside it, and bare ticks merge
|
||||
* with each other at their mean place. A merged tick takes the strongest tone
|
||||
* of those it holds. Labels never overlap: where two would, the one of lower
|
||||
* priority is left out (on a tie, the later one).
|
||||
*
|
||||
* Pure: no DOM.
|
||||
*/
|
||||
|
||||
/** Bare ticks closer than this merge (px). */
|
||||
export const COCKPIT_PROGRESS_MIN_TICK_PX = 6;
|
||||
/** Room kept between two labels (px). */
|
||||
export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8;
|
||||
|
||||
/** Tones from weakest to strongest, for a merged tick. */
|
||||
const TONE_RANK = Object.freeze([
|
||||
'muted',
|
||||
'normal',
|
||||
'live',
|
||||
'estimate',
|
||||
'warning',
|
||||
'alert',
|
||||
]);
|
||||
const stronger = (a, b) =>
|
||||
TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a;
|
||||
|
||||
/** A label's width estimate (px): its longer line in small capitals. */
|
||||
export function estimateProgressLabelPx(label) {
|
||||
const longest = Math.max(
|
||||
String(label?.text ?? '').length,
|
||||
String(label?.detail ?? '').length,
|
||||
);
|
||||
return longest * 6.4 + 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* The progress line at `widthPx`, or null when there is nothing to draw.
|
||||
* @param {{ticks: Array<{at: number, tone: string, label: object|null}>,
|
||||
* marker: {at: number, dwell: boolean}|null, tag: string}|null} progress
|
||||
* As cockpitSubjectHudView gives it: ticks sorted by `at`.
|
||||
* @param {number} widthPx
|
||||
* @param {{minTickPx?: number, gapPx?: number,
|
||||
* measure?: (label: object) => number}} [options]
|
||||
* @returns {{ticks: Array<{x: number, tone: string, count: number,
|
||||
* labelled: boolean}>, labels: Array<{x: number, left: number,
|
||||
* width: number, align: string, text: string, detail: string,
|
||||
* tone: string}>, marker: {x: number, dwell: boolean}|null,
|
||||
* travelledX: number, tag: string}|null}
|
||||
*/
|
||||
export function cockpitProgressLayout(
|
||||
progress,
|
||||
widthPx,
|
||||
{
|
||||
minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX,
|
||||
gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX,
|
||||
measure = estimateProgressLabelPx,
|
||||
} = {},
|
||||
) {
|
||||
const ticks = progress?.ticks;
|
||||
if (!Array.isArray(ticks) || ticks.length < 2) return null;
|
||||
if (!Number.isFinite(widthPx) || widthPx <= 0) return null;
|
||||
const placed = ticks.map((tick, order) => ({
|
||||
x: tick.at * widthPx,
|
||||
tone: tick.tone,
|
||||
label: tick.label,
|
||||
order,
|
||||
}));
|
||||
const labelled = placed.filter((tick) => tick.label);
|
||||
// Bare ticks beside a labelled one go into it.
|
||||
const kept = labelled.map((tick) => ({
|
||||
x: tick.x,
|
||||
tone: tick.tone,
|
||||
count: 1,
|
||||
labelled: true,
|
||||
label: tick.label,
|
||||
order: tick.order,
|
||||
}));
|
||||
const bare = [];
|
||||
for (const tick of placed) {
|
||||
if (tick.label) continue;
|
||||
const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx);
|
||||
if (host) {
|
||||
host.tone = stronger(host.tone, tick.tone);
|
||||
host.count += 1;
|
||||
} else bare.push(tick);
|
||||
}
|
||||
// Bare ticks in a chain closer than minTickPx merge at their mean place.
|
||||
let cluster = null;
|
||||
const flush = () => {
|
||||
if (!cluster) return;
|
||||
kept.push({
|
||||
x: cluster.sum / cluster.count,
|
||||
tone: cluster.tone,
|
||||
count: cluster.count,
|
||||
labelled: false,
|
||||
label: null,
|
||||
order: cluster.order,
|
||||
});
|
||||
cluster = null;
|
||||
};
|
||||
for (const tick of bare) {
|
||||
if (cluster && tick.x - cluster.last < minTickPx) {
|
||||
cluster.sum += tick.x;
|
||||
cluster.count += 1;
|
||||
cluster.last = tick.x;
|
||||
cluster.tone = stronger(cluster.tone, tick.tone);
|
||||
} else {
|
||||
flush();
|
||||
cluster = {
|
||||
sum: tick.x,
|
||||
count: 1,
|
||||
last: tick.x,
|
||||
tone: tick.tone,
|
||||
order: tick.order,
|
||||
};
|
||||
}
|
||||
}
|
||||
flush();
|
||||
kept.sort((a, b) => a.x - b.x || a.order - b.order);
|
||||
|
||||
// Labels by priority; one that would overlap a placed label is left out.
|
||||
const boxes = [];
|
||||
const candidates = kept
|
||||
.filter((tick) => tick.labelled)
|
||||
.sort((a, b) => b.label.priority - a.label.priority || a.order - b.order);
|
||||
for (const tick of candidates) {
|
||||
const width = Math.min(widthPx, Math.max(0, measure(tick.label)));
|
||||
let left =
|
||||
tick.label.align === 'start'
|
||||
? tick.x
|
||||
: tick.label.align === 'end'
|
||||
? tick.x - width
|
||||
: tick.x - width / 2;
|
||||
left = Math.min(Math.max(0, left), widthPx - width);
|
||||
const right = left + width;
|
||||
if (
|
||||
boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx)
|
||||
)
|
||||
continue;
|
||||
boxes.push({
|
||||
x: tick.x,
|
||||
left,
|
||||
right,
|
||||
width,
|
||||
align: tick.label.align,
|
||||
text: tick.label.text,
|
||||
detail: tick.label.detail,
|
||||
tone: tick.tone,
|
||||
order: tick.order,
|
||||
});
|
||||
}
|
||||
boxes.sort((a, b) => a.left - b.left);
|
||||
const marker = progress.marker
|
||||
? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell }
|
||||
: null;
|
||||
return {
|
||||
ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({
|
||||
x,
|
||||
tone,
|
||||
count,
|
||||
labelled: isLabelled,
|
||||
})),
|
||||
labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({
|
||||
x,
|
||||
left,
|
||||
width,
|
||||
align,
|
||||
text,
|
||||
detail,
|
||||
tone,
|
||||
})),
|
||||
marker,
|
||||
travelledX: marker ? marker.x : 0,
|
||||
tag: progress.tag || '',
|
||||
};
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// src/cockpitProgress.test.mjs — a subject's progress line laid out at a
|
||||
// width: close bare ticks merge, labelled ticks keep their place, labels never
|
||||
// overlap (the lower priority gives way) and the part before the marker is
|
||||
// the travelled part.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cockpitProgressLayout } from './cockpitProgress.js';
|
||||
|
||||
const tick = (at, label = null, tone = 'normal') => ({ at, tone, label });
|
||||
const label = (text, priority, align = 'center', detail = '') => ({
|
||||
text,
|
||||
detail,
|
||||
priority,
|
||||
align,
|
||||
});
|
||||
/** Every label is 50 px wide. */
|
||||
const measure = () => 50;
|
||||
|
||||
test('nothing is drawn without two ticks or a width', () => {
|
||||
assert.equal(cockpitProgressLayout(null, 500), null);
|
||||
assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null);
|
||||
assert.equal(
|
||||
cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('A', 1, 'start')),
|
||||
tick(0.1),
|
||||
tick(0.101, null, 'warning'),
|
||||
tick(0.102),
|
||||
tick(0.5),
|
||||
tick(1, label('Z', 1, 'end')),
|
||||
],
|
||||
marker: { at: 0.3, dwell: false },
|
||||
tag: 'TIMETABLE',
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.ticks.map(({ x, count, tone, labelled }) => [
|
||||
Math.round(x),
|
||||
count,
|
||||
tone,
|
||||
labelled,
|
||||
]),
|
||||
[
|
||||
[0, 1, 'normal', true],
|
||||
[101, 3, 'warning', false],
|
||||
[500, 1, 'normal', false],
|
||||
[1000, 1, 'normal', true],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(layout.marker, { x: 300, dwell: false });
|
||||
assert.equal(layout.travelledX, 300);
|
||||
assert.equal(layout.tag, 'TIMETABLE');
|
||||
});
|
||||
|
||||
test('a labelled tick keeps its place and takes in the bare ticks beside it', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('A', 1, 'start')),
|
||||
tick(0.498),
|
||||
tick(0.5, label('Next', 3), 'alert'),
|
||||
tick(0.503, null, 'warning'),
|
||||
tick(1, label('Z', 1, 'end')),
|
||||
],
|
||||
marker: null,
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
const next = layout.ticks.find(({ x }) => Math.round(x) === 500);
|
||||
assert.equal(next.count, 3);
|
||||
assert.equal(next.labelled, true);
|
||||
assert.equal(next.tone, 'alert');
|
||||
assert.equal(layout.ticks.length, 3);
|
||||
assert.equal(layout.marker, null);
|
||||
assert.equal(layout.travelledX, 0);
|
||||
});
|
||||
|
||||
test('labels hang from their ticks inside the line, and overlaps give way by priority', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{
|
||||
ticks: [
|
||||
tick(0, label('First', 1, 'start')),
|
||||
tick(0.04, label('Previous', 2)),
|
||||
tick(0.5, label('Next', 3)),
|
||||
tick(0.52, label('Close', 0)),
|
||||
tick(1, label('Last', 1, 'end')),
|
||||
],
|
||||
marker: { at: 0.2, dwell: true },
|
||||
},
|
||||
1000,
|
||||
{ measure },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.labels.map(({ text, left }) => [text, Math.round(left)]),
|
||||
[
|
||||
['Previous', 15],
|
||||
['Next', 475],
|
||||
['Last', 950],
|
||||
],
|
||||
);
|
||||
// The first stop's label would cover the previous stop's: it gives way.
|
||||
assert.ok(!layout.labels.some(({ text }) => text === 'First'));
|
||||
assert.ok(!layout.labels.some(({ text }) => text === 'Close'));
|
||||
assert.deepEqual(layout.marker, { x: 200, dwell: true });
|
||||
});
|
||||
|
||||
test('a centred label at an edge is clamped inside the line', () => {
|
||||
const layout = cockpitProgressLayout(
|
||||
{ ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null },
|
||||
300,
|
||||
{ measure },
|
||||
);
|
||||
assert.equal(layout.labels[0].left, 0);
|
||||
const right = cockpitProgressLayout(
|
||||
{ ticks: [tick(0), tick(1, label('Edge', 1))], marker: null },
|
||||
300,
|
||||
{ measure },
|
||||
);
|
||||
assert.equal(right.labels[0].left, 250);
|
||||
});
|
||||
|
||||
test('a long run of calls still lays out, every one a tick or part of one', () => {
|
||||
const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59));
|
||||
ticks[0].label = label('First', 1, 'start');
|
||||
ticks[59].label = label('Last', 1, 'end');
|
||||
const narrow = cockpitProgressLayout({ ticks, marker: null }, 200);
|
||||
const wide = cockpitProgressLayout({ ticks, marker: null }, 2000);
|
||||
const count = (layout) =>
|
||||
layout.ticks.reduce((sum, entry) => sum + entry.count, 0);
|
||||
assert.equal(count(narrow), 60);
|
||||
assert.equal(count(wide), 60);
|
||||
assert.ok(narrow.ticks.length < wide.ticks.length);
|
||||
assert.equal(wide.ticks.length, 60);
|
||||
});
|
||||
@@ -1,644 +0,0 @@
|
||||
/**
|
||||
* @module cockpitSubjectProviders
|
||||
* @description Subject providers: kinds of subject an application adds to the
|
||||
* Cockpit beside its own aircraft, satellite and vessel. A provider names the
|
||||
* subject entry would take, holds it while the view rides it, hands the camera
|
||||
* a frame each update and the HUD a view model, and releases it on exit. Core
|
||||
* knows nothing else about it.
|
||||
*
|
||||
* Everything a provider returns is normalized here before the Cockpit uses
|
||||
* it: a malformed provider is left out, a malformed frame ends the view, and a
|
||||
* malformed HUD view is read field by field, never thrown on.
|
||||
*
|
||||
* Pure: no Cesium, no DOM.
|
||||
*/
|
||||
|
||||
/** The kinds the Cockpit rides itself; no provider may take one. */
|
||||
export const COCKPIT_BUILT_IN_KINDS = Object.freeze([
|
||||
'aircraft',
|
||||
'satellite',
|
||||
'vessel',
|
||||
]);
|
||||
|
||||
/** The tones a provider's HUD texts may take. */
|
||||
export const COCKPIT_SUBJECT_TONES = Object.freeze([
|
||||
'normal',
|
||||
'muted',
|
||||
'live',
|
||||
'estimate',
|
||||
'warning',
|
||||
'alert',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The heights a frame may give (m, ellipsoidal): below the deepest dry
|
||||
* ground, above anything ridden near it; outside, the frame stands on the
|
||||
* floor.
|
||||
*/
|
||||
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
|
||||
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
|
||||
|
||||
/** The most readouts, sources and signals a HUD view shows. */
|
||||
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
|
||||
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
|
||||
export const COCKPIT_SUBJECT_MAX_SIGNALS = 4;
|
||||
/** The most ticks a progress line takes. */
|
||||
export const COCKPIT_SUBJECT_MAX_TICKS = 512;
|
||||
|
||||
const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
|
||||
const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const ICON_PATTERN = /^[a-z0-9_]{1,40}$/;
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
||||
const REQUIRED_METHODS = Object.freeze([
|
||||
'candidate',
|
||||
'hold',
|
||||
'read',
|
||||
'hud',
|
||||
'release',
|
||||
]);
|
||||
const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS);
|
||||
const TONES = new Set(COCKPIT_SUBJECT_TONES);
|
||||
|
||||
const isObject = (value) =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
||||
|
||||
/**
|
||||
* `value` as display text: trimmed, at most `max` characters (the last one
|
||||
* an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is
|
||||
* kept whole or left out); '' for anything that is not a string or a number.
|
||||
*/
|
||||
export function cockpitSubjectText(value, max) {
|
||||
if (typeof value !== 'string' && !finite(value)) return '';
|
||||
const text = String(value).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= max) return text;
|
||||
let end = max - 1;
|
||||
const last = text.charCodeAt(end - 1);
|
||||
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
|
||||
return `${text.slice(0, end)}…`;
|
||||
}
|
||||
|
||||
const tone = (value) => (TONES.has(value) ? value : 'normal');
|
||||
|
||||
/** A Material Symbols entry face, or null. */
|
||||
function faceOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const label = cockpitSubjectText(raw.label, 16);
|
||||
if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label)
|
||||
return null;
|
||||
return Object.freeze({ icon: raw.icon, label });
|
||||
}
|
||||
|
||||
/** The labels a provider gives, normalized, or null without the required. */
|
||||
function labelsOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const labels = {
|
||||
view: cockpitSubjectText(raw.view, 48),
|
||||
exit: cockpitSubjectText(raw.exit, 48),
|
||||
exitText: cockpitSubjectText(raw.exitText, 24),
|
||||
kicker: cockpitSubjectText(raw.kicker, 48),
|
||||
};
|
||||
return labels.view && labels.exit && labels.exitText
|
||||
? Object.freeze(labels)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A provider's look, normalized, or null without an aheadLabel. */
|
||||
function lookOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64);
|
||||
if (!aheadLabel) return null;
|
||||
return Object.freeze({
|
||||
restPitchDeg: finite(raw.restPitchDeg)
|
||||
? clamp(raw.restPitchDeg, -90, 90)
|
||||
: 0,
|
||||
aheadLabel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's worded member `key` as it reads now, normalized: the value,
|
||||
* or what the function it is returns when called on `provider` (so it can
|
||||
* follow the page's language); null when it is malformed or a read throws.
|
||||
* Reading the member, and reading what it gives, runs provider code too (a
|
||||
* getter), so both are guarded as the call is.
|
||||
*/
|
||||
function wordedNow(provider, key, normalize) {
|
||||
try {
|
||||
const value = provider[key];
|
||||
return (
|
||||
normalize(typeof value === 'function' ? value.call(provider) : value) ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider's name in a warning: its kind, cut, or its place in the list. */
|
||||
function providerName(provider, order) {
|
||||
try {
|
||||
if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32);
|
||||
} catch {
|
||||
// A kind getter that throws gives no name.
|
||||
}
|
||||
return `#${order}`;
|
||||
}
|
||||
|
||||
/** Why `provider` cannot be kept, or null. */
|
||||
function refusal(provider, kinds) {
|
||||
if (!isObject(provider)) return 'not an object';
|
||||
if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind))
|
||||
return 'malformed kind';
|
||||
if (RESERVED.has(provider.kind)) return 'reserved kind';
|
||||
if (kinds.has(provider.kind)) return 'repeated kind';
|
||||
if (
|
||||
typeof provider.layerId !== 'string' ||
|
||||
!LAYER_ID_PATTERN.test(provider.layerId)
|
||||
)
|
||||
return 'malformed layer id';
|
||||
for (const method of REQUIRED_METHODS)
|
||||
if (typeof provider[method] !== 'function') return `no ${method}()`;
|
||||
if (
|
||||
provider.isEntryAllowed !== undefined &&
|
||||
typeof provider.isEntryAllowed !== 'function'
|
||||
)
|
||||
return 'isEntryAllowed is not a function';
|
||||
if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face';
|
||||
if (!wordedNow(provider, 'labels', labelsOf))
|
||||
return 'labels need view, exit and exitText';
|
||||
if (provider.look != null && !wordedNow(provider, 'look', lookOf))
|
||||
return 'look needs an aheadLabel';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's subject providers, checked once and frozen, in the order
|
||||
* entry asks them: ascending `priority`, ties in list order. Each has
|
||||
* - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique;
|
||||
* - `layerId`: the catalog layer the subject belongs to (entry needs it on);
|
||||
* - `priority`: a finite number (default 0);
|
||||
* - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at
|
||||
* most 16 characters, in the page's language;
|
||||
* - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit
|
||||
* button's accessible names, the exit button's text and the default kicker,
|
||||
* in the page's language;
|
||||
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
|
||||
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
|
||||
* ahead button named `aheadLabel`;
|
||||
* `entryFace`, `labels` and `look` may each be a function returning the
|
||||
* object: it is called on the provider again whenever they are shown and
|
||||
* when the page's language changes, so its words can follow it
|
||||
* (`./i18n`). A read that throws or is malformed keeps the last
|
||||
* good one; `look` is on or off and `restPitchDeg` fixed as first read;
|
||||
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
|
||||
* updates the camera every rendered frame;
|
||||
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
|
||||
* positive or Infinity, null for the Cockpit's default;
|
||||
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
|
||||
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
|
||||
* synchronous (createCockpitSubjectCaller) and called on the provider, so
|
||||
* a class instance's methods keep their `this`. `isEntryAllowed` allows
|
||||
* entry only by returning `true`. `hold` returns `true` once it holds the
|
||||
* subject, or `false` to refuse entry; any other answer refuses it too,
|
||||
* with one warning. A refused hold, and one that throws or returns a
|
||||
* promise, is followed by `release(id, {restore: false})`, and by another
|
||||
* once that promise settles; `release` must therefore be harmless for a
|
||||
* subject it does not hold or already gave back.
|
||||
*
|
||||
* A provider that is not so, or whose members throw when read (a getter), is
|
||||
* left out with one warning naming it; the providers after it are kept.
|
||||
* @param {unknown} list
|
||||
* @param {{warn?: (message: string) => void}} [options]
|
||||
* @returns {ReadonlyArray<object>}
|
||||
*/
|
||||
export function cockpitSubjectProviders(
|
||||
list = [],
|
||||
{ warn = (message) => console.warn(message) } = {},
|
||||
) {
|
||||
const kept = [];
|
||||
const kinds = new Set();
|
||||
(Array.isArray(list) ? list : []).forEach((provider, order) => {
|
||||
let reason;
|
||||
let copy = null;
|
||||
try {
|
||||
reason = refusal(provider, kinds);
|
||||
if (!reason) copy = keptProvider(provider, order);
|
||||
} catch {
|
||||
reason = 'a member threw when read';
|
||||
}
|
||||
if (reason) {
|
||||
warn(
|
||||
`[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
kinds.add(copy.kind);
|
||||
kept.push(copy);
|
||||
});
|
||||
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
|
||||
return Object.freeze(kept);
|
||||
}
|
||||
|
||||
/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */
|
||||
function keptProvider(provider, order) {
|
||||
// Worded members are read again on each use (see above); the last good
|
||||
// read stands in for one that is malformed or throws.
|
||||
let face = wordedNow(provider, 'entryFace', faceOf);
|
||||
let labels = wordedNow(provider, 'labels', labelsOf);
|
||||
// On or off, and its rest pitch, as first read.
|
||||
let look = wordedNow(provider, 'look', lookOf);
|
||||
const restPitchDeg = look?.restPitchDeg ?? 0;
|
||||
return Object.freeze({
|
||||
kind: provider.kind,
|
||||
layerId: provider.layerId,
|
||||
priority: finite(provider.priority) ? provider.priority : 0,
|
||||
order,
|
||||
get entryFace() {
|
||||
face = wordedNow(provider, 'entryFace', faceOf) ?? face;
|
||||
return face;
|
||||
},
|
||||
get labels() {
|
||||
labels = wordedNow(provider, 'labels', labelsOf) ?? labels;
|
||||
return labels;
|
||||
},
|
||||
get look() {
|
||||
if (!look) return null;
|
||||
const now = wordedNow(provider, 'look', lookOf);
|
||||
if (now)
|
||||
look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel });
|
||||
return look;
|
||||
},
|
||||
cameraIntervalMs:
|
||||
finite(provider.cameraIntervalMs) &&
|
||||
provider.cameraIntervalMs >= 0 &&
|
||||
provider.cameraIntervalMs <= 1000
|
||||
? provider.cameraIntervalMs
|
||||
: null,
|
||||
headingSlewDps:
|
||||
provider.headingSlewDps === Infinity ||
|
||||
(finite(provider.headingSlewDps) && provider.headingSlewDps > 0)
|
||||
? provider.headingSlewDps
|
||||
: null,
|
||||
// Bound, so a method that uses `this` (a class instance's, say) still
|
||||
// reads its own provider when called on this copy.
|
||||
candidate: provider.candidate.bind(provider),
|
||||
isEntryAllowed: provider.isEntryAllowed
|
||||
? provider.isEntryAllowed.bind(provider)
|
||||
: () => true,
|
||||
hold: provider.hold.bind(provider),
|
||||
read: provider.read.bind(provider),
|
||||
hud: provider.hud.bind(provider),
|
||||
release: provider.release.bind(provider),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a
|
||||
* usable id. `name` falls back to the id; `face` is null unless well formed;
|
||||
* `selectedAt` (a number or a short string, else null) is a token the
|
||||
* provider changes each time its subject is selected, even again: a
|
||||
* subject selected after the built-in ones takes entry (entryCandidates).
|
||||
* Null too when reading `raw` throws (a getter runs provider code).
|
||||
* @param {unknown} raw What `candidate()` returned.
|
||||
*/
|
||||
export function cockpitSubjectCandidate(raw) {
|
||||
try {
|
||||
return candidateOf(raw);
|
||||
} catch {
|
||||
// A getter on what candidate() returned threw: no candidate.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateOf(raw) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string') return null;
|
||||
const id = raw.id.trim();
|
||||
if (!id || id.length > 128) return null;
|
||||
const selectedAt =
|
||||
finite(raw.selectedAt) ||
|
||||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
|
||||
? raw.selectedAt
|
||||
: null;
|
||||
return Object.freeze({
|
||||
id,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
|
||||
face: faceOf(raw.face),
|
||||
selectedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One camera frame from a provider, normalized, or null when it cannot place
|
||||
* the camera (the view then ends): not an object, an id other than `heldId`,
|
||||
* a position that is not finite, an eye with no valid `upM` (0..1000), or a
|
||||
* read of it that throws (a getter runs provider code).
|
||||
* A `heightM` that is not finite or lies outside
|
||||
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
|
||||
* the eye then stands on the shared floor, or where it last stood while that
|
||||
* is cold. The anchor is `'inertial'` only when asked for and the frame
|
||||
* gives a speed; otherwise it is `'exact'`. An id is compared with `heldId`
|
||||
* trimmed, as `cockpitSubjectCandidate` trims it.
|
||||
* @param {unknown} raw What `read()` returned.
|
||||
* @param {string} heldId The subject the view holds.
|
||||
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
|
||||
* courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM,
|
||||
* pitchDeg, minClearanceM}}`.
|
||||
*/
|
||||
export function cockpitSubjectFrame(raw, heldId) {
|
||||
try {
|
||||
return frameOf(raw, heldId);
|
||||
} catch {
|
||||
// A getter on what read() returned threw: as a malformed frame.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function frameOf(raw, heldId) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId)
|
||||
return null;
|
||||
const latitude = raw.latitude;
|
||||
const longitude = raw.longitude;
|
||||
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
|
||||
return null;
|
||||
const eye = isObject(raw.eye) ? raw.eye : null;
|
||||
if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null;
|
||||
const heading = (value) =>
|
||||
finite(value) ? ((value % 360) + 360) % 360 : null;
|
||||
const headingDeg = heading(raw.headingDeg);
|
||||
const speedMps =
|
||||
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
|
||||
return {
|
||||
id: heldId,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
|
||||
latitude,
|
||||
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
|
||||
heightM:
|
||||
finite(raw.heightM) &&
|
||||
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
|
||||
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
|
||||
? raw.heightM
|
||||
: null,
|
||||
headingDeg,
|
||||
courseDeg: heading(raw.courseDeg) ?? headingDeg,
|
||||
speedMps,
|
||||
stale: raw.stale === true,
|
||||
groundClamp: raw.groundClamp !== false,
|
||||
// An inertial anchor runs on at the frame's speed; with none it would
|
||||
// close on a moving subject at the correction's floor and fall behind.
|
||||
anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial',
|
||||
eye: {
|
||||
upM: eye.upM,
|
||||
forwardM:
|
||||
finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500
|
||||
? eye.forwardM
|
||||
: 0,
|
||||
pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0,
|
||||
minClearanceM:
|
||||
finite(eye.minClearanceM) && eye.minClearanceM >= 0
|
||||
? eye.minClearanceM
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */
|
||||
function rowsOf(raw, max, row) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const entry of raw) {
|
||||
if (rows.length >= max) break;
|
||||
if (!isObject(entry)) continue;
|
||||
const id = typeof entry.id === 'string' ? entry.id : '';
|
||||
if (!ID_PATTERN.test(id) || ids.has(id)) continue;
|
||||
const read = row(entry);
|
||||
if (!read) continue;
|
||||
ids.add(id);
|
||||
rows.push({ id, ...read });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A progress line, or null without at least two ticks. */
|
||||
function progressOf(raw) {
|
||||
if (!isObject(raw) || !Array.isArray(raw.ticks)) return null;
|
||||
const ticks = [];
|
||||
for (const tick of raw.ticks) {
|
||||
if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break;
|
||||
if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1)
|
||||
continue;
|
||||
let label = null;
|
||||
if (isObject(tick.label)) {
|
||||
const text = cockpitSubjectText(tick.label.text, 40);
|
||||
if (text)
|
||||
label = {
|
||||
text,
|
||||
detail: cockpitSubjectText(tick.label.detail, 64),
|
||||
priority: finite(tick.label.priority)
|
||||
? clamp(Math.round(tick.label.priority), 0, 9)
|
||||
: 0,
|
||||
align: ['start', 'center', 'end'].includes(tick.label.align)
|
||||
? tick.label.align
|
||||
: 'center',
|
||||
};
|
||||
}
|
||||
ticks.push({ at: tick.at, tone: tone(tick.tone), label });
|
||||
}
|
||||
if (ticks.length < 2) return null;
|
||||
ticks.sort((a, b) => a.at - b.at);
|
||||
const marker =
|
||||
isObject(raw.marker) && finite(raw.marker.at)
|
||||
? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true }
|
||||
: null;
|
||||
return {
|
||||
ticks,
|
||||
marker,
|
||||
tag: cockpitSubjectText(raw.tag, 16),
|
||||
summary: cockpitSubjectText(raw.summary, 160),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's HUD view model, normalized; never throws. Texts are coerced
|
||||
* and cut to their caps, lists to their lengths, tones to
|
||||
* COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent.
|
||||
* Null when reading `raw` throws (a getter runs provider code), so the caller
|
||||
* keeps the view it has, as when `hud()` itself throws.
|
||||
* All text is shown as given: the provider words it in the page's language.
|
||||
* @param {unknown} raw What `hud()` returned.
|
||||
* @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value,
|
||||
* unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}],
|
||||
* sources: [{id, label, text, tone}],
|
||||
* route: {title, from, to, status}|null,
|
||||
* progress: {ticks: [{at, tone, label: {text, detail, priority,
|
||||
* align}|null}], marker: {at, dwell}|null, tag, summary}|null,
|
||||
* banner: {title, detail, tone}|null, veil: {level, streaks}|null,
|
||||
* lookReadout, signals: [{id, tone, title, detail}]}`.
|
||||
*/
|
||||
export function cockpitSubjectHudView(raw) {
|
||||
try {
|
||||
return hudViewOf(raw);
|
||||
} catch {
|
||||
// A getter on what hud() returned threw: the caller keeps its last view.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hudViewOf(raw) {
|
||||
const view = isObject(raw) ? raw : {};
|
||||
const meta = isObject(view.meta) ? view.meta : {};
|
||||
const speed =
|
||||
isObject(view.speed) &&
|
||||
(view.speed.value === null ||
|
||||
(finite(view.speed.value) && view.speed.value >= 0))
|
||||
? {
|
||||
value:
|
||||
view.speed.value === null
|
||||
? null
|
||||
: Math.min(view.speed.value, 99_999),
|
||||
unit: cockpitSubjectText(view.speed.unit, 12),
|
||||
tag: cockpitSubjectText(view.speed.tag, 8),
|
||||
tone: tone(view.speed.tone),
|
||||
}
|
||||
: null;
|
||||
const route = isObject(view.route)
|
||||
? {
|
||||
title: cockpitSubjectText(view.route.title, 32),
|
||||
from: cockpitSubjectText(view.route.from, 48),
|
||||
to: cockpitSubjectText(view.route.to, 48),
|
||||
status: cockpitSubjectText(view.route.status, 64),
|
||||
}
|
||||
: null;
|
||||
const bannerTitle = isObject(view.banner)
|
||||
? cockpitSubjectText(view.banner.title, 48)
|
||||
: '';
|
||||
const veil =
|
||||
isObject(view.veil) && finite(view.veil.level)
|
||||
? {
|
||||
level: clamp(view.veil.level, 0, 1),
|
||||
streaks: view.veil.streaks === true,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
kicker: cockpitSubjectText(view.kicker, 48),
|
||||
title: cockpitSubjectText(view.title, 64),
|
||||
meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) },
|
||||
speed,
|
||||
readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => {
|
||||
const label = cockpitSubjectText(row.label, 24);
|
||||
const value = cockpitSubjectText(row.value, 48);
|
||||
return label || value
|
||||
? {
|
||||
label,
|
||||
value,
|
||||
detail: cockpitSubjectText(row.detail, 80),
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => {
|
||||
const text = cockpitSubjectText(row.text, 120);
|
||||
return text
|
||||
? {
|
||||
label: cockpitSubjectText(row.label, 16),
|
||||
text,
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
route: route && (route.from || route.to) ? route : null,
|
||||
progress: progressOf(view.progress),
|
||||
banner: bannerTitle
|
||||
? {
|
||||
title: bannerTitle,
|
||||
detail: cockpitSubjectText(view.banner.detail, 96),
|
||||
tone: tone(view.banner.tone),
|
||||
}
|
||||
: null,
|
||||
veil: veil && veil.level > 0 ? veil : null,
|
||||
lookReadout: cockpitSubjectText(view.lookReadout, 48),
|
||||
signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => {
|
||||
const title = cockpitSubjectText(row.title, 48);
|
||||
return title
|
||||
? {
|
||||
title,
|
||||
detail: cockpitSubjectText(row.detail, 96),
|
||||
tone: tone(row.tone),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A guarded caller for provider methods: `call(provider, method, args,
|
||||
* fallback)` returns what the method returns, or `fallback` when it throws,
|
||||
* warning once per provider and method.
|
||||
*
|
||||
* Provider methods are synchronous: the Cockpit calls them on its render
|
||||
* cadence and uses the answer at once. One that returns a promise (an async
|
||||
* function, say) breaks that contract: the promise is refused as a throw
|
||||
* would be (its rejection, if any, is swallowed), with one warning, and
|
||||
* `onSettled`, when given, runs once the promise has settled, so a caller
|
||||
* can undo work that lands late.
|
||||
*
|
||||
* `call.warnOnce(provider, method, message, error)` warns on the same
|
||||
* once-per-provider-and-method footing, for an answer the caller finds
|
||||
* malformed.
|
||||
* @param {{warn?: (message: string, error: unknown) => void}} [options]
|
||||
*/
|
||||
export function createCockpitSubjectCaller({
|
||||
warn = (message, error) => console.warn(message, error),
|
||||
} = {}) {
|
||||
const warned = new Set();
|
||||
const warnOnce = (provider, method, message, error) => {
|
||||
const key = `${provider.kind}:${method}`;
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
warn(
|
||||
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
|
||||
error,
|
||||
);
|
||||
};
|
||||
function call(
|
||||
provider,
|
||||
method,
|
||||
args = [],
|
||||
fallback = null,
|
||||
onSettled = null,
|
||||
) {
|
||||
let result;
|
||||
let thenable;
|
||||
try {
|
||||
result = provider[method](...args);
|
||||
// Reading `then` runs provider code too (a getter may throw), so it is
|
||||
// guarded as the call is.
|
||||
thenable =
|
||||
result !== null &&
|
||||
(typeof result === 'object' || typeof result === 'function') &&
|
||||
typeof result.then === 'function';
|
||||
} catch (error) {
|
||||
warnOnce(provider, method, 'failed', error);
|
||||
return fallback;
|
||||
}
|
||||
if (thenable) {
|
||||
// Its work may still land: `onSettled` runs once it has, either way.
|
||||
const settled = () => {
|
||||
if (typeof onSettled === 'function') onSettled();
|
||||
};
|
||||
Promise.resolve(result).then(settled, settled);
|
||||
warnOnce(
|
||||
provider,
|
||||
method,
|
||||
'returned a promise; provider methods are synchronous',
|
||||
null,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
call.warnOnce = warnOnce;
|
||||
return call;
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
// src/cockpitSubjectProviders.test.mjs — an application's subject providers
|
||||
// are checked once and what they return is normalized before the Cockpit
|
||||
// uses it: a malformed provider is left out, a malformed frame ends the view,
|
||||
// and a malformed HUD view is read field by field and never thrown on.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
COCKPIT_BUILT_IN_KINDS,
|
||||
COCKPIT_SUBJECT_MAX_READOUTS,
|
||||
COCKPIT_SUBJECT_MAX_TICKS,
|
||||
cockpitSubjectCandidate,
|
||||
cockpitSubjectFrame,
|
||||
cockpitSubjectHudView,
|
||||
cockpitSubjectProviders,
|
||||
cockpitSubjectText,
|
||||
createCockpitSubjectCaller,
|
||||
} from './cockpitSubjectProviders.js';
|
||||
|
||||
const noop = () => null;
|
||||
/** A well-formed provider, with `overrides`. */
|
||||
const provider = (overrides = {}) => ({
|
||||
kind: 'example-kind',
|
||||
layerId: 'example-layer',
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
labels: {
|
||||
view: 'First-person view',
|
||||
exit: 'Exit first-person view',
|
||||
exitText: 'EXIT VIEW',
|
||||
},
|
||||
candidate: noop,
|
||||
hold: () => true,
|
||||
read: noop,
|
||||
hud: noop,
|
||||
release: noop,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const keep = (list) => {
|
||||
const warnings = [];
|
||||
const kept = cockpitSubjectProviders(list, {
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
return { kept, warnings };
|
||||
};
|
||||
|
||||
test('well-formed providers are kept, frozen, in priority then list order', () => {
|
||||
const { kept, warnings } = keep([
|
||||
provider({ kind: 'late', priority: 5 }),
|
||||
provider({ kind: 'first-of-two', priority: 1 }),
|
||||
provider({ kind: 'second-of-two', priority: 1 }),
|
||||
provider({ kind: 'default-priority' }),
|
||||
]);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(
|
||||
kept.map(({ kind }) => kind),
|
||||
['default-priority', 'first-of-two', 'second-of-two', 'late'],
|
||||
);
|
||||
assert.ok(Object.isFrozen(kept));
|
||||
assert.ok(Object.isFrozen(kept[0]));
|
||||
const [first] = kept;
|
||||
assert.equal(first.priority, 0);
|
||||
assert.equal(first.look, null);
|
||||
assert.equal(first.cameraIntervalMs, null);
|
||||
assert.equal(first.headingSlewDps, null);
|
||||
assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows');
|
||||
assert.deepEqual(first.labels, {
|
||||
view: 'First-person view',
|
||||
exit: 'Exit first-person view',
|
||||
exitText: 'EXIT VIEW',
|
||||
kicker: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed, reserved and repeated providers are left out with one warning each', () => {
|
||||
const cases = [
|
||||
[null, /not an object/],
|
||||
[provider({ kind: 'X' }), /malformed kind/],
|
||||
[provider({ kind: 'a' }), /malformed kind/],
|
||||
[provider({ layerId: 'Bad Layer' }), /malformed layer id/],
|
||||
[provider({ hold: undefined }), /no hold\(\)/],
|
||||
[provider({ read: 'read' }), /no read\(\)/],
|
||||
[provider({ isEntryAllowed: true }), /isEntryAllowed/],
|
||||
[provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/],
|
||||
[provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/],
|
||||
[provider({ labels: { view: 'v', exit: 'e' } }), /labels/],
|
||||
[provider({ look: { restPitchDeg: -3 } }), /aheadLabel/],
|
||||
];
|
||||
for (const [entry, reason] of cases) {
|
||||
const { kept, warnings } = keep([entry]);
|
||||
assert.equal(kept.length, 0, String(reason));
|
||||
assert.equal(warnings.length, 1, String(reason));
|
||||
assert.match(warnings[0], reason);
|
||||
}
|
||||
for (const kind of COCKPIT_BUILT_IN_KINDS) {
|
||||
const { kept, warnings } = keep([provider({ kind })]);
|
||||
assert.equal(kept.length, 0, kind);
|
||||
assert.match(warnings[0], /reserved kind/);
|
||||
}
|
||||
const { kept, warnings } = keep([provider(), provider()]);
|
||||
assert.equal(kept.length, 1);
|
||||
assert.match(warnings[0], /repeated kind/);
|
||||
assert.deepEqual(keep('not a list').kept, []);
|
||||
});
|
||||
|
||||
test('optional settings are kept when in range and defaulted otherwise', () => {
|
||||
const [ranged] = keep([
|
||||
provider({
|
||||
priority: -2,
|
||||
look: { restPitchDeg: -120, aheadLabel: 'Look ahead' },
|
||||
cameraIntervalMs: 0,
|
||||
headingSlewDps: Infinity,
|
||||
labels: {
|
||||
view: 'v',
|
||||
exit: 'e',
|
||||
exitText: 'x',
|
||||
kicker: 'K'.repeat(80),
|
||||
},
|
||||
}),
|
||||
]).kept;
|
||||
assert.equal(ranged.priority, -2);
|
||||
assert.deepEqual(ranged.look, {
|
||||
restPitchDeg: -90,
|
||||
aheadLabel: 'Look ahead',
|
||||
});
|
||||
assert.equal(ranged.cameraIntervalMs, 0);
|
||||
assert.equal(ranged.headingSlewDps, Infinity);
|
||||
assert.equal(ranged.labels.kicker.length, 48);
|
||||
assert.ok(ranged.labels.kicker.endsWith('…'));
|
||||
const [defaulted] = keep([
|
||||
provider({
|
||||
priority: NaN,
|
||||
cameraIntervalMs: 5000,
|
||||
headingSlewDps: -1,
|
||||
}),
|
||||
]).kept;
|
||||
assert.equal(defaulted.priority, 0);
|
||||
assert.equal(defaulted.cameraIntervalMs, null);
|
||||
assert.equal(defaulted.headingSlewDps, null);
|
||||
});
|
||||
|
||||
test('a candidate needs an id; its name and face are read with care', () => {
|
||||
assert.equal(cockpitSubjectCandidate(null), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: 7 }), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: ' ' }), null);
|
||||
assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null);
|
||||
assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), {
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
face: null,
|
||||
selectedAt: null,
|
||||
});
|
||||
// A selection token is a number or a short string.
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
|
||||
'click-2',
|
||||
);
|
||||
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
|
||||
null,
|
||||
);
|
||||
const read = cockpitSubjectCandidate({
|
||||
id: 'a1',
|
||||
name: ' Example 12 ',
|
||||
face: { icon: 'tram', label: 'RIDE' },
|
||||
});
|
||||
assert.equal(read.name, 'Example 12');
|
||||
assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' });
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a frame is the held subject at a finite place with an eye height, else null', () => {
|
||||
const frame = (overrides = {}) => ({
|
||||
id: 'a1',
|
||||
latitude: 47,
|
||||
longitude: 8,
|
||||
eye: { upM: 3.5 },
|
||||
...overrides,
|
||||
});
|
||||
const read = cockpitSubjectFrame(frame(), 'a1');
|
||||
assert.deepEqual(read, {
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
latitude: 47,
|
||||
longitude: 8,
|
||||
heightM: null,
|
||||
headingDeg: null,
|
||||
courseDeg: null,
|
||||
speedMps: null,
|
||||
stale: false,
|
||||
groundClamp: true,
|
||||
// No speed to run an inertial anchor on: it sits on the subject.
|
||||
anchor: 'exact',
|
||||
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
|
||||
});
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor,
|
||||
'inertial',
|
||||
'with a speed it runs on',
|
||||
);
|
||||
// A held id is trimmed (cockpitSubjectCandidate), and so is a frame's.
|
||||
assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1');
|
||||
for (const bad of [
|
||||
null,
|
||||
frame({ id: 'other' }),
|
||||
frame({ latitude: 91 }),
|
||||
frame({ latitude: NaN }),
|
||||
frame({ longitude: Infinity }),
|
||||
frame({ eye: null }),
|
||||
frame({ eye: { upM: -1 } }),
|
||||
frame({ eye: { upM: 2000 } }),
|
||||
frame({ eye: { upM: '3' } }),
|
||||
])
|
||||
assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad));
|
||||
const full = cockpitSubjectFrame(
|
||||
frame({
|
||||
name: 'Example',
|
||||
longitude: 190,
|
||||
heightM: 412.5,
|
||||
headingDeg: -10,
|
||||
courseDeg: 725,
|
||||
speedMps: 22,
|
||||
stale: true,
|
||||
groundClamp: false,
|
||||
anchor: 'exact',
|
||||
eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 },
|
||||
}),
|
||||
'a1',
|
||||
);
|
||||
assert.equal(full.longitude, -170);
|
||||
assert.equal(full.heightM, 412.5);
|
||||
// A height outside any plausible ground reads as none.
|
||||
for (const heightM of [-6_400_000, 1e9, Infinity])
|
||||
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
|
||||
-400,
|
||||
);
|
||||
assert.equal(full.headingDeg, 350);
|
||||
assert.equal(full.courseDeg, 5);
|
||||
assert.equal(full.speedMps, 22);
|
||||
assert.equal(full.stale, true);
|
||||
assert.equal(full.groundClamp, false);
|
||||
assert.equal(full.anchor, 'exact');
|
||||
assert.deepEqual(full.eye, {
|
||||
upM: 2,
|
||||
forwardM: 0,
|
||||
pitchDeg: -90,
|
||||
minClearanceM: 1.5,
|
||||
});
|
||||
// Only exactly false turns the ground clamp off; course falls back to heading.
|
||||
const loose = cockpitSubjectFrame(
|
||||
frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }),
|
||||
'a1',
|
||||
);
|
||||
assert.equal(loose.groundClamp, true);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor,
|
||||
'inertial',
|
||||
);
|
||||
assert.equal(loose.courseDeg, 90);
|
||||
assert.equal(loose.speedMps, null);
|
||||
});
|
||||
|
||||
test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => {
|
||||
const empty = cockpitSubjectHudView(null);
|
||||
assert.deepEqual(empty, {
|
||||
kicker: '',
|
||||
title: '',
|
||||
meta: { text: '', tone: 'normal' },
|
||||
speed: null,
|
||||
readouts: [],
|
||||
sources: [],
|
||||
route: null,
|
||||
progress: null,
|
||||
banner: null,
|
||||
veil: null,
|
||||
lookReadout: '',
|
||||
signals: [],
|
||||
});
|
||||
for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }])
|
||||
assert.doesNotThrow(() => cockpitSubjectHudView(odd));
|
||||
const view = cockpitSubjectHudView({
|
||||
kicker: 'LINE · OPERATOR',
|
||||
title: 'Name → Destination',
|
||||
meta: { text: 'LIVE', tone: 'bogus' },
|
||||
speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' },
|
||||
readouts: [
|
||||
...Array.from({ length: 6 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
label: 'NEXT',
|
||||
value: `Stop ${i}`,
|
||||
tone: 'warning',
|
||||
})),
|
||||
],
|
||||
sources: [
|
||||
{ id: 'track', label: 'TRACK', text: 'operator shape' },
|
||||
{ id: 'track', label: 'TRACK', text: 'repeated id' },
|
||||
{ id: 'Bad Id', text: 'refused' },
|
||||
{ id: 'empty', label: 'X', text: '' },
|
||||
],
|
||||
route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' },
|
||||
banner: { title: 'BANNER', detail: 'detail', tone: 'alert' },
|
||||
veil: { level: 4, streaks: 1 },
|
||||
lookReadout: 'LOOK RIGHT',
|
||||
signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }],
|
||||
});
|
||||
assert.equal(view.meta.tone, 'normal');
|
||||
assert.deepEqual(view.speed, {
|
||||
value: 84.4,
|
||||
unit: 'km/h',
|
||||
tag: 'LIVE',
|
||||
tone: 'live',
|
||||
});
|
||||
assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS);
|
||||
assert.equal(view.readouts[0].tone, 'warning');
|
||||
assert.deepEqual(
|
||||
view.sources.map(({ id }) => id),
|
||||
['track'],
|
||||
);
|
||||
assert.deepEqual(view.route, {
|
||||
title: 'ROUTE',
|
||||
from: 'A',
|
||||
to: 'B',
|
||||
status: '3 stops',
|
||||
});
|
||||
assert.equal(view.banner.tone, 'alert');
|
||||
assert.deepEqual(view.veil, { level: 1, streaks: false });
|
||||
assert.equal(view.lookReadout, 'LOOK RIGHT');
|
||||
assert.equal(view.signals[0].tone, 'warning');
|
||||
// A veil at 0, a speed below 0 and a route with no ends are absent.
|
||||
const absent = cockpitSubjectHudView({
|
||||
veil: { level: 0 },
|
||||
speed: { value: -3 },
|
||||
route: { from: '', to: '' },
|
||||
});
|
||||
assert.equal(absent.veil, null);
|
||||
assert.equal(absent.speed, null);
|
||||
assert.equal(absent.route, null);
|
||||
// A speed of null is a speed not yet known.
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ speed: { value: null } }).speed.value,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => {
|
||||
const read = cockpitSubjectHudView({
|
||||
progress: {
|
||||
ticks: [
|
||||
{ at: 1, label: { text: 'End', align: 'end', priority: 1 } },
|
||||
{ at: 0.5, tone: 'warning' },
|
||||
{ at: 2 },
|
||||
{ at: -0.1 },
|
||||
{ at: 0, label: { text: 'Start', align: 'start', priority: 12 } },
|
||||
{ at: 0.25, label: { text: '', detail: 'no text, no label' } },
|
||||
],
|
||||
marker: { at: 1.4, dwell: true },
|
||||
tag: 'TIMETABLE',
|
||||
summary: 'From Start to End',
|
||||
},
|
||||
}).progress;
|
||||
assert.deepEqual(
|
||||
read.ticks.map(({ at }) => at),
|
||||
[0, 0.25, 0.5, 1],
|
||||
);
|
||||
assert.equal(read.ticks[0].label.priority, 9);
|
||||
assert.equal(read.ticks[0].label.align, 'start');
|
||||
assert.equal(read.ticks[1].label, null);
|
||||
assert.equal(read.ticks[2].tone, 'warning');
|
||||
assert.deepEqual(read.marker, { at: 1, dwell: true });
|
||||
assert.equal(read.tag, 'TIMETABLE');
|
||||
assert.equal(read.summary, 'From Start to End');
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress,
|
||||
null,
|
||||
);
|
||||
const many = cockpitSubjectHudView({
|
||||
progress: {
|
||||
ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({
|
||||
at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40),
|
||||
})),
|
||||
},
|
||||
}).progress;
|
||||
assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS);
|
||||
});
|
||||
|
||||
test('texts are trimmed, collapsed and cut with an ellipsis', () => {
|
||||
assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b');
|
||||
assert.equal(cockpitSubjectText('abcdef', 4), 'abc…');
|
||||
assert.equal(cockpitSubjectText(12.5, 10), '12.5');
|
||||
assert.equal(cockpitSubjectText({}, 10), '');
|
||||
assert.equal(cockpitSubjectText(NaN, 10), '');
|
||||
});
|
||||
|
||||
test('a cut never splits a surrogate pair', () => {
|
||||
// The cut would fall between the emoji's halves: it is left out whole.
|
||||
assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…');
|
||||
// One that fits before the cut is kept whole.
|
||||
assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…');
|
||||
const lone =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
for (let max = 2; max <= 24; max += 1)
|
||||
assert.equal(
|
||||
lone.test(cockpitSubjectText('🚆 Example 12 🚉 Central', max)),
|
||||
false,
|
||||
`cut at ${max}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a provider method that throws returns the fallback and warns once', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const failing = {
|
||||
kind: 'example-kind',
|
||||
read() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
hud: (value) => value * 2,
|
||||
};
|
||||
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
|
||||
assert.equal(call(failing, 'read', [], 'fallback'), 'fallback');
|
||||
assert.equal(call(failing, 'hud', [21]), 42);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /example-kind read\(\) failed/);
|
||||
});
|
||||
|
||||
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const asynchronous = {
|
||||
kind: 'example-kind',
|
||||
hold: async () => true,
|
||||
read: () => Promise.reject(new Error('late')),
|
||||
};
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'read', [], null), null);
|
||||
assert.equal(warnings.length, 2, 'once per method');
|
||||
assert.match(warnings[0], /hold\(\) returned a promise/);
|
||||
// The rejected promise is not left unhandled.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('an answer whose `then` throws when read is handled as a throw', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const tricky = {
|
||||
kind: 'example-kind',
|
||||
hold: () => ({
|
||||
get then() {
|
||||
throw new Error('no then');
|
||||
},
|
||||
}),
|
||||
};
|
||||
assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /example-kind hold\(\) failed/);
|
||||
});
|
||||
|
||||
test('the caller’s warnOnce shares its once-per-provider-and-method record', () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const ride = {
|
||||
kind: 'example-kind',
|
||||
hold() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
call.warnOnce(ride, 'read', 'is odd');
|
||||
call.warnOnce(ride, 'read', 'is odd again');
|
||||
call(ride, 'hold', ['a1']);
|
||||
call.warnOnce(ride, 'hold', 'is odd');
|
||||
assert.deepEqual(warnings, [
|
||||
'[Cockpit] subject provider example-kind read() is odd',
|
||||
'[Cockpit] subject provider example-kind hold() failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a class-based provider keeps its `this`, in its methods and its worded members', () => {
|
||||
class Ride {
|
||||
#word = 'RIDE';
|
||||
#held = null;
|
||||
kind = 'example-kind';
|
||||
layerId = 'example-layer';
|
||||
speak(word) {
|
||||
this.#word = word;
|
||||
}
|
||||
entryFace() {
|
||||
return { icon: 'commute', label: this.#word };
|
||||
}
|
||||
get labels() {
|
||||
return {
|
||||
view: `${this.#word} view`,
|
||||
exit: 'Exit ride view',
|
||||
exitText: 'EXIT RIDE',
|
||||
};
|
||||
}
|
||||
look() {
|
||||
return { aheadLabel: `Look ahead, ${this.#word}` };
|
||||
}
|
||||
candidate() {
|
||||
return { id: 'a1', name: this.#word };
|
||||
}
|
||||
isEntryAllowed(candidate) {
|
||||
return candidate.id === 'a1' && this.#held === null;
|
||||
}
|
||||
hold(id) {
|
||||
this.#held = id;
|
||||
return true;
|
||||
}
|
||||
read() {
|
||||
return this.#held;
|
||||
}
|
||||
hud(frame) {
|
||||
return { title: `${this.#word} ${frame}` };
|
||||
}
|
||||
release() {
|
||||
this.#held = null;
|
||||
return 'released';
|
||||
}
|
||||
}
|
||||
const instance = new Ride();
|
||||
const { kept, warnings } = keep([instance]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.equal(ride.labels.view, 'RIDE view');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE');
|
||||
instance.speak('FAHRT');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT');
|
||||
// Called on the kept copy, as the Cockpit calls them.
|
||||
const failures = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => failures.push(message),
|
||||
});
|
||||
assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' });
|
||||
assert.equal(ride.isEntryAllowed({ id: 'a1' }), true);
|
||||
assert.equal(call(ride, 'hold', ['a1']), true);
|
||||
assert.equal(ride.isEntryAllowed({ id: 'a1' }), false);
|
||||
assert.equal(call(ride, 'read', [0, {}]), 'a1');
|
||||
assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' });
|
||||
assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released');
|
||||
assert.equal(call(ride, 'read', [0, {}]), null);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('labels, entry face and look given as functions are read again on each use', () => {
|
||||
let lang = 'en';
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
|
||||
};
|
||||
const { kept, warnings } = keep([
|
||||
provider({
|
||||
labels: () => words[lang],
|
||||
entryFace: () => ({
|
||||
icon: 'commute',
|
||||
label: { en: 'RIDE', de: 'FAHRT' }[lang],
|
||||
}),
|
||||
look: () => ({
|
||||
restPitchDeg: lang === 'de' ? 40 : -3,
|
||||
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
assert.equal(ride.labels.exitText, 'EXIT RIDE');
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead');
|
||||
lang = 'de';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
assert.equal(ride.look.aheadLabel, 'Nach vorn');
|
||||
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
|
||||
// A read that fails or is malformed keeps the last good one.
|
||||
lang = 'xx';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
// Functions that are malformed from the start leave the provider out.
|
||||
const bad = keep([
|
||||
provider({ kind: 'bad-labels', labels: () => null }),
|
||||
provider({
|
||||
kind: 'thrown-face',
|
||||
entryFace: () => {
|
||||
throw new Error('no face');
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(bad.kept, []);
|
||||
assert.equal(bad.warnings.length, 2);
|
||||
});
|
||||
|
||||
/** `target` with a getter for `key` that throws (provider code runs on a read). */
|
||||
const throwingGetter = (target, key) =>
|
||||
Object.defineProperty(target, key, {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error(`${key} unavailable`);
|
||||
},
|
||||
});
|
||||
|
||||
test('a provider whose getter throws is left out with one warning; the next is kept', () => {
|
||||
const { kept, warnings } = keep([
|
||||
throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'),
|
||||
throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'),
|
||||
throwingGetter(provider({ kind: 'thrown-look' }), 'look'),
|
||||
throwingGetter(provider(), 'kind'),
|
||||
throwingGetter(provider({ kind: 'thrown-method' }), 'read'),
|
||||
// Read only once the provider is accepted, so its kind must stay free.
|
||||
throwingGetter(provider({ kind: 'example-kind' }), 'priority'),
|
||||
provider({ kind: 'example-kind' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
kept.map(({ kind }) => kind),
|
||||
['example-kind'],
|
||||
);
|
||||
assert.deepEqual(warnings, [
|
||||
'[Cockpit] subject provider thrown-face left out: malformed entry face',
|
||||
'[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText',
|
||||
'[Cockpit] subject provider thrown-look left out: a member threw when read',
|
||||
'[Cockpit] subject provider #3 left out: a member threw when read',
|
||||
'[Cockpit] subject provider thrown-method left out: a member threw when read',
|
||||
'[Cockpit] subject provider example-kind left out: a member threw when read',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a worded getter that starts throwing keeps the last good words', () => {
|
||||
let failing = false;
|
||||
const words = (value) => () => {
|
||||
if (failing) throw new Error('translations unavailable');
|
||||
return value;
|
||||
};
|
||||
const definition = provider();
|
||||
for (const [key, value] of Object.entries({
|
||||
labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' },
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
look: { restPitchDeg: 10, aheadLabel: 'Look ahead' },
|
||||
}))
|
||||
Object.defineProperty(definition, key, { get: words(value) });
|
||||
const { kept, warnings } = keep([definition]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
failing = true;
|
||||
assert.equal(ride.labels.view, 'Ride view');
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' });
|
||||
// So does a worded object whose own member throws when read.
|
||||
failing = false;
|
||||
Object.defineProperty(definition, 'labels', {
|
||||
get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'),
|
||||
});
|
||||
assert.equal(ride.labels.view, 'Ride view');
|
||||
});
|
||||
|
||||
test('a candidate, frame or HUD view whose getter throws reads as null', () => {
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')),
|
||||
null,
|
||||
);
|
||||
const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } };
|
||||
assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed');
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }),
|
||||
null,
|
||||
);
|
||||
// Nothing to read is still the empty view, never null.
|
||||
assert.equal(cockpitSubjectHudView(null).title, '');
|
||||
});
|
||||
@@ -481,24 +481,6 @@ export function orbitEntryAllowed({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an application subject provider's candidate can be ridden. Like the
|
||||
* bridge and orbit it needs only its own layer on, and waits out a Context
|
||||
* change in flight; the provider then has its own say (`isEntryAllowed`).
|
||||
* @param {{layerEnabled: boolean, contextModeChanging: boolean,
|
||||
* providerAllows: boolean}} state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function subjectEntryAllowed({
|
||||
layerEnabled,
|
||||
contextModeChanging,
|
||||
providerAllows,
|
||||
}) {
|
||||
return (
|
||||
Boolean(layerEnabled) && !contextModeChanging && providerAllows === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context-mode id → the word the voice tools accept for it.
|
||||
*
|
||||
|
||||
@@ -64,11 +64,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
||||
// A parked or slow aircraft reports no ground track, but often its true
|
||||
// heading (surface messages): the way its nose points, which is what an
|
||||
// aircraft standing still is drawn by. Magnetic heading is not true north.
|
||||
const track =
|
||||
finiteNumber(aircraft?.track) ?? finiteNumber(aircraft?.true_heading);
|
||||
const track = finiteNumber(aircraft?.track);
|
||||
|
||||
return [
|
||||
hex,
|
||||
|
||||
@@ -49,15 +49,3 @@ test('keeps grounded fallback contacts and rejects rows without positions', () =
|
||||
assert.equal(normalized.states[0][7], null);
|
||||
assert.equal(normalized.states[0][8], true);
|
||||
});
|
||||
|
||||
test('an aircraft with no track takes its true heading, never its magnetic one', () => {
|
||||
const at = (extra) =>
|
||||
normalizeAdsbLolAircraftState(
|
||||
{ hex: 'abc123', lat: 37.7, lon: -122.2, alt_baro: 'ground', ...extra },
|
||||
1000,
|
||||
)[10];
|
||||
assert.equal(at({ track: 56.25, true_heading: 40 }), 56.25, 'track first');
|
||||
assert.equal(at({ true_heading: 244.69 }), 244.69, 'true heading');
|
||||
assert.equal(at({ mag_heading: 278.44 }), null, 'not magnetic');
|
||||
assert.equal(at({}), null, 'none');
|
||||
});
|
||||
@@ -458,8 +458,7 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
||||
);
|
||||
flightState._trackedIcao = null;
|
||||
flightState.records.data.set('civil', { emergency: null });
|
||||
// An ordinary model draws its mesh's own colours, whatever the billboard's.
|
||||
assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE);
|
||||
assert.equal(rendering._modelColor('civil'), normal);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||
* Flights, Local ADS-B).
|
||||
@@ -8,24 +6,12 @@ import * as Cesium from 'cesium';
|
||||
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
||||
* when the model is first placed and again when it moves more than 1 km, when
|
||||
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||
* lighting. Without it they are lit from Cesium's default spherical
|
||||
* harmonics. Measured 2026-10-24 (Intel Arc, Google 3D, ~150 models in the
|
||||
* scene): GPU per frame at rest 14.3-14.8 -> 12.1-12.5 ms.
|
||||
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
|
||||
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
|
||||
* 14.3-14.8 -> 12.1-12.5 ms.
|
||||
*/
|
||||
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* How an aircraft model's colour meets its mesh. HIGHLIGHT multiplies, so
|
||||
* white draws the mesh's own colours as built, and the tracked or emergency
|
||||
* colour tints the mesh without hiding its paint. The IR boost draws the
|
||||
* model flat white instead (MIX at 1, with its unlit shader).
|
||||
* @param {boolean} irBoost
|
||||
* @returns {{colorBlendMode: Cesium.ColorBlendMode, colorBlendAmount: number}}
|
||||
*/
|
||||
export function aircraftModelBlend(irBoost) {
|
||||
return irBoost
|
||||
? { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 1 }
|
||||
: { colorBlendMode: Cesium.ColorBlendMode.HIGHLIGHT, colorBlendAmount: 0 };
|
||||
}
|
||||
@@ -243,7 +243,7 @@ export function applyAircraftBillboardTreatment({
|
||||
|
||||
/**
|
||||
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
|
||||
* changing the model's colour blend mode or amount.
|
||||
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||
* @param {object} input
|
||||
* @param {object} input.model
|
||||
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
||||
|
||||
+18
-88
@@ -85,24 +85,16 @@ const TEXT_SHIP_TYPES = [
|
||||
];
|
||||
|
||||
/**
|
||||
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
|
||||
* emitter category `category` and callsign `callsign`: the first of the
|
||||
* type's callsign rules (`aircraft_callsign`) the callsign matches, else the
|
||||
* type's, else the category's, else `default_aircraft`. An airport vehicle
|
||||
* (type ZVEH) is a fire truck, tug or van by its callsign, else a car.
|
||||
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B
|
||||
* emitter category `category`: the type's, else the category's, else
|
||||
* `default_aircraft`.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string} [type]
|
||||
* @param {string} [category]
|
||||
* @param {string} [callsign]
|
||||
* @returns {{model: string, length: number|null}}
|
||||
*/
|
||||
export function resolveAircraft(types, type, category, callsign) {
|
||||
export function resolveAircraft(types, type, category) {
|
||||
const t = type?.trim().toUpperCase();
|
||||
const index = aircraftCallsignRule(types, t, callsign);
|
||||
if (index >= 0) {
|
||||
const rule = types.aircraft_callsign[t][index];
|
||||
return { model: rule.model, length: rule.length };
|
||||
}
|
||||
const [model, length] =
|
||||
(t && (types.aircraft[t] ?? types.helicopters[t])) ||
|
||||
(category && types.categories[category]) ||
|
||||
@@ -110,51 +102,6 @@ export function resolveAircraft(types, type, category, callsign) {
|
||||
return { model, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A callsign as the rules read it: upper case, spaces removed.
|
||||
* @param {string} callsign
|
||||
* @returns {string}
|
||||
*/
|
||||
function callsignKey(callsign) {
|
||||
return String(callsign).toUpperCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of type `type`'s callsign rules (`types.json`'s
|
||||
* `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match
|
||||
* wins) `callsign` matches: its index, -1 when none does or the type has
|
||||
* none. A callsign matches a rule when, upper-cased with spaces removed, it
|
||||
* starts with one of the rule's prefixes.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string|null} [type] ICAO type.
|
||||
* @param {string|null} [callsign]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function aircraftCallsignRule(types, type, callsign) {
|
||||
const t = String(type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const rules = t ? types.aircraft_callsign?.[t] : undefined;
|
||||
if (!rules || !callsign) return -1;
|
||||
const c = callsignKey(callsign);
|
||||
return rules.findIndex((rule) =>
|
||||
rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an aircraft of ICAO type `type` picks its model by callsign.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {string|null} [type]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function aircraftModelByCallsign(types, type) {
|
||||
const t = String(type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return Boolean(t && types.aircraft_callsign?.[t]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reported length as one a hull can have, else undefined (drawn as built).
|
||||
* @param {number} [length]
|
||||
@@ -186,9 +133,8 @@ export function hullBeam(beam) {
|
||||
/**
|
||||
* The model for a vessel of AIS ship type `shipType` and the reported length
|
||||
* and beam (metres) and name: the first rule that matches in the first type
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length;
|
||||
* when it is unknown (most live feeds send none), chosen and drawn at the
|
||||
* range's `typical_len`, else as built.
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length, as
|
||||
* built when it is unknown.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {number} [shipType]
|
||||
* @param {number} [length]
|
||||
@@ -199,14 +145,13 @@ export function hullBeam(beam) {
|
||||
export function resolveVessel(types, shipType, length, beam, name) {
|
||||
const L = hullLength(length) ?? 0;
|
||||
const B = hullBeam(beam) ?? 0;
|
||||
const drawn = L > 0 ? L : null;
|
||||
if (shipType !== undefined && shipType !== null) {
|
||||
for (const group of types.vessels) {
|
||||
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
|
||||
const len = L > 0 ? L : (group.typical_len ?? 0);
|
||||
const drawn = len > 0 ? len : null;
|
||||
for (const rule of group.rules) {
|
||||
if (rule.min_len && len < rule.min_len) continue;
|
||||
if (rule.max_len && len > rule.max_len) continue;
|
||||
if (rule.min_len && L < rule.min_len) continue;
|
||||
if (rule.max_len && L > rule.max_len) continue;
|
||||
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
|
||||
continue;
|
||||
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
|
||||
@@ -214,7 +159,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { model: types.default_vessel, length: L > 0 ? L : null };
|
||||
return { model: types.default_vessel, length: drawn };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,32 +304,24 @@ function specOf(set, resolved, fallback, options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The model an aircraft is drawn with. The type's callsign rules (an airport
|
||||
* vehicle by its callsign), then the type's entry; then, for a
|
||||
* The model an aircraft is drawn with. The type's entry first; then, for a
|
||||
* type the map lacks, the model of its class (a fast jet, a helicopter, a
|
||||
* glider) when the type names one; then the emitter category's; then
|
||||
* `default_aircraft`.
|
||||
* @param {{catalog: object, types: object}} set
|
||||
* @param {{typeCode?: string|null, type?: string|null,
|
||||
* category?: string|number|null, callsign?: string|null}} aircraft
|
||||
* category?: string|number|null}} aircraft
|
||||
* @returns {object} {@link fleetModelSpec}
|
||||
*/
|
||||
export function aircraftModelSpec(
|
||||
set,
|
||||
{ typeCode, type, category, callsign } = {},
|
||||
) {
|
||||
export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
|
||||
const { types, catalog } = set;
|
||||
const code = String(typeCode ?? type ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const emitter = emitterCategory(category);
|
||||
const sign = callsign ? String(callsign) : undefined;
|
||||
let resolved = resolveAircraft(types, code, emitter, sign);
|
||||
// A callsign rule names the model outright; a type's class only stands in
|
||||
// for a type the map lacks.
|
||||
const ruled = aircraftCallsignRule(types, code, sign) >= 0;
|
||||
let resolved = resolveAircraft(types, code, emitter);
|
||||
const typed = code && (types.aircraft[code] || types.helicopters[code]);
|
||||
if (!ruled && !typed && code) {
|
||||
if (!typed && code) {
|
||||
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
|
||||
if (model && catalog[model])
|
||||
resolved = { model, length: catalog[model].length };
|
||||
@@ -404,22 +341,18 @@ export function aircraftModelSpec(
|
||||
* @returns {object} {@link fleetModelSpec}
|
||||
*/
|
||||
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
||||
// A beam counts only beside a reported length: a vessel drawn at its type's
|
||||
// typical length keeps the model's proportions.
|
||||
const reported = hullLength(length ?? undefined);
|
||||
const hullWidth = reported === undefined ? undefined : (beam ?? undefined);
|
||||
const resolved = resolveVessel(
|
||||
set.types,
|
||||
aisShipTypeCode(type),
|
||||
reported,
|
||||
hullWidth,
|
||||
length ?? undefined,
|
||||
beam ?? undefined,
|
||||
name ?? undefined,
|
||||
);
|
||||
return specOf(
|
||||
set,
|
||||
resolved,
|
||||
(r) => ({ model: set.types.default_vessel, length: r.length }),
|
||||
{ beam: hullWidth },
|
||||
{ beam: beam ?? undefined },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -448,9 +381,6 @@ export function namedFleetModels(types) {
|
||||
...pairs(types.aircraft),
|
||||
...pairs(types.helicopters),
|
||||
...pairs(types.categories),
|
||||
...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) =>
|
||||
rules.map((rule) => rule.model),
|
||||
),
|
||||
...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
|
||||
types.default_aircraft[0],
|
||||
types.default_vessel,
|
||||
|
||||
@@ -51,22 +51,12 @@ const sliceTypes = {
|
||||
A21N: ['a320', 44.51],
|
||||
A319: ['a320', 33.84],
|
||||
B38M: ['b738', 39.52],
|
||||
ZVEH: ['car', 4.61],
|
||||
GRND: ['radio_mast', 1.9],
|
||||
},
|
||||
helicopters: { EC35: ['ec35', 10.2] },
|
||||
aircraft_callsign: {
|
||||
ZVEH: [
|
||||
{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 },
|
||||
{ prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 },
|
||||
{ prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 },
|
||||
],
|
||||
},
|
||||
categories: { A1: ['c172', 8.3] },
|
||||
vessels: [
|
||||
{
|
||||
types: [70, 79],
|
||||
typical_len: 300,
|
||||
rules: [
|
||||
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
|
||||
{
|
||||
@@ -121,33 +111,6 @@ test('aircraft resolve by type, then emitter category, then the generic plane',
|
||||
});
|
||||
});
|
||||
|
||||
test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => {
|
||||
const zveh = (callsign) =>
|
||||
resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign);
|
||||
assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 });
|
||||
assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), {
|
||||
model: 'fire_truck',
|
||||
length: 11.01,
|
||||
});
|
||||
assert.equal(zveh('PUSH 14').model, 'pushback');
|
||||
assert.equal(zveh('TUG3').model, 'pushback');
|
||||
assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 });
|
||||
assert.equal(zveh('SQUID20').model, 'van');
|
||||
assert.equal(zveh('SQB9').model, 'van');
|
||||
assert.equal(zveh('S Q B 9').model, 'van');
|
||||
assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 });
|
||||
assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 });
|
||||
// Callsign rules belong to their type: a fire-named airliner is still an airliner.
|
||||
assert.equal(
|
||||
resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model,
|
||||
'a320',
|
||||
);
|
||||
assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), {
|
||||
model: 'radio_mast',
|
||||
length: 1.9,
|
||||
});
|
||||
});
|
||||
|
||||
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
|
||||
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
|
||||
assert.equal(
|
||||
@@ -170,29 +133,6 @@ test('vessels: the first matching rule of the first type range with one, else th
|
||||
});
|
||||
});
|
||||
|
||||
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
|
||||
model: 'general_cargo',
|
||||
length: 90,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 31), {
|
||||
model: 'tug',
|
||||
length: null,
|
||||
});
|
||||
// Every passenger, cargo and tanker range of the set has one.
|
||||
for (const code of [60, 70, 80]) {
|
||||
assert.ok(resolveVessel(types, code).length > 100, String(code));
|
||||
}
|
||||
});
|
||||
|
||||
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
|
||||
model: 'tug',
|
||||
@@ -261,46 +201,6 @@ test('transit bodies mark the share of the route colour they take; other models
|
||||
}
|
||||
});
|
||||
|
||||
test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => {
|
||||
const zveh = (callsign) =>
|
||||
aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign })
|
||||
.model;
|
||||
assert.equal(zveh('FIRE 2'), 'fire_truck');
|
||||
assert.equal(zveh('PUSHBACK1'), 'pushback');
|
||||
assert.equal(zveh('TUG 4'), 'pushback');
|
||||
assert.equal(zveh('DEICER 4'), 'van');
|
||||
assert.equal(zveh('SQUID20'), 'van');
|
||||
assert.equal(zveh('SQB9'), 'van');
|
||||
assert.equal(zveh('LEADER 7'), 'car');
|
||||
assert.equal(zveh('AOPS'), 'car');
|
||||
assert.equal(zveh(null), 'car');
|
||||
const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' });
|
||||
assert.equal(mast.model, 'radio_mast');
|
||||
assert.equal(mast.scale, 1);
|
||||
});
|
||||
|
||||
test('a callsign rule outranks the class model of a type the map lacks', () => {
|
||||
const ruled = {
|
||||
...set,
|
||||
types: {
|
||||
...set.types,
|
||||
aircraft_callsign: {
|
||||
...set.types.aircraft_callsign,
|
||||
PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model,
|
||||
'fire_truck',
|
||||
);
|
||||
// No rule matched: the class model, as before.
|
||||
assert.equal(
|
||||
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model,
|
||||
'generic_heli',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
|
||||
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
|
||||
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
|
||||
@@ -367,12 +267,6 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
||||
const [sx, sy, sz] = box.scaleXYZ;
|
||||
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
|
||||
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
|
||||
// A beam with no reported length: the type's typical length, in the model's
|
||||
// own proportions, not a typical-length hull squeezed to that beam.
|
||||
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
|
||||
assert.equal(unsized.model, 'container_ship');
|
||||
const [ux, uy, uz] = unsized.scaleXYZ;
|
||||
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
|
||||
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
|
||||
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
|
||||
// No type, no size: the generic vessel as built.
|
||||
|
||||
@@ -32,7 +32,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
c172: Object.freeze([4.0507, 0.4481, 0]),
|
||||
c30j: Object.freeze([13.568, 0.9917, 0]),
|
||||
c56x: Object.freeze([7.4627, 0.4526, 0]),
|
||||
car: Object.freeze([2.1851, 0.301, 0]),
|
||||
car: Object.freeze([2.3, 0.418, 0]),
|
||||
cl35: Object.freeze([10.4607, 0.8247, 0]),
|
||||
crj9: Object.freeze([17.8413, 0.8624, 0]),
|
||||
dh8d: Object.freeze([15.7902, 0.7501, 0]),
|
||||
@@ -40,7 +40,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
e55p: Object.freeze([7.6566, 0.503, 0]),
|
||||
ec35: Object.freeze([5.1003, 0.1471, 0]),
|
||||
ec45: Object.freeze([5.8004, 0.555, 0]),
|
||||
fire_truck: Object.freeze([5.39, 0.4227, 0]),
|
||||
fire_truck: Object.freeze([5.5, 0.6051, 0]),
|
||||
generic_heli: Object.freeze([5.4997, 0.1604, 0]),
|
||||
generic_plane: Object.freeze([16.5228, 0.712, 0]),
|
||||
glf4: Object.freeze([13.4603, 0.8243, 0]),
|
||||
@@ -53,9 +53,8 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
mq9: Object.freeze([5.8498, -0.062, 0]),
|
||||
p28a: Object.freeze([3.3695, 0.3959, 0]),
|
||||
pc12: Object.freeze([6.6416, 0.5288, 0]),
|
||||
pushback: Object.freeze([2.9536, 0.388, 0]),
|
||||
pushback: Object.freeze([3, 0.2499, 0]),
|
||||
r44: Object.freeze([4.4219, -0.3001, 0]),
|
||||
radio_mast: Object.freeze([1.2, 0, 0]),
|
||||
s76: Object.freeze([6.6001, 0.1915, 0]),
|
||||
sr22: Object.freeze([3.6762, 0.2376, 0]),
|
||||
t38: Object.freeze([7.0246, -0.3791, 0]),
|
||||
@@ -63,5 +62,5 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
|
||||
tower: Object.freeze([1.2952, -0.0007, 0]),
|
||||
ultralight: Object.freeze([3.2271, 0.3259, 0]),
|
||||
v22: Object.freeze([7.9868, 0.2956, 0]),
|
||||
van: Object.freeze([2.695, 0.2812, 0]),
|
||||
van: Object.freeze([2.75, 0.418, 0]),
|
||||
});
|
||||
@@ -57,14 +57,7 @@ test('every typed aircraft renders at its published length, a real-world aircraf
|
||||
);
|
||||
// A grounded aircraft stands on its wheels: its origin the scaled
|
||||
// clearance above the ground, never below it.
|
||||
// A model built standing on its origin (a ground vehicle, a ground
|
||||
// station's mast: its lowest point at z 0) has no clearance.
|
||||
if (set.catalog[model].min[2] > -0.05)
|
||||
assert.ok(
|
||||
spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale,
|
||||
`${type}: built standing, clearance ${spec.clearanceM}`,
|
||||
);
|
||||
else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
|
||||
assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
|
||||
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
|
||||
}
|
||||
}
|
||||
@@ -303,41 +296,3 @@ test('a layer asking every tick gets one cached spec per type and category', asy
|
||||
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
|
||||
assert.ok(Object.isFrozen(a));
|
||||
});
|
||||
|
||||
test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => {
|
||||
const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import(
|
||||
'../layers/flights/modelSpec.js'
|
||||
);
|
||||
const tender = aircraftLayerModelSpec(
|
||||
{ typeCode: 'ZVEH', callsign: 'FIRE 2' },
|
||||
set,
|
||||
);
|
||||
const car = aircraftLayerModelSpec(
|
||||
{ typeCode: 'ZVEH', callsign: 'LEADER 7' },
|
||||
set,
|
||||
);
|
||||
assert.equal(tender.model, 'fire_truck');
|
||||
assert.equal(car.model, 'car');
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set),
|
||||
);
|
||||
assert.notEqual(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
|
||||
);
|
||||
// Keyed by the rule matched, not the callsign: at most one entry per rule
|
||||
// and one for the type's own model.
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
|
||||
);
|
||||
assert.equal(
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
|
||||
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set),
|
||||
);
|
||||
assert.equal(
|
||||
aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
|
||||
tender,
|
||||
);
|
||||
});
|
||||
@@ -291,7 +291,7 @@ test('tracking layers write gevLabelModel and expose only their cached display p
|
||||
assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false);
|
||||
});
|
||||
|
||||
test('civilian and military trail heads use the lower-centre model anchor', async () => {
|
||||
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
|
||||
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
|
||||
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
|
||||
)));
|
||||
@@ -300,5 +300,7 @@ test('civilian and military trail heads use the lower-centre model anchor', asyn
|
||||
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
|
||||
`${name} trail head uses the dedicated lower-centre model anchor`,
|
||||
);
|
||||
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
|
||||
`${name} keeps diffuse texture contribution weak through code-side MIX`);
|
||||
}
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Coverage points for the Transit layer's globe view: one soft spot per
|
||||
* registered feed, hotter where networks cluster.
|
||||
*
|
||||
* Built from the registry alone. No feed is polled to draw it, so it says
|
||||
* where vehicles CAN be shown, not where any are right now. Only feeds whose
|
||||
* live positions have been seen count: a registry row marked
|
||||
* `liveSeen: false` (added while its services were not running) gets no
|
||||
* spot until it is confirmed.
|
||||
*
|
||||
* Pure: no Cesium, no DOM.
|
||||
*/
|
||||
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
/** Feeds within this distance of a feed count as its neighbours. */
|
||||
export const COVERAGE_NEIGHBOUR_KM = 150;
|
||||
|
||||
/**
|
||||
* Radius of a spot from orbit, km: a lone feed, and the densest cluster.
|
||||
* Far larger than any network: at whole-globe scale a real service area is
|
||||
* under a pixel, and the point is to be seen.
|
||||
*/
|
||||
export const COVERAGE_FAR_RADIUS_KM = Object.freeze({ lone: 150, dense: 230 });
|
||||
|
||||
/**
|
||||
* Radius of a spot at regional zoom, km: the feed's own polling radius,
|
||||
* held between these bounds so a village network is still a visible patch
|
||||
* and a regional coach network does not paint a whole country.
|
||||
*/
|
||||
export const COVERAGE_NEAR_RADIUS_KM = Object.freeze({ min: 22, max: 75 });
|
||||
|
||||
/** Heat is drawn in this many steps, one draw batch each. */
|
||||
export const COVERAGE_HEAT_BUCKETS = 6;
|
||||
|
||||
/**
|
||||
* Share of full strength one spot carries: 1 for a lone network, less for one
|
||||
* in a crowd. Spots in a cluster overlap, and at full strength each they
|
||||
* pile into a flat white blob with visible disc edges; thinned by the square
|
||||
* root of the crowd they add up to one smooth field that still brightens
|
||||
* toward its centre.
|
||||
* @param {number} neighbours
|
||||
* @returns {number} 0..1
|
||||
*/
|
||||
export function coverageSpotStrength(neighbours) {
|
||||
const crowd = Math.max(0, Number(neighbours) || 0);
|
||||
return Number((0.3 + 0.7 / Math.sqrt(1 + crowd)).toFixed(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bus green of the vehicle markers, from a deep green for a lone network
|
||||
* to a pale core where many overlap. The lone end is saturated on purpose: a
|
||||
* light translucent green vanishes into pale basemaps and desert imagery.
|
||||
*/
|
||||
export const COVERAGE_PALETTE = Object.freeze([
|
||||
{ at: 0.0, rgb: [24, 196, 106] },
|
||||
{ at: 0.5, rgb: [94, 240, 138] },
|
||||
{ at: 1.0, rgb: [206, 255, 196] },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Palette colour at a normalized heat.
|
||||
* @param {number} t 0..1 (clamped)
|
||||
* @returns {[number, number, number]} RGB, 0..255.
|
||||
*/
|
||||
export function coverageColorAt(t) {
|
||||
const v = Math.min(1, Math.max(0, Number(t) || 0));
|
||||
let i = 1;
|
||||
while (i < COVERAGE_PALETTE.length - 1 && COVERAGE_PALETTE[i].at < v) i += 1;
|
||||
const a = COVERAGE_PALETTE[i - 1];
|
||||
const b = COVERAGE_PALETTE[i];
|
||||
const k = (v - a.at) / (b.at - a.at || 1);
|
||||
return [0, 1, 2].map((c) => Math.round(a.rgb[c] + (b.rgb[c] - a.rgb[c]) * k));
|
||||
}
|
||||
|
||||
/**
|
||||
* One coverage point per feed that has a centre and has been seen live.
|
||||
*
|
||||
* `heat` is logarithmic in the number of neighbours and normalized against
|
||||
* the densest feed, so a lone network is always 0 (the same deep green and
|
||||
* the same size) however crowded the busiest region is, and a hundred French
|
||||
* networks do not wash out one in Bismarck. Heat is then stepped into
|
||||
* COVERAGE_HEAT_BUCKETS buckets, which set the spot's colour and strength.
|
||||
*
|
||||
* @param {ReadonlyArray<{id?: string, center: {lat: number, lon: number}}>} feeds
|
||||
* @param {{neighbourKm?: number}} [options]
|
||||
* @returns {Array<{id: string|null, lat: number, lon: number, neighbours: number,
|
||||
* heat: number, bucket: number, farRadiusKm: number, nearRadiusKm: number,
|
||||
* strength: number, rgb: [number, number, number]}>}
|
||||
*/
|
||||
export function buildTransitCoveragePoints(
|
||||
feeds,
|
||||
{ neighbourKm = COVERAGE_NEIGHBOUR_KM } = {},
|
||||
) {
|
||||
const centred = (feeds || []).filter(
|
||||
(feed) =>
|
||||
feed?.liveSeen !== false &&
|
||||
Number.isFinite(feed?.center?.lat) &&
|
||||
Number.isFinite(feed?.center?.lon),
|
||||
);
|
||||
const neighbours = centred.map(
|
||||
(feed) =>
|
||||
centred.filter(
|
||||
(other) =>
|
||||
other !== feed &&
|
||||
greatCircleKm(feed.center, other.center) <= neighbourKm,
|
||||
).length,
|
||||
);
|
||||
const densest = Math.max(0, ...neighbours);
|
||||
return centred.map((feed, index) => {
|
||||
const heat = densest
|
||||
? Math.log1p(neighbours[index]) / Math.log1p(densest)
|
||||
: 0;
|
||||
const top = COVERAGE_HEAT_BUCKETS - 1;
|
||||
const bucket = Math.round(heat * top);
|
||||
return {
|
||||
id: feed.id ?? null,
|
||||
lat: feed.center.lat,
|
||||
lon: feed.center.lon,
|
||||
neighbours: neighbours[index],
|
||||
heat,
|
||||
bucket,
|
||||
farRadiusKm: Math.round(
|
||||
COVERAGE_FAR_RADIUS_KM.lone +
|
||||
(COVERAGE_FAR_RADIUS_KM.dense - COVERAGE_FAR_RADIUS_KM.lone) * heat,
|
||||
),
|
||||
nearRadiusKm: Math.min(
|
||||
COVERAGE_NEAR_RADIUS_KM.max,
|
||||
Math.max(
|
||||
COVERAGE_NEAR_RADIUS_KM.min,
|
||||
Number(feed.loadRadiusKm) || COVERAGE_NEAR_RADIUS_KM.min,
|
||||
),
|
||||
),
|
||||
// Colour and strength follow the bucket, so every spot in a batch
|
||||
// shares one material.
|
||||
strength: coverageSpotStrength(
|
||||
Math.expm1((bucket / top) * Math.log1p(densest || 1)),
|
||||
),
|
||||
rgb: coverageColorAt(bucket / top),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
COVERAGE_FAR_RADIUS_KM,
|
||||
COVERAGE_HEAT_BUCKETS,
|
||||
COVERAGE_NEAR_RADIUS_KM,
|
||||
buildTransitCoveragePoints,
|
||||
coverageColorAt,
|
||||
coverageSpotStrength,
|
||||
} from './transitCoverage.js';
|
||||
import { TRANSIT_ENABLED_FEEDS } from './transitFeeds.js';
|
||||
import {
|
||||
ACTIVATION_ENTER_ALTITUDE_M,
|
||||
ACTIVATION_EXIT_ALTITUDE_M,
|
||||
COVERAGE_OPACITY_STOPS,
|
||||
COVERAGE_PEAK_ALPHA,
|
||||
coverageOpacityAt,
|
||||
} from '../layers/transit/policy.js';
|
||||
|
||||
const feed = (id, lat, lon) => ({ id, center: { lat, lon } });
|
||||
|
||||
test('an empty registry gives no points, and a feed without a centre is skipped', () => {
|
||||
assert.deepEqual(buildTransitCoveragePoints([]), []);
|
||||
assert.deepEqual(buildTransitCoveragePoints(null), []);
|
||||
const points = buildTransitCoveragePoints([
|
||||
feed('a', 10, 10),
|
||||
{ id: 'no-centre' },
|
||||
{ id: 'bad', center: { lat: NaN, lon: 3 } },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
points.map((p) => p.id),
|
||||
['a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a lone feed is the smallest, deepest spot', () => {
|
||||
const [point] = buildTransitCoveragePoints([feed('lone', 46.8, -100.8)]);
|
||||
assert.equal(point.neighbours, 0);
|
||||
assert.equal(point.heat, 0);
|
||||
assert.equal(point.bucket, 0);
|
||||
assert.equal(point.strength, 1);
|
||||
assert.equal(point.farRadiusKm, COVERAGE_FAR_RADIUS_KM.lone);
|
||||
assert.deepEqual(point.rgb, coverageColorAt(0));
|
||||
});
|
||||
|
||||
test('a lone feed looks the same however dense the busiest region is', () => {
|
||||
const lone = feed('lone', 46.8, -100.8);
|
||||
const cluster = Array.from({ length: 40 }, (_, i) =>
|
||||
feed(`c${i}`, 48 + (i % 8) * 0.1, 2 + Math.floor(i / 8) * 0.1),
|
||||
);
|
||||
const alone = buildTransitCoveragePoints([lone])[0];
|
||||
const crowded = buildTransitCoveragePoints([lone, ...cluster]);
|
||||
assert.deepEqual(
|
||||
crowded.find((p) => p.id === 'lone'),
|
||||
alone,
|
||||
);
|
||||
const hottest = crowded.find((p) => p.id === 'c0');
|
||||
assert.equal(hottest.neighbours, 39);
|
||||
assert.equal(hottest.heat, 1);
|
||||
assert.equal(hottest.bucket, COVERAGE_HEAT_BUCKETS - 1);
|
||||
assert.equal(hottest.farRadiusKm, COVERAGE_FAR_RADIUS_KM.dense);
|
||||
assert.ok(hottest.strength < 1, 'a spot in a crowd is thinner');
|
||||
});
|
||||
|
||||
test('a spot in a crowd is thinner, so a cluster sums smoothly instead of clipping', () => {
|
||||
assert.equal(coverageSpotStrength(0), 1);
|
||||
assert.equal(coverageSpotStrength(-3), 1);
|
||||
assert.equal(coverageSpotStrength(NaN), 1);
|
||||
let previous = 1;
|
||||
for (const crowd of [1, 3, 8, 15, 28, 100]) {
|
||||
const strength = coverageSpotStrength(crowd);
|
||||
assert.ok(strength < previous, `thinner with ${crowd} neighbours`);
|
||||
assert.ok(strength > 0.3, 'never invisible');
|
||||
// The crowd as a whole still outshines a lone feed.
|
||||
assert.ok(strength * (1 + crowd) > 1);
|
||||
previous = strength;
|
||||
}
|
||||
const [lone] = buildTransitCoveragePoints([feed('lone', 0, 0)]);
|
||||
assert.equal(lone.strength, 1);
|
||||
});
|
||||
|
||||
test('neighbours are counted by distance, across the antimeridian too', () => {
|
||||
const points = buildTransitCoveragePoints([
|
||||
feed('west', 0, 179.8),
|
||||
feed('east', 0, -179.8),
|
||||
feed('far', 0, 170),
|
||||
]);
|
||||
const by = Object.fromEntries(points.map((p) => [p.id, p.neighbours]));
|
||||
assert.deepEqual(by, { west: 1, east: 1, far: 0 });
|
||||
});
|
||||
|
||||
test('the palette runs from the lone green to a pale core and clamps', () => {
|
||||
assert.deepEqual(coverageColorAt(-1), coverageColorAt(0));
|
||||
assert.deepEqual(coverageColorAt(2), coverageColorAt(1));
|
||||
assert.deepEqual(coverageColorAt(NaN), coverageColorAt(0));
|
||||
const brightness = (t) => coverageColorAt(t).reduce((a, b) => a + b, 0);
|
||||
let previous = 0;
|
||||
for (let t = 0; t <= 1.0001; t += 0.1) {
|
||||
assert.ok(brightness(t) >= previous, `does not darken at ${t.toFixed(1)}`);
|
||||
previous = brightness(t);
|
||||
}
|
||||
});
|
||||
|
||||
test('a feed whose live positions have not been seen gets no spot', () => {
|
||||
const points = buildTransitCoveragePoints([
|
||||
feed('seen', 10, 10),
|
||||
{ ...feed('unseen', 10.1, 10.1), liveSeen: false },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
points.map((p) => p.id),
|
||||
['seen'],
|
||||
);
|
||||
assert.equal(points[0].neighbours, 0, 'an unseen feed is not a neighbour');
|
||||
// The registry's own unconfirmed rows stay out; the confirmed ones are in.
|
||||
const unseen = TRANSIT_ENABLED_FEEDS.filter((f) => f.liveSeen === false);
|
||||
assert.ok(unseen.length > 0);
|
||||
const real = new Set(
|
||||
buildTransitCoveragePoints(TRANSIT_ENABLED_FEEDS).map((p) => p.id),
|
||||
);
|
||||
for (const row of unseen) assert.equal(real.has(row.id), false, row.id);
|
||||
assert.ok(real.has('kumamoto-toshi-bus'));
|
||||
assert.ok(real.has('sendai-city-bus'));
|
||||
});
|
||||
|
||||
test('every feed seen live gets exactly one spot, and dense regions read hotter', () => {
|
||||
const points = buildTransitCoveragePoints(TRANSIT_ENABLED_FEEDS);
|
||||
assert.equal(
|
||||
points.length,
|
||||
TRANSIT_ENABLED_FEEDS.filter((f) => f.liveSeen !== false).length,
|
||||
);
|
||||
assert.equal(new Set(points.map((p) => p.id)).size, points.length);
|
||||
const heat = (id) => points.find((p) => p.id === id).heat;
|
||||
assert.ok(heat('axo-creil') > heat('mbta'), 'the Oise outranks Boston');
|
||||
for (const point of points) {
|
||||
assert.ok(point.heat >= 0 && point.heat <= 1);
|
||||
assert.ok(
|
||||
point.farRadiusKm >= COVERAGE_FAR_RADIUS_KM.lone &&
|
||||
point.farRadiusKm <= COVERAGE_FAR_RADIUS_KM.dense,
|
||||
);
|
||||
assert.ok(
|
||||
point.nearRadiusKm >= COVERAGE_NEAR_RADIUS_KM.min &&
|
||||
point.nearRadiusKm <= COVERAGE_NEAR_RADIUS_KM.max,
|
||||
);
|
||||
assert.ok(point.bucket >= 0 && point.bucket < COVERAGE_HEAT_BUCKETS);
|
||||
}
|
||||
});
|
||||
|
||||
test('a regional patch is the feed’s own service area, within bounds', () => {
|
||||
const at = (loadRadiusKm) =>
|
||||
buildTransitCoveragePoints([{ ...feed('f', 0, 0), loadRadiusKm }])[0]
|
||||
.nearRadiusKm;
|
||||
assert.equal(at(30), 30);
|
||||
assert.equal(at(5), COVERAGE_NEAR_RADIUS_KM.min);
|
||||
assert.equal(at(210), COVERAGE_NEAR_RADIUS_KM.max);
|
||||
assert.equal(at(undefined), COVERAGE_NEAR_RADIUS_KM.min);
|
||||
});
|
||||
|
||||
test('wide spots own the globe view, service-area patches the regional view, and they overlap', () => {
|
||||
const far = (h) => coverageOpacityAt(h, 'far');
|
||||
const near = (h) => coverageOpacityAt(h, 'near');
|
||||
assert.equal(far(20_000_000), 1);
|
||||
assert.equal(far(Infinity), 1);
|
||||
assert.equal(near(20_000_000), 0);
|
||||
assert.equal(far(500_000), 0);
|
||||
assert.ok(near(500_000) >= 0.5);
|
||||
assert.equal(far(0), 0);
|
||||
assert.equal(near(0), 0);
|
||||
assert.equal(near(60_000), 0, 'off close in, where vehicles are the picture');
|
||||
assert.equal(far(NaN), 0);
|
||||
assert.equal(near(NaN), 0);
|
||||
assert.equal(coverageOpacityAt(20_000_000), 1, 'the default set is far');
|
||||
// From 250 km up there is never a height with nothing drawn, and the
|
||||
// cross-fade has no dip: the two sets together stay well visible.
|
||||
for (let h = 250_000; h <= 6_000_000; h += 25_000) {
|
||||
assert.ok(far(h) + near(h) >= 0.45, `visible at ${h / 1000} km`);
|
||||
}
|
||||
// Both sides of the altitude gate are covered.
|
||||
assert.ok(
|
||||
far(ACTIVATION_ENTER_ALTITUDE_M) + near(ACTIVATION_ENTER_ALTITUDE_M) > 0.5,
|
||||
);
|
||||
assert.ok(far(ACTIVATION_EXIT_ALTITUDE_M) > 0.5);
|
||||
// The far set only grows with height.
|
||||
let previous = 0;
|
||||
for (let h = 0; h <= 4_000_000; h += 20_000) {
|
||||
assert.ok(far(h) >= previous, `far opacity does not fall at ${h} m`);
|
||||
previous = far(h);
|
||||
}
|
||||
assert.ok(COVERAGE_PEAK_ALPHA > 0.5 && COVERAGE_PEAK_ALPHA <= 1);
|
||||
assert.equal(COVERAGE_OPACITY_STOPS.far.at(-1)[1], 1);
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
|
||||
import { CATALOGUE_TRANSIT_FEED_ROWS } from './transitFeedsCatalogue.js';
|
||||
import { FRANCE_TRANSIT_FEED_ROWS } from './transitFeedsFrance.js';
|
||||
import { JAPAN_TRANSIT_FEED_ROWS } from './transitFeedsJapan.js';
|
||||
import { FRANCE_ROUTE_NAMES } from './transitRouteNamesFrance.js';
|
||||
|
||||
/**
|
||||
* @module transitFeeds
|
||||
@@ -14,10 +10,6 @@ import { FRANCE_ROUTE_NAMES } from './transitRouteNamesFrance.js';
|
||||
* `/api/transit/vehicles/<id>` for a registered id (see SECURITY.md: proxies
|
||||
* never fetch client-supplied URLs). Adding a feed means adding a row here,
|
||||
* a DATA_SOURCES.md row with its license, and a credit in dataCredits.js.
|
||||
* The French networks listed on transport.data.gouv.fr are rows of the same
|
||||
* shape in `transitFeedsFrance.js`, and feeds added from the open catalogues
|
||||
* are in `transitFeedsCatalogue.js` and `transitFeedsJapan.js`; all are appended
|
||||
* to the registry below.
|
||||
*
|
||||
* Admission rules for a feed:
|
||||
* - No key, token, or registration required (identify-yourself headers are
|
||||
@@ -570,58 +562,6 @@ function transitFeed(fields) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A French network row (`transitFeedsFrance.js`) as a registry entry. Every
|
||||
* one is a bus network unless its row says otherwise: `routeModes` names the
|
||||
* route ids that are trams or boats, and a route id it does not name stays a
|
||||
* default, never a resolved "bus". The licence name, record link and terms
|
||||
* note are built from the row's `licence`, `record`, `vehicles` and `note`.
|
||||
* A feed with a table in
|
||||
* `transitRouteNamesFrance.js` reads its line names from it; an id missing
|
||||
* from the table renders unchanged.
|
||||
* @param {object} row
|
||||
* @returns {Readonly<object>}
|
||||
*/
|
||||
function franceTransitFeed(row) {
|
||||
const { routeModes, licence, record, vehicles, note, ...fields } = row;
|
||||
const odbl = licence === 'odc-odbl';
|
||||
const names = Object.hasOwn(FRANCE_ROUTE_NAMES, row.id)
|
||||
? FRANCE_ROUTE_NAMES[row.id]
|
||||
: null;
|
||||
return transitFeed({
|
||||
defaultMode: 'bus',
|
||||
...fields,
|
||||
license: odbl ? 'ODbL' : 'Licence Ouverte 2.0',
|
||||
licenseUrl: `https://transport.data.gouv.fr/datasets/${record}`,
|
||||
terms: {
|
||||
quote: `licence: ${licence}`,
|
||||
note: [
|
||||
`Dataset record’s licence field quoted; this is the record’s own GTFS-RT vehicle feed (${vehicles} vehicles on 2026-11-13).`,
|
||||
odbl
|
||||
? 'ODbL asks for credit and share-alike on a derived database; the layer draws positions and redistributes no database.'
|
||||
: '',
|
||||
note ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
},
|
||||
...(routeModes
|
||||
? {
|
||||
routeMode: (routeId) =>
|
||||
routeId && Object.hasOwn(routeModes, routeId)
|
||||
? routeModes[routeId]
|
||||
: 'unknown',
|
||||
}
|
||||
: {}),
|
||||
...(names
|
||||
? {
|
||||
routeLabel: (routeId) =>
|
||||
routeId && Object.hasOwn(names, routeId) ? names[routeId] : routeId,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
@@ -1646,10 +1586,6 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
},
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
...FRANCE_TRANSIT_FEED_ROWS.map(franceTransitFeed),
|
||||
...[...CATALOGUE_TRANSIT_FEED_ROWS, ...JAPAN_TRANSIT_FEED_ROWS].map((row) =>
|
||||
transitFeed({ defaultMode: 'bus', ...row }),
|
||||
),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -713,13 +713,8 @@ test('feeds that publish km/h are marked for conversion, and only those', () =>
|
||||
.map((f) => f.id)
|
||||
.sort();
|
||||
assert.deepEqual(kmh, [
|
||||
'azalys-blois',
|
||||
'carris-metropolitana-lisboa',
|
||||
'divia-dijon',
|
||||
'maelis-montlucon',
|
||||
'mpm-toulon',
|
||||
'rubis-bourg-en-bresse',
|
||||
'setram-le-mans',
|
||||
'tam-montpellier',
|
||||
]);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,125 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_FEED_REGISTRY,
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
getTransitFeed,
|
||||
publicTransitCatalog,
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
} from './transitFeeds.js';
|
||||
import { CATALOGUE_TRANSIT_FEED_ROWS } from './transitFeedsCatalogue.js';
|
||||
|
||||
test('every catalogue row becomes a live, keyless feed that covers its own centre', () => {
|
||||
const ids = new Set();
|
||||
for (const row of CATALOGUE_TRANSIT_FEED_ROWS) {
|
||||
assert.match(row.id, TRANSIT_FEED_ID_PATTERN);
|
||||
assert.equal(ids.has(row.id), false, `${row.id} is unique`);
|
||||
ids.add(row.id);
|
||||
const feed = getTransitFeed(row.id);
|
||||
assert.ok(feed, `${row.id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((entry) => entry.id === row.id),
|
||||
`${row.id} is offered to the browser`,
|
||||
);
|
||||
const url = new URL(row.url);
|
||||
assert.equal(url.protocol, 'https:');
|
||||
assert.equal(/key=/i.test(url.search), false, `${row.id} has no key`);
|
||||
assert.match(
|
||||
feed.terms.note,
|
||||
/\d+ vehicles on 2026-11-13\./,
|
||||
`${row.id} records the day it was seen live`,
|
||||
);
|
||||
assert.ok(
|
||||
transitFeedsInRange(row.center.lat, row.center.lon).some(
|
||||
(entry) => entry.id === row.id,
|
||||
),
|
||||
`${row.id} covers its own centre`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a catalogue row either quotes terms that were read or links its catalogue record', () => {
|
||||
let read = 0;
|
||||
for (const row of CATALOGUE_TRANSIT_FEED_ROWS) {
|
||||
if (row.license === 'Public realtime vehicle feed') {
|
||||
assert.equal(row.terms.quote, undefined, `${row.id} quotes nothing`);
|
||||
assert.match(
|
||||
row.terms.note,
|
||||
/Keyless feed on .+ Shipped on the same basis as Metro Transit and OVapi|Same server and basis/,
|
||||
`${row.id} names the basis it ships on`,
|
||||
);
|
||||
assert.match(
|
||||
row.licenseUrl,
|
||||
/^https:\/\/(mobilitydatabase\.org|www\.transit\.land|gtfsrt\.io)\//,
|
||||
`${row.id} links its catalogue record`,
|
||||
);
|
||||
} else {
|
||||
read++;
|
||||
assert.ok(row.terms.quote, `${row.id} quotes the grant`);
|
||||
assert.match(
|
||||
row.terms.note,
|
||||
/read 2026-11-13|same click-through agreement/,
|
||||
`${row.id} says when its terms were read`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.equal(read, 10);
|
||||
});
|
||||
|
||||
test('feeds whose terms forbid this use, or could not be read, are not registered', () => {
|
||||
const hosts = new Set(
|
||||
TRANSIT_FEED_REGISTRY.map((feed) => new URL(feed.url).hostname),
|
||||
);
|
||||
for (const host of [
|
||||
// Terms exclude keyless public display.
|
||||
'gtfsrt.prod.obanyc.com',
|
||||
'www.myddotbus.com',
|
||||
'everett.mapstrat.com',
|
||||
'duluthtransit.com',
|
||||
'ridetime.greaterdaytonrta.org',
|
||||
'cttprdtmgtfs.ctttrpcloud.com',
|
||||
'truetime.portauthority.org',
|
||||
// Terms could not be read.
|
||||
'gtfs.sofiatraffic.bg',
|
||||
'track.ua-gis.com',
|
||||
'www.ridedart.com',
|
||||
'ride.smtd.org',
|
||||
'knoxville.syncromatics.com',
|
||||
'bustracker.pvta.com',
|
||||
'bus.gocitybus.com',
|
||||
// A third party's republication.
|
||||
'proxy.busone.app',
|
||||
]) {
|
||||
assert.equal(hosts.has(host), false, `${host} is not fetched`);
|
||||
}
|
||||
});
|
||||
|
||||
test('licences that ask for specific credit get it', () => {
|
||||
const bilbao = getTransitFeed('metro-bilbao');
|
||||
assert.equal(bilbao.creditDated, true);
|
||||
assert.equal(transitModeFor(bilbao, null), 'subway');
|
||||
const fairfax = getTransitFeed('fairfax-connector');
|
||||
assert.match(fairfax.attribution, /Visit fairfaxconnector\.com/);
|
||||
assert.equal(fairfax.sourceUrl, 'https://www.fairfaxconnector.com');
|
||||
assert.match(getTransitFeed('gcrta-cleveland').attribution, /GCRTA/);
|
||||
assert.equal(transitModeFor(getTransitFeed('marc-train-md'), null), 'rail');
|
||||
assert.equal(transitModeFor(getTransitFeed('ny-waterway'), null), 'ferry');
|
||||
});
|
||||
|
||||
test('every catalogue feed is documented with its licence', () => {
|
||||
const lines = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
).split('\n');
|
||||
for (const row of CATALOGUE_TRANSIT_FEED_ROWS) {
|
||||
const line = lines.find((l) => l.startsWith(`| **${row.name}** `));
|
||||
assert.ok(line, `${row.name} has a DATA_SOURCES row`);
|
||||
assert.ok(line.includes(row.license), `${row.name} row names its licence`);
|
||||
assert.ok(
|
||||
line.includes(row.licenseUrl),
|
||||
`${row.name} row links its source`,
|
||||
);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,201 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_FEED_REGISTRY,
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
getTransitFeed,
|
||||
publicTransitCatalog,
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
transitRouteLabel,
|
||||
} from './transitFeeds.js';
|
||||
import { FRANCE_TRANSIT_FEED_ROWS } from './transitFeedsFrance.js';
|
||||
import { FRANCE_ROUTE_NAMES } from './transitRouteNamesFrance.js';
|
||||
import {
|
||||
TRANSIT_ADMISSION_MAX_GLOBAL,
|
||||
TRANSIT_ADMISSION_WINDOW_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
} from './transitProxy.js';
|
||||
|
||||
const FRANCE_IDS = new Set(FRANCE_TRANSIT_FEED_ROWS.map((row) => row.id));
|
||||
|
||||
test('every French row becomes a live feed under its record’s open licence', () => {
|
||||
assert.equal(FRANCE_IDS.size, FRANCE_TRANSIT_FEED_ROWS.length);
|
||||
for (const row of FRANCE_TRANSIT_FEED_ROWS) {
|
||||
assert.match(row.id, TRANSIT_FEED_ID_PATTERN);
|
||||
const feed = getTransitFeed(row.id);
|
||||
assert.ok(feed, `${row.id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((entry) => entry.id === row.id),
|
||||
`${row.id} is offered to the browser`,
|
||||
);
|
||||
assert.ok(
|
||||
['lov2', 'odc-odbl'].includes(row.licence),
|
||||
`${row.id} states an open licence`,
|
||||
);
|
||||
assert.equal(
|
||||
feed.license,
|
||||
row.licence === 'lov2' ? 'Licence Ouverte 2.0' : 'ODbL',
|
||||
);
|
||||
assert.equal(
|
||||
feed.licenseUrl,
|
||||
`https://transport.data.gouv.fr/datasets/${row.record}`,
|
||||
`${row.id} links its dataset record`,
|
||||
);
|
||||
assert.equal(feed.terms.quote, `licence: ${row.licence}`);
|
||||
assert.match(
|
||||
feed.terms.note,
|
||||
/\(\d+ vehicles on 2026-11-13\)/,
|
||||
`${row.id} records the day it was seen live`,
|
||||
);
|
||||
assert.ok(row.vehicles > 0, `${row.id} had vehicles when it was added`);
|
||||
assert.ok(
|
||||
transitFeedsInRange(row.center.lat, row.center.lon).some(
|
||||
(entry) => entry.id === row.id,
|
||||
),
|
||||
`${row.id} covers its own centre`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no French row repeats a feed that is already registered', () => {
|
||||
const seen = new Map();
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
const url = new URL(feed.url);
|
||||
const key = `${url.origin}${url.pathname.replace(/\/$/, '')}${url.search}`;
|
||||
assert.equal(
|
||||
seen.has(key),
|
||||
false,
|
||||
`${feed.id} and ${seen.get(key)} fetch the same upstream`,
|
||||
);
|
||||
seen.set(key, feed.id);
|
||||
}
|
||||
// The networks that were already here through the operator's own endpoint
|
||||
// stay single: no French row points at a dataset record one of them cites.
|
||||
const earlier = TRANSIT_FEED_REGISTRY.filter(
|
||||
(feed) => !FRANCE_IDS.has(feed.id),
|
||||
);
|
||||
for (const id of [
|
||||
'tbm-bordeaux',
|
||||
'tam-montpellier',
|
||||
'astuce-rouen',
|
||||
'mpm-toulon',
|
||||
'lia-le-havre',
|
||||
'filbleu-tours',
|
||||
]) {
|
||||
assert.ok(earlier.some((feed) => feed.id === id));
|
||||
}
|
||||
const earlierRecords = new Set(earlier.map((feed) => feed.licenseUrl));
|
||||
for (const row of FRANCE_TRANSIT_FEED_ROWS) {
|
||||
assert.equal(
|
||||
earlierRecords.has(getTransitFeed(row.id).licenseUrl),
|
||||
false,
|
||||
`${row.id} is not a second copy of an earlier feed`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('one viewpoint never asks for more feeds than the proxy admits', () => {
|
||||
// The browser polls each in-range feed once per proxy TTL, and the proxy
|
||||
// admits a fixed number of upstream requests a minute across all feeds.
|
||||
const pollsPerFeed = TRANSIT_ADMISSION_WINDOW_MS / TRANSIT_PROXY_TTL_MS;
|
||||
for (const feed of TRANSIT_FEED_REGISTRY) {
|
||||
const inRange = transitFeedsInRange(feed.center.lat, feed.center.lon);
|
||||
assert.ok(
|
||||
inRange.length * pollsPerFeed <= TRANSIT_ADMISSION_MAX_GLOBAL,
|
||||
`${inRange.length} feeds overlap at ${feed.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('French trams and boats take their mode; every other route stays a default', () => {
|
||||
const cases = [
|
||||
['divia-dijon', '4-T1', 'tram'],
|
||||
['bibus-brest', 'A', 'tram'],
|
||||
['setram-le-mans', 'T2', 'tram'],
|
||||
['lignes-dazur-nice', 'L2', 'tram'],
|
||||
['irigo-angers', 'C', 'tram'],
|
||||
['orizo-avignon', 'T1', 'tram'],
|
||||
['transvilles-valenciennes', 'NAVINEO:Line::190:LOC', 'tram'],
|
||||
['aleop-pays-de-la-loire', 'YEU', 'ferry'],
|
||||
['martinique-sud', '4001', 'ferry'],
|
||||
];
|
||||
for (const [id, routeId, mode] of cases) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.equal(transitModeFor(feed, routeId), mode, `${id} ${routeId}`);
|
||||
assert.equal(transitModeResolved(feed, routeId), true);
|
||||
assert.equal(transitModeFor(feed, 'no-such-route'), 'bus');
|
||||
assert.equal(
|
||||
transitModeResolved(feed, 'no-such-route'),
|
||||
false,
|
||||
`${id} defaults rather than resolves an unlisted route`,
|
||||
);
|
||||
}
|
||||
const busOnly = getTransitFeed('ametis-amiens');
|
||||
assert.equal(transitModeFor(busOnly, '7'), 'bus');
|
||||
assert.equal(transitModeResolved(busOnly, '7'), false);
|
||||
});
|
||||
|
||||
test('French feeds that publish km/h are marked for conversion, and only those', () => {
|
||||
assert.deepEqual(
|
||||
FRANCE_TRANSIT_FEED_ROWS.filter((row) => row.speedUnit === 'km/h')
|
||||
.map((row) => row.id)
|
||||
.sort(),
|
||||
[
|
||||
'azalys-blois',
|
||||
'divia-dijon',
|
||||
'maelis-montlucon',
|
||||
'rubis-bourg-en-bresse',
|
||||
'setram-le-mans',
|
||||
],
|
||||
);
|
||||
for (const row of FRANCE_TRANSIT_FEED_ROWS.filter((r) => r.speedUnit)) {
|
||||
assert.match(getTransitFeed(row.id).terms.note, /Speeds are km\/h/);
|
||||
}
|
||||
});
|
||||
|
||||
test('French route-name tables belong to registered feeds and never blank a line', () => {
|
||||
for (const [id, names] of Object.entries(FRANCE_ROUTE_NAMES)) {
|
||||
assert.ok(FRANCE_IDS.has(id), `${id} is a registered French feed`);
|
||||
const feed = getTransitFeed(id);
|
||||
for (const [routeId, name] of Object.entries(names)) {
|
||||
assert.ok(typeof name === 'string' && name.trim() === name && name);
|
||||
assert.notEqual(name, routeId, `${id} ${routeId} is not a no-op`);
|
||||
assert.equal(transitRouteLabel(feed, routeId), name);
|
||||
}
|
||||
assert.equal(transitRouteLabel(feed, 'no-such-route'), 'no-such-route');
|
||||
assert.equal(transitRouteLabel(feed, null), null);
|
||||
}
|
||||
assert.equal(transitRouteLabel(getTransitFeed('divia-dijon'), '4-T1'), 'T1');
|
||||
assert.equal(
|
||||
transitRouteLabel(
|
||||
getTransitFeed('transvilles-valenciennes'),
|
||||
'NAVINEO:Line::190:LOC',
|
||||
),
|
||||
'T1',
|
||||
);
|
||||
// A feed whose ids already read as the line has no table and is left alone.
|
||||
assert.equal(transitRouteLabel(getTransitFeed('ametis-amiens'), '7'), '7');
|
||||
});
|
||||
|
||||
test('every French feed is documented with its licence', () => {
|
||||
const lines = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
).split('\n');
|
||||
for (const row of FRANCE_TRANSIT_FEED_ROWS) {
|
||||
const feed = getTransitFeed(row.id);
|
||||
const line = lines.find((l) => l.startsWith(`| **${feed.name}** `));
|
||||
assert.ok(line, `${feed.name} has a DATA_SOURCES row`);
|
||||
assert.ok(
|
||||
line.includes(feed.licenseUrl),
|
||||
`${feed.name} row links its record`,
|
||||
);
|
||||
assert.ok(
|
||||
line.includes(feed.license),
|
||||
`${feed.name} row names its licence`,
|
||||
);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,74 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TRANSIT_FEED_ID_PATTERN,
|
||||
getTransitFeed,
|
||||
publicTransitCatalog,
|
||||
transitFeedsInRange,
|
||||
} from './transitFeeds.js';
|
||||
import { JAPAN_TRANSIT_FEED_ROWS } from './transitFeedsJapan.js';
|
||||
|
||||
test('every Japanese row is a live, keyless feed under CC0 or CC BY', () => {
|
||||
const ids = new Set();
|
||||
for (const row of JAPAN_TRANSIT_FEED_ROWS) {
|
||||
assert.match(row.id, TRANSIT_FEED_ID_PATTERN);
|
||||
assert.equal(ids.has(row.id), false, `${row.id} is unique`);
|
||||
ids.add(row.id);
|
||||
assert.ok(getTransitFeed(row.id), `${row.id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((entry) => entry.id === row.id),
|
||||
`${row.id} is offered to the browser`,
|
||||
);
|
||||
assert.ok(['CC0 1.0', 'CC BY 4.0'].includes(row.license));
|
||||
assert.match(
|
||||
row.licenseUrl,
|
||||
/^https:\/\/mobilitydatabase\.org\/feeds\/gtfs_rt\//,
|
||||
`${row.id} links its catalogue record`,
|
||||
);
|
||||
assert.match(
|
||||
row.terms.quote,
|
||||
row.license === 'CC0 1.0'
|
||||
? /creativecommons\.org\/publicdomain\/zero\/1\.0/
|
||||
: /creativecommons\.org\/licenses\/by\/4\.0/,
|
||||
`${row.id} quotes the licence it names`,
|
||||
);
|
||||
assert.match(
|
||||
row.terms.note,
|
||||
/Vehicle positions were seen on|its positions have not been seen yet/,
|
||||
`${row.id} says whether positions were seen`,
|
||||
);
|
||||
assert.equal(
|
||||
row.liveSeen === false,
|
||||
/have not been seen yet/.test(row.terms.note),
|
||||
`${row.id} is flagged exactly when its positions are unseen`,
|
||||
);
|
||||
assert.equal(new URL(row.url).protocol, 'https:');
|
||||
assert.ok(
|
||||
// Japan, Hokkaidō to Okinawa.
|
||||
row.center.lat > 24 &&
|
||||
row.center.lat < 46 &&
|
||||
row.center.lon > 122 &&
|
||||
row.center.lon < 146,
|
||||
`${row.id} is centred in Japan`,
|
||||
);
|
||||
assert.ok(
|
||||
transitFeedsInRange(row.center.lat, row.center.lon).some(
|
||||
(entry) => entry.id === row.id,
|
||||
),
|
||||
`${row.id} covers its own centre`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every Japanese feed is documented with its licence', () => {
|
||||
const lines = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
).split('\n');
|
||||
for (const row of JAPAN_TRANSIT_FEED_ROWS) {
|
||||
const line = lines.find((l) => l.startsWith(`| **${row.name}** `));
|
||||
assert.ok(line, `${row.name} has a DATA_SOURCES row`);
|
||||
assert.ok(line.includes(row.license) && line.includes(row.licenseUrl));
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,517 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { windProxy } from '../../server/providers/wind.js';
|
||||
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
|
||||
|
||||
// Imported per test so a missing worker decoder fails those tests, not the file.
|
||||
const createWindDecoder = async (options) =>
|
||||
(await import('../../server/providers/wind/decoder.js')).createWindDecoder(
|
||||
options,
|
||||
);
|
||||
const require = createRequire(import.meta.url);
|
||||
/** Whether this thread has loaded ecCodes (a CommonJS package). */
|
||||
const ecCodesLoadedHere = () =>
|
||||
Object.keys(require.cache).some((file) => file.includes('eccodes-wasm'));
|
||||
|
||||
const DECODE_MODULE = new URL(
|
||||
'../../server/providers/wind/decode.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/**
|
||||
* A worker that runs the provider's real serving loop over a stand-in for
|
||||
* ecCodes. The first byte selects the behaviour, the second a synchronous busy
|
||||
* time in tens of milliseconds, which is what a WASM decode looks like to the
|
||||
* thread that runs it.
|
||||
*/
|
||||
const FAKE_WORKER = new URL(
|
||||
`data:text/javascript,${encodeURIComponent(`
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
import { serveWindDecodes } from ${JSON.stringify(DECODE_MODULE)};
|
||||
serveWindDecodes(parentPort, (bytes) => {
|
||||
if (bytes[0] === 1) throw new Error('bad message');
|
||||
if (bytes[0] === 2) {
|
||||
setTimeout(() => { throw new Error('crash'); });
|
||||
return new Promise(() => {});
|
||||
}
|
||||
if (bytes[0] === 3) process.exit(1);
|
||||
if (bytes[0] === 4) for (;;);
|
||||
const until = Date.now() + (bytes[1] || 0) * 10;
|
||||
while (Date.now() < until);
|
||||
return {
|
||||
ni: 4, nj: 3, lo1: 0, la1: 90, di: 90, dj: 90,
|
||||
values: Float64Array.from({ length: 12 }, (_, i) => bytes[0] + i / 8),
|
||||
units: 'm/s',
|
||||
};
|
||||
});
|
||||
`)}`,
|
||||
);
|
||||
|
||||
/** Run `work` while a 5 ms heartbeat records the longest main-thread stall. */
|
||||
async function longestStall(work) {
|
||||
let last = performance.now();
|
||||
let gap = 0;
|
||||
const beat = setInterval(() => {
|
||||
const t = performance.now();
|
||||
gap = Math.max(gap, t - last);
|
||||
last = t;
|
||||
}, 5);
|
||||
try {
|
||||
const started = performance.now();
|
||||
const value = await work();
|
||||
return { value, gap, elapsed: performance.now() - started };
|
||||
} finally {
|
||||
clearInterval(beat);
|
||||
}
|
||||
}
|
||||
|
||||
function install(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (_route, fn) => (handler = fn) },
|
||||
});
|
||||
return async (url = '/') => {
|
||||
const res = {
|
||||
writeHead(status) {
|
||||
this.statusCode = status;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url, method: 'GET' }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
/** Manifest (minus fetchedAt) and grid bytes exactly as a client receives them. */
|
||||
async function served(request, url) {
|
||||
const { fetchedAt, ...manifest } = JSON.parse((await request(url)).body);
|
||||
const grid = manifest.gridUrl
|
||||
? (await request(manifest.gridUrl.replace('/api/wind', ''))).body
|
||||
: null;
|
||||
return { manifest, grid };
|
||||
}
|
||||
|
||||
/** One GRIB2 message on a regular lat/lon grid with 16-bit simple packing. */
|
||||
function syntheticGrib({ category, number, surface, level, values }) {
|
||||
const [ni, nj] = [8, 5];
|
||||
const u8 = (...bytes) => Buffer.from(bytes);
|
||||
const u16 = (value) => {
|
||||
const out = Buffer.alloc(2);
|
||||
out.writeUInt16BE(value);
|
||||
return out;
|
||||
};
|
||||
const u32 = (value) => {
|
||||
const out = Buffer.alloc(4);
|
||||
out.writeUInt32BE(value >>> 0);
|
||||
return out;
|
||||
};
|
||||
const section = (id, ...parts) => {
|
||||
const body = Buffer.concat(parts);
|
||||
return Buffer.concat([u32(body.length + 5), u8(id), body]);
|
||||
};
|
||||
const reference = Buffer.alloc(4);
|
||||
reference.writeFloatBE(Math.min(...values) * 10);
|
||||
const packed = Buffer.alloc(values.length * 2);
|
||||
values.forEach((value, index) =>
|
||||
packed.writeUInt16BE(
|
||||
Math.round(value * 10 - reference.readFloatBE()),
|
||||
index * 2,
|
||||
),
|
||||
);
|
||||
const rest = Buffer.concat([
|
||||
section(1, u16(7), u16(0), u8(2, 1, 1), u16(2026), u8(9, 14, 6, 0, 0, 0, 1)),
|
||||
section(
|
||||
3,
|
||||
u8(0),
|
||||
u32(ni * nj),
|
||||
u8(0, 0),
|
||||
u16(0), // template 3.0, regular lat/lon
|
||||
u8(6, 0),
|
||||
u32(0),
|
||||
u8(0),
|
||||
u32(0),
|
||||
u8(0),
|
||||
u32(0),
|
||||
u32(ni),
|
||||
u32(nj),
|
||||
u32(0),
|
||||
u32(0xffffffff),
|
||||
u32(90e6), // first point 90N 0E, last point 90S 315E, 45 degree steps
|
||||
u32(0),
|
||||
u8(48),
|
||||
u32(90e6 | 0x80000000),
|
||||
u32(315e6),
|
||||
u32(45e6),
|
||||
u32(45e6),
|
||||
u8(0),
|
||||
),
|
||||
section(
|
||||
4,
|
||||
u16(0),
|
||||
u16(0), // template 4.0
|
||||
u8(category, number, 2, 0, 0),
|
||||
u16(0),
|
||||
u8(0, 1),
|
||||
u32(0),
|
||||
u8(surface, 0),
|
||||
u32(level),
|
||||
u8(255, 255),
|
||||
u32(0xffffffff),
|
||||
),
|
||||
// Template 5.0: value = (reference + packed) / 10.
|
||||
section(5, u32(ni * nj), u16(0), reference, u16(0), u16(1), u8(16, 0)),
|
||||
section(6, u8(255)),
|
||||
section(7, packed),
|
||||
Buffer.from('7777'),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from('GRIB'),
|
||||
u8(0, 0, 0, 2),
|
||||
u32(0),
|
||||
u32(16 + rest.length),
|
||||
rest,
|
||||
]);
|
||||
}
|
||||
|
||||
const field = (seed, scale) =>
|
||||
Array.from({ length: 40 }, (_, i) => seed + Math.sin(i * 1.7) * scale);
|
||||
const MESSAGES = {
|
||||
u: syntheticGrib({
|
||||
category: 2,
|
||||
number: 2,
|
||||
surface: 103,
|
||||
level: 10,
|
||||
values: field(3, 14),
|
||||
}),
|
||||
v: syntheticGrib({
|
||||
category: 2,
|
||||
number: 3,
|
||||
surface: 103,
|
||||
level: 10,
|
||||
values: field(-2, 9),
|
||||
}),
|
||||
tmp: syntheticGrib({
|
||||
category: 0,
|
||||
number: 0,
|
||||
surface: 103,
|
||||
level: 2,
|
||||
values: field(281, 25),
|
||||
}),
|
||||
prmsl: syntheticGrib({
|
||||
category: 3,
|
||||
number: 1,
|
||||
surface: 101,
|
||||
level: 0,
|
||||
values: field(101300, 2400),
|
||||
}),
|
||||
};
|
||||
|
||||
/** A GFS object holding the four synthetic messages, plus its `.idx`. */
|
||||
function syntheticUpstream({ log = [], corrupt = () => false } = {}) {
|
||||
const order = ['u', 'v', 'tmp', 'prmsl'];
|
||||
const offsets = [0];
|
||||
for (const name of order) offsets.push(offsets.at(-1) + MESSAGES[name].length);
|
||||
const file = Buffer.concat(order.map((name) => MESSAGES[name]));
|
||||
const idx = [
|
||||
`1:${offsets[0]}:d=2026091406:UGRD:10 m above ground:6 hour fcst:`,
|
||||
`2:${offsets[1]}:d=2026091406:VGRD:10 m above ground:6 hour fcst:`,
|
||||
`3:${offsets[2]}:d=2026091406:TMP:2 m above ground:6 hour fcst:`,
|
||||
`4:${offsets[3]}:d=2026091406:PRMSL:mean sea level:6 hour fcst:`,
|
||||
`5:${offsets[4]}:d=2026091406:HGT:surface:6 hour fcst:`,
|
||||
].join('\n');
|
||||
const ranges = Object.fromEntries(
|
||||
order.map((name, i) => [name, `bytes=${offsets[i]}-${offsets[i + 1] - 1}`]),
|
||||
);
|
||||
const fetchImpl = async (url, options = {}) => {
|
||||
const range = options.headers?.Range;
|
||||
log.push(range ?? 'idx');
|
||||
if (!range) return new Response(idx, { status: 200 });
|
||||
const [, start, end] = /bytes=(\d+)-(\d+)/.exec(range);
|
||||
const name = order.find((key) => ranges[key] === range);
|
||||
const body = Buffer.from(file.subarray(Number(start), Number(end) + 1));
|
||||
if (corrupt(name)) body.fill(0);
|
||||
return new Response(body, { status: 206 });
|
||||
};
|
||||
return { fetchImpl, ranges, log };
|
||||
}
|
||||
|
||||
const NOON = Date.UTC(2026, 8, 14, 12);
|
||||
const KEYS = ['/', '/?overlay=temperature', '/?overlay=pressure'];
|
||||
|
||||
test('a blocking decode runs off the server thread', async (t) => {
|
||||
const decoder = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
t.after(() => decoder.close());
|
||||
// Warm the worker so its start-up is not counted, then block it for 300 ms.
|
||||
await decoder.decode(Uint8Array.of(5, 0));
|
||||
const { value, gap, elapsed } = await longestStall(() =>
|
||||
decoder.decode(Uint8Array.of(7, 30), { targetDx: 90 }),
|
||||
);
|
||||
assert.ok(elapsed >= 280, `decode took ${elapsed} ms`);
|
||||
assert.ok(gap < 150, `server thread stalled for ${gap} ms`);
|
||||
// The worker validated and sampled the field; only the samples came back.
|
||||
assert.equal(value.values, undefined);
|
||||
assert.equal(value.dx, 90);
|
||||
assert.ok(value.samples instanceof Float64Array);
|
||||
assert.equal(value.samples.length, 4 * 3);
|
||||
assert.equal(value.samples[0], 7);
|
||||
});
|
||||
|
||||
test('the default wind provider decodes in the worker and matches the synchronous path byte for byte', async () => {
|
||||
const outputs = [];
|
||||
for (const options of [
|
||||
{}, // default: ecCodes in the worker
|
||||
{ decodeImpl: (bytes) => decodeWindGribMessage(bytes) }, // on this thread
|
||||
]) {
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: syntheticUpstream().fetchImpl,
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
const out = [];
|
||||
for (const key of KEYS) out.push(await served(request, key));
|
||||
// The default path never brings ecCodes into this thread; the injected
|
||||
// synchronous decoder does, which also proves the probe can see it.
|
||||
assert.equal(ecCodesLoadedHere(), outputs.length === 1);
|
||||
outputs.push(out);
|
||||
}
|
||||
const [worker, sync] = outputs;
|
||||
for (let i = 0; i < KEYS.length; i += 1) {
|
||||
assert.equal(worker[i].manifest.unavailable, false, KEYS[i]);
|
||||
assert.deepEqual(worker[i].manifest, sync[i].manifest, KEYS[i]);
|
||||
assert.ok(worker[i].grid.equals(sync[i].grid), `${KEYS[i]} grid bytes`);
|
||||
}
|
||||
assert.equal(worker[0].grid.length, 12 * 7 * 4 * 2);
|
||||
assert.equal(worker[1].grid.length, 12 * 7 * 4 * 3);
|
||||
assert.equal(worker[1].manifest.scalar.kind, 'temperature');
|
||||
assert.equal(worker[2].manifest.scalar.kind, 'pressure');
|
||||
});
|
||||
|
||||
test('partial and failed refreshes match the synchronous path through the worker', async () => {
|
||||
for (const [bad, expected] of [
|
||||
['tmp', { scalarError: 'Temperature field unavailable' }],
|
||||
['u', { unavailable: true }],
|
||||
]) {
|
||||
const outputs = [];
|
||||
for (const options of [
|
||||
{},
|
||||
{ decodeImpl: (bytes) => decodeWindGribMessage(bytes) },
|
||||
]) {
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: syntheticUpstream({ corrupt: (name) => name === bad })
|
||||
.fetchImpl,
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
outputs.push(await served(request, '/?overlay=temperature'));
|
||||
}
|
||||
const [worker, sync] = outputs;
|
||||
assert.deepEqual(worker.manifest, sync.manifest);
|
||||
for (const [name, value] of Object.entries(expected))
|
||||
assert.equal(worker.manifest[name], value);
|
||||
if (sync.grid) assert.ok(worker.grid.equals(sync.grid));
|
||||
else assert.equal(worker.grid, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('an overlay change within the same forecast object fetches and decodes only the scalar', async () => {
|
||||
let clock = NOON;
|
||||
let decodes = 0;
|
||||
const upstream = syntheticUpstream();
|
||||
const decodeImpl = (bytes) => {
|
||||
decodes += 1;
|
||||
return decodeWindGribMessage(bytes);
|
||||
};
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => clock,
|
||||
targetDx: 30,
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
decodeImpl,
|
||||
}),
|
||||
);
|
||||
const none = await served(request, '/');
|
||||
assert.deepEqual(upstream.log, ['idx', upstream.ranges.u, upstream.ranges.v]);
|
||||
assert.equal(decodes, 2);
|
||||
|
||||
upstream.log.length = 0;
|
||||
const temperature = await served(request, '/?overlay=temperature');
|
||||
assert.deepEqual(upstream.log, ['idx', upstream.ranges.tmp]);
|
||||
assert.equal(decodes, 3);
|
||||
upstream.log.length = 0;
|
||||
const pressure = await served(request, '/?overlay=pressure');
|
||||
assert.deepEqual(upstream.log, ['idx', upstream.ranges.prmsl]);
|
||||
assert.equal(decodes, 4);
|
||||
|
||||
// Reused wind gives exactly what a server that fetched everything gives.
|
||||
const fresh = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: syntheticUpstream().fetchImpl,
|
||||
decodeImpl: (bytes) => decodeWindGribMessage(bytes),
|
||||
}),
|
||||
);
|
||||
for (const [url, reused] of [
|
||||
['/?overlay=temperature', temperature],
|
||||
['/?overlay=pressure', pressure],
|
||||
]) {
|
||||
const full = await served(fresh, url);
|
||||
assert.deepEqual(reused.manifest, full.manifest);
|
||||
assert.ok(reused.grid.equals(full.grid));
|
||||
}
|
||||
assert.ok(
|
||||
temperature.grid.subarray(0, none.grid.length).equals(none.grid),
|
||||
'the overlay grid starts with the same U and V',
|
||||
);
|
||||
|
||||
// The next forecast step is a different object: wind is fetched again.
|
||||
clock += 3600_000;
|
||||
upstream.log.length = 0;
|
||||
await served(request, '/?overlay=temperature');
|
||||
assert.deepEqual(upstream.log, [
|
||||
'idx',
|
||||
upstream.ranges.u,
|
||||
upstream.ranges.v,
|
||||
upstream.ranges.tmp,
|
||||
]);
|
||||
});
|
||||
|
||||
test('wind that failed its checks is never retained for a later overlay', async () => {
|
||||
const upstream = syntheticUpstream({ corrupt: (name) => name === 'v' });
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => NOON,
|
||||
targetDx: 30,
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
decodeImpl: (bytes) => decodeWindGribMessage(bytes),
|
||||
}),
|
||||
);
|
||||
assert.equal((await served(request, '/')).manifest.unavailable, true);
|
||||
upstream.log.length = 0;
|
||||
const overlay = await served(request, '/?overlay=temperature');
|
||||
assert.equal(overlay.manifest.unavailable, true);
|
||||
assert.ok(upstream.log.includes(upstream.ranges.u), 'U is fetched again');
|
||||
});
|
||||
|
||||
test('a throwing, crashing, exiting or hung decode rejects its job and the next decode recovers', async (t) => {
|
||||
const decoder = await createWindDecoder({
|
||||
workerUrl: FAKE_WORKER,
|
||||
jobTimeoutMs: 400,
|
||||
});
|
||||
t.after(() => decoder.close());
|
||||
const ok = async () => {
|
||||
const value = await decoder.decode(Uint8Array.of(9, 0), { targetDx: 90 });
|
||||
assert.equal(value.samples[0], 9);
|
||||
};
|
||||
await ok();
|
||||
// A decode error is reported for that job only; the worker stays up.
|
||||
const [thrown, sibling] = await Promise.allSettled([
|
||||
decoder.decode(Uint8Array.of(1, 0)),
|
||||
decoder.decode(Uint8Array.of(9, 0), { targetDx: 90 }),
|
||||
]);
|
||||
assert.match(thrown.reason.message, /bad message/);
|
||||
assert.equal(sibling.value.samples[0], 9);
|
||||
// An uncaught error or an exit retires the worker; the next decode restarts it.
|
||||
for (const [mode, pattern] of [
|
||||
[2, /worker failed/],
|
||||
[3, /worker exited/],
|
||||
]) {
|
||||
const results = await Promise.allSettled([
|
||||
decoder.decode(Uint8Array.of(mode, 0)),
|
||||
decoder.decode(Uint8Array.of(9, 0)),
|
||||
]);
|
||||
assert.match(results[0].reason.message, pattern);
|
||||
// A job queued behind an exit dies with the worker; one queued behind an
|
||||
// asynchronous crash may still finish first. Either way it settles.
|
||||
if (mode === 3) assert.equal(results[1].status, 'rejected');
|
||||
await ok();
|
||||
}
|
||||
// A decode that never returns is cut off instead of hanging its request.
|
||||
await assert.rejects(decoder.decode(Uint8Array.of(4, 0)), /timed out/);
|
||||
await ok();
|
||||
// Cancelling stops the wait at once; the budget is checked before any copy.
|
||||
const controller = new AbortController();
|
||||
const slow = decoder.decode(Uint8Array.of(9, 20), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error('cancelled'));
|
||||
await assert.rejects(slow, /cancelled/);
|
||||
await assert.rejects(
|
||||
decoder.decode(new Uint8Array(8 * 1024 * 1024 + 1)),
|
||||
/byte budget/,
|
||||
);
|
||||
await ok();
|
||||
});
|
||||
|
||||
test('a worker crash during a refresh answers the request and the next refresh recovers', async (t) => {
|
||||
const decoder = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
t.after(() => decoder.close());
|
||||
let clock = NOON;
|
||||
let crash = true;
|
||||
const request = install(
|
||||
windProxy({
|
||||
now: () => clock,
|
||||
targetDx: 90,
|
||||
decodeImpl: decoder.decode,
|
||||
fetchImpl: async (url, options = {}) => {
|
||||
if (!options.headers?.Range)
|
||||
return new Response(
|
||||
[
|
||||
'1:0:d=2026091406:UGRD:10 m above ground:anl:',
|
||||
'2:2:d=2026091406:VGRD:10 m above ground:anl:',
|
||||
'3:4:d=2026091406:HGT:surface:anl:',
|
||||
].join('\n'),
|
||||
);
|
||||
return new Response(Uint8Array.of(crash ? 3 : 6, 0), { status: 206 });
|
||||
},
|
||||
}),
|
||||
);
|
||||
const failed = await served(request, '/');
|
||||
assert.equal(failed.manifest.unavailable, true);
|
||||
crash = false;
|
||||
clock += 60_000; // past the failure backoff
|
||||
const recovered = await served(request, '/');
|
||||
assert.equal(recovered.manifest.unavailable, false);
|
||||
assert.equal(recovered.grid.length, 4 * 3 * 4 * 2);
|
||||
});
|
||||
|
||||
test('closing one decoder rejects its jobs and leaves another working', async (t) => {
|
||||
const a = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
const b = await createWindDecoder({ workerUrl: FAKE_WORKER });
|
||||
t.after(() => b.close());
|
||||
const pending = a.decode(Uint8Array.of(9, 10));
|
||||
a.close();
|
||||
await assert.rejects(pending, /closed/);
|
||||
assert.equal((await b.decode(Uint8Array.of(9, 0))).samples[0], 9);
|
||||
});
|
||||
|
||||
test('an idle decode worker does not keep the process alive', () => {
|
||||
const decoderUrl = new URL(
|
||||
'../../server/providers/wind/decoder.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const script = `
|
||||
import(${JSON.stringify(decoderUrl)}).then(async ({ createWindDecoder }) => {
|
||||
const decoder = await createWindDecoder({ workerUrl: new URL(${JSON.stringify(FAKE_WORKER.href)}) });
|
||||
const field = await decoder.decode(Uint8Array.of(9, 0));
|
||||
console.log('decoded', field.samples.length);
|
||||
});
|
||||
`;
|
||||
const child = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000,
|
||||
});
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(child.stdout.trim(), 'decoded 65160');
|
||||
});
|
||||
@@ -3953,12 +3953,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات",
|
||||
"Showing {shown} of {total}": "عرض {shown} من {total}",
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات",
|
||||
"The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.",
|
||||
"Reload": "إعادة التحميل",
|
||||
"Retry": "إعادة المحاولة",
|
||||
"Reset view": "إعادة ضبط العرض"
|
||||
"BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات"
|
||||
}
|
||||
@@ -3605,12 +3605,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen",
|
||||
"Showing {shown} of {total}": "{shown} von {total} angezeigt",
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN",
|
||||
"The globe stopped drawing": "Der Globus zeichnet nicht mehr",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.",
|
||||
"Reload": "Neu laden",
|
||||
"Retry": "Erneut versuchen",
|
||||
"Reset view": "Ansicht zurücksetzen"
|
||||
"BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN"
|
||||
}
|
||||
@@ -3692,12 +3692,5 @@
|
||||
"500–1,000 m": "500–1000 m",
|
||||
"1,000–2,000 m": "1000–2000 m",
|
||||
"2,000–3,000 m": "2000–3000 m",
|
||||
"3,000 m and above": "3000 m o más",
|
||||
"The globe stopped drawing": "El globo dejó de dibujarse",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.",
|
||||
"Reload": "Recargar",
|
||||
"Retry": "Reintentar",
|
||||
"Reset view": "Restablecer la vista"
|
||||
"3,000 m and above": "3000 m o más"
|
||||
}
|
||||
@@ -3692,12 +3692,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées",
|
||||
"Showing {shown} of {total}": "Affichées : {shown} sur {total}",
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES",
|
||||
"The globe stopped drawing": "Le globe ne s’affiche plus",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.",
|
||||
"Reload": "Recharger",
|
||||
"Retry": "Réessayer",
|
||||
"Reset view": "Réinitialiser la vue"
|
||||
"BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES"
|
||||
}
|
||||
@@ -3692,12 +3692,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים",
|
||||
"Showing {shown} of {total}": "מוצגים {shown} מתוך {total}",
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים",
|
||||
"The globe stopped drawing": "הגלובוס הפסיק להצטייר",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.",
|
||||
"Reload": "טען מחדש",
|
||||
"Retry": "נסה שוב",
|
||||
"Reset view": "איפוס תצוגה"
|
||||
"BROWSE DATA LIBRARY": "עיון בספריית הנתונים"
|
||||
}
|
||||
@@ -3692,12 +3692,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte",
|
||||
"Showing {shown} of {total}": "Mostrati: {shown} su {total}",
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI",
|
||||
"The globe stopped drawing": "Il globo ha smesso di disegnare",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.",
|
||||
"Reload": "Ricarica",
|
||||
"Retry": "Riprova",
|
||||
"Reset view": "Ripristina vista"
|
||||
"BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI"
|
||||
}
|
||||
@@ -3518,12 +3518,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除",
|
||||
"Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除",
|
||||
"Showing {shown} of {total}": "{total}件中{shown}件を表示",
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る",
|
||||
"The globe stopped drawing": "地球儀の描画が止まりました",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。",
|
||||
"Reload": "再読み込み",
|
||||
"Retry": "再試行",
|
||||
"Reset view": "ビューをリセット"
|
||||
"BROWSE DATA LIBRARY": "データライブラリを見る"
|
||||
}
|
||||
@@ -3692,12 +3692,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas",
|
||||
"Showing {shown} of {total}": "Mostrando {shown} de {total}",
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS",
|
||||
"The globe stopped drawing": "O globo parou de desenhar",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.",
|
||||
"Reload": "Recarregar",
|
||||
"Retry": "Tentar novamente",
|
||||
"Reset view": "Redefinir vista"
|
||||
"BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS"
|
||||
}
|
||||
@@ -3779,12 +3779,5 @@
|
||||
"500–1,000 m": "500–1000 м",
|
||||
"1,000–2,000 m": "1000–2000 м",
|
||||
"2,000–3,000 m": "2000–3000 м",
|
||||
"3,000 m and above": "3000 м и выше",
|
||||
"The globe stopped drawing": "Глобус перестал отрисовываться",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.",
|
||||
"Reload": "Перезагрузить",
|
||||
"Retry": "Повторить",
|
||||
"Reset view": "Сбросить вид"
|
||||
"3,000 m and above": "3000 м и выше"
|
||||
}
|
||||
@@ -3605,12 +3605,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager",
|
||||
"Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager",
|
||||
"Showing {shown} of {total}": "Visar {shown} av {total}",
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET",
|
||||
"The globe stopped drawing": "Globen ritas inte längre",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.",
|
||||
"Reload": "Ladda om",
|
||||
"Retry": "Försök igen",
|
||||
"Reset view": "Återställ vy"
|
||||
"BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET"
|
||||
}
|
||||
@@ -3779,12 +3779,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»",
|
||||
"Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»",
|
||||
"Showing {shown} of {total}": "Показано {shown} з {total}",
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ",
|
||||
"The globe stopped drawing": "Глобус перестав відмальовуватися",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.",
|
||||
"Reload": "Перезавантажити",
|
||||
"Retry": "Повторити",
|
||||
"Reset view": "Скинути вигляд"
|
||||
"BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ"
|
||||
}
|
||||
@@ -3518,12 +3518,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目",
|
||||
"Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目",
|
||||
"Showing {shown} of {total}": "显示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库",
|
||||
"The globe stopped drawing": "地球已停止绘制",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。",
|
||||
"Reload": "重新加载",
|
||||
"Retry": "重试",
|
||||
"Reset view": "重置视图"
|
||||
"BROWSE DATA LIBRARY": "浏览数据资源库"
|
||||
}
|
||||
@@ -3518,12 +3518,5 @@
|
||||
"Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目",
|
||||
"Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目",
|
||||
"Showing {shown} of {total}": "顯示 {shown} / {total}",
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫",
|
||||
"The globe stopped drawing": "地球已停止繪製",
|
||||
"The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。",
|
||||
"Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。",
|
||||
"Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。",
|
||||
"Reload": "重新加載",
|
||||
"Retry": "重試",
|
||||
"Reset view": "重置視圖"
|
||||
"BROWSE DATA LIBRARY": "瀏覽數據資源庫"
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
aircraftCallsignRule,
|
||||
aircraftModelByCallsign,
|
||||
aircraftModelSpec,
|
||||
fleetModelSet,
|
||||
} from '../../data/fleetModels.js';
|
||||
import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js';
|
||||
import { MODEL_COLOR_BLEND_AMOUNT } from './policy.js';
|
||||
|
||||
/**
|
||||
* What an aircraft draws with before the fleet model set has loaded: no
|
||||
@@ -21,6 +17,7 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
|
||||
bellyM: 0,
|
||||
elevationM: 0,
|
||||
tinted: false,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: Object.freeze([0, 0, 0]),
|
||||
trailAnchorNative: Object.freeze([0, 0, 0]),
|
||||
key: 'unloaded',
|
||||
@@ -28,14 +25,12 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
|
||||
|
||||
/**
|
||||
* The fleet model, life-size scale and measured geometry an aircraft draws
|
||||
* with, chosen by its ICAO type and emitter category, and by its callsign
|
||||
* for the types that pick by callsign (`fleetModels.js`).
|
||||
* with, chosen by its ICAO type and emitter category (`fleetModels.js`).
|
||||
* Shared by the Flights, Military Flights and Local ADS-B layers so all three
|
||||
* draw the same model for the same aircraft. `bellyM` is the model's scaled
|
||||
* clearance: how far a grounded aircraft's origin stands above the ground.
|
||||
* @param {{typeCode?: string|null, type?: string|null,
|
||||
* category?: string|number|null,
|
||||
* callsign?: string|null}|null|undefined} aircraft
|
||||
* category?: string|number|null}|null|undefined} aircraft
|
||||
* @param {object|null} [set] The loaded fleet model set.
|
||||
* @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before
|
||||
* the set has loaded.
|
||||
@@ -45,17 +40,17 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
||||
// Layers ask on every tick; each type and category is resolved once per set.
|
||||
let cache = specCaches.get(set);
|
||||
if (!cache) specCaches.set(set, (cache = new Map()));
|
||||
const key = aircraftModelSpecCacheKey(aircraft, set);
|
||||
const key = aircraftModelSpecCacheKey(aircraft);
|
||||
let layerSpec = cache.get(key);
|
||||
if (!layerSpec) {
|
||||
const spec = aircraftModelSpec(set, {
|
||||
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
||||
category: aircraft?.category ?? null,
|
||||
callsign: aircraft?.callsign ?? null,
|
||||
});
|
||||
layerSpec = Object.freeze({
|
||||
...spec,
|
||||
bellyM: spec.clearanceM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
});
|
||||
cache.set(key, layerSpec);
|
||||
}
|
||||
@@ -66,18 +61,10 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
||||
const specCaches = new WeakMap();
|
||||
|
||||
/**
|
||||
* Cache key of an aircraft's model spec: what selects its model. For a type
|
||||
* that picks its model by callsign (an airport vehicle) the key adds the
|
||||
* rule its callsign matches, or `type` for none, so such a type caches at
|
||||
* most one entry per rule and one for its own model.
|
||||
* Cache key of an aircraft's model spec: what selects its model.
|
||||
* @param {object|null|undefined} aircraft
|
||||
* @param {object|null} [set] The loaded fleet model set.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) {
|
||||
const type = aircraft?.typeCode ?? aircraft?.type ?? '';
|
||||
const key = `${type}|${aircraft?.category ?? ''}`;
|
||||
if (!set || !aircraftModelByCallsign(set.types, type)) return key;
|
||||
const rule = aircraftCallsignRule(set.types, type, aircraft?.callsign);
|
||||
return `${key}|${rule >= 0 ? `r${rule}` : 'type'}`;
|
||||
export function aircraftModelSpecCacheKey(aircraft) {
|
||||
return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`;
|
||||
}
|
||||
@@ -85,6 +85,10 @@ export const MODEL_ALL_KEEP_M = 450000;
|
||||
|
||||
export const MODEL_HEADING_OFFSET_DEG = 180;
|
||||
// every fleet model is nose -X: opposite Cesium heading 0
|
||||
// Owner launch-polish direction: models should read as clean light silhouettes,
|
||||
// with only a weak diffuse contribution from the models' own colours.
|
||||
|
||||
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
||||
|
||||
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
|
||||
|
||||
|
||||
@@ -5,11 +5,6 @@ import {
|
||||
} from '../../data/renderAltitude.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
aircraftCallsignRule,
|
||||
aircraftModelSpec,
|
||||
fleetModelSet,
|
||||
} from '../../data/fleetModels.js';
|
||||
import {
|
||||
approxDistanceKm,
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
@@ -26,7 +21,6 @@ export class FlightRecords {
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
fleetModels = fleetModelSet,
|
||||
}) {
|
||||
this.services = {
|
||||
geoidHeight,
|
||||
@@ -35,8 +29,6 @@ export class FlightRecords {
|
||||
};
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
/** The loaded fleet model set, or null before it loads. */
|
||||
this.fleetModels = fleetModels;
|
||||
this.data = new Map();
|
||||
// Facts about an airframe, not a moment, kept across a reset.
|
||||
this.airframes = new Map();
|
||||
@@ -100,6 +92,7 @@ export class FlightRecords {
|
||||
verticalRateMps: vertical_rate,
|
||||
} = observation;
|
||||
|
||||
const onGround = on_ground === true;
|
||||
this.missingPolls.delete(icao24);
|
||||
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
|
||||
// aircraft it still positions — hold last-known-good instead of
|
||||
@@ -109,27 +102,6 @@ export class FlightRecords {
|
||||
// The airframe's facts: this record's, or those kept across a reset.
|
||||
const airframe = prevMeta ?? this.airframes.get(icao24);
|
||||
this.airframes.delete(icao24);
|
||||
// A ground vehicle or ground station (a feed's ZVEH or GRND: a model
|
||||
// built standing, catalog category `other`) takes the feed's own type,
|
||||
// which no airframe lookup knows; every other aircraft keeps waiting for
|
||||
// its looked-up type. With no altitude at all it stands on the ground,
|
||||
// where it is, instead of at the airborne default.
|
||||
const groundModel = (typeCode) =>
|
||||
drawnAsGroundModel(
|
||||
this.fleetModels(),
|
||||
typeCode,
|
||||
category,
|
||||
stickyText(callsign, prevMeta?.callsign),
|
||||
);
|
||||
const feedType = observation.typeCode || null;
|
||||
const typeCode =
|
||||
airframe?.typeCode ??
|
||||
(feedType && groundModel(feedType) ? feedType : null);
|
||||
const onGround =
|
||||
on_ground === true ||
|
||||
(!Number.isFinite(baro_alt) &&
|
||||
!Number.isFinite(geo_alt) &&
|
||||
groundModel(typeCode));
|
||||
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||
// airborne default (a parked plane must never float).
|
||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||
@@ -347,9 +319,8 @@ export class FlightRecords {
|
||||
prevMeta?.lastContactEpochMs,
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried
|
||||
// across polls; a ground vehicle's or station's from the feed:
|
||||
typeCode,
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
typeName: airframe?.typeName ?? null,
|
||||
registration: airframe?.registration ?? null,
|
||||
airline: routeHeld ? (airframe.airline ?? null) : null,
|
||||
@@ -437,33 +408,3 @@ export class FlightRecords {
|
||||
this.geoidNCache.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Per loaded fleet model set: whether a type, category and callsign rule draw a ground model. */
|
||||
const groundModelCaches = new WeakMap();
|
||||
|
||||
/**
|
||||
* Whether an aircraft of this type, emitter category and callsign is drawn
|
||||
* with a model built standing on the ground (catalog category `other`: an
|
||||
* airport vehicle, a ground station's mast). False before the set loads.
|
||||
* Answered once per type, category and callsign rule matched.
|
||||
* @param {object|null} set The loaded fleet model set.
|
||||
* @param {string|null} typeCode
|
||||
* @param {string|number|null} category
|
||||
* @param {string|null} callsign
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function drawnAsGroundModel(set, typeCode, category, callsign) {
|
||||
if (!set || !typeCode) return false;
|
||||
let cache = groundModelCaches.get(set);
|
||||
if (!cache) groundModelCaches.set(set, (cache = new Map()));
|
||||
const rule = aircraftCallsignRule(set.types, typeCode, callsign);
|
||||
const key = `${typeCode}|${category ?? ''}|${rule}`;
|
||||
let ground = cache.get(key);
|
||||
if (ground === undefined) {
|
||||
ground =
|
||||
aircraftModelSpec(set, { typeCode, category, callsign }).category ===
|
||||
'other';
|
||||
cache.set(key, ground);
|
||||
}
|
||||
return ground;
|
||||
}
|
||||
@@ -1,28 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FlightRecords } from './records.js';
|
||||
|
||||
const FLEET = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../public/models/fleet',
|
||||
);
|
||||
const fleetSet = {
|
||||
catalog: JSON.parse(
|
||||
fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8'),
|
||||
),
|
||||
types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')),
|
||||
};
|
||||
|
||||
function records(fleetModels = () => null) {
|
||||
function records() {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 40,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
approxDistanceKm: () => 0,
|
||||
fleetModels,
|
||||
});
|
||||
}
|
||||
const view = () => ({
|
||||
@@ -275,47 +260,3 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged
|
||||
store.forget('abc123');
|
||||
assert.equal(store.fixRepeated.has('abc123'), false);
|
||||
});
|
||||
|
||||
test('a ground vehicle or station with no altitude stands on the ground; an aircraft does not', () => {
|
||||
const store = records(() => fleetSet);
|
||||
const noAltitude = { baroAltitudeM: null, ellipsoidAltitudeM: null };
|
||||
const vehicle = store.receive(
|
||||
observation({
|
||||
id: 'v1',
|
||||
typeCode: 'ZVEH',
|
||||
callsign: 'FIRE 2',
|
||||
...noAltitude,
|
||||
}),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(vehicle.onGround, true);
|
||||
assert.equal(vehicle.typeCode, 'ZVEH');
|
||||
assert.equal(vehicle.altitude, 0);
|
||||
const mast = store.receive(
|
||||
observation({ id: 'm1', typeCode: 'GRND', callsign: 'RX5', ...noAltitude }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(mast.onGround, true);
|
||||
assert.equal(mast.typeCode, 'GRND');
|
||||
// An airliner with no altitude keeps the airborne default, and its feed
|
||||
// type still waits for the airframe lookup.
|
||||
const plane = store.receive(
|
||||
observation({ id: 'p1', typeCode: 'A320', ...noAltitude }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(plane.onGround, false);
|
||||
assert.equal(plane.altitude, 10000);
|
||||
assert.equal(plane.typeCode, null);
|
||||
// A vehicle reporting an altitude is placed by it.
|
||||
const reported = store.receive(
|
||||
observation({ id: 'v2', typeCode: 'ZVEH', baroAltitudeM: 12 }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(reported.onGround, false);
|
||||
// Before the model set loads nothing is grounded by its model.
|
||||
const unloaded = records().receive(
|
||||
observation({ id: 'v3', typeCode: 'ZVEH', ...noAltitude }),
|
||||
view(),
|
||||
).meta;
|
||||
assert.equal(unloaded.onGround, false);
|
||||
});
|
||||
@@ -45,10 +45,7 @@ import {
|
||||
STALE_ALPHA,
|
||||
displayDelayMs,
|
||||
} from './policy.js';
|
||||
import {
|
||||
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
aircraftModelBlend,
|
||||
} from '../../data/aircraftModelLighting.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
export function createRendering({
|
||||
flightState,
|
||||
@@ -195,12 +192,11 @@ export function createRendering({
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/** Model colour: the mesh's own (white), tinted cyan while tracked and red
|
||||
* while broadcasting an emergency. */
|
||||
/** Model tint, mirroring the billboard color rules. */
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
||||
return _isEmergency(icao24) ? EMERGENCY_TINT : Cesium.Color.WHITE;
|
||||
return _fleetBillboardColor(icao24);
|
||||
}
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
@@ -530,7 +526,10 @@ export function createRendering({
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||
...aircraftModelBlend(flightState._irBoost),
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
// Launch presentation keeps the code-side tint dominant for every approved
|
||||
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||
colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
|
||||
customShader: flightState._irBoost
|
||||
? flightState._IR_UNLIT_SHADER
|
||||
: undefined,
|
||||
@@ -749,7 +748,10 @@ export function createRendering({
|
||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||
scale: trackedSpec.scale,
|
||||
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
|
||||
...aircraftModelBlend(flightState._irBoost),
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
// The tracked aircraft uses the same dominant light tint as the fleet;
|
||||
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||
colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
|
||||
customShader: flightState._irBoost
|
||||
? flightState._IR_UNLIT_SHADER
|
||||
: undefined,
|
||||
|
||||
@@ -700,6 +700,7 @@ export function createLocalAdsbLayer({
|
||||
if (viewer.scene?.primitives) {
|
||||
models = createLocalAdsbModels({
|
||||
viewer,
|
||||
color,
|
||||
resolveAsset,
|
||||
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
||||
...(loadModel ? { loadModel } : {}),
|
||||
|
||||
@@ -503,7 +503,7 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o
|
||||
assert.equal(queries.length, 2, 'each key is asked for once');
|
||||
});
|
||||
|
||||
test('the DISPLAY 3D toggle gives local aircraft their fleet models in their mesh colours', async (t) => {
|
||||
test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => {
|
||||
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
|
||||
const clock = { now: 100_000 };
|
||||
const loads = [];
|
||||
@@ -548,10 +548,13 @@ test('the DISPLAY 3D toggle gives local aircraft their fleet models in their mes
|
||||
assert.equal(model.scale, spec.scale, 'drawn life size');
|
||||
assert.equal(model.id, 'local-adsb:a0b702');
|
||||
assert.ok(
|
||||
Cesium.Color.equals(model.color, Cesium.Color.WHITE),
|
||||
'the mesh draws its own colours',
|
||||
Cesium.Color.equals(
|
||||
model.color,
|
||||
Cesium.Color.fromCssColorString('#ff4fd8'),
|
||||
),
|
||||
);
|
||||
assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.HIGHLIGHT);
|
||||
assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX);
|
||||
assert.equal(model.colorBlendAmount, 0.94);
|
||||
assert.equal(model.show, true);
|
||||
const heli = sources[0].entities.getById('local-adsb:a0b702');
|
||||
assert.equal(
|
||||
|
||||
@@ -16,16 +16,12 @@ import {
|
||||
MODEL_PROX_ADD_M,
|
||||
MODEL_PROX_KEEP_M,
|
||||
} from '../flights/policy.js';
|
||||
import {
|
||||
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
aircraftModelBlend,
|
||||
} from '../../data/aircraftModelLighting.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
const specOf = (marker) =>
|
||||
aircraftLayerModelSpec({
|
||||
typeCode: marker?.typeCode,
|
||||
category: marker?.category,
|
||||
callsign: marker?.record?.callsign,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -34,14 +30,15 @@ const specOf = (marker) =>
|
||||
* Mirrors the public Flights fleet: models only below the altitude ceiling,
|
||||
* the same proximity/all caps and add/keep radii, the same visible-first
|
||||
* eligibility (`selectModelEligible`), the same fleet model per type, life
|
||||
* size (`aircraftLayerModelSpec`) and the same colours: the mesh's own
|
||||
* (`aircraftModelBlend`). Each model owns its matrix. A
|
||||
* size (`aircraftLayerModelSpec`) and the same tint path (a MIX colour blend, here
|
||||
* magenta, as Flights tints military amber). Each model owns its matrix. A
|
||||
* billboard stays the visual until its model is loaded AND placed; a grounded
|
||||
* aircraft's model rides the one-shot ground snap plus the class belly
|
||||
* offset, and without a resolved ground it stays a billboard. Every fleet
|
||||
* model loads once, up front, when the layer starts (`loadFleetModelSet`).
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer Cesium viewer.
|
||||
* @param {Cesium.Color} options.color Model tint.
|
||||
* @param {(url: string) => string} options.resolveAsset
|
||||
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
|
||||
* @param {(options: object) => Promise<object>} [options.loadModel]
|
||||
@@ -49,6 +46,7 @@ const specOf = (marker) =>
|
||||
*/
|
||||
export function createLocalAdsbModels({
|
||||
viewer,
|
||||
color,
|
||||
resolveAsset,
|
||||
groundSnap = null,
|
||||
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
||||
@@ -105,8 +103,9 @@ export function createLocalAdsbModels({
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color: Cesium.Color.WHITE,
|
||||
...aircraftModelBlend(false),
|
||||
color,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
colorBlendAmount: spec.blendAmount,
|
||||
id,
|
||||
},
|
||||
{ resolveAsset, load: loadModel },
|
||||
|
||||
@@ -107,6 +107,10 @@ export const MODEL_ALL_KEEP_M = 450000;
|
||||
|
||||
export const MODEL_HEADING_OFFSET_DEG = 180;
|
||||
// every fleet model is nose -X: opposite Cesium heading 0
|
||||
// Preserve the layer's amber identity while reducing the models' own colours
|
||||
// to a weak diffuse hint, matching civilian launch presentation.
|
||||
|
||||
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
||||
|
||||
/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
MODEL_PROX_KEEP_M,
|
||||
IR_RELOAD_BATCH,
|
||||
MODEL_MIN_PX,
|
||||
MODEL_COLOR_BLEND_AMOUNT,
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
@@ -42,10 +43,7 @@ import {
|
||||
COURSE_MAX_DPS,
|
||||
STALE_ALPHA,
|
||||
} from './policy.js';
|
||||
import {
|
||||
AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
aircraftModelBlend,
|
||||
} from '../../data/aircraftModelLighting.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
export function createRendering({
|
||||
flightState,
|
||||
@@ -83,6 +81,7 @@ export function createRendering({
|
||||
const base = aircraftLayerModelSpec(meta);
|
||||
spec = Object.freeze({
|
||||
...base,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
|
||||
});
|
||||
// Not cached before the model set loads: the answer changes once it has.
|
||||
@@ -153,15 +152,11 @@ export function createRendering({
|
||||
const _iconKind = (icao24, klass) =>
|
||||
tr3bIconKind(icao24, klass, { hot: flightState._irBoost });
|
||||
|
||||
/** Model colour: the mesh's own (white), tinted amber while tracked and red
|
||||
* while broadcasting an emergency. */
|
||||
/** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
return isEmergency(info?.emergency)
|
||||
? EMERGENCY_ICON_COLOR
|
||||
: Cesium.Color.WHITE;
|
||||
return _fleetBillboardColor(icao24);
|
||||
}
|
||||
|
||||
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
|
||||
@@ -448,7 +443,9 @@ export function createRendering({
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||
...aircraftModelBlend(flightState._irBoost),
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
|
||||
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
|
||||
customShader: flightState._irBoost
|
||||
? flightState._IR_UNLIT_SHADER
|
||||
: undefined,
|
||||
@@ -665,7 +662,9 @@ export function createRendering({
|
||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||
scale: trackedSpec.scale,
|
||||
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
|
||||
...aircraftModelBlend(flightState._irBoost),
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
// near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot)
|
||||
colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT,
|
||||
customShader: flightState._irBoost
|
||||
? flightState._IR_UNLIT_SHADER
|
||||
: undefined,
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import { pointAtS } from '../../data/railPaths.js';
|
||||
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
import { displayMotion } from '../transit/movement.js';
|
||||
|
||||
/** Each line's read-only copy for a snapshot's `path.line` (lineOf). */
|
||||
const lines = new WeakMap();
|
||||
|
||||
/**
|
||||
* A line as a snapshot hands it out: a frozen copy of the layer's path, its
|
||||
* number arrays frozen arrays, built once per line. `pointAtS` and
|
||||
* `projectOnPath` read it as they read the path, and nothing written to it
|
||||
* reaches the layer.
|
||||
*/
|
||||
function lineOf(path) {
|
||||
let line = lines.get(path);
|
||||
if (!line) {
|
||||
const copy = {};
|
||||
for (const [name, value] of Object.entries(path))
|
||||
copy[name] = ArrayBuffer.isView(value)
|
||||
? Object.freeze(Array.from(value))
|
||||
: value;
|
||||
line = Object.freeze(copy);
|
||||
lines.set(path, line);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
const NO_STOPS = Object.freeze([]);
|
||||
/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */
|
||||
const stopLists = new WeakMap();
|
||||
|
||||
/** Whether `copy` still holds each of `list`'s stops, field by field. */
|
||||
function sameStops(copy, list) {
|
||||
if (copy.length !== list.length) return false;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const stop = list[i];
|
||||
const kept = copy[i];
|
||||
if (
|
||||
!stop ||
|
||||
typeof stop !== 'object' ||
|
||||
!kept ||
|
||||
typeof kept !== 'object'
|
||||
) {
|
||||
if (!Object.is(stop, kept)) return false;
|
||||
continue;
|
||||
}
|
||||
const names = Object.keys(stop);
|
||||
if (names.length !== Object.keys(kept).length) return false;
|
||||
for (const name of names)
|
||||
if (!Object.is(stop[name], kept[name])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A trip's stops as a snapshot hands them out: a frozen array of frozen
|
||||
* copies, kept per list the stops provider returns while that list still
|
||||
* holds the same stops, so a provider that updates its list in place is
|
||||
* copied again.
|
||||
*/
|
||||
function stopsOf(list) {
|
||||
if (!Array.isArray(list)) return NO_STOPS;
|
||||
let stops = stopLists.get(list);
|
||||
if (!stops || !sameStops(stops, list)) {
|
||||
stops = Object.freeze(
|
||||
list.map((stop) =>
|
||||
stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop,
|
||||
),
|
||||
);
|
||||
stopLists.set(list, stops);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
/**
|
||||
* `previous` when it holds each of `next`'s values, else a frozen copy of
|
||||
* `next`: a part of a snapshot that did not change is the one read before.
|
||||
* `next` is a draft, filled again on every read.
|
||||
*/
|
||||
function reuse(previous, next) {
|
||||
if (!previous) return Object.freeze({ ...next });
|
||||
for (const name in next)
|
||||
if (!Object.is(previous[name], next[name]))
|
||||
return Object.freeze({ ...next });
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read access to one train, and a hold on it for a first-person view.
|
||||
*
|
||||
* `getSelectedTrain()` and `getCockpitSubject()` describe the selected train
|
||||
* and the held one as a frozen snapshot, read again on each call: where it is
|
||||
* along its line, how fast it moves and how that is known, and its stops. A
|
||||
* call while nothing in it changed returns the snapshot it returned before,
|
||||
* and a part that did not change (its feed, its line, its stops) is the same
|
||||
* object in the next one, so a view can read it on every frame. `path.line`
|
||||
* is a frozen copy of the train's line for `pointAtS` and `projectOnPath`.
|
||||
* `setCockpitSubject(key)` holds a train: its marker, 3D model, card and
|
||||
* selected line are hidden, it stays selected (so its stops stay fresh and its feed stays
|
||||
* polled at any distance) and clicks and Escape no longer change the
|
||||
* selection. A held train keeps moving, hidden, and keeps the selection when
|
||||
* its category is turned off, and is the copy of its train drawn though
|
||||
* another feed's copy would outrank it (dedupe.js). Released, it is a train
|
||||
* like any other: one in a category turned off is deselected and stops, and
|
||||
* a better copy takes over at the next poll. The hold ends with
|
||||
* `setCockpitSubject(null)`, and on its own when the train is removed or the
|
||||
* layer is turned off. A train removed while held leaves its feed polled
|
||||
* until the next `setCockpitSubject` call, so a view that waits for it holds
|
||||
* it again if the feed lists it again.
|
||||
* `focusTrain(key)` selects a train and hands it the camera, as a click does.
|
||||
* A snapshot's `selectedAtMs` is when the user last selected the selected
|
||||
* train (a click, or `focusTrain`), even again, for a view's entry to tell a
|
||||
* re-selection. It is null for any other train, after a deselection, and for
|
||||
* a train selected only by a hold: a hold is not a user's selection.
|
||||
*
|
||||
* @param {object} context The layer's state, parts, services and timetable.
|
||||
* @returns {{methods: object}}
|
||||
*/
|
||||
export function createRailCockpitSubject({
|
||||
state,
|
||||
parts,
|
||||
services,
|
||||
timetable,
|
||||
}) {
|
||||
const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M)
|
||||
? services.ground.GROUND_FLOOR_LIFT_M
|
||||
: 0;
|
||||
const finiteOrNull = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
const scratch = {};
|
||||
/** Each train's last snapshot, returned again while nothing in it changed. */
|
||||
const views = new WeakMap();
|
||||
/** The drafts trainView fills on every read (reuse). */
|
||||
const draft = {
|
||||
view: {},
|
||||
feed: {},
|
||||
position: {},
|
||||
path: {},
|
||||
motion: {},
|
||||
provenance: {},
|
||||
};
|
||||
|
||||
/** The catalog's public entry for a feed, else the registry's. */
|
||||
function feedOf(feedId, previous) {
|
||||
const listed = state._catalog?.find((feed) => feed.id === feedId);
|
||||
const feed = listed ?? getRegisteredRailFeed(feedId);
|
||||
if (!feed) return null;
|
||||
const next = draft.feed;
|
||||
next.id = feed.id;
|
||||
next.name = feed.name ?? null;
|
||||
next.operator = feed.operator ?? null;
|
||||
next.region = feed.region ?? null;
|
||||
next.positions = listed?.positions ?? feed.positions ?? 'gps';
|
||||
next.tripUpdates =
|
||||
typeof listed?.tripUpdates === 'boolean'
|
||||
? listed.tripUpdates
|
||||
: typeof feed.tripUpdatesUrl === 'string';
|
||||
next.attribution = feed.attribution ?? null;
|
||||
next.license = feed.license ?? null;
|
||||
next.licenseUrl = feed.licenseUrl ?? null;
|
||||
return reuse(previous, next);
|
||||
}
|
||||
|
||||
/** The route's short (else long) name in the feed's timetable, or null. */
|
||||
function routeNameOf(entry) {
|
||||
const routeId = entry.record?.routeId;
|
||||
if (!routeId) return null;
|
||||
const schedule = timetable?.scheduleFor?.(entry.feedId);
|
||||
const route = schedule?.routes?.[routeId];
|
||||
const name = route?.short || route?.long;
|
||||
return typeof name === 'string' && name ? name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which way the train runs along its shape: as its binding learnt it from
|
||||
* successive reports, else from its drawn course against the line's
|
||||
* bearing there; 0 while neither tells.
|
||||
*/
|
||||
function directionOf(entry) {
|
||||
const binding = entry.path;
|
||||
if (binding.direction === 1 || binding.direction === -1)
|
||||
return binding.direction;
|
||||
const course = finiteOrNull(entry.courseDeg);
|
||||
const point = pointAtS(binding.path, binding.drawS, scratch);
|
||||
if (course === null || !Number.isFinite(point?.bearingDeg)) return 0;
|
||||
const turn = Math.abs(((course - point.bearingDeg + 540) % 360) - 180);
|
||||
return turn > 90 ? -1 : 1;
|
||||
}
|
||||
|
||||
/** The train as a frozen snapshot (see the module comment), or null. */
|
||||
function trainView(entry) {
|
||||
if (!entry?.record) return null;
|
||||
const previous = views.get(entry);
|
||||
const record = entry.record;
|
||||
const sample = entry.sample ?? null;
|
||||
const drawn = entry.drawnHeight ?? null;
|
||||
const binding = entry.path ?? null;
|
||||
const reported = finiteOrNull(record.speedMps);
|
||||
const derived = finiteOrNull(sample?.segmentSpeedMps);
|
||||
const trip = state._selectedKey === entry.key ? state._trip : null;
|
||||
const status = state._feedStatus.get(entry.feedId);
|
||||
|
||||
const position = draft.position;
|
||||
position.latitude =
|
||||
finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat;
|
||||
position.longitude =
|
||||
finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon;
|
||||
position.railHeightM = Number.isFinite(drawn?.h) ? drawn.h - liftM : null;
|
||||
position.heightQuality = Number.isFinite(drawn?.q) ? drawn.q : 0;
|
||||
position.atMs = finiteOrNull(drawn?.at);
|
||||
|
||||
let path = null;
|
||||
if (binding && binding.path) {
|
||||
path = draft.path;
|
||||
path.shapeIndex = binding.shapeIndex;
|
||||
path.s = binding.drawS;
|
||||
path.direction = directionOf(entry);
|
||||
path.lengthM = binding.path.length;
|
||||
path.via = binding.via;
|
||||
path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null;
|
||||
path.line = lineOf(binding.path);
|
||||
}
|
||||
|
||||
const motion = draft.motion;
|
||||
motion.reportedSpeedMps = reported;
|
||||
motion.derivedSpeedMps = derived;
|
||||
motion.speedSource =
|
||||
reported !== null ? 'reported' : derived !== null ? 'derived' : null;
|
||||
motion.courseDeg = finiteOrNull(entry.courseDeg);
|
||||
motion.phase = sample?.phase ?? null;
|
||||
motion.word = displayMotion(entry, Date.now()).word;
|
||||
motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs);
|
||||
motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs);
|
||||
motion.feedStale = status?.stale === true;
|
||||
|
||||
const provenance = draft.provenance;
|
||||
provenance.positionSource = record.positionSource ?? 'gps';
|
||||
provenance.timestampSource = record.timestampSource ?? null;
|
||||
|
||||
const view = draft.view;
|
||||
view.key = entry.key;
|
||||
view.feedId = entry.feedId;
|
||||
view.selectedAtMs =
|
||||
state._selectedKey === entry.key ? state._selectedAtMs : null;
|
||||
view.id = String(record.id ?? '');
|
||||
view.tripId = record.tripId || null;
|
||||
view.routeId = record.routeId || null;
|
||||
view.routeName = routeNameOf(entry);
|
||||
view.label = record.label || null;
|
||||
view.trainNumber = record.trainNumber || null;
|
||||
view.headsign = trip?.headsign || null;
|
||||
view.category = entry.category ?? null;
|
||||
view.mode = entry.railMode ?? null;
|
||||
view.feed = feedOf(entry.feedId, previous?.feed);
|
||||
view.position = reuse(previous?.position, position);
|
||||
view.path = path && reuse(previous?.path, path);
|
||||
view.motion = reuse(previous?.motion, motion);
|
||||
view.provenance = reuse(previous?.provenance, provenance);
|
||||
view.stops = stopsOf(trip?.stops);
|
||||
view.match = trip?.match ?? null;
|
||||
const snapshot = reuse(previous, view);
|
||||
views.set(entry, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** A train's marker and playback again, after a hold on it began or ended. */
|
||||
function replay(entry) {
|
||||
parts.rendering.paint(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
parts.rendering.syncRenderHold();
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** The selected train (see createRailCockpitSubject), or null. */
|
||||
getSelectedTrain() {
|
||||
return trainView(state._trains.get(state._selectedKey));
|
||||
},
|
||||
/** The held train, or null when none is held. */
|
||||
getCockpitSubject() {
|
||||
return trainView(state._trains.get(state._cockpitSubjectKey));
|
||||
},
|
||||
/**
|
||||
* Hold the train `key` for a first-person view, or release the hold with
|
||||
* null. A train it holds is selected, if it was not already.
|
||||
* @param {string|null} key
|
||||
* @returns {boolean} False for a key the layer does not list. A listed
|
||||
* train is held even when its category is off or another feed's copy
|
||||
* is drawn: the held copy is then the one drawn. Releasing returns true.
|
||||
*/
|
||||
setCockpitSubject(key) {
|
||||
const previous = state._trains.get(state._cockpitSubjectKey);
|
||||
if (key === null || key === undefined) {
|
||||
state._cockpitSubjectKey = null;
|
||||
state._cockpitLostFeedId = null;
|
||||
if (previous) {
|
||||
// Its category turned off meanwhile: deselected, as the toggle
|
||||
// does to any other train (controls.js).
|
||||
if (
|
||||
previous.key === state._selectedKey &&
|
||||
state._categories[previous.category] === false
|
||||
)
|
||||
parts.selection.clearSelection();
|
||||
replay(previous);
|
||||
}
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
parts.routes.applyHold?.();
|
||||
return true;
|
||||
}
|
||||
const entry = state._trains.get(key);
|
||||
if (!entry) return false;
|
||||
if (state._selectedKey !== key) {
|
||||
parts.selection.selectTrain(key);
|
||||
// A hold is not a selection the user made (selectedAtMs).
|
||||
state._selectedAtMs = null;
|
||||
}
|
||||
state._cockpitSubjectKey = key;
|
||||
state._cockpitLostFeedId = null;
|
||||
// Another feed's copy drew it: the held copy is drawn now (dedupe.js).
|
||||
if (entry.duplicate === true) parts.ingestion.duplicates?.refresh();
|
||||
if (previous && previous !== entry) replay(previous);
|
||||
replay(entry);
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
parts.routes.applyHold?.();
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Select the train `key` and hand it the camera, as a click on it does.
|
||||
* @param {string} key
|
||||
* @returns {boolean} False for a train the layer does not show: gone, in
|
||||
* a category turned off, or a copy another feed draws (dedupe.js).
|
||||
*/
|
||||
focusTrain(key) {
|
||||
const entry = state._trains.get(key);
|
||||
if (!entry || !parts.rendering.categoryShown(entry)) return false;
|
||||
parts.selection.selectAndFocusTrain(key);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { methods, trainView };
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
// The Rail layer's read API for a first-person view: the selected or held
|
||||
// train as a snapshot (where it is along its line and which way it runs, its
|
||||
// speed and how that is known, its feed's credit, its stops), and a hold that
|
||||
// hides its marker and card, keeps it selected and ends when it goes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RAIL_POLL_MS,
|
||||
STOP_REFRESH_MS,
|
||||
railTrainFollowable,
|
||||
railTrainKey,
|
||||
} from './policy.js';
|
||||
import {
|
||||
BOSTON,
|
||||
railLayerHarness as harness,
|
||||
railTrain as train,
|
||||
} from '../../testSupport/railLayerHarness.mjs';
|
||||
import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
|
||||
import { publicRailCatalog } from '../../data/railFeeds.js';
|
||||
import { pointAtS } from '../../data/railPaths.js';
|
||||
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
|
||||
|
||||
const M_PER_DEG = 111_320;
|
||||
const LAT = 42.36;
|
||||
const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180);
|
||||
const LON0 = -71.08;
|
||||
|
||||
/** A flat [lat, lon, ...] line due east from LON0, one vertex per 100 m. */
|
||||
function eastLine(lengthM) {
|
||||
const out = [];
|
||||
for (let d = 0; d <= lengthM; d += 100) out.push(LAT, LON0 + d / KX);
|
||||
return out;
|
||||
}
|
||||
|
||||
const PACK = Object.freeze({
|
||||
version: 1,
|
||||
feedId: 'mbta-rail',
|
||||
builtAt: '2026-09-09T00:00:00.000Z',
|
||||
shapes: [eastLine(6000)],
|
||||
dist: [[]],
|
||||
shapeSources: ['operator'],
|
||||
trips: { 'trip-T1': 0, 'trip-T2': 0 },
|
||||
routes: { R1: [0] },
|
||||
});
|
||||
|
||||
/** Two trains on the line, T1 running east and T2 west, a step a poll. */
|
||||
async function onTheLine(t, options = {}) {
|
||||
const app = harness(t, {
|
||||
paths: { 'mbta-rail': PACK },
|
||||
at: BOSTON,
|
||||
...options,
|
||||
});
|
||||
let step = 0;
|
||||
app.feeds.set('mbta-rail', () => [
|
||||
train('T1', LAT, LON0 + (1000 + step * 150) / KX, { speedMps: 15 }),
|
||||
train('T2', LAT, LON0 + (5000 - step * 150) / KX),
|
||||
]);
|
||||
await app.enable();
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
const T1 = railTrainKey('mbta-rail', 'T1');
|
||||
const T2 = railTrainKey('mbta-rail', 'T2');
|
||||
|
||||
test('the selected train reads as a snapshot of its place, motion and sources', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
assert.equal(app.layer.getSelectedTrain(), null, 'nothing selected');
|
||||
app.parts().selection.selectTrain(T1);
|
||||
const view = app.layer.getSelectedTrain();
|
||||
assert.ok(Object.isFrozen(view));
|
||||
assert.equal(view.key, T1);
|
||||
assert.equal(view.feedId, 'mbta-rail');
|
||||
assert.equal(view.id, 'T1');
|
||||
assert.equal(view.tripId, 'trip-T1');
|
||||
assert.equal(view.routeId, 'R1');
|
||||
assert.equal(view.category, 'regional');
|
||||
assert.equal(view.mode, 'rail');
|
||||
const listed = publicRailCatalog().find((feed) => feed.id === 'mbta-rail');
|
||||
assert.equal(view.feed.attribution, listed.attribution);
|
||||
assert.equal(view.feed.license, listed.license);
|
||||
assert.equal(view.feed.licenseUrl, listed.licenseUrl);
|
||||
assert.equal(view.feed.positions, 'gps');
|
||||
assert.equal(view.path.shapeIndex, 0);
|
||||
assert.equal(view.path.builtAt, PACK.builtAt);
|
||||
assert.equal(view.path.via, 'trip');
|
||||
assert.ok(Math.abs(view.path.lengthM - 6000) < 1);
|
||||
assert.ok(view.path.s > 1000 && view.path.s < 2000, `s ${view.path.s}`);
|
||||
assert.equal(view.path.direction, 1, 'runs along the shape');
|
||||
assert.equal(typeof view.path.line.length, 'number');
|
||||
assert.equal(view.motion.reportedSpeedMps, 15);
|
||||
assert.equal(view.motion.speedSource, 'reported');
|
||||
assert.equal(view.provenance.positionSource, 'gps');
|
||||
assert.equal(view.provenance.timestampSource, 'vehicle');
|
||||
const drawn = app.entry(T1).drawnHeight;
|
||||
assert.equal(view.position.railHeightM, drawn.h - 1.5);
|
||||
assert.ok(Number.isFinite(view.position.latitude));
|
||||
assert.ok(Array.isArray(view.stops));
|
||||
|
||||
app.parts().selection.selectTrain(T2);
|
||||
const west = app.layer.getSelectedTrain();
|
||||
assert.equal(west.path.direction, -1, 'runs against the shape');
|
||||
assert.equal(west.motion.reportedSpeedMps, null);
|
||||
assert.equal(
|
||||
west.motion.speedSource,
|
||||
west.motion.derivedSpeedMps === null ? null : 'derived',
|
||||
);
|
||||
});
|
||||
|
||||
/** A trip's stops as a stops provider lists them. */
|
||||
const stopList = (...names) =>
|
||||
names.map((name, i) => ({
|
||||
stopId: name,
|
||||
name,
|
||||
lat: LAT,
|
||||
lon: LON0 + (i * 2000) / KX,
|
||||
state: 'upcoming',
|
||||
}));
|
||||
|
||||
test('a snapshot read again while nothing changed is the one read before', async (t) => {
|
||||
let list = stopList('Alpha', 'Bravo', 'Charlie');
|
||||
const app = await onTheLine(t, {
|
||||
stops: { stopsFor: () => ({ stops: list, headsign: 'Charlie' }) },
|
||||
});
|
||||
app.parts().selection.selectTrain(T1);
|
||||
const first = app.layer.getSelectedTrain();
|
||||
assert.equal(app.layer.getSelectedTrain(), first, 'the same snapshot');
|
||||
// Its stops are frozen copies of the provider's.
|
||||
assert.equal(first.stops.length, 3);
|
||||
assert.ok(Object.isFrozen(first.stops));
|
||||
assert.ok(Object.isFrozen(first.stops[0]));
|
||||
assert.notEqual(first.stops[0], list[0]);
|
||||
assert.equal(first.stops[0].name, 'Alpha');
|
||||
|
||||
// It moves on: a new snapshot at its new place, what did not change kept.
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
app.run(RAIL_POLL_MS);
|
||||
const moved = app.layer.getSelectedTrain();
|
||||
assert.notEqual(moved, first, 'a new snapshot');
|
||||
assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.path));
|
||||
assert.ok(moved.path.s > first.path.s, 'at its new place');
|
||||
assert.equal(moved.stops, first.stops, 'the same stops');
|
||||
assert.equal(moved.feed, first.feed);
|
||||
assert.equal(moved.provenance, first.provenance);
|
||||
assert.equal(moved.path.line, first.path.line);
|
||||
assert.equal(app.layer.getSelectedTrain(), moved);
|
||||
|
||||
// The provider lists other stops: new ones.
|
||||
list = stopList('Delta', 'Echo');
|
||||
app.run(STOP_REFRESH_MS);
|
||||
const later = app.layer.getSelectedTrain();
|
||||
assert.notEqual(later.stops, first.stops);
|
||||
assert.deepEqual(
|
||||
later.stops.map((stop) => stop.name),
|
||||
['Delta', 'Echo'],
|
||||
);
|
||||
// A provider that updates its list in place: the stops are copied again.
|
||||
list[0].state = 'passed';
|
||||
app.run(STOP_REFRESH_MS);
|
||||
const updated = app.layer.getSelectedTrain();
|
||||
assert.notEqual(updated.stops, later.stops);
|
||||
assert.equal(updated.stops[0].state, 'passed');
|
||||
assert.equal(app.layer.getSelectedTrain().stops, updated.stops);
|
||||
});
|
||||
|
||||
test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.parts().selection.selectTrain(T1);
|
||||
const { line, s } = app.layer.getSelectedTrain().path;
|
||||
const own = app.entry(T1).path.path;
|
||||
assert.notEqual(line, own, 'not the layer’s own');
|
||||
assert.ok(Object.isFrozen(line));
|
||||
for (const name of ['lat', 'lon', 'x', 'y', 's', 'box'])
|
||||
assert.ok(Object.isFrozen(line[name]), name);
|
||||
const lat = own.lat[1];
|
||||
assert.throws(() => {
|
||||
line.lat[1] = 0;
|
||||
}, TypeError);
|
||||
assert.equal(own.lat[1], lat, 'the layer’s line is as it was');
|
||||
assert.deepEqual(pointAtS(line, s), pointAtS(own, s), 'it reads the same');
|
||||
// Copied once per line: T2 runs on the same one.
|
||||
app.parts().selection.selectTrain(T2);
|
||||
assert.equal(app.layer.getSelectedTrain().path.line, line);
|
||||
});
|
||||
|
||||
test('a held train hides its marker and card and keeps the selection', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
assert.equal(app.layer.setCockpitSubject('rail:mbta-rail:nope'), false);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
assert.equal(app.state()._selectedKey, T1, 'holding selects it');
|
||||
assert.equal(app.layer.getCockpitSubject().key, T1);
|
||||
app.run(1000);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
assert.equal(app.entry(T2).marker.show, true);
|
||||
// From inside the train its selected line would run under the eye.
|
||||
assert.equal(app.state()._route.primitive.show, false, 'no line');
|
||||
assert.deepEqual(
|
||||
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
|
||||
[],
|
||||
'no card',
|
||||
);
|
||||
// Escape is the view's while it holds the train.
|
||||
globalThis.document.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, T1);
|
||||
// It still moves.
|
||||
const before = app.layer.getCockpitSubject().path.s;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
app.run(RAIL_POLL_MS);
|
||||
assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving');
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
app.run(1000);
|
||||
assert.equal(app.entry(T1).marker.show, true);
|
||||
assert.equal(app.state()._route.primitive.show, true, 'the line is back');
|
||||
assert.equal(
|
||||
(app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length,
|
||||
1,
|
||||
'the card is back',
|
||||
);
|
||||
});
|
||||
|
||||
test('a held train draws no 3D model either; let go, its model draws again', async (t) => {
|
||||
const models = await fleetModelFakes(t);
|
||||
const app = await onTheLine(t, { fleet: models.fleet, altitude: 1_000 });
|
||||
// A model's load answers off the frame; the next frames place it.
|
||||
const frames = async () => {
|
||||
app.run(500);
|
||||
await app.settle();
|
||||
app.run(500);
|
||||
};
|
||||
await frames();
|
||||
assert.equal(models.drawn(T1).length, 1, 'near the camera: its model');
|
||||
assert.equal(app.entry(T1).modelOwnsVisual, true);
|
||||
assert.equal(app.entry(T1).marker.show, false, 'its icon hands over');
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
app.run(250);
|
||||
// The view's eye is inside the train.
|
||||
assert.equal(models.drawn(T1).length, 0, 'no model');
|
||||
assert.equal(app.entry(T1).modelOwnsVisual, false);
|
||||
assert.equal(app.entry(T1).marker.show, false, 'and no icon');
|
||||
assert.equal(models.drawn(T2).length, 1, 'another train keeps its model');
|
||||
await frames();
|
||||
assert.equal(models.drawn(T1).length, 0, 'not on a later frame either');
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
await frames();
|
||||
assert.equal(models.drawn(T1).length, 1, 'let go: its model again');
|
||||
assert.equal(app.entry(T1).modelOwnsVisual, true);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
});
|
||||
|
||||
const MADRID = { lat: 40.4168, lon: -3.7038 };
|
||||
|
||||
test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => {
|
||||
const app = harness(t, { at: MADRID });
|
||||
// One Alvia in two feeds: a station-event copy, held, and a poll later a
|
||||
// GPS copy that would outrank it.
|
||||
let step = 0;
|
||||
let gpsCopy = false;
|
||||
const lat = (offset) => MADRID.lat + offset + step * 0.003;
|
||||
app.feeds.set('renfe-cercanias', () => [
|
||||
train('A1', lat(0), MADRID.lon, {
|
||||
label: 'ALVIA 4073',
|
||||
positionSource: 'event',
|
||||
}),
|
||||
]);
|
||||
app.feeds.set('renfe-larga-distancia', () =>
|
||||
gpsCopy ? [train('B1', lat(0.01), MADRID.lon, { label: '4073' })] : [],
|
||||
);
|
||||
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)), 'the train is moving');
|
||||
assert.equal(app.layer.setCockpitSubject(a), true);
|
||||
|
||||
gpsCopy = true;
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
// The view knows the train by its key, and the copies stand apart: the
|
||||
// held copy stays drawn and selected, the other is the one hidden.
|
||||
assert.equal(app.layer.getCockpitSubject()?.key, a);
|
||||
assert.equal(app.state()._selectedKey, a);
|
||||
assert.equal(app.entry(a).duplicate, false);
|
||||
assert.equal(app.entry(b).duplicate, true);
|
||||
assert.equal(app.entry(a).marker.show, false);
|
||||
assert.equal(app.entry(b).marker.show, false, 'no other marker on it');
|
||||
assert.deepEqual(
|
||||
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
|
||||
[],
|
||||
'no card',
|
||||
);
|
||||
// It moves between polls, frame by frame.
|
||||
assert.ok(app.state()._moving.has(app.entry(a)), 'still playing');
|
||||
const before = app.layer.getCockpitSubject().position.latitude;
|
||||
app.run(2000);
|
||||
assert.ok(
|
||||
app.layer.getCockpitSubject().position.latitude > before,
|
||||
'still moving',
|
||||
);
|
||||
|
||||
// Let go: the better copy takes over at the next poll, with the selection.
|
||||
app.layer.setCockpitSubject(null);
|
||||
step += 1;
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
assert.equal(app.entry(a).duplicate, true);
|
||||
assert.equal(app.entry(b).duplicate, false);
|
||||
assert.equal(app.state()._selectedKey, b);
|
||||
assert.equal(app.entry(b).marker.show, true);
|
||||
assert.equal(
|
||||
(app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length,
|
||||
1,
|
||||
'its card',
|
||||
);
|
||||
// The copy hidden is not focused: the selection stays on the one drawn.
|
||||
assert.equal(app.layer.focusTrain(a), false);
|
||||
assert.equal(app.state()._selectedKey, b);
|
||||
|
||||
// A hold of the copy hidden draws it at once, in place of the other.
|
||||
assert.equal(app.layer.setCockpitSubject(a), true);
|
||||
assert.equal(app.state()._selectedKey, a);
|
||||
assert.equal(app.entry(a).duplicate, false);
|
||||
assert.equal(app.entry(b).duplicate, true);
|
||||
assert.equal(app.entry(b).marker.show, false);
|
||||
assert.ok(app.state()._moving.has(app.entry(a)), 'playing');
|
||||
});
|
||||
|
||||
test('a held train keeps the selection and moves, hidden, when its category is turned off', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
app.run(1000);
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing');
|
||||
app.layer.setParams({ regional: false });
|
||||
assert.equal(app.state()._selectedKey, T1, 'still selected');
|
||||
assert.equal(app.layer.getCockpitSubject()?.key, T1);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
assert.equal(app.entry(T2).marker.show, false, 'its category is hidden');
|
||||
assert.equal(app.state()._route.primitive.show, false, 'no line');
|
||||
assert.deepEqual(
|
||||
app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [],
|
||||
[],
|
||||
'no card',
|
||||
);
|
||||
// It moves between polls, frame by frame.
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'still playing');
|
||||
const before = app.layer.getCockpitSubject().path.s;
|
||||
app.run(2000);
|
||||
assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving');
|
||||
assert.equal(app.entry(T1).marker.show, false, 'and still hidden');
|
||||
|
||||
// Let go: a train of a hidden category, deselected and stopped as the
|
||||
// toggle would have left it.
|
||||
app.layer.setCockpitSubject(null);
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.equal(app.state()._moving.has(app.entry(T1)), false);
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
const parked = app.entry(T1).path.drawS;
|
||||
app.run(2000);
|
||||
assert.equal(app.entry(T1).path.drawS, parked, 'stopped');
|
||||
// Held again, hidden, it plays at once; let go, it stops again.
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing at once');
|
||||
app.layer.setCockpitSubject(null);
|
||||
assert.equal(app.state()._moving.has(app.entry(T1)), false);
|
||||
// Shown again, it is drawn and moves as any other.
|
||||
app.layer.setParams({ regional: true });
|
||||
assert.equal(app.entry(T1).marker.show, true);
|
||||
assert.ok(app.state()._moving.has(app.entry(T1)), 'playing again');
|
||||
});
|
||||
|
||||
test('a ride let go with restore does not focus a train its category hides', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
// The documented provider: hold on entry; on exit, release, then focus
|
||||
// the train again when asked to restore.
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
app.layer.setParams({ regional: false });
|
||||
app.layer.setCockpitSubject(null);
|
||||
assert.equal(app.layer.focusTrain(T1), false, 'not drawn');
|
||||
assert.equal(app.state()._selectedKey, null, 'not selected');
|
||||
assert.equal(app.parts().follow.stats(), null, 'not followed');
|
||||
assert.equal(app.viewer.trackedEntity, undefined);
|
||||
assert.equal(app.focusRequests.length, 0, 'not flown to');
|
||||
assert.equal(app.entry(T1).marker.show, false);
|
||||
|
||||
// Shown again, it focuses as before.
|
||||
app.layer.setParams({ regional: true });
|
||||
assert.equal(app.layer.focusTrain(T1), true);
|
||||
assert.equal(app.state()._selectedKey, T1);
|
||||
assert.ok(app.parts().follow.isFollowing(T1), 'the camera is handed to it');
|
||||
});
|
||||
|
||||
test('a held train that goes takes the hold with it; focusTrain selects', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
app.feeds.set('mbta-rail', () => [train('T2', LAT, LON0 + 4000 / KX)]);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
assert.equal(app.entry(T1), undefined, 'the train is gone');
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
assert.equal(app.state()._cockpitSubjectKey, null);
|
||||
assert.equal(app.layer.focusTrain(T1), false);
|
||||
assert.equal(app.layer.focusTrain(T2), true);
|
||||
assert.equal(app.state()._selectedKey, T2);
|
||||
});
|
||||
|
||||
test('a held train its feed drops leaves the feed polled until the view lets go', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
// From the cab the camera looks along the ground, here past the feed's range.
|
||||
await app.moveCamera({ lat: 40.71, lon: -74.0 });
|
||||
assert.equal(app.state()._heldFeedId, 'mbta-rail', 'held for the train');
|
||||
const others = () => [train('T2', LAT, LON0 + 4000 / KX)];
|
||||
app.feeds.set('mbta-rail', others);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
}
|
||||
await app.recheck();
|
||||
assert.equal(app.entry(T1), undefined, 'the train is gone');
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'its feed is polled');
|
||||
await app.moveCamera({ lat: 40.72, lon: -74.0 });
|
||||
assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'whatever the camera');
|
||||
|
||||
// Listed again: the view holds it again.
|
||||
app.feeds.set('mbta-rail', () => [
|
||||
train('T1', LAT, LON0 + 2500 / KX, { speedMps: 15 }),
|
||||
...others(),
|
||||
]);
|
||||
app.run(RAIL_POLL_MS);
|
||||
await app.poll();
|
||||
assert.equal(app.layer.setCockpitSubject(T1), true);
|
||||
assert.equal(app.state()._cockpitLostFeedId, null);
|
||||
assert.equal(app.state()._selectedKey, T1);
|
||||
|
||||
// Let go, and the feed is the viewport's again: out of range, released.
|
||||
app.parts().ingestion.removeTrain(T1);
|
||||
assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail');
|
||||
app.layer.setCockpitSubject(null);
|
||||
await app.moveCamera({ lat: 40.71, lon: -74.0 });
|
||||
assert.equal(app.state()._activeFeeds.has('mbta-rail'), false);
|
||||
assert.equal(app.state()._trains.size, 0);
|
||||
});
|
||||
|
||||
test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
assert.equal(app.layer.focusTrain(T1), true);
|
||||
const first = app.layer.getSelectedTrain().selectedAtMs;
|
||||
assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it');
|
||||
// Selected again: a later stamp.
|
||||
app.run(1000);
|
||||
app.layer.focusTrain(T1);
|
||||
assert.ok(app.layer.getSelectedTrain().selectedAtMs > first);
|
||||
// A hold of another train is not the user's selection.
|
||||
app.layer.setCockpitSubject(T2);
|
||||
assert.equal(app.layer.getCockpitSubject().key, T2);
|
||||
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
|
||||
app.layer.setCockpitSubject(null);
|
||||
// Nor after a deselection (Escape), then a hold.
|
||||
app.layer.focusTrain(T1);
|
||||
globalThis.document.dispatchEvent(
|
||||
Object.assign(new Event('keydown'), { key: 'Escape' }),
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, null);
|
||||
assert.equal(app.state()._selectedAtMs, null, 'deselection clears it');
|
||||
app.layer.setCockpitSubject(T2);
|
||||
assert.equal(app.layer.getCockpitSubject().selectedAtMs, null);
|
||||
// Releasing twice is harmless: the selection stays.
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.state()._selectedKey, T2);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
});
|
||||
|
||||
test('turning the layer off ends the hold', async (t) => {
|
||||
const app = await onTheLine(t);
|
||||
app.layer.setCockpitSubject(T1);
|
||||
app.parts().ingestion.removeTrain(T1);
|
||||
assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail');
|
||||
app.layer.disable(app.viewer);
|
||||
assert.equal(app.state()._cockpitSubjectKey, null);
|
||||
assert.equal(app.state()._cockpitLostFeedId, null);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
});
|
||||
Vendored
+1
-7
@@ -91,13 +91,7 @@ export function createControls({ state, parts, timetable }) {
|
||||
return true;
|
||||
state._categories = next;
|
||||
const selected = state._trains.get(state._selectedKey);
|
||||
// A held train keeps the selection until its view lets it go
|
||||
// (cockpitSubject.js).
|
||||
if (
|
||||
selected &&
|
||||
next[selected.category] === false &&
|
||||
selected.key !== state._cockpitSubjectKey
|
||||
)
|
||||
if (selected && next[selected.category] === false)
|
||||
parts.selection.clearSelection();
|
||||
parts.rendering.applyCategoryVisibility();
|
||||
state._revision += 1;
|
||||
|
||||
@@ -25,13 +25,9 @@ import { greatCircleKm } from '../../geoDistance.js';
|
||||
* 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 copy a first-person view holds
|
||||
* (cockpitSubject.js) stays drawn whatever the scores until the view lets
|
||||
* go: the view knows the train by that copy's key and rides it where that
|
||||
* copy's feed puts it, tens of kilometres from another feed's copy at
|
||||
* times. 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 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -292,12 +288,10 @@ function better(a, b) {
|
||||
* scores strictly higher.
|
||||
* @param {object[][]} groups From matchCopies.
|
||||
* @param {Map<string, string>} [previous] copy key → key drawn last time.
|
||||
* @param {string|null} [held] A copy drawn whatever the scores: the train a
|
||||
* first-person view holds.
|
||||
* @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(), held = null) {
|
||||
export function chooseCopies(groups, previous = new Map()) {
|
||||
const winners = new Map();
|
||||
const hidden = new Set();
|
||||
for (const group of groups) {
|
||||
@@ -311,7 +305,6 @@ export function chooseCopies(groups, previous = new Map(), held = null) {
|
||||
scored.reduce((a, b) => (better(b.score, a.score) ? b : a));
|
||||
for (const candidate of scored)
|
||||
if (better(candidate.score, best.score)) best = candidate;
|
||||
best = scored.find(({ copy }) => copy.key === held) ?? best;
|
||||
for (const { copy } of scored) {
|
||||
winners.set(copy.key, best.copy.key);
|
||||
if (copy.key !== best.copy.key) hidden.add(copy.key);
|
||||
@@ -325,8 +318,7 @@ export function chooseCopies(groups, previous = new Map(), held = null) {
|
||||
* 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). A held copy is never the one hidden, so the hold and the
|
||||
* selection stay on it.
|
||||
* source).
|
||||
* @param {{state: object, parts: object,
|
||||
* defer?: (fn: Function) => unknown}} context
|
||||
*/
|
||||
@@ -363,7 +355,6 @@ export function createDuplicateFilter({
|
||||
const { winners, hidden } = chooseCopies(
|
||||
matchCopies(entries.map(copyOf)),
|
||||
previous,
|
||||
state._cockpitSubjectKey ?? null,
|
||||
);
|
||||
previous = winners;
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -252,32 +252,6 @@ test('the filter hides the weaker copy and the selection follows its train', ()
|
||||
assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 });
|
||||
});
|
||||
|
||||
test('a copy a first-person view holds stays drawn, with the selection', () => {
|
||||
const pair = [
|
||||
copy('a', 'f1', { number: '160', position: 'event' }),
|
||||
copy('b', 'f2', { number: '160', position: 'gps' }),
|
||||
];
|
||||
assert.equal(chooseCopies([pair]).winners.get('a'), 'b');
|
||||
const held = chooseCopies([pair], new Map(), 'a');
|
||||
assert.equal(held.winners.get('b'), 'a', 'drawn whatever the scores');
|
||||
assert.deepEqual([...held.hidden], ['b']);
|
||||
// Let go, the better copy takes over.
|
||||
assert.equal(chooseCopies([pair], held.winners).winners.get('a'), 'b');
|
||||
|
||||
const layer = fakeLayer({ selected: 'feed-a:v160' });
|
||||
layer.state._cockpitSubjectKey = 'feed-a:v160';
|
||||
const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() });
|
||||
filter.refresh();
|
||||
const [unbound, operator] = layer.trains;
|
||||
assert.equal(unbound.duplicate, false, 'the held copy');
|
||||
assert.equal(operator.duplicate, true);
|
||||
assert.deepEqual(layer.calls.selected, [], 'the selection stays on it');
|
||||
layer.state._cockpitSubjectKey = null;
|
||||
filter.refresh();
|
||||
assert.equal(unbound.duplicate, true);
|
||||
assert.deepEqual(layer.calls.selected, [operator.key]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createIngestion } from './ingestion.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createRailCockpitSubject } from './cockpitSubject.js';
|
||||
import { createRouteDisplay } from './routeDisplay.js';
|
||||
import { createRailSchedules } from './schedules.js';
|
||||
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
@@ -79,24 +78,12 @@ function createState({ services }) {
|
||||
_stopAnchors: new Map(),
|
||||
_categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY },
|
||||
_selectedKey: null,
|
||||
/** When the user last selected a train, even the selected one (ms). */
|
||||
_selectedAtMs: 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,
|
||||
/**
|
||||
* The train a first-person view holds (cockpitSubject.js), or null: its
|
||||
* marker, 3D model and card are hidden and the selection stays on it.
|
||||
*/
|
||||
_cockpitSubjectKey: null,
|
||||
/**
|
||||
* The feed of a held train the feed dropped (ingestion.js), polled until
|
||||
* the view takes a train again or lets go (cockpitSubject.js), or null.
|
||||
*/
|
||||
_cockpitLostFeedId: null,
|
||||
_revision: 0,
|
||||
_controlsListener: null,
|
||||
_dataManager: null,
|
||||
@@ -188,13 +175,11 @@ export function createRailLayer({
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.cockpit = createRailCockpitSubject(context);
|
||||
|
||||
return Object.assign(
|
||||
{ updateInterval: RAIL_POLL_MS },
|
||||
parts.lifecycle.methods,
|
||||
parts.controls.methods,
|
||||
parts.cockpit.methods,
|
||||
{
|
||||
/** Test seams: this instance's own state and parts. */
|
||||
_railStateForTest: () => state,
|
||||
|
||||
@@ -69,13 +69,6 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
function removeTrain(key) {
|
||||
const entry = state._trains.get(key);
|
||||
if (!entry) return;
|
||||
// A first-person view on it loses its subject (cockpitSubject.js); its
|
||||
// feed stays polled until the view lets go, so the train is found again
|
||||
// if the feed lists it again.
|
||||
if (state._cockpitSubjectKey === key) {
|
||||
state._cockpitSubjectKey = null;
|
||||
state._cockpitLostFeedId = entry.feedId;
|
||||
}
|
||||
if (state._selectedKey === key) parts.selection.clearSelection();
|
||||
if (entry.marker) state._markers?.remove(entry.marker);
|
||||
entry.marker = null;
|
||||
|
||||
@@ -126,8 +126,6 @@ export function createLifecycle({ state, services, parts, timetable }) {
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
state._heldFeedId = null;
|
||||
state._cockpitSubjectKey = null;
|
||||
state._cockpitLostFeedId = null;
|
||||
state._feedStatus.clear();
|
||||
dropAllTrains();
|
||||
parts.rendering.stopModels();
|
||||
|
||||
@@ -47,31 +47,11 @@ export function pathWindowM(dtMs) {
|
||||
return window > PATH_WINDOW_MAX_M ? NaN : window;
|
||||
}
|
||||
|
||||
/** Arc moved between two reports before it says which way a train runs (m). */
|
||||
const DIRECTION_MIN_M = 1;
|
||||
|
||||
/** +1 or -1 for an arc step longer than DIRECTION_MIN_M, else `known`. */
|
||||
function directionOf(step, known) {
|
||||
return Number.isFinite(step) && Math.abs(step) > DIRECTION_MIN_M
|
||||
? Math.sign(step)
|
||||
: known;
|
||||
}
|
||||
|
||||
function createBinding(shapeIndex, path, via, s, fix) {
|
||||
return {
|
||||
shapeIndex,
|
||||
path,
|
||||
via,
|
||||
/**
|
||||
* Which way the drawn train runs along the shape: +1 along it, -1
|
||||
* against it, 0 until two reports or a playback segment tell. Kept until
|
||||
* a rebind. Once playback has placed the train, only playback changes
|
||||
* it: the newest reports run ahead of the drawn train, so a reversal
|
||||
* they show turns it when playback reaches it.
|
||||
*/
|
||||
direction: 0,
|
||||
/** The direction the last placed playback segment ran, 0 before one. */
|
||||
drawDirection: 0,
|
||||
/** Arc and time of the newest report. */
|
||||
fixS: s,
|
||||
fixT: fix.t,
|
||||
@@ -159,8 +139,6 @@ export function followFix(binding, fix) {
|
||||
pathWindowM(fix.t - binding.fixT),
|
||||
);
|
||||
if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false;
|
||||
if (!binding.drawDirection)
|
||||
binding.direction = directionOf(hit.s - binding.fixS, binding.direction);
|
||||
binding.fixS = hit.s;
|
||||
binding.fixT = fix.t;
|
||||
binding.fixLat = fix.lat;
|
||||
@@ -252,13 +230,6 @@ export function placeOnPath(binding, sample, segment, out) {
|
||||
cache.sFrom = sFrom;
|
||||
cache.sTo = sTo;
|
||||
cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo);
|
||||
if (cache.ok) {
|
||||
binding.drawDirection = directionOf(
|
||||
sTo - sFrom,
|
||||
binding.drawDirection || binding.direction || 0,
|
||||
);
|
||||
binding.direction = binding.drawDirection;
|
||||
}
|
||||
}
|
||||
if (!cache.ok) return false;
|
||||
const f = Number.isFinite(sample.fraction)
|
||||
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user