Merge branch 'main' into feat/changelog-fragments

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-16 12:53:10 +01:00
commit d3449313d4
7 files changed
+236 -3

No files matched your search

+7
View File
@@ -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
+1 -1
View File
@@ -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" |
+1 -1
View File
@@ -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)',
},
{
+4 -1
View File
@@ -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}`);
}
@@ -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}`),
);
});
+25
View File
@@ -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<string, Object>} stationMap - Parsed station info map.
* @returns {Map<string, Object>} 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,
+24
View File
@@ -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',