Merge branch 'main' into feat/tools-area-breakdowns

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-17 14:49:25 -07:00
commit d06e485c12
8 files changed
+471 -8

No files matched your search

+3 -1
View File
@@ -69,10 +69,12 @@ How to read this:
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | | **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" | | **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" |
| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` |
| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" |
| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" | | **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" |
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | | **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | | **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" |
| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
@@ -0,0 +1,6 @@
- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
(Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
(Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
BicinRivas, about 1,840 stations. Station names read underscores as word
breaks, and station capacity counts docks rather than PBSC's geofenced area
(ulihhhh, #1190).
+8 -2
View File
@@ -1277,7 +1277,13 @@ String arrays regardless of the reported feed version. Nonempty English text
(including regional `en-*` tags, case-insensitive) takes precedence, then the first (including regional `en-*` tags, case-insensitive) takes precedence, then the first
usable translation. An unusable `name` falls back to `short_name` with the same usable translation. An unusable `name` falls back to `short_name` with the same
rules; if neither supplies text, the station remains available with an empty name rules; if neither supplies text, the station remains available with an empty name
(the globe uses its station-ID label and the tool returns `null`). Malformed (the globe uses its station-ID label and the tool returns `null`). A run of underscores,
with the spaces around it, reads as one word break and the ends are trimmed:
JCDecaux's Spanish contracts publish `_PLAZA_MUSICO_LOPEZ_CHAVARRI`. Other spacing
stays as published. Capacity counts docks: PBSC adds the bikes a station accepts
in its geofenced area to `capacity` and reports that part as `geofenced_capacity`
(Bilbao Bizi: 839 = 39 docks + 800), so the layer, its colour ratio and
`get_bike_share` read `capacity − geofenced_capacity` when the extension is present. Malformed
translation entries do not interrupt loading of the other stations. This covers translation entries do not interrupt loading of the other stations. This covers
station names, not complete GBFS v3 support. station names, not complete GBFS v3 support.
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer, `scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
@@ -3399,7 +3405,7 @@ its criteria cannot be silently ignored.
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
+5 -2
View File
@@ -305,7 +305,10 @@ export const DATA_CREDITS = [
html: html:
'Bikeshare availability: GBFS operator feeds ' + 'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' + '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)', 'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
}, },
{ {
key: 'urbansharing-gbfs', key: 'urbansharing-gbfs',
@@ -324,7 +327,7 @@ export const DATA_CREDITS = [
{ {
key: 'jcdecaux-gbfs', key: 'jcdecaux-gbfs',
html: html:
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' + 'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>', '<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
}, },
{ {
+108
View File
@@ -604,6 +604,114 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json', 'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
provider: 'AMBici', provider: 'AMBici',
}, },
{
id: 'madrid-bicimad',
city: 'Madrid, Spain',
centerLat: 40.4241,
centerLon: -3.6825,
loadRadiusKm: 40,
stationInformationUrl:
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'BiciMAD',
},
{
id: 'valencia-valenbisi',
city: 'Valencia, Spain',
centerLat: 39.4706,
centerLon: -0.3749,
loadRadiusKm: 40,
stationInformationUrl:
'https://api.cyclocity.fr/contracts/valence/gbfs/station_information.json',
stationStatusUrl:
'https://api.cyclocity.fr/contracts/valence/gbfs/station_status.json',
provider: 'Valenbisi',
},
{
id: 'zaragoza-bizi',
city: 'Zaragoza, Spain',
centerLat: 41.6481,
centerLon: -0.8927,
loadRadiusKm: 40,
stationInformationUrl:
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bizi Zaragoza',
},
{
id: 'sevilla-sevici',
city: 'Seville, Spain',
centerLat: 37.3726,
centerLon: -5.9607,
loadRadiusKm: 40,
stationInformationUrl:
'https://api.cyclocity.fr/contracts/seville/gbfs/station_information.json',
stationStatusUrl:
'https://api.cyclocity.fr/contracts/seville/gbfs/station_status.json',
provider: 'Sevici',
},
{
id: 'valladolid-biki',
city: 'Valladolid, Spain',
centerLat: 41.6233,
centerLon: -4.7382,
loadRadiusKm: 40,
stationInformationUrl:
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'BIKI',
},
{
id: 'a-coruna-bicicoruna',
city: 'A Coruña, Spain',
centerLat: 43.3534,
centerLon: -8.4156,
loadRadiusKm: 40,
stationInformationUrl:
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'BiciCoruña',
},
{
id: 'donostia-dbizi',
city: 'Donostia-San Sebastián, Spain',
centerLat: 43.3075,
centerLon: -1.9696,
loadRadiusKm: 40,
stationInformationUrl:
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Dbizi',
},
{
id: 'bilbao-bilbaobizi',
city: 'Bilbao, Spain',
centerLat: 43.2624,
centerLon: -2.9367,
loadRadiusKm: 40,
stationInformationUrl:
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bilbao Bizi',
},
{
id: 'rivas-bicinrivas',
city: 'Rivas-Vaciamadrid, Spain',
centerLat: 40.3535,
centerLon: -3.5319,
loadRadiusKm: 40,
stationInformationUrl:
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'BicinRivas',
},
]; ];
function normalizeRegistryEntry(entry) { function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '') const id = String(entry?.id || '')
@@ -0,0 +1,256 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each system's host, its stations farthest from the registered centre in
// opposite directions, and the stations its live feed lists away from the
// city: a workshop at (0,0), a lab in Madrid, planned sites with no position
// yet (checked 2026-10-17). JCDecaux names keep their feed spelling here.
const SYSTEMS = {
'madrid-bicimad': {
host: 'madrid.publicbikesystem.net',
provider: 'BiciMAD',
stations: [
{
name: '627 - Alcalde Andrés Madrid Dávila, 92',
lat: 40.398303,
lon: -3.548534,
},
{
name: '672 - Estación de tren de El Barrial-Pozuelo',
lat: 40.464908,
lon: -3.807764,
},
],
},
'valencia-valenbisi': {
host: 'api.cyclocity.fr',
provider: 'Valenbisi',
stations: [
{ name: '_CAMPAMENTO_81', lat: 39.499875, lon: -0.426354 },
{ name: '_EDIFICIO_VELES_E_VENTS', lat: 39.461943, lon: -0.323856 },
],
},
'zaragoza-bizi': {
host: 'zaragoza.publicbikesystem.net',
provider: 'Bizi Zaragoza',
stations: [
{ name: '231- Heroínas Sitios: Gómara', lat: 41.627549, lon: -0.962297 },
{ name: '263- Avda. Real Zaragoza', lat: 41.666994, lon: -0.823078 },
],
},
'sevilla-sevici': {
host: 'api.cyclocity.fr',
provider: 'Sevici',
stations: [
{ name: '_ESTADIO OLIMPICO', lat: 37.415898, lon: -6.005849 },
{ name: '_HOSPITAL VALME', lat: 37.320325, lon: -5.969204 },
],
strays: [
{
station_id: '298',
name: '_LAB MADRID',
lat: 40.448779,
lon: -3.584983,
},
{ station_id: '300', name: '_TALLER SEVILLA', lat: 0, lon: 0 },
],
},
'valladolid-biki': {
host: 'valladolid.publicbikesystem.net',
provider: 'BIKI',
stations: [
{ name: '1002 Puente Duero', lat: 41.55413, lon: -4.79122 },
{ name: '1005 Santovenia Centro Civico', lat: 41.692504, lon: -4.688387 },
],
},
'a-coruna-bicicoruna': {
host: 'acoruna.publicbikesystem.net',
provider: 'BiciCoruña',
stations: [
{ name: 'Novo Mesoiro III', lat: 43.323367, lon: -8.443926 },
{ name: 'Adormideras', lat: 43.3833748, lon: -8.3956482 },
],
strays: [
{ station_id: '24', name: 'Playa de Oza', lat: 0, lon: 0 },
{ station_id: '37', name: 'Campus Elviña 2', lat: 0, lon: 0 },
{ station_id: '38', name: 'Campus Zapateira', lat: 0, lon: 0 },
],
},
'donostia-dbizi': {
host: 'sansebastian.publicbikesystem.net',
provider: 'Dbizi',
stations: [
{ name: '59 - BUENAVISTA', lat: 43.321211, lon: -1.922239 },
{ name: '54 - BERIO 42', lat: 43.305158, lon: -2.016997 },
],
},
'bilbao-bilbaobizi': {
host: 'bilbao.publicbikesystem.net',
provider: 'Bilbao Bizi',
stations: [
{ name: 'ZORROTZAKO KIROLDEGIA', lat: 43.2778696, lon: -2.9735575 },
{ name: 'OTXARKOAGA', lat: 43.258515, lon: -2.899282 },
],
},
'rivas-bicinrivas': {
host: 'rivas.publicbikesystem.net',
provider: 'BicinRivas',
stations: [
{
name: 'Polideportivo Parque Sureste',
lat: 40.3236695,
lon: -3.5127191,
},
{ name: 'OCÉANO ÁRTICO', lat: 40.383292, lon: -3.5343627 },
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = {
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
};
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
/** Station information for `id` through the real ingestion path. */
async function loadThroughIngestion(id, stations) {
const city = CITY_BY_ID.get(id);
const state = {
_stationInfoCache: new Map(),
_inFlightInfo: new Map(),
_inFlightStatus: new Map(),
_loadingOps: 0,
_loading: false,
};
const ingestion = createIngestion({
state,
services: {},
parts,
source: {
async getStations(url) {
assert.equal(url, city.stationInformationUrl);
return { data: { stations } };
},
},
});
return ingestion.loadCityStationInfo(id, 1);
}
test('the Spanish city systems are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.equal(system.provider, expected.provider);
assert.deepEqual(system.hosts, [expected.host]);
for (const feed of [
system.stationInformationUrl,
system.stationStatusUrl,
]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(
isAllowedGbfsPath(url.pathname, url.hostname),
`${id} path ${url.pathname}`,
);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('each load radius reaches its outermost stations', () => {
// The range check floors every radius at 100 km, but station information is
// trimmed to the radius as registered, so it has to reach every station.
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
for (const station of expected.stations) {
const distance = parts.model.haversineKm(
system.centerLat,
system.centerLon,
station.lat,
station.lon,
);
assert.ok(
distance <= system.loadRadiusKm,
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
);
}
}
});
test('a camera over central Madrid activates BiciMAD and BicinRivas', () => {
// Puerta del Sol.
const active = computeInRangeCities({ lat: 40.4169, lon: -3.7035 });
assert.ok(active.has('madrid-bicimad'));
assert.ok(active.has('rivas-bicinrivas'));
});
test('stations a feed lists away from its city are not loaded', async () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
if (!expected.strays) continue;
const local = expected.stations.map((station, index) => ({
station_id: `local-${index}`,
...station,
}));
const stations = await loadThroughIngestion(id, [
...local,
...expected.strays,
]);
assert.deepEqual(
[...stations.keys()],
local.map((station) => station.station_id),
`${id} keeps only its local stations`,
);
}
});
test('JCDecaux station names reach the layer without underscores', async () => {
for (const id of ['valencia-valenbisi', 'sevilla-sevici']) {
const stations = await loadThroughIngestion(
id,
SYSTEMS[id].stations.map((station, index) => ({
station_id: String(index),
...station,
})),
);
for (const station of stations.values())
assert.doesNotMatch(station.name, /_/, `${id}: ${station.name}`);
}
const valenbisi = await loadThroughIngestion('valencia-valenbisi', [
{ station_id: '81', ...SYSTEMS['valencia-valenbisi'].stations[0] },
]);
assert.equal(valenbisi.get('81').name, 'CAMPAMENTO 81');
});
test('PBSC station capacity reaches the layer as docks, without the geofenced area', async () => {
// Bilbao Bizi's ARAGOI and a BiciMAD station as published on 2026-10-17.
const [bilbao] = SYSTEMS['bilbao-bilbaobizi'].stations;
const bilbaoStations = await loadThroughIngestion('bilbao-bilbaobizi', [
{ station_id: '1', ...bilbao, capacity: 839, geofenced_capacity: 800 },
]);
assert.equal(bilbaoStations.get('1').capacity, 39);
const [madrid] = SYSTEMS['madrid-bicimad'].stations;
const madridStations = await loadThroughIngestion('madrid-bicimad', [
{ station_id: '627', ...madrid, capacity: 47, geofenced_capacity: 20 },
]);
assert.equal(madridStations.get('627').capacity, 27);
});
+30 -3
View File
@@ -51,15 +51,25 @@ export function extractStationsArray(payload) {
return []; return [];
} }
/**
* Station name text as a reader expects it. JCDecaux's Spanish contracts
* (Valenbisi, Sevici) prefix every name with "_" and Valenbisi joins words
* with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so an underscore reads as a word
* break. Other spacing is the operator's own and stays as published.
*/
function tidyStationName(text) {
return text.replace(/\s*_+\s*/g, ' ').trim();
}
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */ /** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
function stationName(value) { function stationName(value) {
if (typeof value === 'string') return value.trim(); if (typeof value === 'string') return tidyStationName(value);
if (!Array.isArray(value)) return ''; if (!Array.isArray(value)) return '';
let fallback = ''; let fallback = '';
for (const translation of value) { for (const translation of value) {
if (typeof translation?.text !== 'string') continue; if (typeof translation?.text !== 'string') continue;
const text = translation.text.trim(); const text = tidyStationName(translation.text);
if (!text) continue; if (!text) continue;
if (!fallback) fallback = text; if (!fallback) fallback = text;
if ( if (
@@ -71,6 +81,23 @@ function stationName(value) {
return fallback; return fallback;
} }
/**
* Docks a station physically has. PBSC adds the bikes a station accepts in its
* geofenced area around the docks to `capacity` and reports that part as
* `geofenced_capacity` (not a GBFS field): Bilbao Bizi publishes 39 docks as
* capacity 839 with geofenced_capacity 800. Without the extension, or when it
* would exceed the total, `capacity` is read as given. PBSC also counts the
* bikes parked in that area (`num_bikes_overflow`) in `num_bikes_available`, so
* a crowded station can report more bikes than docks.
*/
function dockCapacity(raw) {
const capacity = toNonNegativeInteger(raw?.capacity);
const geofenced = toNonNegativeInteger(raw?.geofenced_capacity);
if (capacity == null || geofenced == null || geofenced > capacity)
return capacity;
return capacity - geofenced;
}
/** /**
* Parse a GBFS station_information payload into a Map of station metadata. * Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers * Handles field-name variations across different GBFS providers
@@ -95,7 +122,7 @@ export function parseStationInformation(payload) {
name: stationName(raw?.name) || stationName(raw?.short_name), name: stationName(raw?.name) || stationName(raw?.short_name),
lat, lat,
lon, lon,
capacity: toNonNegativeInteger(raw?.capacity), capacity: dockCapacity(raw),
isInstalled: normalizeGbfsBool(raw?.is_installed, true), isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true), isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true), isReturning: normalizeGbfsBool(raw?.is_returning, true),
+55
View File
@@ -15,6 +15,25 @@ const parseName = (fields, version) =>
data: { stations: [station(fields)] }, data: { stations: [station(fields)] },
}).get('s1').name; }).get('s1').name;
const parseCapacity = (fields) =>
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
.capacity;
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23);
assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19);
});
test('capacity is read as given without a usable geofenced_capacity', () => {
assert.equal(parseCapacity({ capacity: 27 }), 27);
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27);
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27);
assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10);
assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null);
});
test('GBFS v2 station names remain trimmed strings', () => { test('GBFS v2 station names remain trimmed strings', () => {
assert.equal( assert.equal(
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'), parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
@@ -22,6 +41,42 @@ test('GBFS v2 station names remain trimmed strings', () => {
); );
}); });
test('underscores in station names read as word breaks', () => {
// As published by JCDecaux's Spanish contracts (checked 2026-10-17).
for (const [published, shown] of [
['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'],
['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'],
['_CAMPAMENTO_81', 'CAMPAMENTO 81'],
['Plaza__Mayor_ ', 'Plaza Mayor'],
]) {
assert.equal(parseName({ name: published }), shown);
}
});
test('spacing without underscores stays as the operator published it', () => {
assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82');
});
test('a name of only underscores falls back to short_name, then to empty', () => {
assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1');
assert.equal(parseName({ name: '__', short_name: '_' }), '');
});
test('underscores are read the same in GBFS v3 localized names', () => {
assert.equal(
parseName(
{
name: [
{ language: 'es', text: '_' },
{ language: 'en', text: '_COLON_I' },
],
},
'3.0',
),
'COLON I',
);
});
test('GBFS v3 station names prefer English over the first translation', () => { test('GBFS v3 station names prefer English over the first translation', () => {
assert.equal( assert.equal(
parseName( parseName(