diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..a3e622b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- Add Bogotá (Tembici, 250 stations) and Santiago de Chile (Bike Itaú, 200 + stations) to the GBFS bikeshare registry, the layer's first stations in + Colombia and Chile. Both are PBSC feeds like the Brazilian Bike Itaú cities, + so no proxy change is needed. Both feeds also list depot stations on other + continents (São Paulo, Madrid) as in service; stations outside a city's load + radius are now dropped instead of drawn as live docks. + - 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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 577cdc413..0e04fb39b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -67,7 +67,7 @@ How to read this: | **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | | **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)" | +| **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)" | | **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" | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index ea3f2ad09..c13c7dd94 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -304,7 +304,7 @@ 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ú and Tembici Bogotá, operated by Tembici; ' + 'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)', }, { diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js index f2127e53e..dbd4d063e 100644 --- a/src/layers/bikeshare/ingestion.js +++ b/src/layers/bikeshare/ingestion.js @@ -97,7 +97,10 @@ export function createIngestion({ const payload = await fetchGbfsJson(city.stationInformationUrl, { signal: controller.signal, }); - const stationMap = parts.model.parseStationInformation(payload); + const stationMap = parts.model.stationsWithinCity( + city, + parts.model.parseStationInformation(payload), + ); if (stationMap.size === 0) { throw new Error(`No station information for ${city.city}`); } diff --git a/src/layers/bikeshare/latamPbscCities.test.mjs b/src/layers/bikeshare/latamPbscCities.test.mjs new file mode 100644 index 000000000..4eda1ad8c --- /dev/null +++ b/src/layers/bikeshare/latamPbscCities.test.mjs @@ -0,0 +1,174 @@ +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 PBSC host, the station farthest from its registered centre, +// and the depots its live feed lists on other continents (checked 2026-10-16). +const CITIES = { + 'bogota-tembici': { + host: 'bogota.publicbikesystem.net', + provider: 'Tembici Bogotá', + stations: [ + { name: '067 - KR 7B Bis con CL 152A', lat: 4.727729, lon: -74.026271 }, + ], + depots: [ + { + station_id: '316', + name: 'Calle 13 - 316', + lat: -23.4008649, + lon: -46.3972181, + }, + ], + }, + 'santiago-bike-itau': { + host: 'santiago.publicbikesystem.net', + provider: 'Bike Itaú Santiago', + stations: [ + { name: 'LC077 - Mall Sport', lat: -33.3707813, lon: -70.5073122 }, + ], + depots: [ + { + station_id: '459', + name: 'Galpon - 459', + lat: 40.4167747, + lon: -3.7037899, + }, + { + station_id: '440', + name: 'Galpon - 440', + lat: -23.5690975, + lon: -46.697366, + }, + ], + }, +}; + +// 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('Bogotá and Santiago are registered and readable through the proxy', () => { + for (const [id, expected] of Object.entries(CITIES)) { + const city = CITY_BY_ID.get(id); + assert.ok(city, `${id} is registered`); + assert.equal(city.provider, expected.provider); + assert.deepEqual(city.hosts, [expected.host]); + for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) { + const url = new URL(feed); + assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`); + assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`); + } + } +}); + +test('a camera over each outermost station activates its system', () => { + for (const [id, expected] of Object.entries(CITIES)) { + 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; the station filter and + // get_bike_share use the radius as registered. + for (const [id, expected] of Object.entries(CITIES)) { + const city = CITY_BY_ID.get(id); + for (const station of expected.stations) { + const distance = parts.model.haversineKm( + city.centerLat, + city.centerLon, + station.lat, + station.lon, + ); + assert.ok( + distance <= city.loadRadiusKm, + `${station.name} is ${distance.toFixed(1)} km from the ${id} centre`, + ); + } + } +}); + +test('stations a feed lists on another continent are not loaded', async () => { + for (const [id, expected] of Object.entries(CITIES)) { + const city = CITY_BY_ID.get(id); + const outermost = expected.stations[0]; + const payload = { + data: { + stations: [ + { + station_id: 'local', + name: outermost.name, + lat: outermost.lat, + lon: outermost.lon, + }, + ...expected.depots, + ], + }, + }; + 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 payload; + }, + }, + }); + + const stations = await ingestion.loadCityStationInfo(id, 1); + assert.deepEqual( + [...stations.keys()], + ['local'], + `${id} keeps only local stations`, + ); + assert.equal(state._stationInfoCache.get(id), stations); + } +}); + +test('a feed with only far-away stations reports no station information', async () => { + const city = CITY_BY_ID.get('santiago-bike-itau'); + const ingestion = createIngestion({ + state: { + _stationInfoCache: new Map(), + _inFlightInfo: new Map(), + _inFlightStatus: new Map(), + _loadingOps: 0, + _loading: false, + }, + services: {}, + parts, + source: { + async getStations() { + return { data: { stations: CITIES['santiago-bike-itau'].depots } }; + }, + }, + }); + await assert.rejects( + ingestion.loadCityStationInfo('santiago-bike-itau', 1), + new RegExp(`No station information for ${city.city}`), + ); +}); diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js index f12148dba..d501d073c 100644 --- a/src/layers/bikeshare/model.js +++ b/src/layers/bikeshare/model.js @@ -39,11 +39,36 @@ export function createModel({ state: layerState, services, parts, source }) { return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } + /** + * Drop stations outside a city's load radius. Some operators list depots on + * other continents as in-service stations in a city's feed (Bogotá's and + * Santiago's PBSC feeds carry warehouses in São Paulo and Madrid), which + * would otherwise draw as live docks thousands of kilometres away. + * @param {{ centerLat: number, centerLon: number, loadRadiusKm: number }} city + * @param {Map} stationMap - Parsed station info map. + * @returns {Map} Stations within the city's load radius. + */ + + function stationsWithinCity(city, stationMap) { + const kept = new Map(); + for (const [stationId, station] of stationMap) { + const distance = haversineKm( + city.centerLat, + city.centerLon, + station.lat, + station.lon, + ); + if (distance <= city.loadRadiusKm) kept.set(stationId, station); + } + return kept; + } + return { normalizeGbfsBool, toNonNegativeInteger, stationKey, haversineKm, + stationsWithinCity, extractStationsArray, parseStationInformation, parseStationStatus, diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index 3d505020d..b56678187 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -330,6 +330,30 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Bike Itaú', }, + { + id: 'bogota-tembici', + city: 'Bogotá, Colombia', + centerLat: 4.6533, + centerLon: -74.0836, + loadRadiusKm: 90, + stationInformationUrl: + 'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Tembici Bogotá', + }, + { + id: 'santiago-bike-itau', + city: 'Santiago, Chile', + centerLat: -33.4372, + centerLon: -70.6506, + loadRadiusKm: 90, + stationInformationUrl: + 'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú Santiago', + }, { id: 'toronto-bike-share', city: 'Toronto, ON',