From ee686a3207d2e09af873bd2cca3664c974a27da0 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:17:13 +0200 Subject: [PATCH 1/6] fix(gbfs): read underscores in station names as word breaks JCDecaux's Spanish contracts prefix every station name with "_", and Valenbisi joins its words with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so the card and HUD would show the raw identifier. The shared parser now reads a run of underscores, with the spaces around it, as one word break and trims the ends. It applies to `name`, the `short_name` fallback and GBFS v3 localized names, so the layer and get_bike_share both read it. No registered system changes: run over all 52 live station_information feeds on 2026-10-17, the old and new parsers give identical names for all 13,397 stations. Other spacing stays as the operator published it. Co-Authored-By: Claude Opus 5.5 --- src/sources/gbfsStations.js | 14 ++++++++++-- src/sources/gbfsStations.test.mjs | 36 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index 74f2efd23..dd4eff898 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 ( diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs index 8c60da90c..6f12db258 100644 --- a/src/sources/gbfsStations.test.mjs +++ b/src/sources/gbfsStations.test.mjs @@ -22,6 +22,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( From 53cda29be43ecafb2eddebf2df8a3f52c9463793 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:02:38 +0200 Subject: [PATCH 2/6] fix(gbfs): count docks, not PBSC's geofenced area, as station capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PBSC adds the bikes a station accepts in the geofenced area around its docks to `capacity`, and reports that part as `geofenced_capacity`, which is not a GBFS field. Bilbao Bizi publishes 39 docks as capacity 839 with geofenced_capacity 800, so its stations would read as nearly empty: red, drawn at maximum size, and carded "39 avail · 0 docks · 839 cap". BiciMAD (671 of 678 stations, about twice the docks) and Dbizi (11 of 70) do the same. The shared parser now reads `capacity − geofenced_capacity` when the extension is present and no larger than the total, so the layer, its colour ratio and get_bike_share all see the docks. No registered system changes: none of the 52 publishes a geofenced capacity above 0 (live feeds, 2026-10-17, all 13,397 capacities identical). Co-Authored-By: Claude Opus 5.5 --- src/sources/gbfsStations.js | 17 ++++++++++++++++- src/sources/gbfsStations.test.mjs | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index dd4eff898..1bfeb037a 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -81,6 +81,21 @@ 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. + */ +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 @@ -105,7 +120,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 6f12db258..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'), From 23df77637493e2f3bee07e360827ea28c813baa4 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:02:38 +0200 Subject: [PATCH 3/6] feat(bikeshare): add nine Spanish city bike systems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the station-based public bike systems of seven Spanish cities on PBSC and two on JCDecaux, 1,843 stations in all (live feeds, 2026-10-17): - PBSC (`.publicbikesystem.net`, GBFS v2 like Bicing): BiciMAD (Madrid), Bizi Zaragoza, BIKI (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and BicinRivas (Rivas-Vaciamadrid). - JCDecaux (`api.cyclocity.fr/contracts/…`, like Lillestrøm): Valenbisi (Valencia) and Sevici (Seville). Both hosts are already allowed by the proxy. Every system uses a 40 km load radius; the farthest station is 11.7 km from its centre (BiciMAD). The radius also keeps out what two feeds list away from their city: Sevici's workshop at (0,0) and lab in Madrid, and BiciCoruña's planned stations with no position yet. spainCitiesSystems.test.mjs checks the registry and proxy for each feed, that the outermost live stations activate their system and sit inside its radius, that central Madrid activates BiciMAD and BicinRivas, that the far-away stations are not loaded through the real ingestion path, and that JCDecaux names arrive without underscores and PBSC capacities as docks. Co-Authored-By: Claude Opus 5.5 --- src/layers/bikeshare/registry.js | 108 ++++++++ .../bikeshare/spainCitiesSystems.test.mjs | 256 ++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/layers/bikeshare/spainCitiesSystems.test.mjs 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); +}); From 5508caa174c60099a347ae7c482d0a7265d8ea2f Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:02:38 +0200 Subject: [PATCH 4/6] docs: credit and document the Spanish city bike systems - dataCredits `gbfs`: names the seven PBSC operators, with BiciMAD's required "Powered by EMT de Madrid" linking to emtmadrid.es. - dataCredits `jcdecaux-gbfs`: adds Valenbisi and Sevici. - DATA_SOURCES.md: a BiciMAD row (Madrid's open-data catalogue publishes the feed under EMT's terms, attribution required), one row for the other six PBSC feeds (no licence published; attribution to the operator), and the JCDecaux row widened to Valencia and Seville. - CURRENT-STATE.md: the Bikeshare row, and the underscore and geofenced-capacity rules in "GBFS upstream bounds". Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 4 +++- docs/CURRENT-STATE.md | 10 ++++++++-- src/data/dataCredits.js | 7 +++++-- 3 files changed, 16 insertions(+), 5 deletions(-) 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6d066a475..9562dc9f5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1270,7 +1270,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, @@ -3392,7 +3398,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', }, { From 42e45e8fbe965630086c68adf49786d5cfc48855 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:33:55 +0200 Subject: [PATCH 5/6] docs(changelog): add the Spanish city bike systems entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/1190-bikeshare-spain-cities.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1190-bikeshare-spain-cities.md 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). From 93ea088f4556a5a65b3292a2d15142d5a6c562bc Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:19:56 +0200 Subject: [PATCH 6/6] docs(gbfs): note that overflow bikes can exceed dock capacity Co-Authored-By: Claude Opus 5.5 --- src/sources/gbfsStations.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index 1bfeb037a..c37c2fae4 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -86,7 +86,9 @@ function stationName(value) { * 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. + * 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);