From 194595e1b6fd97ad312d51f36397c0ffbdd5ae30 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:54:58 +0200 Subject: [PATCH 1/2] feat(bikeshare): add Barcelona's Bicing and AMBici - Bicing (543 stations, PBSC) and AMBici (237 e-bike stations across the metropolitan area, nextbike) join the GBFS registry from the operators' keyless GBFS 2.3 feeds, both listed in MobilityData's systems catalogue. - gbfs.nextbike.net joins the proxy allowlist as an exact host. - Credits, DATA_SOURCES, CURRENT-STATE and CHANGELOG updated; a test pins each system's proxy reachability and the range check for its outermost stations. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 2 +- src/data/dataCredits.js | 3 +- src/data/gbfsSource.js | 1 + .../bikeshare/barcelonaSystems.test.mjs | 84 +++++++++++++++++++ src/layers/bikeshare/registry.js | 26 ++++++ 7 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/layers/bikeshare/barcelonaSystems.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b371af53..5f6cd5452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the + city's PBSC system) and AMBici (237 e-bike stations across the metropolitan + area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in + MobilityData's systems catalogue; AMBici's is CC0. `gbfs.nextbike.net` joins + the proxy allowlist. These are the layer's first stations in Spain. + - Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own licence quote and attribution. A feed can now name its own modes, so Toronto's diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 4aa9b6317..577cdc413 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -68,6 +68,8 @@ How to read this: | **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **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) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and Curitiba | 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)" | +| **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)" | +| **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" | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6373d7255..c08de52d5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3265,7 +3265,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 and Bike Itaú (Brazil) + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| 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 | | 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 d6c265ddc..ea3f2ad09 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -304,7 +304,8 @@ export const DATA_CREDITS = [ key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds ' + - '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)', + '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici; ' + + 'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)', }, { key: 'urbansharing-gbfs', diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js index 57228554e..cd1042114 100644 --- a/src/data/gbfsSource.js +++ b/src/data/gbfsSource.js @@ -8,6 +8,7 @@ const GBFS_ALLOWED_HOSTS = new Set([ 'austin.publicbikesystem.net', 'hon.publicbikesystem.net', 'chat.publicbikesystem.net', + 'gbfs.nextbike.net', 'gbfs.urbansharing.com', 'api.entur.io', 'api.cyclocity.fr', diff --git a/src/layers/bikeshare/barcelonaSystems.test.mjs b/src/layers/bikeshare/barcelonaSystems.test.mjs new file mode 100644 index 000000000..992c0431c --- /dev/null +++ b/src/layers/bikeshare/barcelonaSystems.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CITY_BY_ID } from './registry.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; + +// Each Barcelona system's host, and the stations farthest from its registered +// centre in the live feed (checked 2026-10-16). AMBici spans the metropolitan +// area, so it lists its south-western and north-eastern ends. +const SYSTEMS = { + 'barcelona-bicing': { + host: 'barcelona.publicbikesystem.net', + provider: 'Bicing', + stations: [ + { name: 'AV. RASOS DE PEGUERA, 10', lat: 41.462095, lon: 2.178959 }, + { name: 'C/ 60, NÚMERO 25', lat: 41.3467746, lon: 2.1436235 }, + ], + }, + 'barcelona-ambici': { + host: 'gbfs.nextbike.net', + provider: 'AMBici', + stations: [ + { name: 'Poal', lat: 41.268865, lon: 1.943849 }, + { name: 'Platja del Manresà', lat: 41.460101, lon: 2.267271 }, + { name: 'Can Ruti', lat: 41.482205, lon: 2.237607 }, + ], + }, +}; + +// 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, +}); + +test('Bicing and AMBici 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('a camera over central Barcelona activates both systems', () => { + // Plaça de Catalunya. + const active = computeInRangeCities({ lat: 41.387, lon: 2.17 }); + assert.ok(active.has('barcelona-bicing')); + assert.ok(active.has('barcelona-ambici')); +}); + +test('the nextbike allowance is that one host, not its domain', () => { + assert.equal(isAllowedGbfsHost('gbfs.nextbike.net'), true); + assert.equal(isAllowedGbfsHost('api.nextbike.net'), false); + assert.equal(isAllowedGbfsHost('gbfs.nextbike.net.example'), false); +}); diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index 916d50123..3d505020d 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -554,6 +554,32 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json', provider: 'Bysykkel Lillestrøm', }, + { + id: 'barcelona-bicing', + city: 'Barcelona, Spain', + centerLat: 41.3926, + centerLon: 2.164, + loadRadiusKm: 40, + stationInformationUrl: + 'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bicing', + }, + // AMBici covers the metropolitan municipalities around Barcelona, from + // Castelldefels to Badalona, and overlaps Bicing only at the city edge. + { + id: 'barcelona-ambici', + city: 'Barcelona metropolitan area, Spain', + centerLat: 41.37, + centerLon: 2.07, + loadRadiusKm: 40, + stationInformationUrl: + 'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_information.json', + stationStatusUrl: + 'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json', + provider: 'AMBici', + }, ]; function normalizeRegistryEntry(entry) { const id = String(entry?.id || '') From dec79b6fcff1214a75b68d750b63fa956af55604 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:20:52 +0200 Subject: [PATCH 2/2] test(bikeshare): pin the Barcelona load radii to their outermost stations The map range check floors every radius at 100 km, so the station test passed with any loadRadiusKm up to that. get_bike_share uses the radius as registered, so assert each system's radius reaches the stations it lists. Co-Authored-By: Claude Opus 5.5 --- .../bikeshare/barcelonaSystems.test.mjs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/layers/bikeshare/barcelonaSystems.test.mjs b/src/layers/bikeshare/barcelonaSystems.test.mjs index 992c0431c..cdca11307 100644 --- a/src/layers/bikeshare/barcelonaSystems.test.mjs +++ b/src/layers/bikeshare/barcelonaSystems.test.mjs @@ -70,6 +70,26 @@ test('a camera over each outermost station activates its system', () => { } }); +test('each load radius reaches its outermost stations', () => { + // The range check floors every radius at 100 km, so the test above passes + // with any loadRadiusKm; get_bike_share uses the radius as registered. + 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 Barcelona activates both systems', () => { // Plaça de Catalunya. const active = computeInRangeCities({ lat: 41.387, lon: 2.17 });