diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 0e04fb39b..d930bcb8f 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -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 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)" |
+| **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" |
| **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)" |
-| **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) |
| **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) |
diff --git a/changelog.d/1190-bikeshare-spain-cities.md b/changelog.d/1190-bikeshare-spain-cities.md
new file mode 100644
index 000000000..f07faa813
--- /dev/null
+++ b/changelog.d/1190-bikeshare-spain-cities.md
@@ -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).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index c09a8a645..a7e191f8a 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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
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
-(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
station names, not complete GBFS v3 support.
`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) |
| 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) |
-| 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 |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index c13c7dd94..8d3949351 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -305,7 +305,10 @@ export const DATA_CREDITS = [
html:
'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; ' +
- '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, Powered by EMT de Madrid; ' +
+ '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',
@@ -324,7 +327,7 @@ export const DATA_CREDITS = [
{
key: 'jcdecaux-gbfs',
html:
- 'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
+ 'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
'Licence Ouverte',
},
{
diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js
index b56678187..9b3c29f20 100644
--- a/src/layers/bikeshare/registry.js
+++ b/src/layers/bikeshare/registry.js
@@ -604,6 +604,114 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
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) {
const id = String(entry?.id || '')
diff --git a/src/layers/bikeshare/spainCitiesSystems.test.mjs b/src/layers/bikeshare/spainCitiesSystems.test.mjs
new file mode 100644
index 000000000..2c502456f
--- /dev/null
+++ b/src/layers/bikeshare/spainCitiesSystems.test.mjs
@@ -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);
+});
diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js
index 74f2efd23..c37c2fae4 100644
--- a/src/sources/gbfsStations.js
+++ b/src/sources/gbfsStations.js
@@ -51,15 +51,25 @@ export function extractStationsArray(payload) {
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. */
function stationName(value) {
- if (typeof value === 'string') return value.trim();
+ if (typeof value === 'string') return tidyStationName(value);
if (!Array.isArray(value)) return '';
let fallback = '';
for (const translation of value) {
if (typeof translation?.text !== 'string') continue;
- const text = translation.text.trim();
+ const text = tidyStationName(translation.text);
if (!text) continue;
if (!fallback) fallback = text;
if (
@@ -71,6 +81,23 @@ function stationName(value) {
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.
* Handles field-name variations across different GBFS providers
@@ -95,7 +122,7 @@ export function parseStationInformation(payload) {
name: stationName(raw?.name) || stationName(raw?.short_name),
lat,
lon,
- capacity: toNonNegativeInteger(raw?.capacity),
+ capacity: dockCapacity(raw),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs
index 8c60da90c..91983df98 100644
--- a/src/sources/gbfsStations.test.mjs
+++ b/src/sources/gbfsStations.test.mjs
@@ -15,6 +15,25 @@ const parseName = (fields, version) =>
data: { stations: [station(fields)] },
}).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', () => {
assert.equal(
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', () => {
assert.equal(
parseName(