diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fd853b5..3f07ded53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # 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 + 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..0e04fb39b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -67,7 +67,9 @@ 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" | | **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 7bdaefa53..3780f67d1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1262,6 +1262,15 @@ cancels the body unread. A rejected or timed-out body is an upstream miss and follows each route's existing error and cached-data paths. FIRMS map-key status, the OpenSky token exchange and CCTV media keep their current handling. +The route also bounds request frequency: 120 requests/minute per client +address, with a global backstop of three times that. A refused request answers +429 with `Retry-After` and `Cache-Control: no-store` and performs no outbound +fetch. The bikeshare layer's own demand is about 15 requests a minute with +every catalogued system in range, so an ordinary session never meets the limit; +the method, host and path guards run unchanged. + +Updated: September 21, 2026 + ## Remaining local service modules Overpass query validation, geometry simplification, disk caching and upstream @@ -3302,7 +3311,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/scripts/format-scope.json b/scripts/format-scope.json index 56dd54da1..c62c6886f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1053,5 +1053,6 @@ "scripts/generate-icons.mjs", "src/tooling/webAppIcons.test.mjs", "src/ui/flyToDataSource.js", - "src/ui/flyToDataSource.test.mjs" + "src/ui/flyToDataSource.test.mjs", + "src/data/gbfsProxyRateLimit.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 02ddce387..9aa1d1bed 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,6 +158,8 @@ "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js", "src/data/gbfsSource.js", "src/sources/httpBody.js" ], diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 990642c8b..c6e3ba4ec 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -1,4 +1,5 @@ import { readResponseTextCapped } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { isAllowedGbfsHost, isAllowedGbfsPath, @@ -17,6 +18,19 @@ export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view'; export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB +/** + * Requests/minute/IP admitted by the proxy. + * + * The bikeshare layer's own demand is small and known: `src/layers/bikeshare/ + * registry.js` catalogues 15 systems, station information is cached per city + * after its first fetch, and station status refreshes once per city every + * `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every + * catalogued system in camera range at once that is about 15 requests a minute, + * so 120 is eight times a maximal real session while still capping a caller + * using this proxy as an open relay against a third-party GBFS host. + */ +export const GBFS_RATE_PER_MIN = 120; + function gbfsRedirectHost(location, requestUrl) { if (!location) return ''; try { @@ -121,7 +135,12 @@ export async function fetchGbfsUpstream( * * @returns {import('vite').Plugin} */ -export function gbfsProxy() { +export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) { + const allow = makeRateLimiter({ + windowMs: 60_000, + max: ratePerMinute, + globalMax: ratePerMinute * 3, + }); const installMiddleware = (server) => { server.middlewares.use('/api/gbfs', async (req, res) => { try { @@ -134,6 +153,19 @@ export function gbfsProxy() { return; } + // Frequency, checked before the target is even parsed: the allowlist + // already says WHERE this proxy may go, nothing said HOW OFTEN. A + // refused request performs no outbound fetch and starts no timer. + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '/', 'http://localhost'); const encodedTarget = url.pathname.replace(/^\/+/, ''); if (!encodedTarget) { diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index d6c265ddc..c13c7dd94 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ú and Tembici Bogotá, 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/gbfsProxyRateLimit.test.mjs b/src/data/gbfsProxyRateLimit.test.mjs new file mode 100644 index 000000000..72ee38152 --- /dev/null +++ b/src/data/gbfsProxyRateLimit.test.mjs @@ -0,0 +1,152 @@ +// GBFS PROXY — the frequency the allowlist never bounded. +// +// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a +// station_information/station_status path, a 12 s timeout, a 5 MB body cap and +// no redirects. Nothing said HOW OFTEN, so the dev server would relay an +// unbounded stream of requests at a third-party bikeshare operator on any +// caller's behalf — one outbound fetch and one timeout timer each. +// +// These cases drive the real mounted middleware. `fetch` is stubbed per test +// and restored afterwards, so a case that reaches the network fails loudly, +// and the stub's call count is what proves a refused request costs nothing. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js'; + +const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json'; +const STATION_BODY = '{"data":{"stations":[]}}'; + +/** Mount the proxy the way the dev server does; returns its route handler. */ +function mount(options) { + const routes = new Map(); + gbfsProxy(options).configureServer({ + middlewares: { + use(route, handler) { + routes.set(route, handler); + }, + }, + }); + return routes.get('/api/gbfs'); +} + +function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) { + return new Promise((resolve, reject) => { + const req = { + method: method || 'GET', + // connect strips the mount prefix before the handler sees the URL. + url: '/' + encodeURIComponent(target), + headers: {}, + socket: remoteAddress ? { remoteAddress } : undefined, + }; + let status = 200; + let headers = {}; + const res = { + writeHead(code, sent) { + status = code; + headers = sent || {}; + return res; + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ status, headers, body: parsed }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +/** Answer every allowlisted upstream, counting the calls that got through. */ +function stubUpstream(t) { + const asked = []; + const original = globalThis.fetch; + globalThis.fetch = async (url) => { + asked.push(String(url)); + return new Response(STATION_BODY, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + t.after(() => { + globalThis.fetch = original; + }); + return asked; +} + +test('a maximal real session is never throttled', async (t) => { + const asked = stubUpstream(t); + const handler = mount(); + + // The bikeshare layer catalogues 15 systems and refreshes each one's status + // once every 60 s, so a minute of the busiest possible session is ~15 + // requests. Run eight times that and expect every one of them served. + let served = 0; + for (let i = 0; i < 120; i += 1) { + const res = await request(handler); + if (res.status === 200) served += 1; + } + assert.equal(served, 120, 'the shipped ceiling leaves a real session alone'); + assert.equal(asked.length, 120); + assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on'); +}); + +test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 5 }); + + const responses = []; + for (let i = 0; i < 12; i += 1) responses.push(await request(handler)); + + const served = responses.filter((r) => r.status === 200); + const refused = responses.filter((r) => r.status === 429); + assert.equal(served.length, 5, 'exactly the admitted budget is served'); + assert.equal(refused.length, 7); + assert.equal(refused[0].body.error, 'Rate limit exceeded'); + assert.equal(refused[0].headers['Retry-After'], '10'); + assert.equal(refused[0].headers['Cache-Control'], 'no-store'); + assert.equal( + asked.length, + 5, + 'a refused request never reaches the GBFS operator', + ); +}); + +test('a different client keeps its own budget', async (t) => { + stubUpstream(t); + const handler = mount({ ratePerMinute: 3 }); + + for (let i = 0; i < 6; i += 1) + await request(handler, { remoteAddress: '10.0.0.1' }); + const noisy = await request(handler, { remoteAddress: '10.0.0.1' }); + assert.equal(noisy.status, 429, 'the noisy client spent its own budget'); + + const quiet = await request(handler, { remoteAddress: '10.0.0.2' }); + assert.equal(quiet.status, 200, 'a quiet client is not collateral'); +}); + +test('the limiter does not displace the existing guards', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 10 }); + + const wrongMethod = await request(handler, { method: 'POST' }); + assert.equal(wrongMethod.status, 405); + + const offAllowlist = await request(handler, { + target: 'https://example.com/gbfs/station_status.json', + }); + assert.equal(offAllowlist.status, 403); + assert.equal(offAllowlist.body.error, 'GBFS host not allowed'); + + const wrongPath = await request(handler, { + target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json', + }); + assert.equal(wrongPath.status, 400); + + assert.equal(asked.length, 0, 'none of those reached upstream'); +}); 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..cdca11307 --- /dev/null +++ b/src/layers/bikeshare/barcelonaSystems.test.mjs @@ -0,0 +1,104 @@ +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('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 }); + 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/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 916d50123..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', @@ -554,6 +578,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 || '')