From eb12303f46700bcfb67c9b93b0730a004f176ee8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:18:48 -0700 Subject: [PATCH] refactor: move GBFS station parsing into a portable source module The bike-share layer parsed station documents inside its model factory. Move the parsers unchanged to src/sources/gbfsStations.js and have the model import them, so code outside the layer can read GBFS stations without constructing the layer. --- scripts/package-boundaries.json | 3 + src/layers/bikeshare/model.js | 127 ++------------------------------ src/sources/gbfsStations.js | 115 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 119 deletions(-) create mode 100644 src/sources/gbfsStations.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3ccd6bda3..14ce87c0c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1080,6 +1080,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/sources/gbfsStations.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", @@ -1526,6 +1527,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/sources/gbfsStations.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", @@ -2191,6 +2193,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/sources/gbfsStations.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js index 89628f613..f12148dba 100644 --- a/src/layers/bikeshare/model.js +++ b/src/layers/bikeshare/model.js @@ -1,36 +1,12 @@ +import { + extractStationsArray, + normalizeGbfsBool, + parseStationInformation, + parseStationStatus, + toNonNegativeInteger, +} from '../../sources/gbfsStations.js'; + export function createModel({ state: layerState, services, parts, source }) { - /** - * Coerce a GBFS boolean field to a native boolean. - * GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings. - * @param {*} value - Raw field value from GBFS JSON. - * @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized. - * @returns {boolean} - */ - - function normalizeGbfsBool(value, fallback = true) { - if (value == null) return fallback; - if (typeof value === 'boolean') return value; - const num = Number(value); - if (Number.isFinite(num)) return num !== 0; - const text = String(value).trim().toLowerCase(); - if (text === 'true' || text === 'yes') return true; - if (text === 'false' || text === 'no') return false; - return fallback; - } - - /** - * Parse a value as a non-negative integer, returning fallback on failure. - * @param {*} value - Raw numeric value. - * @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number. - * @returns {number|null} - */ - - function toNonNegativeInteger(value, fallback = null) { - const n = Number(value); - if (!Number.isFinite(n) || n < 0) return fallback; - return Math.round(n); - } - /** * Build a globally unique render key for a station. * @param {string} cityId - City identifier. @@ -63,93 +39,6 @@ export function createModel({ state: layerState, services, parts, source }) { return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } - /** - * Extract the stations array from a GBFS JSON payload. - * Handles multiple response shapes: { data: { stations: [...] } }, - * { data: [...] }, and nested objects with a stations sub-key. - * @param {Object} payload - Parsed GBFS JSON response. - * @returns {Object[]} Array of raw station objects (may be empty). - */ - - function extractStationsArray(payload) { - const data = payload?.data; - if (Array.isArray(data?.stations)) return data.stations; - if (Array.isArray(data)) return data; - // Some feeds nest stations under an additional key (e.g. locale wrappers) - if (data && typeof data === 'object') { - for (const value of Object.values(data)) { - if (Array.isArray(value?.stations)) return value.stations; - } - } - return []; - } - - /** - * Parse a GBFS station_information payload into a Map of station metadata. - * Handles field-name variations across different GBFS providers - * (station_id vs id, lat vs latitude, etc.). Stations with missing or - * invalid coordinates are silently skipped. - * @param {Object} payload - Parsed station_information.json response. - * @returns {Map} Map of stationId to station info objects. - */ - - function parseStationInformation(payload) { - const stations = extractStationsArray(payload); - const stationMap = new Map(); - - for (const raw of stations) { - // Accept both GBFS 2.x (station_id) and alternate (id) field names - const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) - continue; - - stationMap.set(stationId, { - stationId, - name: String(raw?.name || raw?.short_name || '').trim(), - lat, - lon, - capacity: toNonNegativeInteger(raw?.capacity), - isInstalled: normalizeGbfsBool(raw?.is_installed, true), - isRenting: normalizeGbfsBool(raw?.is_renting, true), - isReturning: normalizeGbfsBool(raw?.is_returning, true), - }); - } - - return stationMap; - } - - /** - * Parse a GBFS station_status payload into a Map of real-time availability. - * @param {Object} payload - Parsed station_status.json response. - * @returns {Map} Map of stationId to status objects containing - * bikesAvailable, docksAvailable, and operational flags. - */ - - function parseStationStatus(payload) { - const stations = extractStationsArray(payload); - const statusMap = new Map(); - - for (const raw of stations) { - const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - if (!stationId) continue; - - const bikes = toNonNegativeInteger(raw?.num_bikes_available); - const docks = toNonNegativeInteger(raw?.num_docks_available); - statusMap.set(stationId, { - stationId, - bikesAvailable: bikes, - docksAvailable: docks, - isInstalled: normalizeGbfsBool(raw?.is_installed, true), - isRenting: normalizeGbfsBool(raw?.is_renting, true), - isReturning: normalizeGbfsBool(raw?.is_returning, true), - lastReported: toNonNegativeInteger(raw?.last_reported), - }); - } - - return statusMap; - } return { normalizeGbfsBool, toNonNegativeInteger, diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js new file mode 100644 index 000000000..9c5b93528 --- /dev/null +++ b/src/sources/gbfsStations.js @@ -0,0 +1,115 @@ +/** Parse GBFS station documents, tolerating field-name variations across providers. */ + +/** + * Coerce a GBFS boolean field to a native boolean. + * GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings. + * @param {*} value - Raw field value from GBFS JSON. + * @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized. + * @returns {boolean} + */ +export function normalizeGbfsBool(value, fallback = true) { + if (value == null) return fallback; + if (typeof value === 'boolean') return value; + const num = Number(value); + if (Number.isFinite(num)) return num !== 0; + const text = String(value).trim().toLowerCase(); + if (text === 'true' || text === 'yes') return true; + if (text === 'false' || text === 'no') return false; + return fallback; +} + +/** + * Parse a value as a non-negative integer, returning fallback on failure. + * @param {*} value - Raw numeric value. + * @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number. + * @returns {number|null} + */ +export function toNonNegativeInteger(value, fallback = null) { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.round(n); +} + +/** + * Extract the stations array from a GBFS JSON payload. + * Handles multiple response shapes: { data: { stations: [...] } }, + * { data: [...] }, and nested objects with a stations sub-key. + * @param {Object} payload - Parsed GBFS JSON response. + * @returns {Object[]} Array of raw station objects (may be empty). + */ +export function extractStationsArray(payload) { + const data = payload?.data; + if (Array.isArray(data?.stations)) return data.stations; + if (Array.isArray(data)) return data; + // Some feeds nest stations under an additional key (e.g. locale wrappers) + if (data && typeof data === 'object') { + for (const value of Object.values(data)) { + if (Array.isArray(value?.stations)) return value.stations; + } + } + return []; +} + +/** + * Parse a GBFS station_information payload into a Map of station metadata. + * Handles field-name variations across different GBFS providers + * (station_id vs id, lat vs latitude, etc.). Stations with missing or + * invalid coordinates are silently skipped. + * @param {Object} payload - Parsed station_information.json response. + * @returns {Map} Map of stationId to station info objects. + */ +export function parseStationInformation(payload) { + const stations = extractStationsArray(payload); + const stationMap = new Map(); + + for (const raw of stations) { + // Accept both GBFS 2.x (station_id) and alternate (id) field names + const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); + const lat = Number(raw?.lat ?? raw?.latitude); + const lon = Number(raw?.lon ?? raw?.longitude); + if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + + stationMap.set(stationId, { + stationId, + name: String(raw?.name || raw?.short_name || '').trim(), + lat, + lon, + capacity: toNonNegativeInteger(raw?.capacity), + isInstalled: normalizeGbfsBool(raw?.is_installed, true), + isRenting: normalizeGbfsBool(raw?.is_renting, true), + isReturning: normalizeGbfsBool(raw?.is_returning, true), + }); + } + + return stationMap; +} + +/** + * Parse a GBFS station_status payload into a Map of real-time availability. + * @param {Object} payload - Parsed station_status.json response. + * @returns {Map} Map of stationId to status objects containing + * bikesAvailable, docksAvailable, and operational flags. + */ +export function parseStationStatus(payload) { + const stations = extractStationsArray(payload); + const statusMap = new Map(); + + for (const raw of stations) { + const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); + if (!stationId) continue; + + const bikes = toNonNegativeInteger(raw?.num_bikes_available); + const docks = toNonNegativeInteger(raw?.num_docks_available); + statusMap.set(stationId, { + stationId, + bikesAvailable: bikes, + docksAvailable: docks, + isInstalled: normalizeGbfsBool(raw?.is_installed, true), + isRenting: normalizeGbfsBool(raw?.is_renting, true), + isReturning: normalizeGbfsBool(raw?.is_returning, true), + lastReported: toNonNegativeInteger(raw?.last_reported), + }); + } + + return statusMap; +}