mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Compare commits
15
Commits
No files matched your search
+1
-1
@@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900
|
|||||||
AISSTREAM_API_KEY=
|
AISSTREAM_API_KEY=
|
||||||
# Optional AISStream subscription filters.
|
# Optional AISStream subscription filters.
|
||||||
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
||||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
|
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport
|
||||||
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
||||||
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
||||||
# default worldwide subscription — a narrow filter above can be legitimately
|
# default worldwide subscription — a narrow filter above can be legitimately
|
||||||
|
|||||||
+1
-307
@@ -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) |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **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)" |
|
| **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)" |
|
| **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.
|
- **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`.
|
- **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.
|
- **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
|
- **Rail (GTFS-Realtime trains).** Each feed in `src/data/railFeeds.js` records its licence, its credit and the passage of its terms the decision rests on, and a feed is switched on only when those terms were read and cover this use, or, for those that publish no licence at all, on the basis its entry records. Such a basis is never a licence, and the credit does not present it as one: its label says there is no licence ("No licence published", "No licence stated") or names the publication the use rests on. Five enabled feeds publish no licence for their train positions. MARC is used on what its publication says (open publication with a no-endorsement disclaimer), and METRO light rail on the same basis as the Transit layer's Metro Transit feed. Tri-Rail and Charlotte's LYNX are used without a published licence by project owner decision of 2026-11-10. The fifth is Irish Rail, whose train positions publish no licence while its NTA timetable is CC BY 4.0. The registry lists 30 feeds, all switched on, and every one is keyless: its realtime feed and its static timetable are published for anyone to fetch, so no feed needs a key, an account or a registration, and the server holds no rail credential. No rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`). `/api/rail/feeds` lists the offered feeds, `/api/rail/trains/<feedId>` returns one feed's train snapshot, `/api/rail/updates/<feedId>` its trip updates, and `/api/rail/paths/<feedId>` and `/api/rail/schedule/<feedId>` its path and schedule packs, when built. The proxy requests only registered feeds and reuses the Transit proxy's request policy (a short snapshot cache with single-flight refresh, serve-stale, admission limits, the failure cooldown ladder, redirects followed hop by hop only on the feed's own https origin, and an 8 MiB body cap unless a feed sets its own, at most 64 MiB). Many operators publish trains in the same feed as their buses, so each feed has a classifier: the proxy keeps only vehicles it recognises as trains, tags each with a category (`transit`, `local`, `regional`, `national` or `international`) and a mode (`rail`, `metro` or `tram`), and drops everything else server-side, counted in the snapshot as `dropped`; the browser never receives a bus. Every train shown is live, where its operator last reported it: 29 feeds publish live GPS positions (SJ Nord's through SIRI VM), and Irish Rail reports the station or timing point a train last passed. No train is placed or moved from a timetable; a feed whose live positions fail shows as unavailable, and its trains age out as any quiet train does. 22 feeds register trip updates, polled while one of their trains is selected and that feed's schedule pack is loaded. 28 feeds work out of the box. Packs are an opt-in extra for live trains: nothing is downloaded when the app starts, and `node scripts/build-rail-paths.mjs --feed <id>` (repeatable) or `--all` downloads a feed's static GTFS (29 of the 30 have one) and writes a path pack (the operator's track shapes) and a schedule pack (the stop times for a window of days) to `.cache/rail-paths/` (or to `RAIL_PATHS_DIR` when set, the directory the server then serves). With them, a live train is drawn on its operator's route line and its card names its stops, its next stop and, from the trip updates, their delays. Renfe long and medium distance and KTMB, whose vehicles name no route, are classified from their pack's routes, so `/api/rail/feeds` lists them only once that pack is built. Lines are the operator's own shapes as published: nothing is routed or redrawn from other map data, and no line is drawn where an operator publishes no shape. **Share-alike sources bind the deployer.** A pack built from a ShareAlike or ODbL source is a derived database of it, so a deployer who serves it publicly makes it subject to the same licence and must offer it under that licence with the attribution kept. That covers the Montpellier packs (ODbL). For the ODbL datasets of France's national access point, its special conditions also require that "Les bases de données ainsi repartagées doivent être publiées sur le Point d'accès national transport.data.gouv.fr, dans le format d'origine" ([Licence ODbL](https://doc.transport.data.gouv.fr/le-point-d-acces-national/cadre-juridique/conditions-dutilisation-des-donnees/licence-odbl), read 2026-11-10): a deployer who serves the Montpellier packs publicly takes on republishing that derived data there, as GTFS. The build downloads each static GTFS itself, over https and with no credential. A feed's `staticRouteTypes` lists the route types it keeps when its classifier accepts every route of a mode-specific feed. A frequency-based trip becomes one run per departure on its template's stops and run times. Packs are build output, not part of the repository, and cover a fixed window of days, so they are rebuilt before it ends, anLine truncated
|
||||||
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
- **Radio 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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Fix the page freezing for seconds at a time with Transit on over a terrain
|
||||||
|
globe, most visibly while zooming over a large fleet. Every reread asked the
|
||||||
|
terrain for the height of every trail fix of every vehicle in view; heights
|
||||||
|
are now kept per point, read again only while the camera moves or tiles
|
||||||
|
load, and read within a time budget that puts vehicle positions first.
|
||||||
|
Switching the terrain provider or globe starts the kept heights afresh
|
||||||
|
(mazy1998, review by kvnloo, #1688).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Read terrain heights through one shared, cached and budgeted reader
|
||||||
|
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
|
||||||
|
data layers. Opening Data Centers no longer blocks the page for over two
|
||||||
|
seconds, opening Bike Share for most of a second, and Rail asks the terrain
|
||||||
|
about a twentieth as often (mazy1998, #1689).
|
||||||
@@ -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,11 @@
|
|||||||
|
- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON,
|
||||||
|
light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx)
|
||||||
|
is now the fleet set's buoy of its IALA mark (cardinal, port or starboard
|
||||||
|
hand, safe water, isolated danger or special mark), standing still with no
|
||||||
|
heading, and its icon is a disc rather than a chevron. Its card and readout
|
||||||
|
show its kind of mark with no speed or heading. AISStream now subscribes to
|
||||||
|
aid-to-navigation reports and names each by its kind of mark and its full
|
||||||
|
name; the feed lists every ship ahead of any aid, and `vessels_in_area`
|
||||||
|
counts ships only. A virtual aid (a mark broadcast where nothing physical
|
||||||
|
stands) keeps its disc, is never drawn as a buoy, and its card says it is
|
||||||
|
virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733).
|
||||||
+34
-1
@@ -5960,6 +5960,39 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
|
|||||||
settings and screen position rather than observed brightness. Selected-trail
|
settings and screen position rather than observed brightness. Selected-trail
|
||||||
diagnostics report every condition even when selection fails.
|
diagnostics report every condition even when selection fails.
|
||||||
|
|
||||||
|
Heights of the terrain globe's loaded surface come from one shared reader,
|
||||||
|
`src/services/terrainSurface.js`, which the ground service hands to every layer
|
||||||
|
(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
|
||||||
|
drawn tile's mesh, so the reader keeps each answer per point, about a metre
|
||||||
|
apart. While the camera moves or tiles load a kept height is read again after
|
||||||
|
2 s; a height read once both have been still for 5 s is kept until either
|
||||||
|
changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
|
||||||
|
of it for re-reads, longest-kept first. A request ranks as a marker (always
|
||||||
|
read), drawn geometry, or background (a quarter of each budget). Past the
|
||||||
|
budget a kept point keeps its last height and a new point takes the last
|
||||||
|
height read in its ~111 m floor cell or waits.
|
||||||
|
|
||||||
|
- Transit asks for every vehicle and trail fix: vehicle positions as markers,
|
||||||
|
the path a marker is drawn along next, the undrawn trail history of
|
||||||
|
unselected vehicles as background, and its reread cycle continues until no
|
||||||
|
point waits. Before this, every reread cast a ray for every trail fix of
|
||||||
|
every vehicle in view, which blocked the page for up to 19 s at a time over
|
||||||
|
a 650-vehicle fleet while zooming.
|
||||||
|
- Rail asks for each train every frame on a grid about 4 m apart, as a marker,
|
||||||
|
so a moving train reads a few times a second and a standing one not at all.
|
||||||
|
- Local data layers (Data Centers, Dams) read terrain floors through it, and
|
||||||
|
their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a
|
||||||
|
frame per layer and continue on the next frames.
|
||||||
|
- Bike Share draws a city's stations at once at the fallback height and finds
|
||||||
|
their ground heights afterwards in 4 ms steps, from the terrain reader on a
|
||||||
|
terrain globe and from depth samples where the globe is hidden.
|
||||||
|
|
||||||
|
Verified in the browser on a keyless terrain globe only: Boston for Transit,
|
||||||
|
Rail and Bike Share, northern Virginia for Data Centers. Not checked there:
|
||||||
|
photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams,
|
||||||
|
and any city steeper than Boston. Street Traffic keeps its own height cache
|
||||||
|
and ALPR cameras their own sample budget.
|
||||||
|
|
||||||
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
|
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
|
||||||
stroke over 3.25 px dark backing. Other contact themes retain their existing
|
stroke over 3.25 px dark backing. Other contact themes retain their existing
|
||||||
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
|
||||||
@@ -7430,7 +7463,7 @@ pins and share links already supply complete boxes.
|
|||||||
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
||||||
can retain a bounded retry delay of up to 30 seconds.
|
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, 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.
|
||||||
- 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.
|
- 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. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. 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). 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 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.
|
- **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.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({
|
|||||||
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
||||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||||
|
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
|
||||||
|
|
||||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
||||||
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ How the layers use them:
|
|||||||
type's published length (a vessel to its reported AIS length and beam), so
|
type's published length (a vessel to its reported AIS length and beam), so
|
||||||
every model is drawn life size. Ground objects, balloons and obstacles in
|
every model is drawn life size. Ground objects, balloons and obstacles in
|
||||||
the `other` category are drawn at the size they are built.
|
the `other` category are drawn at the size they are built.
|
||||||
|
- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA
|
||||||
|
mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is
|
||||||
|
drawn as the `buoys` model of its mark (cardinal north, east, south or west,
|
||||||
|
port or starboard hand, safe water, isolated danger, special mark), a red
|
||||||
|
can when its type names no mark, at its built size and with no heading
|
||||||
|
(`vesselModelSpec`, `aidToNavigationModel`).
|
||||||
- An aircraft model's origin is its axis; it stands that model's scaled
|
- An aircraft model's origin is its axis; it stands that model's scaled
|
||||||
clearance above the ground. A ship's origin is its waterline, and a transit
|
clearance above the ground. A ship's origin is its waterline, and a transit
|
||||||
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
or rail vehicle's the road or rail top (a monorail or gondola is raised by
|
||||||
|
|||||||
@@ -808,16 +808,6 @@
|
|||||||
"server/providers/transitHistory.js",
|
"server/providers/transitHistory.js",
|
||||||
"server/providers/transitHistory.test.mjs",
|
"server/providers/transitHistory.test.mjs",
|
||||||
"src/data/rioRouteNames.js",
|
"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.js",
|
||||||
"src/data/transitFeeds.test.mjs",
|
"src/data/transitFeeds.test.mjs",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"src/data/localGeojsonLod.js",
|
"src/data/localGeojsonLod.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/infrastructureOverlayEntry.js",
|
"src/data/infrastructureOverlayEntry.js",
|
||||||
"src/sources/infrastructureData.js"
|
"src/sources/infrastructureData.js",
|
||||||
|
"src/services/terrainSurface.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -251,6 +252,7 @@
|
|||||||
"src/data/adsbLolFallback.js",
|
"src/data/adsbLolFallback.js",
|
||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js",
|
"src/data/aisWatchdog.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"server/providers/acartia.js",
|
"server/providers/acartia.js",
|
||||||
@@ -1514,6 +1516,7 @@
|
|||||||
"exports": ["./layers/flights"],
|
"exports": ["./layers/flights"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -1593,6 +1596,7 @@
|
|||||||
"src/layers/flights/modelSpec.js",
|
"src/layers/flights/modelSpec.js",
|
||||||
"src/layers/flights/policy.js",
|
"src/layers/flights/policy.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js",
|
"src/data/fleetTrailAnchors.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
@@ -2110,6 +2114,7 @@
|
|||||||
"src/layers/bikeshare/state.js",
|
"src/layers/bikeshare/state.js",
|
||||||
"src/layers/bikeshare/testing.js",
|
"src/layers/bikeshare/testing.js",
|
||||||
"src/layers/bikeshare/viewport.js",
|
"src/layers/bikeshare/viewport.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/cameraSensitivity.js",
|
"src/data/cameraSensitivity.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
@@ -3061,6 +3066,7 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/services/groundSnap.js",
|
"src/services/groundSnap.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
@@ -3336,10 +3342,6 @@
|
|||||||
"src/data/transit.js",
|
"src/data/transit.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
@@ -3355,8 +3357,6 @@
|
|||||||
"src/layers/transit/state.js",
|
"src/layers/transit/state.js",
|
||||||
"src/layers/transit/testing.js",
|
"src/layers/transit/testing.js",
|
||||||
"src/layers/transit/viewport.js",
|
"src/layers/transit/viewport.js",
|
||||||
"src/layers/transit/coverage.js",
|
|
||||||
"src/data/transitCoverage.js",
|
|
||||||
"src/data/cameraSensitivity.js",
|
"src/data/cameraSensitivity.js",
|
||||||
"src/data/contactPlayback.js",
|
"src/data/contactPlayback.js",
|
||||||
"src/data/contactTrailRenderer.js",
|
"src/data/contactTrailRenderer.js",
|
||||||
@@ -4247,6 +4247,7 @@
|
|||||||
"src/scopeMaskPolicy.js",
|
"src/scopeMaskPolicy.js",
|
||||||
"src/scopeMask.js",
|
"src/scopeMask.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
"src/data/pointerOwnerNames.js",
|
"src/data/pointerOwnerNames.js",
|
||||||
@@ -4331,10 +4332,6 @@
|
|||||||
"src/data/railTimeZones.js",
|
"src/data/railTimeZones.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
@@ -4350,8 +4347,6 @@
|
|||||||
"src/layers/transit/state.js",
|
"src/layers/transit/state.js",
|
||||||
"src/layers/transit/testing.js",
|
"src/layers/transit/testing.js",
|
||||||
"src/layers/transit/viewport.js",
|
"src/layers/transit/viewport.js",
|
||||||
"src/layers/transit/coverage.js",
|
|
||||||
"src/data/transitCoverage.js",
|
|
||||||
"src/data/cameraSensitivity.js",
|
"src/data/cameraSensitivity.js",
|
||||||
"src/data/contactPlayback.js",
|
"src/data/contactPlayback.js",
|
||||||
"src/data/contactTrailRenderer.js",
|
"src/data/contactTrailRenderer.js",
|
||||||
@@ -4601,6 +4596,7 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/services/groundSnap.js",
|
"src/services/groundSnap.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
@@ -4829,6 +4825,7 @@
|
|||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftEmergency.js",
|
"src/data/aircraftEmergency.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetTrailAnchors.js"
|
"src/data/fleetTrailAnchors.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
@@ -5192,10 +5189,6 @@
|
|||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
@@ -5213,10 +5206,6 @@
|
|||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/sources/transitHistory.js"
|
"src/sources/transitHistory.js"
|
||||||
@@ -5227,6 +5216,7 @@
|
|||||||
"exports": ["./layers/transit/standalone"],
|
"exports": ["./layers/transit/standalone"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
@@ -5258,10 +5248,6 @@
|
|||||||
"src/data/transit.js",
|
"src/data/transit.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
@@ -5281,14 +5267,13 @@
|
|||||||
"src/layers/transit/testing.js",
|
"src/layers/transit/testing.js",
|
||||||
"src/layers/transit/trails.js",
|
"src/layers/transit/trails.js",
|
||||||
"src/layers/transit/viewport.js",
|
"src/layers/transit/viewport.js",
|
||||||
"src/layers/transit/coverage.js",
|
|
||||||
"src/data/transitCoverage.js",
|
|
||||||
"src/overlays/worldOverlay.js",
|
"src/overlays/worldOverlay.js",
|
||||||
"src/overlays/worldOverlayDraw.js",
|
"src/overlays/worldOverlayDraw.js",
|
||||||
"src/overlays/worldOverlayTokens.js",
|
"src/overlays/worldOverlayTokens.js",
|
||||||
"src/renderGovernor.js",
|
"src/renderGovernor.js",
|
||||||
"src/services/application.js",
|
"src/services/application.js",
|
||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/services/requests.js",
|
"src/services/requests.js",
|
||||||
"src/services/terrainHeights.js",
|
"src/services/terrainHeights.js",
|
||||||
@@ -5325,6 +5310,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/data/fleetModels.js",
|
"src/data/fleetModels.js",
|
||||||
|
"src/data/vesselTypes.js",
|
||||||
"src/data/fleetModelInstances.js",
|
"src/data/fleetModelInstances.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftModelLighting.js",
|
"src/data/aircraftModelLighting.js",
|
||||||
@@ -5335,15 +5321,12 @@
|
|||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/geoDistance.js",
|
||||||
"src/data/transitIcons.js",
|
"src/data/transitIcons.js",
|
||||||
"src/data/transitPresetStyle.js",
|
"src/data/transitPresetStyle.js",
|
||||||
"src/layers/transit/controls.js",
|
"src/layers/transit/controls.js",
|
||||||
"src/layers/transit/height.js",
|
"src/layers/transit/height.js",
|
||||||
|
"src/services/terrainSurface.js",
|
||||||
"src/layers/transit/index.js",
|
"src/layers/transit/index.js",
|
||||||
"src/layers/transit/cockpitSubject.js",
|
"src/layers/transit/cockpitSubject.js",
|
||||||
"src/layers/transit/ingestion.js",
|
"src/layers/transit/ingestion.js",
|
||||||
@@ -5355,8 +5338,6 @@
|
|||||||
"src/layers/transit/state.js",
|
"src/layers/transit/state.js",
|
||||||
"src/layers/transit/testing.js",
|
"src/layers/transit/testing.js",
|
||||||
"src/layers/transit/viewport.js",
|
"src/layers/transit/viewport.js",
|
||||||
"src/layers/transit/coverage.js",
|
|
||||||
"src/data/transitCoverage.js",
|
|
||||||
"src/data/cameraSensitivity.js",
|
"src/data/cameraSensitivity.js",
|
||||||
"src/data/contactPlayback.js",
|
"src/data/contactPlayback.js",
|
||||||
"src/data/contactTrailRenderer.js",
|
"src/data/contactTrailRenderer.js",
|
||||||
@@ -5414,10 +5395,6 @@
|
|||||||
"src/data/railNoPositions.js",
|
"src/data/railNoPositions.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
@@ -5448,10 +5425,6 @@
|
|||||||
"src/data/railNoPositions.js",
|
"src/data/railNoPositions.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
@@ -5468,10 +5441,6 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/geoDistance.js",
|
||||||
"src/data/transitProxy.js",
|
"src/data/transitProxy.js",
|
||||||
"src/data/gtfsRealtime.js",
|
"src/data/gtfsRealtime.js",
|
||||||
@@ -5739,10 +5708,6 @@
|
|||||||
"src/layers/transit/policy.js",
|
"src/layers/transit/policy.js",
|
||||||
"src/data/transitFeeds.js",
|
"src/data/transitFeeds.js",
|
||||||
"src/data/rioRouteNames.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/layers/transit/movement.js",
|
||||||
"src/data/contactPlayback.js",
|
"src/data/contactPlayback.js",
|
||||||
"src/data/layerSnapshot.js",
|
"src/data/layerSnapshot.js",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [
|
|||||||
'ExtendedClassBPositionReport',
|
'ExtendedClassBPositionReport',
|
||||||
'ShipStaticData',
|
'ShipStaticData',
|
||||||
'StaticDataReport',
|
'StaticDataReport',
|
||||||
|
// Buoys, beacons and other aids to navigation (AIS message 21).
|
||||||
|
'AidsToNavigationReport',
|
||||||
];
|
];
|
||||||
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is
|
||||||
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
||||||
|
import {
|
||||||
|
aidToNavigationTypeName,
|
||||||
|
isAidToNavigation,
|
||||||
|
} from '../../../src/data/vesselTypes.js';
|
||||||
export const AISSTREAM_CACHE_MAX = 50000;
|
export const AISSTREAM_CACHE_MAX = 50000;
|
||||||
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
||||||
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
||||||
@@ -112,13 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||||
|
|
||||||
const staticData = _aisStreamStatic.get(mmsi) || {};
|
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||||
|
const aidReport = messageType === 'AidsToNavigationReport';
|
||||||
_aisStreamVessels.set(mmsi, {
|
_aisStreamVessels.set(mmsi, {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
|
name:
|
||||||
|
(aidReport && aidNameFromAis(message)) ||
|
||||||
|
vesselNameFromAis(metadata, message, staticData) ||
|
||||||
|
`MMSI ${mmsi}`,
|
||||||
mmsi,
|
mmsi,
|
||||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||||
type: vesselTypeFromAis(message, staticData),
|
// An aid-to-navigation report's type is the kind of aid (0 to 31), not
|
||||||
|
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
|
||||||
|
type: aidReport
|
||||||
|
? aidToNavigationTypeName(message.Type)
|
||||||
|
: vesselTypeFromAis(message, staticData),
|
||||||
destination: stringValue(message.Destination ?? staticData.destination),
|
destination: stringValue(message.Destination ?? staticData.destination),
|
||||||
length: staticData.length ?? null,
|
length: staticData.length ?? null,
|
||||||
beam: staticData.beam ?? null,
|
beam: staticData.beam ?? null,
|
||||||
@@ -131,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
// Use the AIS message's own report time, not server ingest wall-clock —
|
// Use the AIS message's own report time, not server ingest wall-clock —
|
||||||
// trail spacing and dead reckoning depend on true fix epochs.
|
// trail spacing and dead reckoning depend on true fix epochs.
|
||||||
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
||||||
|
// A virtual aid is broadcast where nothing physical stands. Only such a
|
||||||
|
// row carries the field, so ship rows stay their size.
|
||||||
|
...(aidReport && message.VirtualAtoN === true && { virtual: true }),
|
||||||
_updatedAt: Date.now(),
|
_updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +291,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aid's whole name: an aid-to-navigation report carries the first 20
|
||||||
|
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
|
||||||
|
*/
|
||||||
|
function aidNameFromAis(message) {
|
||||||
|
const name = stringValue(message.Name);
|
||||||
|
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function vesselTypeFromAis(message, staticData = {}) {
|
function vesselTypeFromAis(message, staticData = {}) {
|
||||||
return stringValue(
|
return stringValue(
|
||||||
message.Type ??
|
message.Type ??
|
||||||
@@ -285,14 +309,25 @@ function vesselTypeFromAis(message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current rows, newest first, with every vessel ahead of every aid to
|
||||||
|
* navigation: a capped request never gives up a ship for a buoy.
|
||||||
|
*/
|
||||||
export function aisStreamRows(maxRows) {
|
export function aisStreamRows(maxRows) {
|
||||||
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||||
const rows = [];
|
const vessels = [];
|
||||||
|
const aids = [];
|
||||||
for (const row of _aisStreamVessels.values()) {
|
for (const row of _aisStreamVessels.values()) {
|
||||||
if (row._updatedAt >= cutoff) rows.push(row);
|
if (row._updatedAt >= cutoff)
|
||||||
|
(isAidToNavigation(row) ? aids : vessels).push(row);
|
||||||
}
|
}
|
||||||
rows.sort((a, b) => b._updatedAt - a._updatedAt);
|
const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
|
||||||
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
vessels.sort(newestFirst);
|
||||||
|
if (vessels.length < maxRows) aids.sort(newestFirst);
|
||||||
|
return vessels
|
||||||
|
.concat(aids)
|
||||||
|
.slice(0, maxRows)
|
||||||
|
.map(({ _updatedAt, ...row }) => row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisStreamCache() {
|
function pruneAisStreamCache() {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function createApplicationCatalog({
|
|||||||
displayParams: () => flights.getParams(),
|
displayParams: () => flights.getParams(),
|
||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
}),
|
}),
|
||||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
createApplicationBikeshare({ surface, source: sources.bikeshare }),
|
||||||
createApplicationDirections({ source: sources.directions }),
|
createApplicationDirections({ source: sources.directions }),
|
||||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||||
createApplicationOsmPlaces(),
|
createApplicationOsmPlaces(),
|
||||||
@@ -205,7 +205,10 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
createApplicationCyclones({ feed: sources.cyclones }),
|
createApplicationCyclones({ feed: sources.cyclones }),
|
||||||
...createOsmOverlayLayers(),
|
...createOsmOverlayLayers(),
|
||||||
...createInfrastructureLayers(localGeoJsonServices),
|
...createInfrastructureLayers({
|
||||||
|
...localGeoJsonServices,
|
||||||
|
terrainSurface: surface.groundFloor.terrainSurface,
|
||||||
|
}),
|
||||||
createApplicationCables({ source: sources.cables }),
|
createApplicationCables({ source: sources.cables }),
|
||||||
createApplicationRail({
|
createApplicationRail({
|
||||||
surface,
|
surface,
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js';
|
|||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** Construct one layer using the application scene owners and a supplied source. */
|
||||||
export function createApplicationBikeshare({ source }) {
|
export function createApplicationBikeshare({ source, surface }) {
|
||||||
return createBikeshareLayer({
|
return createBikeshareLayer({
|
||||||
source,
|
source,
|
||||||
services: { render, sprites, picking, overlays },
|
services: {
|
||||||
|
render,
|
||||||
|
sprites,
|
||||||
|
picking,
|
||||||
|
overlays,
|
||||||
|
// Station heights come from the terrain surface every layer shares.
|
||||||
|
ground: { terrainSurface: surface?.groundFloor?.terrainSurface },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () =>
|
|||||||
_setVesselStateForTest({ enabled: false });
|
_setVesselStateForTest({ enabled: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid to navigation card shows its kind, never a speed or heading', () => {
|
||||||
|
const aid = makeRecord({
|
||||||
|
name: 'SF APPROACH N',
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
speed: 0,
|
||||||
|
course: 135,
|
||||||
|
heading: 135,
|
||||||
|
});
|
||||||
|
const card = buildVesselCard(aid);
|
||||||
|
assert.deepEqual(card.details, ['CARDINAL MARK N']);
|
||||||
|
assert.equal(card.accent, '217, 210, 192');
|
||||||
|
const selected = buildSelectedVesselCard(aid);
|
||||||
|
assert.equal(selected.details[0], 'CARDINAL MARK N');
|
||||||
|
assert.equal(selected.accent, '217, 210, 192');
|
||||||
|
// Known only by its MMSI, an aid is named as one.
|
||||||
|
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
|
||||||
|
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
|
||||||
|
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
|
||||||
|
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||||
|
try {
|
||||||
|
Object.assign(harness.record, {
|
||||||
|
type: 'Port hand Mark',
|
||||||
|
speed: 0,
|
||||||
|
heading: 135,
|
||||||
|
course: 135,
|
||||||
|
});
|
||||||
|
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||||
|
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
|
||||||
|
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
|
||||||
|
harness.record.virtual = true;
|
||||||
|
aisLiveVesselsLayer.selectById(harness.record.mmsi);
|
||||||
|
assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/);
|
||||||
|
} finally {
|
||||||
|
harness.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation card says it is virtual', () => {
|
||||||
|
const aid = makeRecord({
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Isolated danger',
|
||||||
|
virtual: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']);
|
||||||
|
assert.equal(
|
||||||
|
buildSelectedVesselCard(aid).details[0],
|
||||||
|
'ISOLATED DANGER · VIRTUAL',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -316,3 +316,97 @@ test('a Class B report carries its dimensions in part B', () => {
|
|||||||
assert.equal(row.length, 12);
|
assert.equal(row.length, 12);
|
||||||
assert.equal(row.beam, 4);
|
assert.equal(row.beam, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => {
|
||||||
|
assert.equal(
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: {
|
||||||
|
UserID: 993672001,
|
||||||
|
Type: 20,
|
||||||
|
Name: 'SF APPROACH N',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672001',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const row = rowFor('993672001');
|
||||||
|
assert.equal(row.type, 'Cardinal Mark N');
|
||||||
|
assert.equal(row.name, 'SF APPROACH N');
|
||||||
|
assert.equal(row.speed, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an aid's name runs on into its name extension", () => {
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: {
|
||||||
|
UserID: 993672002,
|
||||||
|
Type: 13,
|
||||||
|
Name: 'SAN FRANCISCO MAIN S',
|
||||||
|
NameExtension: 'HIP CHANNEL 2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672002',
|
||||||
|
ShipName: 'SAN FRANCISCO MAIN S',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a capped row request keeps every vessel ahead of aids to navigation', () => {
|
||||||
|
// The aid reports last, so it is the newest row.
|
||||||
|
ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 }));
|
||||||
|
ingestAisStreamEnvelope({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } },
|
||||||
|
MetaData: {
|
||||||
|
MMSI: '993672003',
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rows = aisStreamRows(50000);
|
||||||
|
const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99'));
|
||||||
|
const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99'));
|
||||||
|
assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`);
|
||||||
|
const vesselCount =
|
||||||
|
rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length;
|
||||||
|
assert.ok(
|
||||||
|
aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => {
|
||||||
|
const aidReport = (mmsi, VirtualAtoN) => ({
|
||||||
|
MessageType: 'AidsToNavigationReport',
|
||||||
|
Message: {
|
||||||
|
AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN },
|
||||||
|
},
|
||||||
|
MetaData: {
|
||||||
|
MMSI: mmsi,
|
||||||
|
latitude: 37.79,
|
||||||
|
longitude: -122.52,
|
||||||
|
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672004', true));
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672005', false));
|
||||||
|
assert.equal(rowFor('993672004').virtual, true);
|
||||||
|
assert.equal('virtual' in rowFor('993672005'), false);
|
||||||
|
// A later real report of the same aid drops the flag.
|
||||||
|
ingestAisStreamEnvelope(aidReport('993672004', false));
|
||||||
|
assert.equal('virtual' in rowFor('993672004'), false);
|
||||||
|
});
|
||||||
+69
-5
@@ -11,6 +11,9 @@
|
|||||||
* vehicle's road or rail top), and is drawn life size: a type's published
|
* vehicle's road or rail top), and is drawn life size: a type's published
|
||||||
* length over the model's built length is its scale.
|
* length over the model's built length is its scale.
|
||||||
*
|
*
|
||||||
|
* An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as
|
||||||
|
* the set's buoy of its IALA mark, by the mark its type names.
|
||||||
|
*
|
||||||
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
|
||||||
* fetches the JSON and every GLB once, up front, so no model is fetched
|
* fetches the JSON and every GLB once, up front, so no model is fetched
|
||||||
* mid-session.
|
* mid-session.
|
||||||
@@ -18,6 +21,7 @@
|
|||||||
|
|
||||||
import { classifyAircraft } from './aircraftClass.js';
|
import { classifyAircraft } from './aircraftClass.js';
|
||||||
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
|
||||||
|
import { isAidToNavigation } from './vesselTypes.js';
|
||||||
|
|
||||||
/** Where the model set is served, relative to the application base. */
|
/** Where the model set is served, relative to the application base. */
|
||||||
export const FLEET_MODEL_BASE = '/models/fleet/';
|
export const FLEET_MODEL_BASE = '/models/fleet/';
|
||||||
@@ -84,6 +88,44 @@ const TEXT_SHIP_TYPES = [
|
|||||||
[/pleasure|yacht/i, 37],
|
[/pleasure|yacht/i, 37],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buoy an aid to navigation is drawn as, by the mark its type names
|
||||||
|
* ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern
|
||||||
|
* that matches. Beacons, lights, RACONs and reference points with no mark of
|
||||||
|
* their own are the special mark.
|
||||||
|
*/
|
||||||
|
const AID_MODELS = [
|
||||||
|
[/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'],
|
||||||
|
[/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'],
|
||||||
|
[/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'],
|
||||||
|
[/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'],
|
||||||
|
[/isolated danger/i, 'buoy_isolated_danger'],
|
||||||
|
[/safe water/i, 'buoy_safe_water'],
|
||||||
|
[/port hand/i, 'buoy_lateral_port'],
|
||||||
|
[/starboard hand/i, 'buoy_lateral_starboard'],
|
||||||
|
[/special mark|light vessel|lanby/i, 'buoy_special'],
|
||||||
|
[
|
||||||
|
/^(beacon|light\b|leading light|racon|reference point|navigation aid)/i,
|
||||||
|
'buoy_special',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The buoy an aid to navigation whose type names no mark is drawn as. */
|
||||||
|
export const DEFAULT_AID_MODEL = 'can_red';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buoy model of an aid to navigation's type, else undefined when the
|
||||||
|
* type names no mark.
|
||||||
|
* @param {string|null} [type]
|
||||||
|
* @returns {string|undefined}
|
||||||
|
*/
|
||||||
|
export function aidToNavigationModel(type) {
|
||||||
|
const text = String(type ?? '').trim();
|
||||||
|
for (const [pattern, model] of AID_MODELS)
|
||||||
|
if (pattern.test(text)) return model;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
|
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
|
||||||
* emitter category `category` and callsign `callsign`: the first of the
|
* emitter category `category` and callsign `callsign`: the first of the
|
||||||
@@ -397,13 +439,33 @@ export function aircraftModelSpec(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The model a vessel is drawn with, from its AIS ship type (a code or a text
|
* The model a vessel is drawn with, from its AIS ship type (a code or a text
|
||||||
* family), reported length and beam (metres) and name.
|
* family), reported length and beam (metres) and name. An aid to navigation
|
||||||
|
* ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or
|
||||||
|
* light, or its MMSI is an aid's) is the buoy of its mark
|
||||||
|
* ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as
|
||||||
|
* built and flagged `fixed`: it has no heading to turn to. A virtual aid
|
||||||
|
* (`virtual`) is broadcast where nothing physical stands, so it has no model.
|
||||||
* @param {{catalog: object, types: object}} set
|
* @param {{catalog: object, types: object}} set
|
||||||
* @param {{type?: string|number|null, length?: number|null,
|
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
||||||
* beam?: number|null, name?: string|null}} vessel
|
* virtual?: boolean, length?: number|null, beam?: number|null,
|
||||||
* @returns {object} {@link fleetModelSpec}
|
* name?: string|null}} vessel
|
||||||
|
* @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid;
|
||||||
|
* null for a virtual aid
|
||||||
*/
|
*/
|
||||||
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
export function vesselModelSpec(
|
||||||
|
set,
|
||||||
|
{ type, mmsi, virtual, length, beam, name } = {},
|
||||||
|
) {
|
||||||
|
if (virtual === true) return null;
|
||||||
|
if (isAidToNavigation({ type, mmsi })) {
|
||||||
|
const model = aidToNavigationModel(type);
|
||||||
|
const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL;
|
||||||
|
if (set.catalog[known])
|
||||||
|
return Object.freeze({
|
||||||
|
...fleetModelSpec(set, known, null),
|
||||||
|
fixed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
// A beam counts only beside a reported length: a vessel drawn at its type's
|
// A beam counts only beside a reported length: a vessel drawn at its type's
|
||||||
// typical length keeps the model's proportions.
|
// typical length keeps the model's proportions.
|
||||||
const reported = hullLength(length ?? undefined);
|
const reported = hullLength(length ?? undefined);
|
||||||
@@ -460,6 +522,8 @@ export function namedFleetModels(types) {
|
|||||||
transit.default[0],
|
transit.default[0],
|
||||||
...Object.keys(transit.elevation),
|
...Object.keys(transit.elevation),
|
||||||
...Object.values(CLASS_MODELS),
|
...Object.values(CLASS_MODELS),
|
||||||
|
...AID_MODELS.map(([, model]) => model),
|
||||||
|
DEFAULT_AID_MODEL,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_AID_MODEL,
|
||||||
FLEET_MODEL_BASE,
|
FLEET_MODEL_BASE,
|
||||||
|
aidToNavigationModel,
|
||||||
aircraftModelSpec,
|
aircraftModelSpec,
|
||||||
aisShipTypeCode,
|
aisShipTypeCode,
|
||||||
emitterCategory,
|
emitterCategory,
|
||||||
@@ -381,6 +383,67 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
|||||||
assert.equal(unknown.scale, 1);
|
assert.equal(unknown.scale, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('aids to navigation are the buoy of the mark their type names', () => {
|
||||||
|
const cases = [
|
||||||
|
['Cardinal Mark N', 'buoy_cardinal_north'],
|
||||||
|
['Beacon, Cardinal E', 'buoy_cardinal_east'],
|
||||||
|
['Cardinal Mark S', 'buoy_cardinal_south'],
|
||||||
|
['Beacon, Cardinal W', 'buoy_cardinal_west'],
|
||||||
|
['Port Hand Mark', 'buoy_lateral_port'],
|
||||||
|
['Beacon, Port hand', 'buoy_lateral_port'],
|
||||||
|
['Starboard Hand Mark', 'buoy_lateral_starboard'],
|
||||||
|
['Preferred Channel Starboard hand', 'buoy_lateral_starboard'],
|
||||||
|
['Safe Water', 'buoy_safe_water'],
|
||||||
|
['Isolated danger', 'buoy_isolated_danger'],
|
||||||
|
['Beacon, Special Mark', 'buoy_special'],
|
||||||
|
['Special Mark', 'buoy_special'],
|
||||||
|
['Light Vessel / LANBY / Rigs', 'buoy_special'],
|
||||||
|
['RACON', 'buoy_special'],
|
||||||
|
['Light, with Sectors', 'buoy_special'],
|
||||||
|
['Reference point', 'buoy_special'],
|
||||||
|
['Navigation aid', 'buoy_special'],
|
||||||
|
];
|
||||||
|
for (const [type, model] of cases) {
|
||||||
|
assert.equal(aidToNavigationModel(type), model, type);
|
||||||
|
assert.ok(catalog[model], model);
|
||||||
|
const spec = vesselModelSpec(set, { type, mmsi: '993672001' });
|
||||||
|
assert.equal(spec.model, model, type);
|
||||||
|
assert.equal(spec.category, 'buoys', type);
|
||||||
|
assert.equal(spec.fixed, true, type);
|
||||||
|
// A buoy is drawn as built, whatever length the report gives.
|
||||||
|
assert.equal(spec.scale, 1, type);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// An aid of no stated kind, known only by its MMSI, is the default can.
|
||||||
|
assert.ok(catalog[DEFAULT_AID_MODEL]);
|
||||||
|
const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' });
|
||||||
|
assert.equal(unnamed.model, DEFAULT_AID_MODEL);
|
||||||
|
assert.equal(unnamed.fixed, true);
|
||||||
|
assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined);
|
||||||
|
// A virtual aid is broadcast where nothing stands: it keeps its disc.
|
||||||
|
assert.equal(
|
||||||
|
vesselModelSpec(set, {
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
mmsi: '993672001',
|
||||||
|
virtual: true,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Vessels, including those that work on buoys, keep their hulls.
|
||||||
|
for (const vessel of [
|
||||||
|
{ type: 'Buoy Tender', mmsi: '366999001', length: 60 },
|
||||||
|
{ type: '70', mmsi: '366999002' },
|
||||||
|
{ type: 'Tug', mmsi: '' },
|
||||||
|
]) {
|
||||||
|
const spec = vesselModelSpec(set, vessel);
|
||||||
|
assert.notEqual(spec.category, 'buoys', vessel.type);
|
||||||
|
assert.equal(spec.fixed, undefined, vessel.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
|
||||||
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
|
||||||
model: 'intercity',
|
model: 'intercity',
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under
|
|||||||
'infrastructureOverlayEntry.js',
|
'infrastructureOverlayEntry.js',
|
||||||
'localGeojsonCore.js',
|
'localGeojsonCore.js',
|
||||||
'localGeojsonLod.js',
|
'localGeojsonLod.js',
|
||||||
|
'terrainSurface.js',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
entry.imports,
|
entry.imports,
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import {
|
|||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
|
|
||||||
/** The catalog's services, with the overlay host each test supplies. */
|
/** The catalog's services, with the overlay host each test supplies. */
|
||||||
const createLocalGeoJsonLayer = (options) =>
|
const createLocalGeoJsonLayer = (options, services = {}) =>
|
||||||
createLayer(options, {
|
createLayer(options, {
|
||||||
...localGeoJsonServices,
|
...localGeoJsonServices,
|
||||||
|
...services,
|
||||||
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,10 +59,15 @@ class MockLayerEvent {
|
|||||||
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
|
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
|
||||||
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
|
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
|
||||||
* (default: throws like a scene whose tiles are not sampleable yet).
|
* (default: throws like a scene whose tiles are not sampleable yet).
|
||||||
|
* @param {number[][]} [options.points] `[lon, lat]` sites to load
|
||||||
|
* instead of the single dam polygon.
|
||||||
|
* @param {object} [options.services] Extra services for the layer.
|
||||||
*/
|
*/
|
||||||
async function createRealLocalLayerHarness({
|
async function createRealLocalLayerHarness({
|
||||||
sampleHeightSupported = false,
|
sampleHeightSupported = false,
|
||||||
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
|
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
|
||||||
|
points = null,
|
||||||
|
services = {},
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({
|
|||||||
globalThis.fetch = async () => ({
|
globalThis.fetch = async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
text: async () => JSON.stringify({
|
text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({
|
||||||
|
type: 'Feature',
|
||||||
|
id: `site-${index}`,
|
||||||
|
properties: { name: `Site ${index}` },
|
||||||
|
// A small outline: the layer stems its centre. (Cesium draws a point
|
||||||
|
// feature as a canvas pin, which Node cannot make.)
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [[
|
||||||
|
[lon - 0.001, lat - 0.001],
|
||||||
|
[lon + 0.001, lat - 0.001],
|
||||||
|
[lon, lat + 0.001],
|
||||||
|
[lon - 0.001, lat - 0.001],
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
})).join('\n') : JSON.stringify({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
id: 'real-dam',
|
id: 'real-dam',
|
||||||
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
|
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
|
||||||
@@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({
|
|||||||
setInputAction() {},
|
setInputAction() {},
|
||||||
destroy() {},
|
destroy() {},
|
||||||
}),
|
}),
|
||||||
});
|
}, services);
|
||||||
try {
|
try {
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G
|
|||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
|
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
|
||||||
globe.tilesLoaded = true;
|
globe.tilesLoaded = true;
|
||||||
clock.advance(500);
|
// The terrain surface reuses a height for 2 s before reading the mesh again.
|
||||||
|
clock.advance(2_100);
|
||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
|
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
|
||||||
|
|
||||||
terrain = 110;
|
terrain = 110;
|
||||||
clock.advance(500);
|
clock.advance(2_100);
|
||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
|
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
|
||||||
terrain = 140;
|
terrain = 140;
|
||||||
@@ -1493,3 +1515,161 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
|
|||||||
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/** A loaded terrain globe that counts its ray casts. */
|
||||||
|
function countingTerrain(height) {
|
||||||
|
const globe = {
|
||||||
|
show: true,
|
||||||
|
tilesLoaded: true,
|
||||||
|
reads: 0,
|
||||||
|
getHeight: () => { globe.reads += 1; return height; },
|
||||||
|
};
|
||||||
|
return globe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Height of every stem base, in entity order. */
|
||||||
|
function allBaseHeightsM(env) {
|
||||||
|
return env.dataSources[0].entities.values.map(
|
||||||
|
(entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_SITES = [
|
||||||
|
[-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205],
|
||||||
|
];
|
||||||
|
|
||||||
|
test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => {
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||||
|
});
|
||||||
|
const clock = installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
const globe = countingTerrain(117);
|
||||||
|
env.viewer.scene.globe = globe;
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
for (let walk = 0; walk < 40; walk += 1) {
|
||||||
|
env.preRender.raise();
|
||||||
|
clock.advance(500);
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
||||||
|
// 20 s parked: a few reads while the surface settles, then none.
|
||||||
|
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`);
|
||||||
|
const settled = globe.reads;
|
||||||
|
for (let walk = 0; walk < 20; walk += 1) {
|
||||||
|
env.preRender.raise();
|
||||||
|
clock.advance(500);
|
||||||
|
}
|
||||||
|
assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => {
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||||
|
});
|
||||||
|
const clock = installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
const globe = countingTerrain(117);
|
||||||
|
env.viewer.scene.globe = globe;
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
env.setBaseInView(false);
|
||||||
|
for (let walk = 0; walk < 40; walk += 1) {
|
||||||
|
env.preRender.raise();
|
||||||
|
clock.advance(500);
|
||||||
|
}
|
||||||
|
assert.equal(env.sampleCalls.count, 0);
|
||||||
|
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => {
|
||||||
|
const asked = [];
|
||||||
|
const terrainSurface = {
|
||||||
|
height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; },
|
||||||
|
};
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||||
|
points: [[-97.695, 30.205]],
|
||||||
|
services: { terrainSurface },
|
||||||
|
});
|
||||||
|
const clock = installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
const globe = countingTerrain(117);
|
||||||
|
env.viewer.scene.globe = globe;
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself');
|
||||||
|
clock.advance(500);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.ok(asked.length >= 2);
|
||||||
|
for (const call of asked) {
|
||||||
|
assert.equal(call.viewer, env.viewer);
|
||||||
|
assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002,
|
||||||
|
`asked at ${call.lat}, ${call.lon}`);
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface');
|
||||||
|
assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => {
|
||||||
|
const heights = new Map();
|
||||||
|
let clock = null;
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true,
|
||||||
|
points: SAMPLE_SITES,
|
||||||
|
sampleHeight: (carto) => {
|
||||||
|
clock.advance(15); // one GPU depth render
|
||||||
|
const height = 100 + heights.size;
|
||||||
|
heights.set(`${carto.longitude},${carto.latitude}`, height);
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
_resetRenderGovernorForTest();
|
||||||
|
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
|
||||||
|
installRenderGovernor({ scene: governorScene });
|
||||||
|
governorScene.renders = 0; // discard the governor's own install settling frame
|
||||||
|
clock = installFakeClock(t);
|
||||||
|
t.after(() => {
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
env.cleanup();
|
||||||
|
_resetRenderGovernorForTest();
|
||||||
|
});
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget');
|
||||||
|
assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it');
|
||||||
|
|
||||||
|
// The following frames arrive well inside the 450 ms walk interval.
|
||||||
|
for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) {
|
||||||
|
clock.advance(16);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped');
|
||||||
|
}
|
||||||
|
assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled');
|
||||||
|
clock.advance(16);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice');
|
||||||
|
|
||||||
|
const expected = env.dataSources[0].entities.values.map((entity) => {
|
||||||
|
const carto = entity.__localBaseCarto;
|
||||||
|
return heights.get(`${carto.longitude},${carto.latitude}`);
|
||||||
|
});
|
||||||
|
assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected);
|
||||||
|
for (const entity of env.dataSources[0].entities.values) {
|
||||||
|
const stem = entity.polyline.positions.getValue();
|
||||||
|
assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => {
|
||||||
|
const env = await createRealLocalLayerHarness({
|
||||||
|
sampleHeightSupported: true,
|
||||||
|
points: SAMPLE_SITES,
|
||||||
|
sampleHeight: () => 117,
|
||||||
|
});
|
||||||
|
installFakeClock(t);
|
||||||
|
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||||
|
setCameraAltitude(env, 20_000);
|
||||||
|
env.preRender.raise();
|
||||||
|
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length);
|
||||||
|
for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01);
|
||||||
|
});
|
||||||
+134
-15
@@ -11,6 +11,10 @@ import {
|
|||||||
applyInfraEvictionGrace,
|
applyInfraEvictionGrace,
|
||||||
shouldRecomputeInfraLod,
|
shouldRecomputeInfraLod,
|
||||||
} from './localGeojsonLod.js';
|
} from './localGeojsonLod.js';
|
||||||
|
import {
|
||||||
|
createTerrainSurface,
|
||||||
|
TERRAIN_NEED,
|
||||||
|
} from '../services/terrainSurface.js';
|
||||||
|
|
||||||
const DEFAULT_LABEL_MAX = 900;
|
const DEFAULT_LABEL_MAX = 900;
|
||||||
const DEFAULT_LABEL_GRID_PX = 132;
|
const DEFAULT_LABEL_GRID_PX = 132;
|
||||||
@@ -38,6 +42,15 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
|
|||||||
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
|
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
|
||||||
*/
|
*/
|
||||||
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
|
||||||
|
/**
|
||||||
|
* Milliseconds of scene.sampleHeight one frame may spend. Each call is an
|
||||||
|
* offscreen depth render (about 15 ms measured), and a first walk near a dense
|
||||||
|
* cluster wants one per site in view: 168 of them held the main thread for
|
||||||
|
* 2.6 s. Past the budget the remaining sites queue, and each following frame
|
||||||
|
* takes as many as fit, so the sites reach the same heights a frame or a few
|
||||||
|
* apart instead of all inside one blocked frame.
|
||||||
|
*/
|
||||||
|
export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8;
|
||||||
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
||||||
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
|
||||||
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
|
||||||
@@ -336,8 +349,20 @@ export function createLocalGeoJsonLayer(
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
showOsmCredit,
|
showOsmCredit,
|
||||||
hideOsmCredit,
|
hideOsmCredit,
|
||||||
|
terrainSurface,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* What ground sampling shares across a layer's records: the terrain
|
||||||
|
* surface (the application's, so every layer reads a point once and spends
|
||||||
|
* one budget, or this layer's own), the sample time spent this frame, and
|
||||||
|
* the records the frame budget left for a later frame.
|
||||||
|
*/
|
||||||
|
const _ground = {
|
||||||
|
terrain: terrainSurface || createTerrainSurface(),
|
||||||
|
sampleMs: 0,
|
||||||
|
queued: [],
|
||||||
|
};
|
||||||
let _dataSource = null;
|
let _dataSource = null;
|
||||||
let _enabled = false;
|
let _enabled = false;
|
||||||
let _clickHandler = null;
|
let _clickHandler = null;
|
||||||
@@ -444,6 +469,42 @@ export function createLocalGeoJsonLayer(
|
|||||||
clearTimeout(_groundRetryTimer);
|
clearTimeout(_groundRetryTimer);
|
||||||
_groundRetryTimer = null;
|
_groundRetryTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample the sites an earlier frame's budget left over, within this
|
||||||
|
* frame's. They were never attempted, so the 2 s retry spacing does not
|
||||||
|
* apply to them.
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {number} now
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function sampleQueuedGround(viewer, now) {
|
||||||
|
const queued = _ground.queued;
|
||||||
|
if (queued.length === 0) return;
|
||||||
|
_ground.queued = [];
|
||||||
|
for (const record of queued) {
|
||||||
|
record.groundSampleQueued = false;
|
||||||
|
// The camera may have left the site since it queued.
|
||||||
|
if (_lodComputed && !_activeLodIds.has(record.id)) continue;
|
||||||
|
const distance = Cesium.Cartesian3.distance(
|
||||||
|
viewer.camera.positionWC,
|
||||||
|
record.base,
|
||||||
|
);
|
||||||
|
if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue;
|
||||||
|
// Past the budget this puts the record back in the queue.
|
||||||
|
if (sampleLocalGroundHeight(viewer, record, now, _ground)) {
|
||||||
|
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
||||||
|
_groundRetryArms = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask for the frame that samples what this one's budget left over. */
|
||||||
|
function requestQueuedGroundFrame() {
|
||||||
|
if (_ground.queued.length > 0)
|
||||||
|
governorRequestRender(`local-ground-sample:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
|
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
|
||||||
sourceId: id,
|
sourceId: id,
|
||||||
host: overlayHost,
|
host: overlayHost,
|
||||||
@@ -472,6 +533,7 @@ export function createLocalGeoJsonLayer(
|
|||||||
const source = _dataSource;
|
const source = _dataSource;
|
||||||
_dataSource = null;
|
_dataSource = null;
|
||||||
_stemRecords = [];
|
_stemRecords = [];
|
||||||
|
_ground.queued = [];
|
||||||
_stemGeometryDirty = true;
|
_stemGeometryDirty = true;
|
||||||
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
||||||
removeEntityContextsForLayer(id);
|
removeEntityContextsForLayer(id);
|
||||||
@@ -744,6 +806,8 @@ export function createLocalGeoJsonLayer(
|
|||||||
// Store references for bounded stem scaling and native picking.
|
// Store references for bounded stem scaling and native picking.
|
||||||
feature.__localBaseCarto = carto;
|
feature.__localBaseCarto = carto;
|
||||||
feature.__localBaseCartesian = base;
|
feature.__localBaseCartesian = base;
|
||||||
|
const latDeg = Cesium.Math.toDegrees(carto.latitude);
|
||||||
|
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
|
||||||
const context = registerEntityContext(feature, {
|
const context = registerEntityContext(feature, {
|
||||||
id: `${id}:${recordId}`,
|
id: `${id}:${recordId}`,
|
||||||
layerId: id,
|
layerId: id,
|
||||||
@@ -795,7 +859,11 @@ export function createLocalGeoJsonLayer(
|
|||||||
stemPositionBuffers,
|
stemPositionBuffers,
|
||||||
stemPositionBufferIndex: 0,
|
stemPositionBufferIndex: 0,
|
||||||
groundHeight,
|
groundHeight,
|
||||||
|
// Degrees, as the terrain surface takes them.
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
groundSampled: false,
|
groundSampled: false,
|
||||||
|
groundSampleQueued: false,
|
||||||
lastGroundSampleMs: 0,
|
lastGroundSampleMs: 0,
|
||||||
priority,
|
priority,
|
||||||
entry: labels
|
entry: labels
|
||||||
@@ -916,7 +984,13 @@ export function createLocalGeoJsonLayer(
|
|||||||
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||||
if (!_enabled || !_dataSource) return;
|
if (!_enabled || !_dataSource) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return;
|
// One sample budget per frame, shared by the queue and the walk.
|
||||||
|
_ground.sampleMs = 0;
|
||||||
|
sampleQueuedGround(viewer, now);
|
||||||
|
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) {
|
||||||
|
requestQueuedGroundFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_lastVisibilityUpdate = now;
|
_lastVisibilityUpdate = now;
|
||||||
|
|
||||||
const cameraPos = viewer.camera.positionWC;
|
const cameraPos = viewer.camera.positionWC;
|
||||||
@@ -967,6 +1041,9 @@ export function createLocalGeoJsonLayer(
|
|||||||
_lastGroundSampleCapability = canSampleGround;
|
_lastGroundSampleCapability = canSampleGround;
|
||||||
let groundRetryPending = false;
|
let groundRetryPending = false;
|
||||||
let groundSampleProgress = false;
|
let groundSampleProgress = false;
|
||||||
|
// This walk asks for no height while tiles load, so it shows the
|
||||||
|
// surface the load itself; see terrainSurface.js observe().
|
||||||
|
_ground.terrain.observe?.(viewer);
|
||||||
|
|
||||||
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
|
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
|
||||||
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
|
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
|
||||||
@@ -1028,9 +1105,10 @@ export function createLocalGeoJsonLayer(
|
|||||||
const terrainFloorChanged = refreshLocalTerrainFloor(
|
const terrainFloorChanged = refreshLocalTerrainFloor(
|
||||||
viewer,
|
viewer,
|
||||||
record,
|
record,
|
||||||
|
_ground,
|
||||||
);
|
);
|
||||||
if (refreshStemGeometry || terrainFloorChanged) {
|
if (refreshStemGeometry || terrainFloorChanged) {
|
||||||
updateLocalStemGeometry(viewer, record, now);
|
updateLocalStemGeometry(viewer, record, now, _ground);
|
||||||
} else if (
|
} else if (
|
||||||
canSampleGround &&
|
canSampleGround &&
|
||||||
!record.groundSampled &&
|
!record.groundSampled &&
|
||||||
@@ -1044,9 +1122,9 @@ export function createLocalGeoJsonLayer(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||||
sampleLocalGroundHeight(viewer, record, now)
|
sampleLocalGroundHeight(viewer, record, now, _ground)
|
||||||
) {
|
) {
|
||||||
updateLocalStemGeometry(viewer, record, now, distance);
|
updateLocalStemGeometry(viewer, record, now, _ground, distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!wasGroundSampled && record.groundSampled)
|
if (!wasGroundSampled && record.groundSampled)
|
||||||
@@ -1058,16 +1136,19 @@ export function createLocalGeoJsonLayer(
|
|||||||
// the user happens to move. Schedule the frame the retry needs.
|
// the user happens to move. Schedule the frame the retry needs.
|
||||||
// Gated on a sampleable scene and in-range, in-view records only,
|
// Gated on a sampleable scene and in-range, in-view records only,
|
||||||
// so a far camera (or a keyless scene) stays fully idle; the
|
// so a far camera (or a keyless scene) stays fully idle; the
|
||||||
// distance is only computed for still-unsampled stems.
|
// distance is only computed for still-unsampled stems. A record
|
||||||
|
// the frame budget queued is sampled by the frame the queue asks
|
||||||
|
// for, with no timer.
|
||||||
if (
|
if (
|
||||||
canSampleGround &&
|
canSampleGround &&
|
||||||
!record.groundSampled &&
|
!record.groundSampled &&
|
||||||
|
!record.groundSampleQueued &&
|
||||||
!groundRetryPending &&
|
!groundRetryPending &&
|
||||||
Cesium.Cartesian3.distance(
|
Cesium.Cartesian3.distance(
|
||||||
viewer.camera.positionWC,
|
viewer.camera.positionWC,
|
||||||
record.base,
|
record.base,
|
||||||
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||||
groundSampleInView(viewer, record)
|
groundSampleInView(viewer, record, _ground)
|
||||||
) {
|
) {
|
||||||
groundRetryPending = true;
|
groundRetryPending = true;
|
||||||
}
|
}
|
||||||
@@ -1078,6 +1159,7 @@ export function createLocalGeoJsonLayer(
|
|||||||
// so the records still waiting get their own bounded run of retries.
|
// so the records still waiting get their own bounded run of retries.
|
||||||
if (groundSampleProgress) _groundRetryArms = 0;
|
if (groundSampleProgress) _groundRetryArms = 0;
|
||||||
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
||||||
|
requestQueuedGroundFrame();
|
||||||
|
|
||||||
const canvas = viewer.scene.canvas;
|
const canvas = viewer.scene.canvas;
|
||||||
const cohort = selectLocalInfrastructureOverlayCohort(
|
const cohort = selectLocalInfrastructureOverlayCohort(
|
||||||
@@ -1268,7 +1350,7 @@ function insertLocalCellContender(contenders, record) {
|
|||||||
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshLocalTerrainFloor(viewer, record) {
|
function refreshLocalTerrainFloor(viewer, record, ground) {
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
||||||
return false;
|
return false;
|
||||||
@@ -1280,7 +1362,15 @@ function refreshLocalTerrainFloor(viewer, record) {
|
|||||||
// Camera motion can refine terrain after the first successful depth sample.
|
// Camera motion can refine terrain after the first successful depth sample.
|
||||||
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
||||||
// another GPU readback just to maintain this floor. Keep roof elevations.
|
// another GPU readback just to maintain this floor. Keep roof elevations.
|
||||||
const height = globe.getHeight?.(record.carto);
|
// The surface keeps the height per point and casts the ray again only after
|
||||||
|
// the camera moves or tiles load. MARKER: the answer is this point's own
|
||||||
|
// height, never a neighbour's, since a height taken here is kept for good.
|
||||||
|
const height = ground.terrain.height(
|
||||||
|
viewer,
|
||||||
|
record.latDeg,
|
||||||
|
record.lonDeg,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(height) ||
|
!Number.isFinite(height) ||
|
||||||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
||||||
@@ -1323,11 +1413,20 @@ function groundSamplePoint(record, height, result) {
|
|||||||
* sample can accept, so high or below-ellipsoid ground in view is never
|
* sample can accept, so high or below-ellipsoid ground in view is never
|
||||||
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
||||||
* sees it.
|
* sees it.
|
||||||
|
*
|
||||||
|
* The terrain height comes from the shared surface, which keeps it per point:
|
||||||
|
* this runs for every unsampled site in range on every walk, and a ray cast
|
||||||
|
* each time was 17,733 casts and 1.3 s over one zoom. The view test needs the
|
||||||
|
* height only roughly, so a height the surface has not read yet, or tiles
|
||||||
|
* still loading, count as unknown ground.
|
||||||
*/
|
*/
|
||||||
function groundSampleInView(viewer, record) {
|
function groundSampleInView(viewer, record, ground) {
|
||||||
const camera = viewer.camera;
|
const camera = viewer.camera;
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
|
const terrain =
|
||||||
|
globe?.show && globe.tilesLoaded !== false
|
||||||
|
? ground.terrain.height(viewer, record.latDeg, record.lonDeg)
|
||||||
|
: undefined;
|
||||||
const known =
|
const known =
|
||||||
Number.isFinite(terrain) &&
|
Number.isFinite(terrain) &&
|
||||||
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||||
@@ -1368,18 +1467,30 @@ function groundSampleInView(viewer, record) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sampleLocalGroundHeight(viewer, record, now) {
|
function sampleLocalGroundHeight(viewer, record, now, ground) {
|
||||||
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
||||||
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
||||||
if (!groundSampleInView(viewer, record)) return false;
|
// This frame's samples have used its budget: leave the record for the next
|
||||||
|
// frame, unattempted, so the retry spacing does not hold it back.
|
||||||
|
if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) {
|
||||||
|
if (!record.groundSampleQueued) {
|
||||||
|
record.groundSampleQueued = true;
|
||||||
|
ground.queued.push(record);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!groundSampleInView(viewer, record, ground)) return false;
|
||||||
record.lastGroundSampleMs = now;
|
record.lastGroundSampleMs = now;
|
||||||
const globe = viewer.scene.globe;
|
const globe = viewer.scene.globe;
|
||||||
if (globe?.show && globe.tilesLoaded === false) return false;
|
if (globe?.show && globe.tilesLoaded === false) return false;
|
||||||
let sampled;
|
let sampled;
|
||||||
|
const startedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
|
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
|
||||||
} catch {
|
} catch {
|
||||||
return false; // tiles not ready; retry on a later bounded update
|
return false; // tiles not ready; retry on a later bounded update
|
||||||
|
} finally {
|
||||||
|
ground.sampleMs += performance.now() - startedAt;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(sampled) ||
|
!Number.isFinite(sampled) ||
|
||||||
@@ -1389,7 +1500,9 @@ function sampleLocalGroundHeight(viewer, record, now) {
|
|||||||
// A coarse scene-depth sample can lie below the terrain already loaded by
|
// A coarse scene-depth sample can lie below the terrain already loaded by
|
||||||
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
||||||
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
||||||
// inactive terrain must not constrain their geometry.
|
// inactive terrain must not constrain their geometry. Read from the globe
|
||||||
|
// itself, once per site: the floor under a sample must be the mesh that is
|
||||||
|
// loaded now, not a height kept from a moment before.
|
||||||
const terrainHeight = globe?.show
|
const terrainHeight = globe?.show
|
||||||
? globe.getHeight?.(record.carto)
|
? globe.getHeight?.(record.carto)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1416,12 +1529,18 @@ function setLocalGroundHeight(record, height) {
|
|||||||
record.entity.__localBaseCartesian = record.base;
|
record.entity.__localBaseCartesian = record.base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
|
function updateLocalStemGeometry(
|
||||||
|
viewer,
|
||||||
|
record,
|
||||||
|
now,
|
||||||
|
ground,
|
||||||
|
knownDistance = null,
|
||||||
|
) {
|
||||||
const distance = Number.isFinite(knownDistance)
|
const distance = Number.isFinite(knownDistance)
|
||||||
? knownDistance
|
? knownDistance
|
||||||
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
||||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
||||||
sampleLocalGroundHeight(viewer, record, now);
|
sampleLocalGroundHeight(viewer, record, now, ground);
|
||||||
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
||||||
// minimum made a marker hundreds of metres tall beside a nearby building.
|
// minimum made a marker hundreds of metres tall beside a nearby building.
|
||||||
const effectiveDistance = Math.max(distance, 1);
|
const effectiveDistance = Math.max(distance, 1);
|
||||||
|
|||||||
@@ -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 { greatCircleKm } from '../geoDistance.js';
|
||||||
import { RIO_ROUTE_NAMES } from './rioRouteNames.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
|
* @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
|
* `/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,
|
* 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.
|
* 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:
|
* Admission rules for a feed:
|
||||||
* - No key, token, or registration required (identify-yourself headers are
|
* - 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.
|
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
* `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',
|
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)
|
.map((f) => f.id)
|
||||||
.sort();
|
.sort();
|
||||||
assert.deepEqual(kmh, [
|
assert.deepEqual(kmh, [
|
||||||
'azalys-blois',
|
|
||||||
'carris-metropolitana-lisboa',
|
'carris-metropolitana-lisboa',
|
||||||
'divia-dijon',
|
|
||||||
'maelis-montlucon',
|
|
||||||
'mpm-toulon',
|
'mpm-toulon',
|
||||||
'rubis-bourg-en-bresse',
|
|
||||||
'setram-le-mans',
|
|
||||||
'tam-montpellier',
|
'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
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
|
|||||||
];
|
];
|
||||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aid to navigation's disc and card accent: a bone white apart from every
|
||||||
|
* ship type's colour.
|
||||||
|
*/
|
||||||
|
export const AID_TO_NAVIGATION_STYLE = Object.freeze({
|
||||||
|
css: '#d9d2c0',
|
||||||
|
accent: '217, 210, 192',
|
||||||
|
});
|
||||||
|
|
||||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||||
// and tests match on them.
|
// and tests match on them.
|
||||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||||
|
|||||||
@@ -45,3 +45,110 @@ export function normalizeVesselType(type) {
|
|||||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind of aid to navigation an AIS aid-to-navigation report (message 21)
|
||||||
|
* names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and
|
||||||
|
* floating marks by their cardinal quadrant, lateral side, safe water,
|
||||||
|
* isolated danger or special mark, and the lights, RACONs and reference
|
||||||
|
* points around them. Code 0 names no kind.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_NAMES = Object.freeze([
|
||||||
|
'Navigation aid',
|
||||||
|
'Reference point',
|
||||||
|
'RACON',
|
||||||
|
'Fixed structure off shore',
|
||||||
|
'Emergency wreck marking buoy',
|
||||||
|
'Light, without sectors',
|
||||||
|
'Light, with sectors',
|
||||||
|
'Leading light front',
|
||||||
|
'Leading light rear',
|
||||||
|
'Beacon, Cardinal N',
|
||||||
|
'Beacon, Cardinal E',
|
||||||
|
'Beacon, Cardinal S',
|
||||||
|
'Beacon, Cardinal W',
|
||||||
|
'Beacon, Port hand',
|
||||||
|
'Beacon, Starboard hand',
|
||||||
|
'Beacon, Preferred channel port hand',
|
||||||
|
'Beacon, Preferred channel starboard hand',
|
||||||
|
'Beacon, Isolated danger',
|
||||||
|
'Beacon, Safe water',
|
||||||
|
'Beacon, Special mark',
|
||||||
|
'Cardinal Mark N',
|
||||||
|
'Cardinal Mark E',
|
||||||
|
'Cardinal Mark S',
|
||||||
|
'Cardinal Mark W',
|
||||||
|
'Port hand Mark',
|
||||||
|
'Starboard hand Mark',
|
||||||
|
'Preferred Channel Port hand',
|
||||||
|
'Preferred Channel Starboard hand',
|
||||||
|
'Isolated danger',
|
||||||
|
'Safe Water',
|
||||||
|
'Special Mark',
|
||||||
|
'Light Vessel / LANBY / Rigs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special
|
||||||
|
* mark"); "Navigation aid" for code 0 and for a code outside 0 to 31.
|
||||||
|
* @param {string|number|null|undefined} code
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function aidToNavigationTypeName(code) {
|
||||||
|
const text = String(code ?? '').trim();
|
||||||
|
const index = /^\d+$/.test(text) ? Number(text) : -1;
|
||||||
|
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every kind {@link aidToNavigationTypeName} names, lowercased. */
|
||||||
|
const AID_TYPE_NAME_SET = new Set(
|
||||||
|
AID_TYPE_NAMES.map((name) => name.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words a vessel type uses for an aid to navigation. A vessel that works on
|
||||||
|
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
|
||||||
|
* not a mark on the water.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_TEXT =
|
||||||
|
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
|
||||||
|
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
|
||||||
|
|
||||||
|
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
|
||||||
|
const AID_MMSI = /^99\d{7}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether each type text seen names an aid. Vessel layers ask per vessel on
|
||||||
|
* their draw passes, and a feed uses few distinct type texts, so each is
|
||||||
|
* matched once; the memo is bounded against a feed of free-form types.
|
||||||
|
*/
|
||||||
|
const AID_TYPE_MEMO = new Map();
|
||||||
|
const AID_TYPE_MEMO_MAX = 1024;
|
||||||
|
|
||||||
|
function typeNamesAid(text) {
|
||||||
|
let aid = AID_TYPE_MEMO.get(text);
|
||||||
|
if (aid !== undefined) return aid;
|
||||||
|
aid =
|
||||||
|
AID_TYPE_NAME_SET.has(text.toLowerCase()) ||
|
||||||
|
(AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text));
|
||||||
|
if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear();
|
||||||
|
AID_TYPE_MEMO.set(text, aid);
|
||||||
|
return aid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
|
||||||
|
* RACON or light) rather than a vessel: its type is one of the kinds an aid
|
||||||
|
* report names or uses a word for one, or its MMSI is an aid-to-navigation
|
||||||
|
* station's, or it is a virtual aid. A numeric AIS ship type never names one.
|
||||||
|
* @param {{type?: string|number|null, mmsi?: string|number|null,
|
||||||
|
* virtual?: boolean}} vessel
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isAidToNavigation({ type, mmsi, virtual } = {}) {
|
||||||
|
if (virtual === true) return true;
|
||||||
|
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
|
||||||
|
if (text && typeNamesAid(text)) return true;
|
||||||
|
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
|
||||||
|
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js';
|
||||||
|
|
||||||
|
test('an aid-to-navigation type code names its kind of aid', () => {
|
||||||
|
assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N');
|
||||||
|
assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand');
|
||||||
|
assert.equal(aidToNavigationTypeName(2), 'RACON');
|
||||||
|
assert.equal(aidToNavigationTypeName(29), 'Safe Water');
|
||||||
|
assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs');
|
||||||
|
// Code 0, a code past 31 and no code name no kind.
|
||||||
|
for (const code of [0, 32, -1, '', null, undefined, 'x'])
|
||||||
|
assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aids to navigation are known by their type, else their MMSI', () => {
|
||||||
|
for (const type of [
|
||||||
|
'Beacon, Special Mark',
|
||||||
|
'Cardinal Mark N',
|
||||||
|
'Port Hand Mark',
|
||||||
|
'Starboard Hand Mark',
|
||||||
|
'Safe Water',
|
||||||
|
'Isolated danger',
|
||||||
|
'Preferred Channel Port hand',
|
||||||
|
'RACON',
|
||||||
|
'Light Vessel / LANBY / Rigs',
|
||||||
|
'Reference point',
|
||||||
|
'Navigation aid',
|
||||||
|
'AtoN',
|
||||||
|
'Lighthouse',
|
||||||
|
'Buoy',
|
||||||
|
// Every kind an aid report names is an aid, whatever its MMSI.
|
||||||
|
'Light, with sectors',
|
||||||
|
'Leading light rear',
|
||||||
|
'Fixed structure off shore',
|
||||||
|
'Emergency wreck marking buoy',
|
||||||
|
])
|
||||||
|
assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type);
|
||||||
|
// A virtual aid is an aid whatever its type or MMSI.
|
||||||
|
assert.equal(
|
||||||
|
isAidToNavigation({ type: '', mmsi: '', virtual: true }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
|
||||||
|
assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true);
|
||||||
|
assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true);
|
||||||
|
for (const vessel of [
|
||||||
|
{ type: 'Buoy Tender', mmsi: '366999001' },
|
||||||
|
{ type: 'Buoy-Laying Vessel', mmsi: '' },
|
||||||
|
{ type: 'Buoy Layer', mmsi: '' },
|
||||||
|
{ type: 'Lighter', mmsi: '' },
|
||||||
|
{ type: 'Flight deck', mmsi: '' },
|
||||||
|
{ type: 70, mmsi: 366999002 },
|
||||||
|
{ type: '70', mmsi: '366999002' },
|
||||||
|
{ type: '0', mmsi: '' },
|
||||||
|
{ type: 'Cargo', mmsi: '99' },
|
||||||
|
{ type: 'Passenger', mmsi: '9936720011' },
|
||||||
|
{},
|
||||||
|
])
|
||||||
|
assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel));
|
||||||
|
});
|
||||||
@@ -776,6 +776,8 @@
|
|||||||
"TANKER": "ناقلة",
|
"TANKER": "ناقلة",
|
||||||
"OTHER": "أخرى",
|
"OTHER": "أخرى",
|
||||||
"VESSEL": "سفينة",
|
"VESSEL": "سفينة",
|
||||||
|
"AID TO NAVIGATION": "مساعدة ملاحية",
|
||||||
|
"VIRTUAL": "افتراضي",
|
||||||
"POS: LIVE": "الموضع: مباشر",
|
"POS: LIVE": "الموضع: مباشر",
|
||||||
"POS: {time}Z": "الموضع: {time}Z",
|
"POS: {time}Z": "الموضع: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
|
|||||||
@@ -416,6 +416,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "SCHIFF",
|
"VESSEL": "SCHIFF",
|
||||||
|
"AID TO NAVIGATION": "SEEZEICHEN",
|
||||||
|
"VIRTUAL": "VIRTUELL",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
||||||
|
|||||||
@@ -650,6 +650,8 @@
|
|||||||
"TANKER": "PETROLERO",
|
"TANKER": "PETROLERO",
|
||||||
"OTHER": "OTRO",
|
"OTHER": "OTRO",
|
||||||
"VESSEL": "BUQUE",
|
"VESSEL": "BUQUE",
|
||||||
|
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
|
||||||
|
"VIRTUAL": "VIRTUAL",
|
||||||
"POS: LIVE": "POS: EN VIVO",
|
"POS: LIVE": "POS: EN VIVO",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
|
|||||||
@@ -1291,6 +1291,8 @@
|
|||||||
"AIS: {name}": "AIS : {name}",
|
"AIS: {name}": "AIS : {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
||||||
"VESSEL": "NAVIRE",
|
"VESSEL": "NAVIRE",
|
||||||
|
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
|
||||||
|
"VIRTUAL": "VIRTUEL",
|
||||||
"{speed}KT": "{speed} ND",
|
"{speed}KT": "{speed} ND",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS : DIRECT",
|
"POS: LIVE": "POS : DIRECT",
|
||||||
|
|||||||
@@ -1149,6 +1149,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "אונייה",
|
"VESSEL": "אונייה",
|
||||||
|
"AID TO NAVIGATION": "עזר ניווט",
|
||||||
|
"VIRTUAL": "וירטואלי",
|
||||||
"{speed}KT": "{speed} קשר",
|
"{speed}KT": "{speed} קשר",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: חי",
|
"POS: LIVE": "POS: חי",
|
||||||
|
|||||||
@@ -1291,6 +1291,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
||||||
"VESSEL": "NAVE",
|
"VESSEL": "NAVE",
|
||||||
|
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
|
||||||
|
"VIRTUAL": "VIRTUALE",
|
||||||
"{speed}KT": "{speed}KT",
|
"{speed}KT": "{speed}KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
|
|||||||
@@ -1253,6 +1253,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "航路標識",
|
||||||
|
"VIRTUAL": "仮想",
|
||||||
"{speed}KT": "{speed} kt",
|
"{speed}KT": "{speed} kt",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: ライブ",
|
"POS: LIVE": "POS: ライブ",
|
||||||
|
|||||||
@@ -1289,6 +1289,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "EMBARCAÇÃO",
|
"VESSEL": "EMBARCAÇÃO",
|
||||||
|
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
|
||||||
|
"VIRTUAL": "VIRTUAL",
|
||||||
"{speed}KT": "{speed} KT",
|
"{speed}KT": "{speed} KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: AO VIVO",
|
"POS: LIVE": "POS: AO VIVO",
|
||||||
|
|||||||
@@ -394,6 +394,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
|
||||||
|
"VIRTUAL": "ВИРТУАЛЬНЫЙ",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
||||||
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
||||||
|
|||||||
@@ -382,6 +382,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "FARTYG",
|
"VESSEL": "FARTYG",
|
||||||
|
"AID TO NAVIGATION": "SJÖMÄRKE",
|
||||||
|
"VIRTUAL": "VIRTUELL",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
||||||
|
|||||||
@@ -1309,6 +1309,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
|
||||||
|
"VIRTUAL": "ВІРТУАЛЬНИЙ",
|
||||||
"{speed}KT": "{speed} ВУЗ",
|
"{speed}KT": "{speed} ВУЗ",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
||||||
|
|||||||
@@ -1229,6 +1229,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航标志",
|
||||||
|
"VIRTUAL": "虚拟",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:实时",
|
"POS: LIVE": "POS:实时",
|
||||||
|
|||||||
@@ -1224,6 +1224,8 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航標誌",
|
||||||
|
"VIRTUAL": "虛擬",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:實時",
|
"POS: LIVE": "POS:實時",
|
||||||
|
|||||||
@@ -411,7 +411,8 @@
|
|||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional",
|
"Regional",
|
||||||
"{datasets} · {tiles} · {packs}"
|
"{datasets} · {tiles} · {packs}",
|
||||||
|
"VIRTUAL"
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
@@ -1067,7 +1068,8 @@
|
|||||||
"ICAO: {id}",
|
"ICAO: {id}",
|
||||||
"MMSI: {id}",
|
"MMSI: {id}",
|
||||||
"NORAD: {id}",
|
"NORAD: {id}",
|
||||||
"Regional"
|
"Regional",
|
||||||
|
"VIRTUAL"
|
||||||
],
|
],
|
||||||
"ru": [
|
"ru": [
|
||||||
"1900–1945",
|
"1900–1945",
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// Station ground heights. A city's stations are all drawn at once at the
|
||||||
|
// fallback height; their ground heights are found afterwards a few
|
||||||
|
// milliseconds at a time, so a city of hundreds of docks never holds the main
|
||||||
|
// thread for the length of hundreds of depth renders. Each case drives the
|
||||||
|
// real rendering part (and the real lifecycle where it tears down) over a
|
||||||
|
// scene whose height reads are counted and timed.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createState } from './state.js';
|
||||||
|
import { createModel } from './model.js';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
import { createLifecycle } from './lifecycle.js';
|
||||||
|
import { POINT_HEIGHT_OFFSET_M } from './policy.js';
|
||||||
|
import { createTerrainSurface } from '../../services/terrainSurface.js';
|
||||||
|
|
||||||
|
globalThis.document ??= {
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Milliseconds one fake depth render holds the thread. */
|
||||||
|
const SAMPLE_COST_MS = 1;
|
||||||
|
/** The ground under a point, as the fake scene and globe both report it. */
|
||||||
|
const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10;
|
||||||
|
|
||||||
|
function busyWait(ms) {
|
||||||
|
const until = performance.now() + ms;
|
||||||
|
while (performance.now() < until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `count` stations on a line through Boston. */
|
||||||
|
function stations(count, prefix = 's') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
stations: Array.from({ length: count }, (_, i) => ({
|
||||||
|
station_id: `${prefix}${i}`,
|
||||||
|
name: `Dock ${i}`,
|
||||||
|
lat: 42.3 + i * 1e-4,
|
||||||
|
lon: -71.1 + i * 1e-4,
|
||||||
|
capacity: 10,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendering part over a scene that counts its height reads.
|
||||||
|
* @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options
|
||||||
|
* `terrainGlobe`: the globe is showing (else photorealistic tiles hide it);
|
||||||
|
* `shared`: the application's terrain surface is supplied;
|
||||||
|
* `unloaded`: latitudes whose terrain tile is not drawn.
|
||||||
|
*/
|
||||||
|
function makeLayer({
|
||||||
|
terrainGlobe = false,
|
||||||
|
shared = false,
|
||||||
|
unloaded = () => false,
|
||||||
|
} = {}) {
|
||||||
|
const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 };
|
||||||
|
// sampleHeight calls in this turn of the event loop, and the most in any
|
||||||
|
// turn so far. Two numbers, not a list: a slow run has a great many turns.
|
||||||
|
let thisTurn = 0;
|
||||||
|
let mostInATurn = 0;
|
||||||
|
let ticking = true;
|
||||||
|
const tick = () => {
|
||||||
|
if (!ticking) return;
|
||||||
|
thisTurn = 0;
|
||||||
|
setImmediate(tick);
|
||||||
|
};
|
||||||
|
setImmediate(tick);
|
||||||
|
|
||||||
|
const degrees = (carto) => [
|
||||||
|
Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
];
|
||||||
|
const viewer = {
|
||||||
|
camera: {
|
||||||
|
positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000),
|
||||||
|
changed: { addEventListener() {}, removeEventListener() {} },
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
sampleHeightSupported: true,
|
||||||
|
sampleHeight(carto) {
|
||||||
|
calls.sampleHeight += 1;
|
||||||
|
thisTurn += 1;
|
||||||
|
if (thisTurn > mostInATurn) mostInATurn = thisTurn;
|
||||||
|
busyWait(SAMPLE_COST_MS);
|
||||||
|
return groundAt(...degrees(carto));
|
||||||
|
},
|
||||||
|
globe: {
|
||||||
|
show: terrainGlobe,
|
||||||
|
tilesLoaded: true,
|
||||||
|
getHeight(carto) {
|
||||||
|
calls.getHeight += 1;
|
||||||
|
const [lat, lon] = degrees(carto);
|
||||||
|
return unloaded(lat) ? undefined : groundAt(lat, lon);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primitives: { add() {}, remove() {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const surface = createTerrainSurface();
|
||||||
|
const services = {
|
||||||
|
render: {
|
||||||
|
governorRequestRender() {
|
||||||
|
calls.render += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
setOverlayEntries() {},
|
||||||
|
setOverlaySourceVisible() {},
|
||||||
|
clearOverlaySource() {},
|
||||||
|
},
|
||||||
|
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
||||||
|
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||||
|
};
|
||||||
|
if (shared) {
|
||||||
|
services.ground = {
|
||||||
|
terrainSurface: {
|
||||||
|
...surface,
|
||||||
|
height(...args) {
|
||||||
|
calls.sharedHeight += 1;
|
||||||
|
return surface.height(...args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const state = createState({ services });
|
||||||
|
const parts = {};
|
||||||
|
const context = { state, services, parts, source: {} };
|
||||||
|
parts.model = createModel(context);
|
||||||
|
parts.rendering = createRendering(context);
|
||||||
|
parts.selection = {
|
||||||
|
_installClickHandler() {},
|
||||||
|
_clearSelection() {},
|
||||||
|
_onKeyDown() {},
|
||||||
|
};
|
||||||
|
parts.ingestion = { abortAllInFlight() {} };
|
||||||
|
// Leaves the points alone, so a test of disable() sees what disable() stops.
|
||||||
|
parts.viewport = {
|
||||||
|
onCameraChanged() {},
|
||||||
|
deactivateAllCities() {},
|
||||||
|
runProximityCheck: async () => {},
|
||||||
|
};
|
||||||
|
parts.lifecycle = createLifecycle(context);
|
||||||
|
parts.lifecycle.methods.init(viewer);
|
||||||
|
state._pointCollection = {
|
||||||
|
show: true,
|
||||||
|
add: (options) => ({ ...options }),
|
||||||
|
remove() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const heightOf = (key) =>
|
||||||
|
Cesium.Cartographic.fromCartesian(
|
||||||
|
state._stationRenderMap.get(key).point.position,
|
||||||
|
).height;
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
viewer,
|
||||||
|
calls,
|
||||||
|
rendering: parts.rendering,
|
||||||
|
lifecycle: parts.lifecycle.methods,
|
||||||
|
heightOf,
|
||||||
|
add(cityId, count) {
|
||||||
|
const info = parts.model.parseStationInformation(stations(count, cityId));
|
||||||
|
parts.rendering.ensureCityPoints(cityId, info);
|
||||||
|
return info;
|
||||||
|
},
|
||||||
|
/** Wait until every queued station has its height, or fail. */
|
||||||
|
async refined() {
|
||||||
|
const deadline = performance.now() + 20_000;
|
||||||
|
while (parts.rendering.pendingHeightCount() > 0) {
|
||||||
|
assert.ok(performance.now() < deadline, 'refinement finished');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** The most sampleHeight calls made without yielding to the loop. */
|
||||||
|
longestBurst() {
|
||||||
|
return mostInATurn;
|
||||||
|
},
|
||||||
|
done() {
|
||||||
|
ticking = false;
|
||||||
|
parts.rendering.stopHeightRefinement();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
test('a city of stations is drawn at once without sampling heights', (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
const started = performance.now();
|
||||||
|
layer.add('boston', 300);
|
||||||
|
const spent = performance.now() - started;
|
||||||
|
|
||||||
|
assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn');
|
||||||
|
assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing');
|
||||||
|
assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3,
|
||||||
|
'drawn at the fallback height',
|
||||||
|
);
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heights arrive afterwards in short steps and match a direct sample', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
const info = layer.add('boston', 120);
|
||||||
|
const rendersBefore = layer.calls.render;
|
||||||
|
await layer.refined();
|
||||||
|
|
||||||
|
assert.equal(layer.calls.sampleHeight, 120, 'one sample per station');
|
||||||
|
// 120 ms of samples in one go is the stall; a step is a few milliseconds.
|
||||||
|
assert.ok(
|
||||||
|
layer.longestBurst() <= 10,
|
||||||
|
`longest burst was ${layer.longestBurst()} samples`,
|
||||||
|
);
|
||||||
|
for (const station of info.values()) {
|
||||||
|
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
||||||
|
const actual = layer.heightOf(`boston:${station.stationId}`);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(actual - expected) < 1e-3,
|
||||||
|
`${station.stationId}: ${actual} vs ${expected}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => {
|
||||||
|
const layer = makeLayer({ terrainGlobe: true, shared: true });
|
||||||
|
t.after(layer.done);
|
||||||
|
const info = layer.add('boston', 200);
|
||||||
|
assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing');
|
||||||
|
await layer.refined();
|
||||||
|
|
||||||
|
assert.equal(layer.calls.sampleHeight, 0);
|
||||||
|
assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface');
|
||||||
|
assert.equal(layer.calls.getHeight, 200);
|
||||||
|
for (const station of info.values()) {
|
||||||
|
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
|
||||||
|
const actual = layer.heightOf(`boston:${station.stationId}`);
|
||||||
|
assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a shared surface the layer reads the terrain globe itself', async (t) => {
|
||||||
|
const layer = makeLayer({ terrainGlobe: true });
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 50);
|
||||||
|
await layer.refined();
|
||||||
|
assert.equal(layer.calls.sampleHeight, 0);
|
||||||
|
assert.equal(layer.calls.getHeight, 50);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(
|
||||||
|
layer.heightOf('boston:boston7') -
|
||||||
|
(groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M),
|
||||||
|
) < 1e-3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => {
|
||||||
|
// Stations 10 and up lie on a tile the globe is not drawing.
|
||||||
|
const layer = makeLayer({
|
||||||
|
terrainGlobe: true,
|
||||||
|
shared: true,
|
||||||
|
unloaded: (lat) => lat > 42.30095,
|
||||||
|
});
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 20);
|
||||||
|
await layer.refined();
|
||||||
|
assert.equal(layer.calls.sampleHeight, 10);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(
|
||||||
|
layer.heightOf('boston:boston15') -
|
||||||
|
(groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M),
|
||||||
|
) < 1e-3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a city stops its refinement and leaves the other running', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 40);
|
||||||
|
layer.add('toronto', 40);
|
||||||
|
layer.rendering.removeCityPoints('boston');
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 40);
|
||||||
|
await layer.refined();
|
||||||
|
assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city');
|
||||||
|
assert.ok(layer.heightOf('toronto:toronto3') > 50);
|
||||||
|
|
||||||
|
layer.add('boston', 40);
|
||||||
|
layer.rendering.removeCityPoints('boston');
|
||||||
|
layer.rendering.removeCityPoints('toronto');
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
||||||
|
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
||||||
|
await pause(60);
|
||||||
|
assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabling the layer stops refinement and leaves no timer', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.lifecycle.enable(layer.viewer);
|
||||||
|
layer.add('boston', 200);
|
||||||
|
await pause(30);
|
||||||
|
const partWay = layer.calls.sampleHeight;
|
||||||
|
assert.ok(partWay < 200, 'still refining');
|
||||||
|
layer.lifecycle.disable(layer.viewer);
|
||||||
|
|
||||||
|
assert.equal(layer.rendering.pendingHeightCount(), 0);
|
||||||
|
assert.equal(layer.rendering.heightRefinementScheduled(), false);
|
||||||
|
await pause(60);
|
||||||
|
assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a station selected before its height arrives is re-selected at it', async (t) => {
|
||||||
|
const layer = makeLayer();
|
||||||
|
t.after(layer.done);
|
||||||
|
layer.add('boston', 3);
|
||||||
|
const reselected = [];
|
||||||
|
layer.state._selectedKey = 'boston:boston1';
|
||||||
|
// The selection part's select path; here it records the key.
|
||||||
|
layer.parts.selection._selectStation = (key) => reselected.push(key);
|
||||||
|
await layer.refined();
|
||||||
|
assert.deepEqual(reselected, ['boston:boston1']);
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ export function createLifecycle({
|
|||||||
layerState._inFlightInfo = new Map();
|
layerState._inFlightInfo = new Map();
|
||||||
layerState._inFlightStatus = new Map();
|
layerState._inFlightStatus = new Map();
|
||||||
layerState._stationRenderMap = new Map();
|
layerState._stationRenderMap = new Map();
|
||||||
|
parts.rendering?.stopHeightRefinement?.();
|
||||||
layerState._clickHandler = null;
|
layerState._clickHandler = null;
|
||||||
layerState._selectedKey = null;
|
layerState._selectedKey = null;
|
||||||
layerState._selectedEntity = null;
|
layerState._selectedEntity = null;
|
||||||
@@ -134,6 +135,7 @@ export function createLifecycle({
|
|||||||
|
|
||||||
parts.ingestion.abortAllInFlight();
|
parts.ingestion.abortAllInFlight();
|
||||||
parts.viewport.deactivateAllCities();
|
parts.viewport.deactivateAllCities();
|
||||||
|
parts.rendering?.stopHeightRefinement?.();
|
||||||
layerState._cityRuntime.clear();
|
layerState._cityRuntime.clear();
|
||||||
layerState._pointCollection.show = false;
|
layerState._pointCollection.show = false;
|
||||||
layerState._count = 0;
|
layerState._count = 0;
|
||||||
@@ -165,6 +167,7 @@ export function createLifecycle({
|
|||||||
layerState._cameraChangedAttached = false;
|
layerState._cameraChangedAttached = false;
|
||||||
}
|
}
|
||||||
parts.ingestion.abortAllInFlight();
|
parts.ingestion.abortAllInFlight();
|
||||||
|
parts.rendering?.stopHeightRefinement?.();
|
||||||
if (layerState._pointCollection) {
|
if (layerState._pointCollection) {
|
||||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||||
layerState._pointCollection = null;
|
layerState._pointCollection = null;
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15;
|
|||||||
|
|
||||||
export const POINT_HEIGHT_OFFSET_M = 2.0;
|
export const POINT_HEIGHT_OFFSET_M = 2.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds one step may spend finding station ground heights. A height
|
||||||
|
* the terrain globe cannot give is a depth render of a millisecond or more.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const HEIGHT_REFINE_BUDGET_MS = 4;
|
||||||
|
|
||||||
|
/** Milliseconds between those steps, about a frame. */
|
||||||
|
|
||||||
|
export const HEIGHT_REFINE_INTERVAL_MS = 16;
|
||||||
|
|
||||||
/** Hard cap on total rendered station points across all cities. */
|
/** Hard cap on total rendered station points across all cities. */
|
||||||
|
|
||||||
export const MAX_TOTAL_POINTS = 8000;
|
export const MAX_TOTAL_POINTS = 8000;
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import {
|
|||||||
POINT_HEIGHT_OFFSET_M,
|
POINT_HEIGHT_OFFSET_M,
|
||||||
MAX_TOTAL_POINTS,
|
MAX_TOTAL_POINTS,
|
||||||
COLOR_OUTLINE,
|
COLOR_OUTLINE,
|
||||||
|
HEIGHT_REFINE_BUDGET_MS,
|
||||||
|
HEIGHT_REFINE_INTERVAL_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import {
|
||||||
|
createTerrainSurface,
|
||||||
|
TERRAIN_NEED,
|
||||||
|
} from '../../services/terrainSurface.js';
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
@@ -20,6 +26,14 @@ export function createRendering({
|
|||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
const { governorRequestRender } = services.render;
|
const { governorRequestRender } = services.render;
|
||||||
|
// The terrain globe's loaded surface: the application's shared reader, or
|
||||||
|
// the layer's own when it is built without one.
|
||||||
|
const terrainSurface =
|
||||||
|
services.ground?.terrainSurface || createTerrainSurface();
|
||||||
|
/** Stations drawn at the fallback height, waiting for their ground height. */
|
||||||
|
let heightQueue = [];
|
||||||
|
let heightTimer = null;
|
||||||
|
const heightPoint = new Cesium.Cartographic();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the effective dock capacity for a station.
|
* Resolve the effective dock capacity for a station.
|
||||||
@@ -87,8 +101,10 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Cartesian3 position for a station, terrain-clamped when possible.
|
* Create a Cartesian3 position for a station at the fallback height. Its
|
||||||
* Falls back to a fixed small height offset if terrain sampling is unsupported.
|
* ground height is found afterwards (refineHeights): a city's stations are
|
||||||
|
* drawn at once, and finding hundreds of heights in the same task held the
|
||||||
|
* main thread for most of a second.
|
||||||
* @param {Object} station - Station info object with lat/lon.
|
* @param {Object} station - Station info object with lat/lon.
|
||||||
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
||||||
*/
|
*/
|
||||||
@@ -97,18 +113,78 @@ export function createRendering({
|
|||||||
const lon = Number(station?.lon);
|
const lon = Number(station?.lon);
|
||||||
const lat = Number(station?.lat);
|
const lat = Number(station?.lat);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
|
||||||
let height = POINT_HEIGHT_OFFSET_M;
|
|
||||||
// Sample terrain height so points sit on ground rather than at ellipsoid level
|
|
||||||
if (layerState._viewer?.scene?.sampleHeightSupported) {
|
|
||||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
|
||||||
const sampled = layerState._viewer.scene.sampleHeight(carto);
|
|
||||||
if (Number.isFinite(sampled)) {
|
|
||||||
height = sampled + POINT_HEIGHT_OFFSET_M;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
/**
|
||||||
|
* The ground height under a station, or undefined when the scene cannot say.
|
||||||
|
* A terrain globe answers from its loaded surface, which is cheap. Where it
|
||||||
|
* cannot (the globe is hidden under photorealistic tiles, or draws no tile
|
||||||
|
* at the point) the scene renders the depth there, at a millisecond or more
|
||||||
|
* a point.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function sampleGroundHeight(lat, lon) {
|
||||||
|
const viewer = layerState._viewer;
|
||||||
|
const scene = viewer?.scene;
|
||||||
|
if (!scene) return undefined;
|
||||||
|
// A station is a marker: its one read is not left for a later window.
|
||||||
|
// The step's own time budget bounds what a pass spends.
|
||||||
|
const terrain = terrainSurface.height(
|
||||||
|
viewer,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
|
if (Number.isFinite(terrain)) return terrain;
|
||||||
|
if (!scene.sampleHeightSupported) return undefined;
|
||||||
|
Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint);
|
||||||
|
const sampled = scene.sampleHeight(heightPoint);
|
||||||
|
return Number.isFinite(sampled) ? sampled : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move queued stations onto the ground for a few milliseconds, then leave
|
||||||
|
* the rest to the next step. A station the scene has no height for keeps
|
||||||
|
* the fallback height, as it did when heights were read while drawing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function refineHeights() {
|
||||||
|
heightTimer = null;
|
||||||
|
const started = performance.now();
|
||||||
|
let moved = false;
|
||||||
|
while (
|
||||||
|
heightQueue.length > 0 &&
|
||||||
|
performance.now() - started < HEIGHT_REFINE_BUDGET_MS
|
||||||
|
) {
|
||||||
|
const { record, lat, lon } = heightQueue.pop();
|
||||||
|
const ground = sampleGroundHeight(lat, lon);
|
||||||
|
if (ground === undefined) continue;
|
||||||
|
record.point.position = Cesium.Cartesian3.fromDegrees(
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
ground + POINT_HEIGHT_OFFSET_M,
|
||||||
|
);
|
||||||
|
moved = true;
|
||||||
|
// The highlight and card of a selected station copy its position.
|
||||||
|
if (layerState._selectedKey === record.key)
|
||||||
|
parts.selection._selectStation(record.key);
|
||||||
|
}
|
||||||
|
if (moved) governorRequestRender('bikeshare-heights');
|
||||||
|
scheduleHeightRefinement();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHeightRefinement() {
|
||||||
|
if (heightTimer !== null || heightQueue.length === 0) return;
|
||||||
|
heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every queued station and the pending step. */
|
||||||
|
|
||||||
|
function stopHeightRefinement() {
|
||||||
|
heightQueue = [];
|
||||||
|
clearTimeout(heightTimer);
|
||||||
|
heightTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +254,7 @@ export function createRendering({
|
|||||||
id: key,
|
id: key,
|
||||||
});
|
});
|
||||||
|
|
||||||
layerState._stationRenderMap.set(key, {
|
const record = {
|
||||||
key,
|
key,
|
||||||
cityId,
|
cityId,
|
||||||
stationId: station.stationId,
|
stationId: station.stationId,
|
||||||
@@ -191,12 +267,19 @@ export function createRendering({
|
|||||||
isInstalled: station.isInstalled,
|
isInstalled: station.isInstalled,
|
||||||
isRenting: station.isRenting,
|
isRenting: station.isRenting,
|
||||||
isReturning: station.isReturning,
|
isReturning: station.isReturning,
|
||||||
|
};
|
||||||
|
layerState._stationRenderMap.set(key, record);
|
||||||
|
heightQueue.push({
|
||||||
|
record,
|
||||||
|
lat: Number(station.lat),
|
||||||
|
lon: Number(station.lon),
|
||||||
});
|
});
|
||||||
|
|
||||||
runtime.stationKeys.add(key);
|
runtime.stationKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
layerState._count = layerState._stationRenderMap.size;
|
layerState._count = layerState._stationRenderMap.size;
|
||||||
|
scheduleHeightRefinement();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,6 +307,12 @@ export function createRendering({
|
|||||||
layerState._stationRenderMap.delete(key);
|
layerState._stationRenderMap.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Its stations no longer wait for a height.
|
||||||
|
heightQueue = heightQueue.filter(
|
||||||
|
({ record }) => layerState._stationRenderMap.get(record.key) === record,
|
||||||
|
);
|
||||||
|
if (heightQueue.length === 0) stopHeightRefinement();
|
||||||
|
|
||||||
layerState._cityRuntime.delete(cityId);
|
layerState._cityRuntime.delete(cityId);
|
||||||
layerState._count = layerState._stationRenderMap.size;
|
layerState._count = layerState._stationRenderMap.size;
|
||||||
}
|
}
|
||||||
@@ -280,5 +369,10 @@ export function createRendering({
|
|||||||
ensureCityPoints,
|
ensureCityPoints,
|
||||||
removeCityPoints,
|
removeCityPoints,
|
||||||
applyStatusToPoints,
|
applyStatusToPoints,
|
||||||
|
stopHeightRefinement,
|
||||||
|
/** Stations still waiting for their ground height. */
|
||||||
|
pendingHeightCount: () => heightQueue.length,
|
||||||
|
/** Whether a refinement step is waiting on a timer. */
|
||||||
|
heightRefinementScheduled: () => heightTimer !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
|
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
|
||||||
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
||||||
|
import {
|
||||||
|
TERRAIN_NEED,
|
||||||
|
createTerrainSurface,
|
||||||
|
} from '../../services/terrainSurface.js';
|
||||||
import {
|
import {
|
||||||
COLD_CELL_FLUSH_MS,
|
COLD_CELL_FLUSH_MS,
|
||||||
COLD_CELL_RETRY_MS,
|
COLD_CELL_RETRY_MS,
|
||||||
@@ -44,6 +48,11 @@ const CATEGORY_TINTS = Object.fromEntries(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const SPRITE_PX = 64;
|
const SPRITE_PX = 64;
|
||||||
|
/**
|
||||||
|
* Terrain reads per degree for a train's height: about 3.7 m apart. At 11 m
|
||||||
|
* a train on fine tiles stood up to a couple of metres off the surface.
|
||||||
|
*/
|
||||||
|
const TERRAIN_GRID = 3e4;
|
||||||
/** Billboard tints; the setter copies, so sharing one instance is safe. */
|
/** Billboard tints; the setter copies, so sharing one instance is safe. */
|
||||||
const FULL_TINT = Cesium.Color.WHITE;
|
const FULL_TINT = Cesium.Color.WHITE;
|
||||||
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
|
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
|
||||||
@@ -117,8 +126,11 @@ export function createRendering({ state, services, parts }) {
|
|||||||
neighborFloorM = null,
|
neighborFloorM = null,
|
||||||
coarseFloorCoord = null,
|
coarseFloorCoord = null,
|
||||||
} = services.ground;
|
} = services.ground;
|
||||||
|
// The terrain globe's loaded surface: the application's shared reader, or
|
||||||
|
// the layer's own when it is built without one.
|
||||||
|
const terrainSurface =
|
||||||
|
services.ground.terrainSurface || createTerrainSurface();
|
||||||
const sprites = new Map();
|
const sprites = new Map();
|
||||||
const terrainPoint = new Cesium.Cartographic();
|
|
||||||
const scratchPosition = new Cesium.Cartesian3();
|
const scratchPosition = new Cesium.Cartesian3();
|
||||||
/** Scratch for the on-line position; place() runs every frame. */
|
/** Scratch for the on-line position; place() runs every frame. */
|
||||||
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
|
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
|
||||||
@@ -395,8 +407,18 @@ export function createRendering({ state, services, parts }) {
|
|||||||
function floorHeight(lat, lon, entry = null, stop = false) {
|
function floorHeight(lat, lon, entry = null, stop = false) {
|
||||||
const globe = state._viewer?.scene?.globe;
|
const globe = state._viewer?.scene?.globe;
|
||||||
if (globe?.show === true) {
|
if (globe?.show === true) {
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
// A moving train is asked about every frame, at a new point each
|
||||||
const rendered = globe.getHeight?.(terrainPoint);
|
// time. On a ~4 m grid it asks the terrain a few times a second
|
||||||
|
// instead, and a standing train not at all; the drawn height is
|
||||||
|
// smoothed along the track anyway (displayHeightFor). Read as a
|
||||||
|
// marker, never rationed: a train that starts on a fallback floor only
|
||||||
|
// creeps to the terrain at a railway's grade.
|
||||||
|
const rendered = terrainSurface.height(
|
||||||
|
state._viewer,
|
||||||
|
Math.round(lat * TERRAIN_GRID) / TERRAIN_GRID,
|
||||||
|
Math.round(lon * TERRAIN_GRID) / TERRAIN_GRID,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
if (Number.isFinite(rendered))
|
if (Number.isFinite(rendered))
|
||||||
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
|
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
|
|
||||||
import { buildTransitCoveragePoints } from '../../data/transitCoverage.js';
|
|
||||||
import { COVERAGE_PEAK_ALPHA, coverageOpacityAt } from './policy.js';
|
|
||||||
|
|
||||||
/** Height of the spots above the ellipsoid, metres: on the surface. */
|
|
||||||
const SPOT_HEIGHT_M = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Gaussian falloff from the centre of each spot to exactly zero at its
|
|
||||||
* rim. No plateau and no edge, so overlapping spots sum into one smooth
|
|
||||||
* field instead of showing where each circle ends.
|
|
||||||
*/
|
|
||||||
const GLOW_SHADER = `
|
|
||||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
|
||||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
|
||||||
vec2 p = (materialInput.st - 0.5) * 2.0;
|
|
||||||
float sigma2 = 2.0 * 0.46 * 0.46;
|
|
||||||
float rim = exp(-1.0 / sigma2);
|
|
||||||
float glow = clamp((exp(-dot(p, p) / sigma2) - rim) / (1.0 - rim), 0.0, 1.0);
|
|
||||||
material.diffuse = color.rgb;
|
|
||||||
material.alpha = color.a * glow;
|
|
||||||
return material;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The coverage glow.
|
|
||||||
*
|
|
||||||
* Above the altitude gate the layer polls nothing and draws no vehicles, so
|
|
||||||
* a person looking at the whole Earth had no way to tell where Transit has
|
|
||||||
* anything to show. This draws one soft spot per feed seen live, built once
|
|
||||||
* from the registry: wide spots from orbit, cross-fading into patches the
|
|
||||||
* size of each feed's service area as a backdrop under the vehicles at
|
|
||||||
* continental and regional zoom, and off close in. Overlapping spots blend,
|
|
||||||
* so regions with many networks read hotter. It never polls a feed.
|
|
||||||
*
|
|
||||||
* Spots are flat discs drawn in the OPAQUE pass with ordinary alpha
|
|
||||||
* blending, not billboards and not draped imagery. Translucent billboards
|
|
||||||
* go through Cesium's order-independent transparency, whose weights clamp at
|
|
||||||
* orbital distances and turn a faint glow into a dark disc with a rim; and
|
|
||||||
* the photoreal tiles do not take a draped raster. A disc blended in the
|
|
||||||
* opaque pass is drawn over the tiles and the globe alike, under the vehicle
|
|
||||||
* markers, and is smooth at any strength.
|
|
||||||
*
|
|
||||||
* @param {object} context
|
|
||||||
* @returns {object}
|
|
||||||
*/
|
|
||||||
export function createCoverage({ state }) {
|
|
||||||
function batch(points, radiusKm, set) {
|
|
||||||
const [r, g, b] = points[0].rgb;
|
|
||||||
const material = new Cesium.Material({
|
|
||||||
translucent: false,
|
|
||||||
fabric: {
|
|
||||||
uniforms: { color: Cesium.Color.fromBytes(r, g, b, 0) },
|
|
||||||
source: GLOW_SHADER,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const vertexFormat =
|
|
||||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat;
|
|
||||||
const primitive = new Cesium.Primitive({
|
|
||||||
geometryInstances: points.map(
|
|
||||||
(point) =>
|
|
||||||
new Cesium.GeometryInstance({
|
|
||||||
geometry: new Cesium.EllipseGeometry({
|
|
||||||
center: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
|
||||||
semiMajorAxis: radiusKm(point) * 1000,
|
|
||||||
semiMinorAxis: radiusKm(point) * 1000,
|
|
||||||
height: SPOT_HEIGHT_M,
|
|
||||||
vertexFormat,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
appearance: new Cesium.MaterialAppearance({
|
|
||||||
material,
|
|
||||||
flat: true,
|
|
||||||
translucent: false,
|
|
||||||
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
|
|
||||||
renderState: {
|
|
||||||
// No depth test: at orbital distances the photoreal mesh is a few
|
|
||||||
// coarse facets that bulge kilometres off the ellipsoid, and a
|
|
||||||
// disc tested against it comes out speckled. Back-face culling
|
|
||||||
// does the hiding instead. A disc lies along the surface, so it
|
|
||||||
// faces away from the camera exactly when it is over the horizon.
|
|
||||||
depthTest: { enabled: false },
|
|
||||||
depthMask: false,
|
|
||||||
cull: { enabled: true, face: Cesium.CullFace.BACK },
|
|
||||||
blending: Cesium.BlendingState.ALPHA_BLEND,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asynchronous: false,
|
|
||||||
allowPicking: false,
|
|
||||||
show: false,
|
|
||||||
});
|
|
||||||
state._viewer.scene.primitives.add(primitive);
|
|
||||||
// A regional patch is the network's real footprint, and neighbours there
|
|
||||||
// overlap far less than the wide orbit spots do, so it is not thinned.
|
|
||||||
const strength = set === 'near' ? 1 : points[0].strength;
|
|
||||||
return { primitive, material, strength, set };
|
|
||||||
}
|
|
||||||
|
|
||||||
function build() {
|
|
||||||
if (!state._viewer?.scene?.primitives) return null;
|
|
||||||
const buckets = new Map();
|
|
||||||
for (const point of buildTransitCoveragePoints(TRANSIT_ENABLED_FEEDS)) {
|
|
||||||
if (!buckets.has(point.bucket)) buckets.set(point.bucket, []);
|
|
||||||
buckets.get(point.bucket).push(point);
|
|
||||||
}
|
|
||||||
const batches = [];
|
|
||||||
for (const points of buckets.values()) {
|
|
||||||
batches.push(batch(points, (point) => point.farRadiusKm, 'far'));
|
|
||||||
batches.push(batch(points, (point) => point.nearRadiusKm, 'near'));
|
|
||||||
}
|
|
||||||
return batches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match the glow to the camera. Cheap enough for every camera event: the
|
|
||||||
* spots are built on the first call that needs them and never again, and a
|
|
||||||
* zoom only moves one colour uniform per batch.
|
|
||||||
* @param {number} altitude Camera height above the ellipsoid, metres.
|
|
||||||
*/
|
|
||||||
function sync(altitude) {
|
|
||||||
if (!state._enabled || !state._viewer) return;
|
|
||||||
const opacity = {
|
|
||||||
far: coverageOpacityAt(altitude, 'far'),
|
|
||||||
near: coverageOpacityAt(altitude, 'near'),
|
|
||||||
};
|
|
||||||
const key = `${opacity.far}/${opacity.near}`;
|
|
||||||
if (key === state._coverageOpacity) return;
|
|
||||||
state._coverageOpacity = key;
|
|
||||||
// Nothing built and nothing to show: do not build for a session that
|
|
||||||
// never leaves street level.
|
|
||||||
if (!state._coverageSpots) {
|
|
||||||
if (opacity.far === 0 && opacity.near === 0) return;
|
|
||||||
try {
|
|
||||||
state._coverageSpots = build();
|
|
||||||
} catch (error) {
|
|
||||||
// A host that cannot build geometry (headless tests) has no glow.
|
|
||||||
state._coverageSpots = [];
|
|
||||||
console.warn('[Data:Transit] Coverage glow unavailable:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const entry of state._coverageSpots || []) {
|
|
||||||
const value = opacity[entry.set];
|
|
||||||
entry.primitive.show = value > 0;
|
|
||||||
entry.material.uniforms.color = Cesium.Color.clone(
|
|
||||||
entry.material.uniforms.color,
|
|
||||||
).withAlpha(value * COVERAGE_PEAK_ALPHA * entry.strength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
state._coverageOpacity = null;
|
|
||||||
for (const entry of state._coverageSpots || [])
|
|
||||||
entry.primitive.show = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy() {
|
|
||||||
state._coverageOpacity = null;
|
|
||||||
for (const entry of state._coverageSpots || []) {
|
|
||||||
state._viewer?.scene?.primitives?.remove(entry.primitive);
|
|
||||||
}
|
|
||||||
state._coverageSpots = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sync, hide, destroy };
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
|
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
|
||||||
withinViewBounds,
|
withinViewBounds,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import {
|
||||||
|
TERRAIN_NEED,
|
||||||
|
createTerrainSurface,
|
||||||
|
} from '../../services/terrainSurface.js';
|
||||||
|
|
||||||
/** Surface preparation off the render path, shared by markers and history. */
|
/** Surface preparation off the render path, shared by markers and history. */
|
||||||
export function createHeight({ state, services, parts }) {
|
export function createHeight({ state, services, parts }) {
|
||||||
@@ -47,35 +51,44 @@ export function createHeight({ state, services, parts }) {
|
|||||||
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loaded-terrain heights come from the application's shared reader, which
|
||||||
|
// keeps them per point and rations the reads (services/terrainSurface.js).
|
||||||
|
// A layer built without one keeps its own.
|
||||||
|
const sharedSurface = services.ground.terrainSurface;
|
||||||
|
const terrainSurface = sharedSurface || createTerrainSurface();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The floor under a vehicle, and the best fallback if its own cell is cold.
|
* The floor under a vehicle, and the best fallback if its own cell is cold.
|
||||||
* @param {number} lat
|
* @param {number} lat
|
||||||
* @param {number} lon
|
* @param {number} lon
|
||||||
|
* @param {?object} [entry] The vehicle, for a fixture floor.
|
||||||
|
* @param {number} [need] How much the answer matters; see TERRAIN_NEED.
|
||||||
* @returns {{cell: string, height: number|null, prior: number|null}}
|
* @returns {{cell: string, height: number|null, prior: number|null}}
|
||||||
*/
|
*/
|
||||||
const terrainPoint = new Cesium.Cartographic();
|
function requestHeight(lat, lon, entry = null, need = TERRAIN_NEED.PATH) {
|
||||||
function requestHeight(lat, lon, entry = null) {
|
|
||||||
const coarse = coarseFloorCoord(lat, lon);
|
const coarse = coarseFloorCoord(lat, lon);
|
||||||
const cell = `${coarse.lat},${coarse.lon}`;
|
const cell = `${coarse.lat},${coarse.lon}`;
|
||||||
const fixtureFloor =
|
const fixtureFloor =
|
||||||
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
|
||||||
if (Number.isFinite(fixtureFloor))
|
if (Number.isFinite(fixtureFloor))
|
||||||
return { cell, height: fixtureFloor, prior: null };
|
return { cell, height: fixtureFloor, prior: null };
|
||||||
// On a terrain globe, use the loaded surface under the marker. This is
|
// On a terrain globe, use the loaded surface under the marker. This reads
|
||||||
// synchronous cache work and does not launch a terrain request.
|
// tiles already loaded and does not launch a terrain request.
|
||||||
const globe = state._viewer?.scene?.globe;
|
const globe = state._viewer?.scene?.globe;
|
||||||
if (globe?.show === true) {
|
if (globe?.show === true) {
|
||||||
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
if (
|
||||||
const rendered = globe.getHeight?.(terrainPoint);
|
|
||||||
const flat =
|
|
||||||
state._viewer?.terrainProvider instanceof
|
state._viewer?.terrainProvider instanceof
|
||||||
Cesium.EllipsoidTerrainProvider;
|
Cesium.EllipsoidTerrainProvider
|
||||||
if (Number.isFinite(rendered) || flat)
|
) {
|
||||||
return {
|
// Nothing is asked of the terrain while the globe is flat: show the
|
||||||
cell,
|
// reader the flat provider, so the heights kept from the terrain
|
||||||
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
|
// before it do not come back with the terrain after it.
|
||||||
prior: null,
|
terrainSurface.observe(state._viewer);
|
||||||
};
|
return { cell, height: GROUND_FLOOR_LIFT_M, prior: null };
|
||||||
|
}
|
||||||
|
const rendered = terrainSurface.height(state._viewer, lat, lon, need);
|
||||||
|
if (Number.isFinite(rendered))
|
||||||
|
return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
|
||||||
}
|
}
|
||||||
const floor = cachedGroundFloor(lat, lon);
|
const floor = cachedGroundFloor(lat, lon);
|
||||||
if (Number.isFinite(floor)) {
|
if (Number.isFinite(floor)) {
|
||||||
@@ -107,6 +120,7 @@ export function createHeight({ state, services, parts }) {
|
|||||||
function rereadFloors() {
|
function rereadFloors() {
|
||||||
state._floorTimer = null;
|
state._floorTimer = null;
|
||||||
if (!state._enabled) return;
|
if (!state._enabled) return;
|
||||||
|
const waitingBefore = terrainSurface.waitingCount();
|
||||||
const onGround = nearGround(),
|
const onGround = nearGround(),
|
||||||
candidates = [];
|
candidates = [];
|
||||||
const entries = [...state._vehicles.values()].filter(
|
const entries = [...state._vehicles.values()].filter(
|
||||||
@@ -120,6 +134,13 @@ export function createHeight({ state, services, parts }) {
|
|||||||
Number(b.key === state._selectedKey) -
|
Number(b.key === state._selectedKey) -
|
||||||
Number(a.key === state._selectedKey),
|
Number(a.key === state._selectedKey),
|
||||||
);
|
);
|
||||||
|
// Every vehicle's own position before any trail: the first trails of a
|
||||||
|
// pass must not spend the budget the last markers are waiting on.
|
||||||
|
for (const entry of entries) {
|
||||||
|
const newest = entry.fixes.at(-1);
|
||||||
|
if (newest)
|
||||||
|
requestHeight(newest.lat, newest.lon, entry, TERRAIN_NEED.MARKER);
|
||||||
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const cells = new Map();
|
const cells = new Map();
|
||||||
const collect = (point, answer) => {
|
const collect = (point, answer) => {
|
||||||
@@ -141,7 +162,12 @@ export function createHeight({ state, services, parts }) {
|
|||||||
};
|
};
|
||||||
parts.trails.prepareEntry(entry, collect);
|
parts.trails.prepareEntry(entry, collect);
|
||||||
const newest = entry.fixes.at(-1);
|
const newest = entry.fixes.at(-1);
|
||||||
const answer = requestHeight(newest.lat, newest.lon, entry);
|
const answer = requestHeight(
|
||||||
|
newest.lat,
|
||||||
|
newest.lon,
|
||||||
|
entry,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
entry.heightCell = answer.cell;
|
entry.heightCell = answer.cell;
|
||||||
// The selected vehicle's path also offers its history, newest segment
|
// The selected vehicle's path also offers its history, newest segment
|
||||||
// first; the vehicle's own cell is read where its newest fix is.
|
// first; the vehicle's own cell is read where its newest fix is.
|
||||||
@@ -227,7 +253,11 @@ export function createHeight({ state, services, parts }) {
|
|||||||
for (const entry of entries) parts.trails.prepareEntry(entry);
|
for (const entry of entries) parts.trails.prepareEntry(entry);
|
||||||
parts.rendering.requestVisibility();
|
parts.rendering.requestVisibility();
|
||||||
governorRequestRender('transit-floor');
|
governorRequestRender('transit-floor');
|
||||||
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS)
|
// A pass that ran out of terrain budget before reaching every point
|
||||||
|
// comes back for the rest. Each pass reads its budget, and points stop
|
||||||
|
// falling due once the terrain settles, so this ends.
|
||||||
|
const waiting = terrainSurface.waitingCount() - waitingBefore;
|
||||||
|
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0)
|
||||||
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
|
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +288,8 @@ export function createHeight({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
state._floorAttempts = 0;
|
state._floorAttempts = 0;
|
||||||
state._heightDirty.clear();
|
state._heightDirty.clear();
|
||||||
|
// The shared reader's heights belong to every layer still showing.
|
||||||
|
if (!sharedSurface) terrainSurface.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -267,5 +299,7 @@ export function createHeight({ state, services, parts }) {
|
|||||||
anchorFloors,
|
anchorFloors,
|
||||||
rereadFloors,
|
rereadFloors,
|
||||||
clear,
|
clear,
|
||||||
|
/** Terrain read counters since the layer was created, for checks. */
|
||||||
|
terrainStats: () => terrainSurface.stats(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createHeight } from './height.js';
|
||||||
|
import { FLOOR_REREAD_ATTEMPTS, FLOOR_REREAD_MS } from './policy.js';
|
||||||
|
import {
|
||||||
|
TERRAIN_HEIGHT_CACHE_MAX,
|
||||||
|
TERRAIN_HEIGHT_FRESH_MS,
|
||||||
|
TERRAIN_NEED,
|
||||||
|
TERRAIN_READ_BUDGET_MS,
|
||||||
|
TERRAIN_READ_WINDOW_MS,
|
||||||
|
TERRAIN_REFRESH_BUDGET_MS,
|
||||||
|
TERRAIN_SETTLE_MS,
|
||||||
|
} from '../../services/terrainSurface.js';
|
||||||
|
|
||||||
|
const LIFT = 1.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A height part over a terrain globe whose every read costs `costMs` of a
|
||||||
|
* clock the test owns. `surface(lat, lon)` is the terrain; `reads` lists the
|
||||||
|
* points actually read.
|
||||||
|
*/
|
||||||
|
function terrain(t, { costMs = 0, surface = () => 100, provider = {} } = {}) {
|
||||||
|
const clock = { now: 1_000_000 };
|
||||||
|
t.mock.method(performance, 'now', () => clock.now);
|
||||||
|
const reads = [];
|
||||||
|
const world = { surface, costMs };
|
||||||
|
const globe = {
|
||||||
|
show: true,
|
||||||
|
getHeight(point) {
|
||||||
|
const lat = Cesium.Math.toDegrees(point.latitude);
|
||||||
|
const lon = Cesium.Math.toDegrees(point.longitude);
|
||||||
|
reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`);
|
||||||
|
clock.now += world.costMs;
|
||||||
|
return world.surface(lat, lon);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
_enabled: true,
|
||||||
|
_selectedKey: null,
|
||||||
|
_vehicles: new Map(),
|
||||||
|
_visible: new Set(),
|
||||||
|
_floorCursor: 0,
|
||||||
|
_floorAttempts: 0,
|
||||||
|
_floorTimer: null,
|
||||||
|
_heightDirty: new Set(),
|
||||||
|
_viewer: {
|
||||||
|
camera: { positionCartographic: { height: 500 } },
|
||||||
|
scene: { globe },
|
||||||
|
terrainProvider: provider,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const parts = {
|
||||||
|
trails: { prepareEntry() {} },
|
||||||
|
rendering: { requestVisibility() {} },
|
||||||
|
};
|
||||||
|
const height = createHeight({
|
||||||
|
state,
|
||||||
|
services: {
|
||||||
|
render: { governorRequestRender() {} },
|
||||||
|
ground: {
|
||||||
|
coarseFloorCoord: (lat, lon) => ({
|
||||||
|
lat: Number(lat.toFixed(3)),
|
||||||
|
lon: Number(lon.toFixed(3)),
|
||||||
|
}),
|
||||||
|
cachedGroundFloor: () => null,
|
||||||
|
neighborFloorM: () => null,
|
||||||
|
warmGroundFloor() {},
|
||||||
|
GROUND_FLOOR_LIFT_M: LIFT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parts,
|
||||||
|
});
|
||||||
|
t.after(() => height.clear());
|
||||||
|
const nextWindow = () => {
|
||||||
|
clock.now += TERRAIN_READ_WINDOW_MS;
|
||||||
|
};
|
||||||
|
return { height, state, parts, globe, world, clock, reads, nextWindow };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a point is read from the terrain once and reused until its reuse time passes', (t) => {
|
||||||
|
const { height, reads, clock, world } = terrain(t);
|
||||||
|
const first = height.requestHeight(42.36, -71.06);
|
||||||
|
assert.deepEqual(first, { cell: '42.36,-71.06', height: 100 + LIFT, prior: null });
|
||||||
|
for (let i = 0; i < 500; i++) height.requestHeight(42.36, -71.06);
|
||||||
|
assert.equal(reads.length, 1, '501 requests, one terrain read');
|
||||||
|
|
||||||
|
// A point a metre away is its own point; the same cell does not stand in
|
||||||
|
// for it while the budget allows a read.
|
||||||
|
world.surface = (lat) => (lat > 42.36 ? 140 : 100);
|
||||||
|
assert.equal(height.requestHeight(42.36002, -71.06).height, 140 + LIFT);
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
|
||||||
|
// The surface under the first point changed (the globe drew a finer tile).
|
||||||
|
world.surface = () => 120;
|
||||||
|
clock.now += TERRAIN_HEIGHT_FRESH_MS - 1;
|
||||||
|
assert.equal(height.requestHeight(42.36, -71.06).height, 100 + LIFT);
|
||||||
|
clock.now += 1;
|
||||||
|
assert.equal(height.requestHeight(42.36, -71.06).height, 120 + LIFT);
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
assert.deepEqual(height.terrainStats(), {
|
||||||
|
reads: 3,
|
||||||
|
reused: 501,
|
||||||
|
deferred: 0,
|
||||||
|
borrowed: 0,
|
||||||
|
held: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terrain that is not loaded yet is not remembered as an answer', (t) => {
|
||||||
|
const { height, reads, world } = terrain(t, { surface: () => undefined });
|
||||||
|
assert.deepEqual(height.requestHeight(42.36, -71.06), {
|
||||||
|
cell: '42.36,-71.06',
|
||||||
|
height: null,
|
||||||
|
prior: null,
|
||||||
|
});
|
||||||
|
assert.equal(height.requestHeight(42.36, -71.06).height, null);
|
||||||
|
assert.equal(reads.length, 2, 'each request asks again');
|
||||||
|
world.surface = () => 12;
|
||||||
|
assert.equal(height.requestHeight(42.36, -71.06).height, 12 + LIFT);
|
||||||
|
assert.equal(height.terrainStats().held, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tile that leaves the globe keeps the height it gave', (t) => {
|
||||||
|
const { height, clock, world } = terrain(t, { surface: () => 30 });
|
||||||
|
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
|
||||||
|
world.surface = () => undefined;
|
||||||
|
clock.now += TERRAIN_HEIGHT_FRESH_MS * 20;
|
||||||
|
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new points stop being read once a window has spent its budget', (t) => {
|
||||||
|
const costMs = 5;
|
||||||
|
const { height, reads, nextWindow } = terrain(t, {
|
||||||
|
costMs,
|
||||||
|
surface: (lat, lon) => Math.round(lon * -1000),
|
||||||
|
});
|
||||||
|
const affordable = Math.ceil(TERRAIN_READ_BUDGET_MS / costMs);
|
||||||
|
// One point per floor cell (0.001° apart), more than the window affords.
|
||||||
|
const answers = [];
|
||||||
|
for (let i = 0; i < affordable + 3; i++)
|
||||||
|
answers.push(height.requestHeight(42.36, -71.06 - i * 0.001).height);
|
||||||
|
assert.equal(reads.length, affordable);
|
||||||
|
assert.deepEqual(
|
||||||
|
answers.slice(affordable),
|
||||||
|
[null, null, null],
|
||||||
|
'a point in a cell never read waits',
|
||||||
|
);
|
||||||
|
assert.ok(reads.length * costMs < TERRAIN_READ_BUDGET_MS + costMs, 'the overrun is one read at most');
|
||||||
|
|
||||||
|
// A new point in a cell that was read borrows that cell's height.
|
||||||
|
const borrowed = height.requestHeight(42.36004, -71.06);
|
||||||
|
assert.equal(borrowed.height, 71060 + LIFT);
|
||||||
|
assert.equal(reads.length, affordable, 'borrowing reads nothing');
|
||||||
|
assert.equal(height.terrainStats().borrowed, 1);
|
||||||
|
|
||||||
|
// The next window reads the points that waited, and the borrower its own.
|
||||||
|
nextWindow();
|
||||||
|
for (let i = affordable; i < affordable + 3; i++)
|
||||||
|
assert.equal(
|
||||||
|
height.requestHeight(42.36, -71.06 - i * 0.001).height,
|
||||||
|
Math.round((-71.06 - i * 0.001) * -1000) + LIFT,
|
||||||
|
);
|
||||||
|
assert.equal(reads.length, affordable + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a vehicle's own position is read even when the window is spent", (t) => {
|
||||||
|
const { height, reads } = terrain(t, { costMs: TERRAIN_READ_BUDGET_MS });
|
||||||
|
height.requestHeight(42.36, -71.06);
|
||||||
|
assert.equal(height.requestHeight(42.37, -71.07).height, null, 'a trail point waits');
|
||||||
|
assert.equal(height.requestHeight(42.38, -71.08, null, TERRAIN_NEED.MARKER).height, 100 + LIFT);
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undrawn trail history leaves most of the budget to the paths markers are drawn along', (t) => {
|
||||||
|
const costMs = 1;
|
||||||
|
const { height, reads } = terrain(t, { costMs });
|
||||||
|
// New points, one per floor cell: history first, as a pass offers them.
|
||||||
|
let lon = -71;
|
||||||
|
const ask = (need) => height.requestHeight(42, (lon -= 0.001), null, need).height;
|
||||||
|
const history = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.BACKGROUND));
|
||||||
|
const read = history.filter((h) => h !== null).length;
|
||||||
|
assert.equal(read, TERRAIN_READ_BUDGET_MS / 4, 'history stops at a quarter of the budget');
|
||||||
|
const path = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.PATH));
|
||||||
|
assert.equal(path.filter((h) => h !== null).length, TERRAIN_READ_BUDGET_MS - read, 'the path takes the rest');
|
||||||
|
assert.equal(ask(TERRAIN_NEED.BACKGROUND), null);
|
||||||
|
assert.equal(ask(TERRAIN_NEED.MARKER), 100 + LIFT, 'a marker is still read');
|
||||||
|
assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('held points keep their last height while re-reads are over budget, and the longest-held are re-read first so none starves', (t) => {
|
||||||
|
const costMs = TERRAIN_REFRESH_BUDGET_MS / 2;
|
||||||
|
const { height, reads, clock, world, nextWindow } = terrain(t, { surface: () => 50 });
|
||||||
|
const points = Array.from({ length: 40 }, (_, i) => [42.36, -71.06 - i * 0.001]);
|
||||||
|
for (const [lat, lon] of points) height.requestHeight(lat, lon);
|
||||||
|
assert.equal(reads.length, 40);
|
||||||
|
|
||||||
|
world.costMs = costMs;
|
||||||
|
world.surface = () => 80;
|
||||||
|
clock.now += TERRAIN_HEIGHT_FRESH_MS;
|
||||||
|
reads.length = 0;
|
||||||
|
const pass = () => points.map(([lat, lon]) => height.requestHeight(lat, lon).height);
|
||||||
|
const first = pass();
|
||||||
|
const perWindow = Math.ceil(TERRAIN_REFRESH_BUDGET_MS / costMs);
|
||||||
|
assert.equal(reads.length, perWindow, 'one window re-reads its budget and no more');
|
||||||
|
assert.deepEqual(first.slice(0, perWindow), Array(perWindow).fill(80 + LIFT));
|
||||||
|
assert.deepEqual(first.slice(perWindow), Array(40 - perWindow).fill(50 + LIFT), 'the rest keep their last height');
|
||||||
|
|
||||||
|
// Pass after pass, a window apart: the budget moves down the list instead
|
||||||
|
// of returning to the first points, whose reuse time runs out again long
|
||||||
|
// before the last ones have had a turn (40 points, ${perWindow} a window).
|
||||||
|
const turns = new Map();
|
||||||
|
for (const point of reads) turns.set(point, 1);
|
||||||
|
const cycle = Math.ceil(40 / perWindow);
|
||||||
|
for (let passes = 1; passes < cycle; passes++) {
|
||||||
|
nextWindow();
|
||||||
|
reads.length = 0;
|
||||||
|
pass();
|
||||||
|
for (const point of reads) turns.set(point, (turns.get(point) || 0) + 1);
|
||||||
|
}
|
||||||
|
assert.equal(turns.size, 40, 'every point was re-read in one cycle');
|
||||||
|
assert.ok([...turns.values()].every((count) => count === 1), 'and none twice before the others once');
|
||||||
|
assert.ok(cycle * TERRAIN_READ_WINDOW_MS > TERRAIN_HEIGHT_FRESH_MS, 'the cycle is longer than the reuse time, so the first points were stale again');
|
||||||
|
assert.deepEqual(pass().slice(-1), [80 + LIFT]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('once the camera and the tiles have settled a height is kept until either changes', (t) => {
|
||||||
|
const { height, state, globe, reads, clock, world } = terrain(t, { surface: () => 10 });
|
||||||
|
const read = () => height.requestHeight(42.36, -71.06).height;
|
||||||
|
assert.equal(read(), 10 + LIFT);
|
||||||
|
// Still settling: the point falls due and is read again.
|
||||||
|
world.surface = () => 20;
|
||||||
|
clock.now += TERRAIN_SETTLE_MS;
|
||||||
|
assert.equal(read(), 20 + LIFT);
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
// Read after the settle time: kept, however long the view stays put.
|
||||||
|
world.surface = () => 99;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
clock.now += 60_000;
|
||||||
|
assert.equal(read(), 20 + LIFT);
|
||||||
|
}
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
|
||||||
|
// The camera moves: the globe may draw the point from a finer tile.
|
||||||
|
state._viewer.camera.positionCartographic = { longitude: 0, latitude: 0, height: 250 };
|
||||||
|
clock.now += TERRAIN_READ_WINDOW_MS;
|
||||||
|
assert.equal(read(), 99 + LIFT);
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
|
||||||
|
// Settled again, then a tile starts loading with the camera still.
|
||||||
|
world.surface = () => 101;
|
||||||
|
clock.now += TERRAIN_SETTLE_MS;
|
||||||
|
assert.equal(read(), 101 + LIFT);
|
||||||
|
clock.now += 60_000;
|
||||||
|
assert.equal(read(), 101 + LIFT);
|
||||||
|
assert.equal(reads.length, 4);
|
||||||
|
world.surface = () => 105;
|
||||||
|
globe.tilesLoaded = false;
|
||||||
|
clock.now += TERRAIN_READ_WINDOW_MS;
|
||||||
|
assert.equal(read(), 105 + LIFT);
|
||||||
|
assert.equal(reads.length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another terrain provider or globe is read afresh, however settled the old heights were', (t) => {
|
||||||
|
const { height, state, globe, world, clock, reads } = terrain(t);
|
||||||
|
const viewer = state._viewer;
|
||||||
|
const at = () => height.requestHeight(42.36, -71.06).height;
|
||||||
|
// Read once the camera and tiles have long settled: kept indefinitely.
|
||||||
|
height.requestHeight(10, 10);
|
||||||
|
clock.now += TERRAIN_SETTLE_MS * 2;
|
||||||
|
reads.length = 0;
|
||||||
|
assert.equal(at(), 100 + LIFT);
|
||||||
|
clock.now += TERRAIN_SETTLE_MS * 10;
|
||||||
|
world.surface = () => 250;
|
||||||
|
assert.equal(at(), 100 + LIFT, 'same terrain: the kept height stands');
|
||||||
|
assert.equal(reads.length, 1);
|
||||||
|
|
||||||
|
viewer.terrainProvider = {};
|
||||||
|
assert.equal(at(), 250 + LIFT, 'a new provider is a new surface');
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
|
||||||
|
world.surface = () => 400;
|
||||||
|
viewer.scene.globe = { show: true, getHeight: globe.getHeight };
|
||||||
|
assert.equal(at(), 400 + LIFT, 'and so is a new globe');
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
|
||||||
|
// Terrain → flat → the same terrain provider: nothing asks the terrain
|
||||||
|
// while the globe is flat, yet the old heights must not come back.
|
||||||
|
const real = viewer.terrainProvider;
|
||||||
|
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
|
||||||
|
assert.equal(at(), LIFT);
|
||||||
|
world.surface = () => 30;
|
||||||
|
viewer.terrainProvider = real;
|
||||||
|
assert.equal(at(), 30 + LIFT);
|
||||||
|
assert.equal(reads.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a flat globe answers without reading terrain', (t) => {
|
||||||
|
const { height, reads } = terrain(t, {
|
||||||
|
provider: new Cesium.EllipsoidTerrainProvider(),
|
||||||
|
});
|
||||||
|
assert.deepEqual(height.requestHeight(42.36, -71.06), {
|
||||||
|
cell: '42.36,-71.06',
|
||||||
|
height: LIFT,
|
||||||
|
prior: null,
|
||||||
|
});
|
||||||
|
assert.equal(reads.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hidden globe reads no terrain and clear() forgets what was read', (t) => {
|
||||||
|
const { height, reads, globe } = terrain(t);
|
||||||
|
height.requestHeight(42.36, -71.06);
|
||||||
|
height.clear();
|
||||||
|
assert.equal(height.terrainStats().held, 0);
|
||||||
|
height.requestHeight(42.36, -71.06);
|
||||||
|
assert.equal(reads.length, 2, 'a cleared layer reads again');
|
||||||
|
globe.show = false;
|
||||||
|
assert.equal(height.requestHeight(42.5, -71.5).height, null);
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the points kept stay bounded', (t) => {
|
||||||
|
const { height } = terrain(t);
|
||||||
|
const total = TERRAIN_HEIGHT_CACHE_MAX + 5_000;
|
||||||
|
for (let i = 0; i < total; i++)
|
||||||
|
height.requestHeight(10 + Math.floor(i / 1000) * 0.001, 20 + (i % 1000) * 0.0001);
|
||||||
|
const { held, reads } = height.terrainStats();
|
||||||
|
assert.equal(reads, total);
|
||||||
|
assert.ok(held <= TERRAIN_HEIGHT_CACHE_MAX, `${held} held`);
|
||||||
|
assert.ok(held >= TERRAIN_HEIGHT_CACHE_MAX * 0.7, `${held} held`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fleet rereading every trail fix reads each point once, not once per request', (t) => {
|
||||||
|
const { height, state, parts, reads } = terrain(t);
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
// 600 vehicles with 40 fixes each, 25 m apart along a street.
|
||||||
|
const fleet = Array.from({ length: 600 }, (_, v) => ({
|
||||||
|
key: `bus-${v}`,
|
||||||
|
fixes: Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
lat: 42 + v * 0.002,
|
||||||
|
lon: -71 + i * 0.0003,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
for (const entry of fleet) {
|
||||||
|
state._vehicles.set(entry.key, entry);
|
||||||
|
state._visible.add(entry);
|
||||||
|
}
|
||||||
|
let requests = 0;
|
||||||
|
parts.trails.prepareEntry = (entry, collect) => {
|
||||||
|
for (const fix of entry.fixes) {
|
||||||
|
requests++;
|
||||||
|
const answer = height.requestHeight(fix.lat, fix.lon, entry);
|
||||||
|
if (collect) collect(fix, answer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
height.anchorFloors();
|
||||||
|
for (let i = 0; i < FLOOR_REREAD_ATTEMPTS; i++) t.mock.timers.tick(FLOOR_REREAD_MS);
|
||||||
|
assert.equal(requests, 600 * 40 * 2 * (FLOOR_REREAD_ATTEMPTS + 1));
|
||||||
|
assert.equal(new Set(reads).size, 600 * 40);
|
||||||
|
assert.equal(reads.length, 600 * 40, `${requests} requests, ${reads.length} terrain reads`);
|
||||||
|
assert.ok(fleet.every((entry) => entry.heightM === 100 + LIFT && entry.heightResolved));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reread cycle outlasts its attempts while trail points still wait for a read, then stops', (t) => {
|
||||||
|
const costMs = 4;
|
||||||
|
const { height, state, parts, reads, clock } = terrain(t, { costMs });
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const entry = {
|
||||||
|
key: 'bus',
|
||||||
|
// One fix per floor cell, so nothing can be borrowed.
|
||||||
|
fixes: Array.from({ length: 60 }, (_, i) => ({ lat: 42, lon: -71 + i * 0.001 })),
|
||||||
|
};
|
||||||
|
state._vehicles.set(entry.key, entry);
|
||||||
|
state._visible.add(entry);
|
||||||
|
parts.trails.prepareEntry = (e) => {
|
||||||
|
for (const fix of e.fixes) height.requestHeight(fix.lat, fix.lon, e);
|
||||||
|
};
|
||||||
|
height.anchorFloors();
|
||||||
|
let passes = 1;
|
||||||
|
while (state._floorTimer && passes < 200) {
|
||||||
|
clock.now += FLOOR_REREAD_MS;
|
||||||
|
t.mock.timers.tick(FLOOR_REREAD_MS);
|
||||||
|
passes++;
|
||||||
|
}
|
||||||
|
assert.equal(new Set(reads).size, 60, 'every fix was read in the end');
|
||||||
|
assert.ok(passes > FLOOR_REREAD_ATTEMPTS + 1, `${passes} passes: the cycle kept going for the unread fixes`);
|
||||||
|
assert.ok(passes < 120, `${passes} passes: and stopped once none waited`);
|
||||||
|
assert.equal(state._floorTimer, null);
|
||||||
|
});
|
||||||
@@ -6,7 +6,6 @@ import { createRendering } from './rendering.js';
|
|||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
import { createIngestion } from './ingestion.js';
|
import { createIngestion } from './ingestion.js';
|
||||||
import { createViewport } from './viewport.js';
|
import { createViewport } from './viewport.js';
|
||||||
import { createCoverage } from './coverage.js';
|
|
||||||
import { createQueries } from './queries.js';
|
import { createQueries } from './queries.js';
|
||||||
import { createControls } from './controls.js';
|
import { createControls } from './controls.js';
|
||||||
import { createLifecycle } from './lifecycle.js';
|
import { createLifecycle } from './lifecycle.js';
|
||||||
@@ -51,7 +50,6 @@ export function createTransitLayer({
|
|||||||
parts.selection = createSelection(context);
|
parts.selection = createSelection(context);
|
||||||
parts.rendering = createRendering(context);
|
parts.rendering = createRendering(context);
|
||||||
parts.ingestion = createIngestion(context);
|
parts.ingestion = createIngestion(context);
|
||||||
parts.coverage = createCoverage(context);
|
|
||||||
parts.viewport = createViewport(context);
|
parts.viewport = createViewport(context);
|
||||||
parts.queries = createQueries(context);
|
parts.queries = createQueries(context);
|
||||||
parts.controls = createControls(context);
|
parts.controls = createControls(context);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
transitVehicleKey,
|
transitVehicleKey,
|
||||||
vehicleReportTimeMs,
|
vehicleReportTimeMs,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
|
||||||
import {
|
import {
|
||||||
transitModeFor,
|
transitModeFor,
|
||||||
transitModeResolved,
|
transitModeResolved,
|
||||||
@@ -274,7 +275,12 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
// DEM one, or a change of map regime, moves a parked vehicle too. A
|
// DEM one, or a change of map regime, moves a parked vehicle too. A
|
||||||
// cell still cold takes the lowest neighbour as a prior, as before.
|
// cell still cold takes the lowest neighbour as a prior, as before.
|
||||||
const priorHeightM = entry.heightM;
|
const priorHeightM = entry.heightM;
|
||||||
const height = parts.height.requestHeight(record.lat, record.lon);
|
const height = parts.height.requestHeight(
|
||||||
|
record.lat,
|
||||||
|
record.lon,
|
||||||
|
null,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
if (height.height !== null) {
|
if (height.height !== null) {
|
||||||
entry.heightCell = height.cell;
|
entry.heightCell = height.cell;
|
||||||
entry.heightM = height.height;
|
entry.heightM = height.height;
|
||||||
@@ -311,7 +317,12 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const height = parts.height.requestHeight(record.lat, record.lon);
|
const height = parts.height.requestHeight(
|
||||||
|
record.lat,
|
||||||
|
record.lon,
|
||||||
|
null,
|
||||||
|
TERRAIN_NEED.MARKER,
|
||||||
|
);
|
||||||
// Keep the local prior separate from resolved historical floors.
|
// Keep the local prior separate from resolved historical floors.
|
||||||
// A neighbouring cell is not a guaranteed lower bound.
|
// A neighbouring cell is not a guaranteed lower bound.
|
||||||
const prior = height.height === null ? height.prior : height.height;
|
const prior = height.height === null ? height.prior : height.height;
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
state._preRenderRemove = null;
|
state._preRenderRemove = null;
|
||||||
}
|
}
|
||||||
parts.ingestion.abortAllInFlight();
|
parts.ingestion.abortAllInFlight();
|
||||||
parts.coverage.hide();
|
|
||||||
state._activeFeeds.clear();
|
state._activeFeeds.clear();
|
||||||
state._feedStatus.clear();
|
state._feedStatus.clear();
|
||||||
for (const entry of state._vehicles.values()) {
|
for (const entry of state._vehicles.values()) {
|
||||||
@@ -271,7 +270,6 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
async update() {
|
async update() {
|
||||||
if (!state._enabled) return;
|
if (!state._enabled) return;
|
||||||
parts.ingestion.sweepAgedVehicles(Date.now());
|
parts.ingestion.sweepAgedVehicles(Date.now());
|
||||||
parts.coverage.sync(parts.viewport.getCameraAltitude());
|
|
||||||
if (state._activeFeeds.size === 0) return;
|
if (state._activeFeeds.size === 0) return;
|
||||||
const generation = state._generation;
|
const generation = state._generation;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -287,7 +285,6 @@ export function createLifecycle({ state, services, parts }) {
|
|||||||
*/
|
*/
|
||||||
destroy(viewer) {
|
destroy(viewer) {
|
||||||
this.disable(viewer);
|
this.disable(viewer);
|
||||||
parts.coverage.destroy();
|
|
||||||
parts.rendering.stopModels(true);
|
parts.rendering.stopModels(true);
|
||||||
if (state._markers) {
|
if (state._markers) {
|
||||||
unregisterSpriteCollection('transit', state._markers);
|
unregisterSpriteCollection('transit', state._markers);
|
||||||
|
|||||||
@@ -31,62 +31,6 @@ export const TRANSIT_POLL_MS = 15_000;
|
|||||||
*/
|
*/
|
||||||
/** Camera altitude (m) above which the layer idles: a national fleet still reads at 3,000 km. */
|
/** Camera altitude (m) above which the layer idles: a national fleet still reads at 3,000 km. */
|
||||||
export const ACTIVATION_ALTITUDE_M = 3_000_000;
|
export const ACTIVATION_ALTITUDE_M = 3_000_000;
|
||||||
/**
|
|
||||||
* How strongly the coverage glow shows at each camera height (m), as
|
|
||||||
* [height, opacity] stops joined by straight lines. There are two sets of
|
|
||||||
* spots and they cross-fade:
|
|
||||||
*
|
|
||||||
* - `far`: wide spots, far larger than any network, so coverage reads from
|
|
||||||
* orbit. Full above the altitude gate, gone by regional zoom.
|
|
||||||
* - `near`: patches the size of each feed's own service area, drawn as a
|
|
||||||
* backdrop under the vehicles at continental and regional zoom so a person
|
|
||||||
* can see which neighbouring cities are covered. Off close in, where a
|
|
||||||
* city's own vehicles are the picture.
|
|
||||||
*
|
|
||||||
* Between them there is no height from 250 km up with nothing drawn.
|
|
||||||
*/
|
|
||||||
export const COVERAGE_OPACITY_STOPS = Object.freeze({
|
|
||||||
far: Object.freeze([
|
|
||||||
Object.freeze([1_200_000, 0]),
|
|
||||||
Object.freeze([3_400_000, 1]),
|
|
||||||
]),
|
|
||||||
near: Object.freeze([
|
|
||||||
Object.freeze([60_000, 0]),
|
|
||||||
Object.freeze([250_000, 0.8]),
|
|
||||||
Object.freeze([1_500_000, 0.9]),
|
|
||||||
Object.freeze([3_400_000, 0]),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
/** Fine enough that a zoom reads as a continuous fade, not as steps. */
|
|
||||||
const COVERAGE_FADE_STEPS = 100;
|
|
||||||
/** Alpha at the centre of a lone spot at full strength. */
|
|
||||||
export const COVERAGE_PEAK_ALPHA = 0.9;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opacity of one set of coverage spots at a camera height, quantized so a
|
|
||||||
* zoom does not restyle the glow on every camera event.
|
|
||||||
* @param {number} altitude Camera height above the ellipsoid, metres.
|
|
||||||
* @param {'far'|'near'} [set='far']
|
|
||||||
* @returns {number} 0..1
|
|
||||||
*/
|
|
||||||
export function coverageOpacityAt(altitude, set = 'far') {
|
|
||||||
const stops = COVERAGE_OPACITY_STOPS[set] || COVERAGE_OPACITY_STOPS.far;
|
|
||||||
const last = stops[stops.length - 1][1];
|
|
||||||
if (!Number.isFinite(altitude)) return altitude === Infinity ? last : 0;
|
|
||||||
let value = last;
|
|
||||||
if (altitude <= stops[0][0]) value = stops[0][1];
|
|
||||||
else {
|
|
||||||
for (let i = 1; i < stops.length; i++) {
|
|
||||||
if (altitude <= stops[i][0]) {
|
|
||||||
const [h0, v0] = stops[i - 1];
|
|
||||||
const [h1, v1] = stops[i];
|
|
||||||
value = v0 + ((v1 - v0) * (altitude - h0)) / (h1 - h0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Math.round(value * COVERAGE_FADE_STEPS) / COVERAGE_FADE_STEPS;
|
|
||||||
}
|
|
||||||
export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000;
|
export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000;
|
||||||
export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000;
|
export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000;
|
||||||
/** Debounce (ms) for camera-change proximity checks. */
|
/** Debounce (ms) for camera-change proximity checks. */
|
||||||
@@ -133,7 +77,6 @@ export const FLOOR_WARM_PER_POLL = 300;
|
|||||||
export const FLOOR_REREAD_MS = 900;
|
export const FLOOR_REREAD_MS = 900;
|
||||||
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
|
||||||
export const FLOOR_REREAD_ATTEMPTS = 6;
|
export const FLOOR_REREAD_ATTEMPTS = 6;
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
|
||||||
export const MARKER_PIXEL_SIZE = 20;
|
export const MARKER_PIXEL_SIZE = 20;
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ test('leaving a feed whose tick failed repaints its error away', async () => {
|
|||||||
sweepAgedVehicles() {},
|
sweepAgedVehicles() {},
|
||||||
},
|
},
|
||||||
rendering: { requestVisibility() {}, syncRenderHold() {} },
|
rendering: { requestVisibility() {}, syncRenderHold() {} },
|
||||||
coverage: { sync() {} },
|
|
||||||
};
|
};
|
||||||
parts.viewport = createViewport({ state, services, parts });
|
parts.viewport = createViewport({ state, services, parts });
|
||||||
const queries = createQueries({ state, parts }).methods;
|
const queries = createQueries({ state, parts }).methods;
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ export function createState({ services }) {
|
|||||||
state._cameraChangedAttached = false;
|
state._cameraChangedAttached = false;
|
||||||
state._cameraDebounceTimer = null;
|
state._cameraDebounceTimer = null;
|
||||||
state._altitudeGateOpen = false;
|
state._altitudeGateOpen = false;
|
||||||
/** Coverage glow: its draw batches and the last opacities drawn. */
|
|
||||||
state._coverageSpots = null;
|
|
||||||
state._coverageOpacity = null;
|
|
||||||
/** Camera sensitivity as we found it, so disable() can hand it back. */
|
/** Camera sensitivity as we found it, so disable() can hand it back. */
|
||||||
state._priorPercentageChanged = null;
|
state._priorPercentageChanged = null;
|
||||||
/** The value this layer actually wrote, for an identity-guarded restore. */
|
/** The value this layer actually wrote, for an identity-guarded restore. */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../data/contactPlayback.js';
|
} from '../../data/contactPlayback.js';
|
||||||
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
|
||||||
import { fixDistanceM } from './movement.js';
|
import { fixDistanceM } from './movement.js';
|
||||||
|
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
|
||||||
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
|
||||||
|
|
||||||
const MODE_COLOR = {
|
const MODE_COLOR = {
|
||||||
@@ -72,8 +73,13 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
b = {};
|
b = {};
|
||||||
let vertices = 0,
|
let vertices = 0,
|
||||||
signature = '';
|
signature = '';
|
||||||
const floor = (point) => {
|
const floor = (point, need = TERRAIN_NEED.PATH) => {
|
||||||
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
|
const answer = parts.height.requestHeight(
|
||||||
|
point.lat,
|
||||||
|
point.lon,
|
||||||
|
entry,
|
||||||
|
need,
|
||||||
|
);
|
||||||
if (collect) collect(point, answer);
|
if (collect) collect(point, answer);
|
||||||
return answer.height;
|
return answer.height;
|
||||||
};
|
};
|
||||||
@@ -99,12 +105,6 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
lon: a.lon + ((b.lon - a.lon) * i) / n,
|
lon: a.lon + ((b.lon - a.lon) * i) / n,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (let i = 0; i < entry.track.count; i++) {
|
|
||||||
readFix(entry.track, i, a);
|
|
||||||
const h = floor(a);
|
|
||||||
if (h !== null) correctHeight(entry.track, a.seq, h);
|
|
||||||
}
|
|
||||||
const order = [];
|
|
||||||
const activeIndex = Math.max(
|
const activeIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
@@ -112,6 +112,18 @@ export function createTrails({ state, services, parts, source }) {
|
|||||||
entry.sample.fromSeq - entry.track.baseSeq,
|
entry.sample.fromSeq - entry.track.baseSeq,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
for (let i = 0; i < entry.track.count; i++) {
|
||||||
|
readFix(entry.track, i, a);
|
||||||
|
// Fixes behind an unselected vehicle are kept but not drawn.
|
||||||
|
const h = floor(
|
||||||
|
a,
|
||||||
|
entry === selected || i >= activeIndex
|
||||||
|
? TERRAIN_NEED.PATH
|
||||||
|
: TERRAIN_NEED.BACKGROUND,
|
||||||
|
);
|
||||||
|
if (h !== null) correctHeight(entry.track, a.seq, h);
|
||||||
|
}
|
||||||
|
const order = [];
|
||||||
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
|
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
|
||||||
if (entry === selected)
|
if (entry === selected)
|
||||||
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ export function createViewport({ state, services, parts }) {
|
|||||||
if (!state._enabled || !state._viewer) return;
|
if (!state._enabled || !state._viewer) return;
|
||||||
refreshViewBounds();
|
refreshViewBounds();
|
||||||
const altitude = getCameraAltitude();
|
const altitude = getCameraAltitude();
|
||||||
parts.coverage.sync(altitude);
|
|
||||||
const wasGateOpen = state._altitudeGateOpen;
|
const wasGateOpen = state._altitudeGateOpen;
|
||||||
state._altitudeGateOpen = altitudeGateOpen(altitude);
|
state._altitudeGateOpen = altitudeGateOpen(altitude);
|
||||||
let changed = state._altitudeGateOpen !== wasGateOpen;
|
let changed = state._altitudeGateOpen !== wasGateOpen;
|
||||||
@@ -137,8 +136,6 @@ export function createViewport({ state, services, parts }) {
|
|||||||
// A person dragging the globe should not wait a third of a second for the
|
// A person dragging the globe should not wait a third of a second for the
|
||||||
// vehicles under the cursor to start moving again.
|
// vehicles under the cursor to start moving again.
|
||||||
refreshViewBounds();
|
refreshViewBounds();
|
||||||
// The glow follows the zoom as it happens, not a third of a second later.
|
|
||||||
parts.coverage.sync(getCameraAltitude());
|
|
||||||
clearTimeout(state._cameraDebounceTimer);
|
clearTimeout(state._cameraDebounceTimer);
|
||||||
state._cameraDebounceTimer = setTimeout(() => {
|
state._cameraDebounceTimer = setTimeout(() => {
|
||||||
state._cameraDebounceTimer = null;
|
state._cameraDebounceTimer = null;
|
||||||
|
|||||||
+45
-10
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
accentForVesselType,
|
accentForVesselType,
|
||||||
displayVesselType,
|
displayVesselType,
|
||||||
normalizeVesselType,
|
normalizeVesselType,
|
||||||
} from '../../data/vesselLabels.js';
|
} from '../../data/vesselLabels.js';
|
||||||
|
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||||
import { formatNumber, t } from '../../i18n/index.js';
|
import { formatNumber, t } from '../../i18n/index.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||||
|
|
||||||
@@ -16,10 +18,17 @@ export function createCards({
|
|||||||
function updateSelectedVesselHud(record) {
|
function updateSelectedVesselHud(record) {
|
||||||
// Pinned vessels missing from recent refreshes get a stale marker
|
// Pinned vessels missing from recent refreshes get a stale marker
|
||||||
const stale = (record.missedRefreshes || 0) > 0;
|
const stale = (record.missedRefreshes || 0) > 0;
|
||||||
|
// An aid to navigation is a fixed mark: it has no speed or heading to show.
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
services.readouts?.presentSelectedVessel({
|
services.readouts?.presentSelectedVessel({
|
||||||
text: [
|
text: [
|
||||||
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
||||||
t('{type} SPD: {speed} HDG: {heading}', {
|
aid
|
||||||
|
? aidKind(
|
||||||
|
trimHudValue(record.type || t('AID TO NAVIGATION'), 24),
|
||||||
|
record,
|
||||||
|
)
|
||||||
|
: t('{type} SPD: {speed} HDG: {heading}', {
|
||||||
type: trimHudValue(record.type || t('VESSEL'), 24),
|
type: trimHudValue(record.type || t('VESSEL'), 24),
|
||||||
speed: formatSpeed(record.speed),
|
speed: formatSpeed(record.speed),
|
||||||
heading: formatHeading(record.heading ?? record.course),
|
heading: formatHeading(record.heading ?? record.course),
|
||||||
@@ -43,20 +52,22 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
||||||
* compact type/speed/heading detail line, anchored at the record's current
|
* compact type/speed/heading detail line, anchored at the record's current
|
||||||
* rendered position (height-datum caveat: no datum work here). Pure —
|
* rendered position (height-datum caveat: no datum work here). An aid to
|
||||||
* exported for unit tests.
|
* navigation's line is its kind alone. Pure — exported for unit tests.
|
||||||
* @param {Object} record - Vessel record.
|
* @param {Object} record - Vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildVesselCard(record) {
|
function buildVesselCard(record) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const type = vesselTypeShort(record);
|
const type = vesselTypeShort(record);
|
||||||
if (type) parts.push(type);
|
if (type) parts.push(type);
|
||||||
if (record.speed !== null && record.speed !== undefined)
|
if (record.virtual === true) parts.push(t('VIRTUAL'));
|
||||||
|
if (!aid && record.speed !== null && record.speed !== undefined)
|
||||||
parts.push(formatSpeed(record.speed));
|
parts.push(formatSpeed(record.speed));
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
if (Number.isFinite(direction))
|
if (!aid && Number.isFinite(direction))
|
||||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||||
return {
|
return {
|
||||||
id: vesselOverlayEntryId(record),
|
id: vesselOverlayEntryId(record),
|
||||||
@@ -65,7 +76,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 10,
|
gapPx: 10,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 26),
|
title: trimHudValue(displayVesselName(record), 26),
|
||||||
details: parts.length ? [parts.join(' · ')] : [],
|
details: parts.length ? [parts.join(' · ')] : [],
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -76,16 +87,20 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for the click-selected vessel — the full-detail card, drawn last
|
* Card model for the click-selected vessel — the full-detail card, drawn last
|
||||||
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
|
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
|
||||||
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
|
* vessels carry a STALE marker (mirrors the HUD readout). An aid to
|
||||||
* for unit tests.
|
* navigation shows its kind with no speed or heading. Pure — exported for
|
||||||
|
* unit tests.
|
||||||
* @param {Object} record - Selected vessel record.
|
* @param {Object} record - Selected vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildSelectedVesselCard(record) {
|
function buildSelectedVesselCard(record) {
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
const details = [
|
const details = [
|
||||||
[
|
aid
|
||||||
|
? aidKind(vesselTypeShort(record) || t('AID TO NAVIGATION'), record)
|
||||||
|
: [
|
||||||
vesselTypeShort(record) || t('VESSEL'),
|
vesselTypeShort(record) || t('VESSEL'),
|
||||||
formatSpeed(record.speed),
|
formatSpeed(record.speed),
|
||||||
Number.isFinite(direction)
|
Number.isFinite(direction)
|
||||||
@@ -106,7 +121,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 12,
|
gapPx: 12,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 32),
|
title: trimHudValue(displayVesselName(record), 32),
|
||||||
details,
|
details,
|
||||||
selected: true,
|
selected: true,
|
||||||
@@ -114,6 +129,20 @@ export function createCards({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An aid's kind, marked virtual when nothing physical stands there. */
|
||||||
|
|
||||||
|
function aidKind(kind, record) {
|
||||||
|
return record.virtual === true ? `${kind} · ${t('VIRTUAL')}` : kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card's accent: an aid's disc colour, else its ship type's. */
|
||||||
|
|
||||||
|
function vesselAccent(record, aid) {
|
||||||
|
return aid
|
||||||
|
? AID_TO_NAVIGATION_STYLE.accent
|
||||||
|
: accentForVesselType(record.type);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
||||||
|
|
||||||
function vesselOverlayEntryId(record) {
|
function vesselOverlayEntryId(record) {
|
||||||
@@ -129,9 +158,15 @@ export function createCards({
|
|||||||
* Uppercased AIS type for the card (empty string when unknown). Free feed
|
* Uppercased AIS type for the card (empty string when unknown). Free feed
|
||||||
* text is bounded to the card width; a translated type token is a short
|
* text is bounded to the card width; a translated type token is a short
|
||||||
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
|
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
|
||||||
|
* An aid's kind is the whole line, so it keeps the room speed and heading
|
||||||
|
* would take ("CARDINAL MARK N", not "CARDINAL MARK ").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselTypeShort(record) {
|
function vesselTypeShort(record) {
|
||||||
|
if (isAidToNavigation(record)) {
|
||||||
|
const kind = String(record.type || '').trim();
|
||||||
|
return kind ? trimHudValue(kind.toUpperCase(), 26) : '';
|
||||||
|
}
|
||||||
const feedType = normalizeVesselType(record.type);
|
const feedType = normalizeVesselType(record.type);
|
||||||
const shown = displayVesselType(record.type);
|
const shown = displayVesselType(record.type);
|
||||||
return shown === feedType
|
return shown === feedType
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export function createIngestion({
|
|||||||
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
||||||
course: record.courseDeg,
|
course: record.courseDeg,
|
||||||
heading: record.headingDeg,
|
heading: record.headingDeg,
|
||||||
|
virtual: record.virtual === true,
|
||||||
last_position_epoch:
|
last_position_epoch:
|
||||||
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
||||||
last_position_UTC:
|
last_position_UTC:
|
||||||
|
|||||||
@@ -515,3 +515,50 @@ test('AIS feed rows stay keyed by their MMSI', async () => {
|
|||||||
['235000001', '235000001'],
|
['235000001', '235000001'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid to navigation is drawn as a disc, a vessel as its chevron', async () => {
|
||||||
|
const aid = {
|
||||||
|
...observation('993672001'),
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Port Hand Mark',
|
||||||
|
speedMps: 0,
|
||||||
|
};
|
||||||
|
const ship = { ...observation('366999002'), type: 'Cargo' };
|
||||||
|
const { layer, primitives } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return snapshot([aid, ship]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
const svgOf = (id) => {
|
||||||
|
const primitive = [...primitives].find((p) => p.id?.id === id);
|
||||||
|
assert.ok(primitive, id);
|
||||||
|
return atob(primitive.image.split(',')[1]);
|
||||||
|
};
|
||||||
|
assert.match(svgOf('993672001'), /<circle/);
|
||||||
|
// The source's virtual flag reaches the layer's record.
|
||||||
|
const recordOf = (id) => [...primitives].find((p) => p.id?.id === id).id;
|
||||||
|
assert.equal(recordOf('993672001').virtual, false);
|
||||||
|
assert.equal(recordOf('366999002').virtual, false);
|
||||||
|
assert.doesNotMatch(svgOf('993672001'), /<path/);
|
||||||
|
assert.match(svgOf('366999002'), /<path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a virtual aid to navigation keeps its disc and its virtual flag', async () => {
|
||||||
|
const aid = {
|
||||||
|
...observation('993672009'),
|
||||||
|
mmsi: '993672009',
|
||||||
|
type: 'Isolated danger',
|
||||||
|
virtual: true,
|
||||||
|
};
|
||||||
|
const { layer, primitives } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return snapshot([aid]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
const primitive = [...primitives].find((p) => p.id?.id === '993672009');
|
||||||
|
assert.ok(primitive);
|
||||||
|
assert.equal(primitive.id.virtual, true);
|
||||||
|
assert.match(atob(primitive.image.split(',')[1]), /<circle/);
|
||||||
|
});
|
||||||
@@ -31,6 +31,8 @@ export function normalizeVessel(row) {
|
|||||||
speed: finiteNumber(row.speed),
|
speed: finiteNumber(row.speed),
|
||||||
course: finiteNumber(row.course),
|
course: finiteNumber(row.course),
|
||||||
heading: finiteNumber(row.heading),
|
heading: finiteNumber(row.heading),
|
||||||
|
// A virtual aid to navigation: a mark with nothing physical there.
|
||||||
|
virtual: row.virtual === true,
|
||||||
lastPositionUtc: String(row.last_position_UTC || ''),
|
lastPositionUtc: String(row.last_position_UTC || ''),
|
||||||
lastPositionEpoch: finiteNumber(row.last_position_epoch),
|
lastPositionEpoch: finiteNumber(row.last_position_epoch),
|
||||||
missedRefreshes: 0,
|
missedRefreshes: 0,
|
||||||
@@ -160,6 +162,7 @@ export class VesselRecords {
|
|||||||
record.speed = next.speed;
|
record.speed = next.speed;
|
||||||
record.course = next.course;
|
record.course = next.course;
|
||||||
record.heading = next.heading;
|
record.heading = next.heading;
|
||||||
|
record.virtual = next.virtual;
|
||||||
record.lastPositionUtc = next.lastPositionUtc;
|
record.lastPositionUtc = next.lastPositionUtc;
|
||||||
record.lastPositionEpoch = next.lastPositionEpoch;
|
record.lastPositionEpoch = next.lastPositionEpoch;
|
||||||
record.missedRefreshes = 0;
|
record.missedRefreshes = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
vesselTypeCss,
|
vesselTypeCss,
|
||||||
vesselOverlayCohortLimit,
|
vesselOverlayCohortLimit,
|
||||||
VESSEL_OVERLAY_SOURCE_ID,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
vesselModelSpec,
|
vesselModelSpec,
|
||||||
} from '../../data/fleetModels.js';
|
} from '../../data/fleetModels.js';
|
||||||
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
||||||
|
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||||
|
|
||||||
/** How often vessels move on as the world clock plays (wall ms). */
|
/** How often vessels move on as the world clock plays (wall ms). */
|
||||||
const FOLLOW_POSITION_MS = 250;
|
const FOLLOW_POSITION_MS = 250;
|
||||||
@@ -182,12 +184,14 @@ export function createRendering({
|
|||||||
* Best available real-world direction of travel for a vessel, degrees
|
* Best available real-world direction of travel for a vessel, degrees
|
||||||
* clockwise from north (true heading preferred, course-over-ground fallback).
|
* clockwise from north (true heading preferred, course-over-ground fallback).
|
||||||
* Screen rotation is computed from this by the shared projected-rotation
|
* Screen rotation is computed from this by the shared projected-rotation
|
||||||
* pass in updateVisibility — never directly from the compass value.
|
* pass in updateVisibility — never directly from the compass value. An aid
|
||||||
|
* to navigation is fixed, so it has none.
|
||||||
* @param {Object} record - Vessel record.
|
* @param {Object} record - Vessel record.
|
||||||
* @returns {number} Course in degrees (0 when unknown).
|
* @returns {number} Course in degrees (0 when unknown).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselCourseDeg(record) {
|
function vesselCourseDeg(record) {
|
||||||
|
if (isAidToNavigation(record)) return 0;
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
return Number.isFinite(direction) ? direction : 0;
|
return Number.isFinite(direction) ? direction : 0;
|
||||||
}
|
}
|
||||||
@@ -195,6 +199,7 @@ export function createRendering({
|
|||||||
/**
|
/**
|
||||||
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
||||||
* The shape points north (up) so billboard rotation maps directly to heading.
|
* The shape points north (up) so billboard rotation maps directly to heading.
|
||||||
|
* An aid to navigation, which has no heading, is a ringed disc instead.
|
||||||
* One icon is generated per color+variant and reused across all billboards.
|
* One icon is generated per color+variant and reused across all billboards.
|
||||||
* @param {Object} record - Vessel record (drives per-type tint).
|
* @param {Object} record - Vessel record (drives per-type tint).
|
||||||
* @param {boolean} selected - True for the white/brighter selected variant.
|
* @param {boolean} selected - True for the white/brighter selected variant.
|
||||||
@@ -202,16 +207,25 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function shipIcon(record, selected) {
|
function shipIcon(record, selected) {
|
||||||
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
const aid = isAidToNavigation(record);
|
||||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
const cssColor = selected
|
||||||
|
? '#ffffff'
|
||||||
|
: aid
|
||||||
|
? AID_TO_NAVIGATION_STYLE.css
|
||||||
|
: vesselTypeCss(record.type);
|
||||||
|
const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`;
|
||||||
if (vesselState.shipIconCache.has(key))
|
if (vesselState.shipIconCache.has(key))
|
||||||
return vesselState.shipIconCache.get(key);
|
return vesselState.shipIconCache.get(key);
|
||||||
|
|
||||||
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
||||||
const strokeWidth = selected ? 1.1 : 0.7;
|
const strokeWidth = selected ? 1.1 : 0.7;
|
||||||
|
const shape = aid
|
||||||
|
? `<circle r="8" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth * 2}"/>
|
||||||
|
<circle r="3" fill="${stroke}"/>`
|
||||||
|
: `<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>`;
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
<g transform="translate(16,16)">
|
<g transform="translate(16,16)">
|
||||||
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
|
${shape}
|
||||||
</g>
|
</g>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||||
@@ -260,18 +274,24 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The model item for `record`, its spec rebuilt only when its type or size changes. */
|
/**
|
||||||
|
* The model item for `record`, its spec rebuilt only when its type or size
|
||||||
|
* changes; null for a virtual aid to navigation, which keeps its disc: no
|
||||||
|
* buoy stands there (vesselModelSpec).
|
||||||
|
*/
|
||||||
function modelItem(record, set) {
|
function modelItem(record, set) {
|
||||||
|
if (record.virtual === true) return null;
|
||||||
let item = modelItems.get(record.id);
|
let item = modelItems.get(record.id);
|
||||||
if (!item || item.record !== record)
|
if (!item || item.record !== record)
|
||||||
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
|
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
|
||||||
const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`;
|
const specKey = `${record.type}|${record.mmsi}|${record.length}|${record.beam}|${record.name}`;
|
||||||
if (item.specKey !== specKey) {
|
if (item.specKey !== specKey) {
|
||||||
item.specKey = specKey;
|
item.specKey = specKey;
|
||||||
item.spec = vesselModelSpec(set, record);
|
item.spec = vesselModelSpec(set, record);
|
||||||
}
|
}
|
||||||
item.position = getVisual(record).waterline;
|
item.position = getVisual(record).waterline;
|
||||||
item.headingDeg = vesselCourseDeg(record);
|
// An aid to navigation is a fixed mark: it does not turn to a heading.
|
||||||
|
item.headingDeg = item.spec?.fixed ? 0 : vesselCourseDeg(record);
|
||||||
item.show = record.id !== state.cockpitSubjectId;
|
item.show = record.id !== state.cockpitSubjectId;
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -294,7 +314,8 @@ export function createRendering({
|
|||||||
limitSq
|
limitSq
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
into.set(record.id, modelItem(record, set));
|
const item = modelItem(record, set);
|
||||||
|
if (item) into.set(record.id, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,8 +351,10 @@ export function createRendering({
|
|||||||
gatherModelItems(set, next);
|
gatherModelItems(set, next);
|
||||||
} else {
|
} else {
|
||||||
for (const [id, item] of modelItems)
|
for (const [id, item] of modelItems)
|
||||||
if (state.records.byId.get(id) === item.record)
|
if (state.records.byId.get(id) === item.record) {
|
||||||
next.set(id, modelItem(item.record, set));
|
const kept = modelItem(item.record, set);
|
||||||
|
if (kept) next.set(id, kept);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const [id, item] of modelItems)
|
for (const [id, item] of modelItems)
|
||||||
if (next.get(id) !== item) handBackVisual(item.record, occluder);
|
if (next.get(id) !== item) handBackVisual(item.record, occluder);
|
||||||
@@ -465,7 +488,8 @@ export function createRendering({
|
|||||||
visible &&
|
visible &&
|
||||||
record.id !== state.cockpitSubjectId &&
|
record.id !== state.cockpitSubjectId &&
|
||||||
!visual.modelOwnsVisual;
|
!visual.modelOwnsVisual;
|
||||||
if (visible && doRotations && scene) {
|
// An aid's disc is never turned (vesselCourseDeg).
|
||||||
|
if (visible && doRotations && scene && !isAidToNavigation(record)) {
|
||||||
const rot = screenProjectedRotation(
|
const rot = screenProjectedRotation(
|
||||||
scene,
|
scene,
|
||||||
visual.position,
|
visual.position,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createTerrainSurface } from './terrainSurface.js';
|
||||||
/** Construct an instance-owned groundFloor service with explicit dependencies. */
|
/** Construct an instance-owned groundFloor service with explicit dependencies. */
|
||||||
export function createGroundFloor({ terrain, signal }) {
|
export function createGroundFloor({ terrain, signal }) {
|
||||||
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
|
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
|
||||||
@@ -698,6 +699,8 @@ export function createGroundFloor({ terrain, signal }) {
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
// The terrain globe's loaded surface, read once per point for every layer.
|
||||||
|
terrainSurface: createTerrainSurface(),
|
||||||
GROUND_FLOOR_LIFT_M,
|
GROUND_FLOOR_LIFT_M,
|
||||||
MESH_FLOOR_BELOW_PRIOR_M,
|
MESH_FLOOR_BELOW_PRIOR_M,
|
||||||
MESH_FLOOR_ABOVE_PRIOR_M,
|
MESH_FLOOR_ABOVE_PRIOR_M,
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The height of the terrain globe's loaded surface at a point, kept per point
|
||||||
|
* and read within a time budget.
|
||||||
|
*
|
||||||
|
* `globe.getHeight` casts a ray against the mesh of the tile drawn under the
|
||||||
|
* point: geometry work, not a table lookup, and dearer the finer the tile. A
|
||||||
|
* layer that asks for every vehicle, trail fix or site on every pass asks the
|
||||||
|
* same few thousand points over and over, so the answers are kept and the
|
||||||
|
* reads are rationed. One instance serves every layer of an application, so
|
||||||
|
* the layers share one budget instead of each spending its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While the camera moves or tiles load, a kept height is reused this long
|
||||||
|
* before the point is read again.
|
||||||
|
*/
|
||||||
|
export const TERRAIN_HEIGHT_FRESH_MS = 2_000;
|
||||||
|
/**
|
||||||
|
* A height read once the camera has been still and the globe's tiles loaded
|
||||||
|
* for this long is kept until either changes: the globe draws a point from
|
||||||
|
* another tile only after one of them does.
|
||||||
|
*/
|
||||||
|
export const TERRAIN_SETTLE_MS = 5_000;
|
||||||
|
/** Read budgets are counted over windows of this length. */
|
||||||
|
export const TERRAIN_READ_WINDOW_MS = 200;
|
||||||
|
/**
|
||||||
|
* Milliseconds of terrain reads in one window. Past it a point never read
|
||||||
|
* takes the last height read in its ~111 m floor cell, or waits for a later
|
||||||
|
* request; a MARKER point is read regardless.
|
||||||
|
*/
|
||||||
|
export const TERRAIN_READ_BUDGET_MS = 16;
|
||||||
|
/**
|
||||||
|
* The share of that budget re-reads of kept points may use, the longest-kept
|
||||||
|
* first, leaving the rest for points never read.
|
||||||
|
*/
|
||||||
|
export const TERRAIN_REFRESH_BUDGET_MS = 12;
|
||||||
|
/**
|
||||||
|
* How much a height matters to what is drawn, lowest first. BACKGROUND is a
|
||||||
|
* point nobody is looking at (the undrawn trail history of a vehicle) and may
|
||||||
|
* use a quarter of each budget; PATH is drawn geometry; MARKER is a thing's
|
||||||
|
* own position, read even when the window's budget is spent, since a marker
|
||||||
|
* without a height is not drawn.
|
||||||
|
*/
|
||||||
|
export const TERRAIN_NEED = Object.freeze({
|
||||||
|
BACKGROUND: 0,
|
||||||
|
PATH: 1,
|
||||||
|
MARKER: 2,
|
||||||
|
});
|
||||||
|
/** Points kept; the oldest quarter is dropped past it. */
|
||||||
|
export const TERRAIN_HEIGHT_CACHE_MAX = 60_000;
|
||||||
|
|
||||||
|
/** A number unique to a point on a `scale`-per-degree grid. */
|
||||||
|
function gridKey(lat, lon, scale) {
|
||||||
|
return (
|
||||||
|
(Math.round(lat * scale) + 90 * scale) * (360 * scale + 1) +
|
||||||
|
Math.round(lon * scale) +
|
||||||
|
180 * scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{
|
||||||
|
* height: (viewer: object, lat: number, lon: number, need?: number) => number|undefined,
|
||||||
|
* observe: (viewer: object) => void,
|
||||||
|
* waitingCount: () => number,
|
||||||
|
* stats: () => {reads: number, reused: number, deferred: number, borrowed: number, held: number},
|
||||||
|
* clear: () => void,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function createTerrainSurface() {
|
||||||
|
const point = new Cesium.Cartographic();
|
||||||
|
/** Point key → `{ h, at }`: the height read there and when. */
|
||||||
|
const heights = new Map();
|
||||||
|
/** Floor-cell key → the last height read anywhere in that cell. */
|
||||||
|
const cells = new Map();
|
||||||
|
/** The globe and terrain provider the kept heights were read from. */
|
||||||
|
let globeRead = null;
|
||||||
|
let providerRead = null;
|
||||||
|
let windowAt = -Infinity;
|
||||||
|
let readMs = 0;
|
||||||
|
let refreshMs = 0;
|
||||||
|
// Per need (background, path): the oldest read among the points this window
|
||||||
|
// left for later, and the bound the next window re-reads up to.
|
||||||
|
const deferredAt = [Infinity, Infinity];
|
||||||
|
const refreshUpTo = [Infinity, Infinity];
|
||||||
|
let waiting = 0;
|
||||||
|
/** When the camera last moved or the globe was last loading tiles. */
|
||||||
|
let changedAt = -Infinity;
|
||||||
|
let cameraLon = NaN,
|
||||||
|
cameraLat = NaN,
|
||||||
|
cameraHeight = NaN;
|
||||||
|
const counters = { reads: 0, reused: 0, deferred: 0, borrowed: 0 };
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
heights.clear();
|
||||||
|
cells.clear();
|
||||||
|
globeRead = providerRead = null;
|
||||||
|
windowAt = -Infinity;
|
||||||
|
readMs = refreshMs = 0;
|
||||||
|
deferredAt.fill(Infinity);
|
||||||
|
refreshUpTo.fill(Infinity);
|
||||||
|
changedAt = -Infinity;
|
||||||
|
cameraLon = cameraLat = cameraHeight = NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The globe redraws a point from another tile only after the camera moves
|
||||||
|
* or a tile arrives: note when either was last seen.
|
||||||
|
*/
|
||||||
|
function noteChange(viewer, globe, now) {
|
||||||
|
const camera = viewer.camera?.positionCartographic;
|
||||||
|
if (
|
||||||
|
globe.tilesLoaded === false ||
|
||||||
|
camera?.longitude !== cameraLon ||
|
||||||
|
camera?.latitude !== cameraLat ||
|
||||||
|
camera?.height !== cameraHeight
|
||||||
|
) {
|
||||||
|
changedAt = now;
|
||||||
|
cameraLon = camera?.longitude;
|
||||||
|
cameraLat = camera?.latitude;
|
||||||
|
cameraHeight = camera?.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewer's terrain globe, the one whose heights are held, or null. */
|
||||||
|
function terrainGlobe(viewer) {
|
||||||
|
const globe = viewer?.scene?.globe;
|
||||||
|
if (globe?.show !== true || typeof globe.getHeight !== 'function')
|
||||||
|
return null;
|
||||||
|
// A height belongs to the terrain it was read from: another globe or
|
||||||
|
// another terrain provider is another surface, however settled the camera.
|
||||||
|
const provider = viewer.terrainProvider;
|
||||||
|
if (globe !== globeRead || provider !== providerRead) {
|
||||||
|
clear();
|
||||||
|
globeRead = globe;
|
||||||
|
providerRead = provider;
|
||||||
|
}
|
||||||
|
return globe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look at the camera and the globe's tile loading without asking for a
|
||||||
|
* height. The surface only sees a change when it is asked something, so a
|
||||||
|
* caller that asks nothing while tiles load (it waits for them) calls this
|
||||||
|
* on its own walk; otherwise the load would pass unseen and the heights
|
||||||
|
* kept from before it would never be read again.
|
||||||
|
* @param {object} viewer
|
||||||
|
*/
|
||||||
|
function observe(viewer) {
|
||||||
|
const globe = terrainGlobe(viewer);
|
||||||
|
if (globe) noteChange(viewer, globe, performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loaded terrain's height at a point, in metres above the ellipsoid, or
|
||||||
|
* undefined when it is not known yet or the viewer has no terrain globe
|
||||||
|
* showing. A point read within the reuse time answers from memory, and so
|
||||||
|
* does one read after the camera and the globe's tiles had settled: the
|
||||||
|
* surface under it cannot have changed since. Past the window's budget a
|
||||||
|
* kept point keeps its last height and a new point borrows its floor
|
||||||
|
* cell's. Requests tend to come in the same order every pass, so re-reads
|
||||||
|
* go to the longest-kept points first: otherwise the first few would be
|
||||||
|
* re-read forever and the rest never.
|
||||||
|
* @param {object} viewer The Cesium viewer whose globe is read.
|
||||||
|
* @param {number} lat Degrees.
|
||||||
|
* @param {number} lon Degrees.
|
||||||
|
* @param {number} [need] How much the answer matters; see TERRAIN_NEED.
|
||||||
|
*/
|
||||||
|
function height(viewer, lat, lon, need = TERRAIN_NEED.PATH) {
|
||||||
|
const globe = terrainGlobe(viewer);
|
||||||
|
if (!globe) return undefined;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - windowAt >= TERRAIN_READ_WINDOW_MS) {
|
||||||
|
for (let i = 0; i < deferredAt.length; i++) {
|
||||||
|
refreshUpTo[i] = deferredAt[i];
|
||||||
|
deferredAt[i] = Infinity;
|
||||||
|
}
|
||||||
|
windowAt = now;
|
||||||
|
readMs = refreshMs = 0;
|
||||||
|
noteChange(viewer, globe, now);
|
||||||
|
}
|
||||||
|
const key = gridKey(lat, lon, 1e5);
|
||||||
|
const held = heights.get(key);
|
||||||
|
if (
|
||||||
|
held &&
|
||||||
|
(now - held.at < TERRAIN_HEIGHT_FRESH_MS ||
|
||||||
|
held.at >= changedAt + TERRAIN_SETTLE_MS)
|
||||||
|
) {
|
||||||
|
counters.reused++;
|
||||||
|
return held.h;
|
||||||
|
}
|
||||||
|
const cellKey = gridKey(lat, lon, 1e3);
|
||||||
|
const marker = need === TERRAIN_NEED.MARKER;
|
||||||
|
// Background may use a quarter of each budget; drawn geometry the rest.
|
||||||
|
const share = need === TERRAIN_NEED.BACKGROUND ? 0.25 : 1;
|
||||||
|
const affordable =
|
||||||
|
marker && !held
|
||||||
|
? true
|
||||||
|
: readMs < TERRAIN_READ_BUDGET_MS * (marker ? 1 : share) &&
|
||||||
|
(!held ||
|
||||||
|
marker ||
|
||||||
|
(refreshMs < TERRAIN_REFRESH_BUDGET_MS * share &&
|
||||||
|
held.at <= refreshUpTo[need]));
|
||||||
|
if (!affordable) {
|
||||||
|
counters.deferred++;
|
||||||
|
if (held) {
|
||||||
|
if (!marker && held.at < deferredAt[need]) deferredAt[need] = held.at;
|
||||||
|
waiting++;
|
||||||
|
return held.h;
|
||||||
|
}
|
||||||
|
const borrowed = cells.get(cellKey);
|
||||||
|
if (borrowed !== undefined) {
|
||||||
|
counters.borrowed++;
|
||||||
|
return borrowed;
|
||||||
|
}
|
||||||
|
waiting++;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
Cesium.Cartographic.fromDegrees(lon, lat, 0, point);
|
||||||
|
const rendered = globe.getHeight(point);
|
||||||
|
const spent = performance.now() - now;
|
||||||
|
readMs += spent;
|
||||||
|
if (held) refreshMs += spent;
|
||||||
|
counters.reads++;
|
||||||
|
// A tile that left the globe keeps the height it gave.
|
||||||
|
if (!Number.isFinite(rendered)) return held?.h;
|
||||||
|
if (held) {
|
||||||
|
held.h = rendered;
|
||||||
|
held.at = now;
|
||||||
|
} else {
|
||||||
|
if (heights.size >= TERRAIN_HEIGHT_CACHE_MAX) {
|
||||||
|
let drop = TERRAIN_HEIGHT_CACHE_MAX >> 2;
|
||||||
|
for (const old of heights.keys()) {
|
||||||
|
heights.delete(old);
|
||||||
|
if (--drop <= 0) break;
|
||||||
|
}
|
||||||
|
cells.clear();
|
||||||
|
}
|
||||||
|
heights.set(key, { h: rendered, at: now });
|
||||||
|
}
|
||||||
|
cells.set(cellKey, rendered);
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
observe,
|
||||||
|
/**
|
||||||
|
* Requests so far that were left unread or due a re-read. A caller that
|
||||||
|
* reads it before and after its own pass learns whether to come back.
|
||||||
|
*/
|
||||||
|
waitingCount: () => waiting,
|
||||||
|
/** Read counters since creation, for checks. */
|
||||||
|
stats: () => ({ ...counters, held: heights.size }),
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
TERRAIN_NEED,
|
||||||
|
TERRAIN_READ_BUDGET_MS,
|
||||||
|
TERRAIN_READ_WINDOW_MS,
|
||||||
|
TERRAIN_SETTLE_MS,
|
||||||
|
createTerrainSurface,
|
||||||
|
} from './terrainSurface.js';
|
||||||
|
|
||||||
|
/** A viewer over a terrain globe whose every read costs `costMs` of a clock the test owns. */
|
||||||
|
function world(t, { costMs = 0, surface = () => 100 } = {}) {
|
||||||
|
const clock = { now: 5_000_000 };
|
||||||
|
t.mock.method(performance, 'now', () => clock.now);
|
||||||
|
const reads = [];
|
||||||
|
const model = { surface, costMs };
|
||||||
|
const globe = () => ({
|
||||||
|
show: true,
|
||||||
|
getHeight(point) {
|
||||||
|
const lat = Cesium.Math.toDegrees(point.latitude);
|
||||||
|
const lon = Cesium.Math.toDegrees(point.longitude);
|
||||||
|
reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`);
|
||||||
|
clock.now += model.costMs;
|
||||||
|
return model.surface(lat, lon);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const viewer = {
|
||||||
|
scene: { globe: globe() },
|
||||||
|
camera: {
|
||||||
|
positionCartographic: { longitude: 0, latitude: 0, height: 900 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { viewer, clock, reads, model, globe };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a viewer without a terrain globe showing has no terrain height', (t) => {
|
||||||
|
const { viewer, reads } = world(t);
|
||||||
|
const surface = createTerrainSurface();
|
||||||
|
viewer.scene.globe.show = false;
|
||||||
|
assert.equal(surface.height(viewer, 42, -71), undefined);
|
||||||
|
assert.equal(surface.height({ scene: {} }, 42, -71), undefined);
|
||||||
|
assert.equal(surface.height(null, 42, -71), undefined);
|
||||||
|
assert.equal(reads.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every caller of one surface shares its heights and its budget', (t) => {
|
||||||
|
const costMs = 2;
|
||||||
|
const { viewer, reads, clock } = world(t, { costMs });
|
||||||
|
const surface = createTerrainSurface();
|
||||||
|
// Two layers asking about the same street: the second reads nothing.
|
||||||
|
const transit = () => surface.height(viewer, 42.36, -71.06);
|
||||||
|
const rail = () => surface.height(viewer, 42.36, -71.06);
|
||||||
|
assert.equal(transit(), 100);
|
||||||
|
assert.equal(rail(), 100);
|
||||||
|
assert.equal(reads.length, 1);
|
||||||
|
|
||||||
|
// And what one spends in a window the other cannot spend again.
|
||||||
|
const affordable = TERRAIN_READ_BUDGET_MS / costMs;
|
||||||
|
let lon = -72;
|
||||||
|
const fresh = () => surface.height(viewer, 42, (lon -= 0.001));
|
||||||
|
for (let i = 0; i < affordable - 1; i++) assert.equal(fresh(), 100);
|
||||||
|
const before = surface.waitingCount();
|
||||||
|
assert.equal(fresh(), undefined, 'the window is spent, whoever asks');
|
||||||
|
assert.equal(surface.waitingCount(), before + 1);
|
||||||
|
assert.equal(
|
||||||
|
surface.height(viewer, 43, -73, TERRAIN_NEED.MARKER),
|
||||||
|
100,
|
||||||
|
'a marker is still read',
|
||||||
|
);
|
||||||
|
clock.now += TERRAIN_READ_WINDOW_MS;
|
||||||
|
assert.equal(fresh(), 100);
|
||||||
|
assert.deepEqual(surface.stats(), {
|
||||||
|
reads: affordable + 2,
|
||||||
|
reused: 1,
|
||||||
|
deferred: 1,
|
||||||
|
borrowed: 0,
|
||||||
|
held: affordable + 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another globe's surface is read afresh", (t) => {
|
||||||
|
const { viewer, reads, clock, model, globe } = world(t, {
|
||||||
|
surface: () => 10,
|
||||||
|
});
|
||||||
|
const surface = createTerrainSurface();
|
||||||
|
assert.equal(surface.height(viewer, 42.36, -71.06), 10);
|
||||||
|
clock.now += TERRAIN_SETTLE_MS;
|
||||||
|
assert.equal(surface.height(viewer, 42.36, -71.06), 10);
|
||||||
|
clock.now += 60_000;
|
||||||
|
model.surface = () => 250;
|
||||||
|
assert.equal(surface.height(viewer, 42.36, -71.06), 10, 'settled: kept');
|
||||||
|
// The map source changed and the viewer has a new globe.
|
||||||
|
viewer.scene.globe = globe();
|
||||||
|
assert.equal(surface.height(viewer, 42.36, -71.06), 250);
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
assert.equal(surface.stats().held, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another terrain provider is read afresh, however settled the old heights were', (t) => {
|
||||||
|
const { viewer, reads, model, clock } = world(t);
|
||||||
|
const surface = createTerrainSurface();
|
||||||
|
viewer.terrainProvider = {};
|
||||||
|
const at = () => surface.height(viewer, 42.36, -71.06);
|
||||||
|
// Read once the camera and tiles have long settled: kept indefinitely.
|
||||||
|
surface.height(viewer, 10, 10);
|
||||||
|
clock.now += TERRAIN_SETTLE_MS * 2;
|
||||||
|
reads.length = 0;
|
||||||
|
assert.equal(at(), 100);
|
||||||
|
clock.now += TERRAIN_SETTLE_MS * 10;
|
||||||
|
model.surface = () => 250;
|
||||||
|
assert.equal(at(), 100, 'same terrain: the kept height stands');
|
||||||
|
assert.equal(reads.length, 1);
|
||||||
|
|
||||||
|
const real = (viewer.terrainProvider = {});
|
||||||
|
assert.equal(at(), 250, 'a new provider is a new surface');
|
||||||
|
assert.equal(reads.length, 2);
|
||||||
|
|
||||||
|
// Terrain → flat → the same provider. A caller answers a flat globe
|
||||||
|
// itself and asks nothing, so it shows the flat provider with observe().
|
||||||
|
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
|
||||||
|
surface.observe(viewer);
|
||||||
|
model.surface = () => 30;
|
||||||
|
viewer.terrainProvider = real;
|
||||||
|
assert.equal(at(), 30);
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observe() lets a caller that asks nothing while tiles load report the load', (t) => {
|
||||||
|
const { viewer, reads, clock, model } = world(t, { surface: () => 10 });
|
||||||
|
const surface = createTerrainSurface();
|
||||||
|
const read = () => surface.height(viewer, 42.36, -71.06);
|
||||||
|
read();
|
||||||
|
clock.now += TERRAIN_SETTLE_MS;
|
||||||
|
read();
|
||||||
|
clock.now += 60_000;
|
||||||
|
assert.equal(reads.length, 2, 'settled and kept');
|
||||||
|
|
||||||
|
// Finer tiles load and finish while nobody asks for a height.
|
||||||
|
model.surface = () => 14;
|
||||||
|
viewer.scene.globe.tilesLoaded = false;
|
||||||
|
clock.now += 1_000;
|
||||||
|
surface.observe(viewer);
|
||||||
|
viewer.scene.globe.tilesLoaded = true;
|
||||||
|
clock.now += 3_000;
|
||||||
|
assert.equal(
|
||||||
|
read(),
|
||||||
|
14,
|
||||||
|
'the height kept from before the load is read again',
|
||||||
|
);
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
surface.observe(null);
|
||||||
|
surface.observe({ scene: {} });
|
||||||
|
});
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
* snapshots. A vessel record's `id` is whatever its source gives, such as a
|
* snapshots. A vessel record's `id` is whatever its source gives, such as a
|
||||||
* provider id; its `mmsi` is an optional attribute ('' or absent when
|
* provider id; its `mmsi` is an optional attribute ('' or absent when
|
||||||
* unknown), never its key, since vessels can share an MMSI. A source that
|
* unknown), never its key, since vessels can share an MMSI. A source that
|
||||||
* only has MMSIs uses the MMSI as the id.
|
* only has MMSIs uses the MMSI as the id. A vessel record's `virtual` is true
|
||||||
|
* for a virtual aid to navigation, a mark broadcast where nothing physical
|
||||||
|
* stands (AIS message 21's virtual flag), else false; any source may set it.
|
||||||
*
|
*
|
||||||
* A source may also declare a timeline (see readSnapshotTimeline): it can
|
* A source may also declare a timeline (see readSnapshotTimeline): it can
|
||||||
* answer a snapshot at a given time, so the layer follows the world clock.
|
* answer a snapshot at a given time, so the layer follows the world clock.
|
||||||
|
|||||||
@@ -221,6 +221,18 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
|
|||||||
assert.equal(row.altitudeDatum, 'sea-surface');
|
assert.equal(row.altitudeDatum, 'sea-surface');
|
||||||
assert.equal(row.observedAtMs, now);
|
assert.equal(row.observedAtMs, now);
|
||||||
assert.equal('extra' in row, false);
|
assert.equal('extra' in row, false);
|
||||||
|
// Only a source's own true marks a virtual aid to navigation.
|
||||||
|
assert.equal(row.virtual, false);
|
||||||
|
const base = { mmsi: '993672001', lat: 30, lon: -97 };
|
||||||
|
assert.equal(
|
||||||
|
normalizeVesselObservation({ ...base, virtual: true }).virtual,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const virtual of ['true', 1, false, null])
|
||||||
|
assert.equal(
|
||||||
|
normalizeVesselObservation({ ...base, virtual }).virtual,
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
|
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function normalizeVesselObservation(row, reference = null, id = null) {
|
|||||||
epoch(row.last_position_epoch, 1000) ??
|
epoch(row.last_position_epoch, 1000) ??
|
||||||
epoch(Date.parse(row.last_position_UTC)),
|
epoch(Date.parse(row.last_position_UTC)),
|
||||||
altitudeDatum: 'sea-surface',
|
altitudeDatum: 'sea-surface',
|
||||||
|
// A virtual aid to navigation: a mark broadcast with nothing there.
|
||||||
|
virtual: row.virtual === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
thinEvenly,
|
thinEvenly,
|
||||||
} from '../results.js';
|
} from '../results.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||||
import { normalizeVesselType } from '../../data/vesselTypes.js';
|
import {
|
||||||
|
isAidToNavigation,
|
||||||
|
normalizeVesselType,
|
||||||
|
} from '../../data/vesselTypes.js';
|
||||||
|
|
||||||
const MMSI_SCHEMA = Object.freeze({
|
const MMSI_SCHEMA = Object.freeze({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -116,7 +119,8 @@ export const vesselsInArea = defineTool({
|
|||||||
title: 'Vessels in an area',
|
title: 'Vessels in an area',
|
||||||
description:
|
description:
|
||||||
'Ships currently reported by AIS inside an area, nearest the center ' +
|
'Ships currently reported by AIS inside an area, nearest the center ' +
|
||||||
'first, optionally filtered by vessel type. group_by counts every matched ' +
|
'first, optionally filtered by vessel type. Buoys, beacons and other ' +
|
||||||
|
'aids to navigation are not ships and are left out. group_by counts every matched ' +
|
||||||
'ship by type or destination, and stats summarizes their speed; both ' +
|
'ship by type or destination, and stats summarizes their speed; both ' +
|
||||||
'cover all matches, not just the rows returned.',
|
'cover all matches, not just the rows returned.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
@@ -152,6 +156,8 @@ export const vesselsInArea = defineTool({
|
|||||||
const rows = snapshot.records
|
const rows = snapshot.records
|
||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
|
// A buoy or beacon in the feed is not a ship to count.
|
||||||
|
!isAidToNavigation(record) &&
|
||||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||||
(!wanted ||
|
(!wanted ||
|
||||||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
|
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
|
||||||
|
|||||||
@@ -467,3 +467,44 @@ test('vessels in an area can be counted by destination with a speed summary', as
|
|||||||
speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 },
|
speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('vessels in an area leave out buoys and beacons', async () => {
|
||||||
|
const withAids = {
|
||||||
|
...vessels(),
|
||||||
|
async getSnapshot() {
|
||||||
|
return {
|
||||||
|
records: [
|
||||||
|
...records,
|
||||||
|
vessel('993672001', 37.8, -122.42, {
|
||||||
|
name: 'SF APPROACH N',
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
speedMps: 0,
|
||||||
|
headingDeg: 135,
|
||||||
|
}),
|
||||||
|
vessel('993672002', 37.79, -122.43, { type: '' }),
|
||||||
|
],
|
||||||
|
source: 'AISStream',
|
||||||
|
observedAtMs: Date.UTC(2026, 0, 1),
|
||||||
|
freshness: 'current',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { vessels: withAids },
|
||||||
|
});
|
||||||
|
const all = await catalog.call('vessels_in_area', {
|
||||||
|
area: bay,
|
||||||
|
group_by: 'type',
|
||||||
|
});
|
||||||
|
assert.equal(all.data.total, 2);
|
||||||
|
assert.equal(all.summary.split(' in ')[0], '2 vessels');
|
||||||
|
assert.deepEqual(all.data.rows.map((row) => row.mmsi).sort(), [
|
||||||
|
'366999712',
|
||||||
|
'538001234',
|
||||||
|
]);
|
||||||
|
assert.equal(all.data.breakdown.unknown_count, 0);
|
||||||
|
assert.ok(
|
||||||
|
!all.data.breakdown.groups.some((group) => /mark/i.test(group.value)),
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user