mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(gbfs): exclude stations with invalid coordinates (#1232)
Reject invalid bike-share station coordinates while preserving genuine zero coordinates and numeric aliases. Contribution by @Bortlesboat.
This commit is contained in:
5 files changed
+132
-4
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
- Skip bike-share stations with missing, blank or invalid coordinates instead
|
||||||
|
of placing them at coerced zero coordinates or accepting out-of-range values.
|
||||||
|
Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232).
|
||||||
@@ -1531,7 +1531,11 @@ camera is refused there with a JSON 404 and is served solely through
|
|||||||
## GBFS upstream bounds
|
## GBFS upstream bounds
|
||||||
|
|
||||||
Station information is normalized in `src/sources/gbfsStations.js` for both the
|
Station information is normalized in `src/sources/gbfsStations.js` for both the
|
||||||
globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized
|
globe layer and `get_bike_share`. Stations with missing, blank, nonnumeric or
|
||||||
|
out-of-range coordinates (latitude outside ±90, longitude outside ±180) are
|
||||||
|
skipped. Numbers and nonblank numeric strings are accepted, including genuine
|
||||||
|
zero and the `latitude`/`longitude` aliases; other JSON types are rejected.
|
||||||
|
Names accept legacy strings or GBFS v3 Localized
|
||||||
String arrays regardless of the reported feed version. Nonempty English text
|
String arrays regardless of the reported feed version. Nonempty English text
|
||||||
(including regional `en-*` tags, case-insensitive) takes precedence, then the first
|
(including regional `en-*` tags, case-insensitive) takes precedence, then the first
|
||||||
usable translation. An unusable `name` falls back to `short_name` with the same
|
usable translation. An unusable `name` falls back to `short_name` with the same
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ function dockCapacity(raw) {
|
|||||||
return capacity - geofenced;
|
return capacity - geofenced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read a numeric coordinate without treating blank text or other JSON types as zero. */
|
||||||
|
function coordinateNumber(value) {
|
||||||
|
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim()))
|
||||||
|
return NaN;
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||||
* Handles field-name variations across different GBFS providers
|
* Handles field-name variations across different GBFS providers
|
||||||
@@ -113,9 +120,16 @@ export function parseStationInformation(payload) {
|
|||||||
for (const raw of stations) {
|
for (const raw of stations) {
|
||||||
// Accept both GBFS (station_id) and alternate (id) field names
|
// Accept both GBFS (station_id) and alternate (id) field names
|
||||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
const lat = coordinateNumber(raw?.lat ?? raw?.latitude);
|
||||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
const lon = coordinateNumber(raw?.lon ?? raw?.longitude);
|
||||||
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
if (
|
||||||
|
!stationId ||
|
||||||
|
!Number.isFinite(lat) ||
|
||||||
|
!Number.isFinite(lon) ||
|
||||||
|
Math.abs(lat) > 90 ||
|
||||||
|
Math.abs(lon) > 180
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
stationMap.set(stationId, {
|
stationMap.set(stationId, {
|
||||||
stationId,
|
stationId,
|
||||||
|
|||||||
@@ -19,6 +19,73 @@ const parseCapacity = (fields) =>
|
|||||||
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
|
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
|
||||||
.capacity;
|
.capacity;
|
||||||
|
|
||||||
|
test('station information skips missing, blank and malformed coordinates', () => {
|
||||||
|
for (const field of ['lat', 'lon', 'latitude', 'longitude']) {
|
||||||
|
for (const value of [
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
' \t ',
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
[],
|
||||||
|
[0],
|
||||||
|
{},
|
||||||
|
'bad',
|
||||||
|
NaN,
|
||||||
|
Infinity,
|
||||||
|
]) {
|
||||||
|
const raw = { station_id: 'bad', lat: 0, lon: 0 };
|
||||||
|
if (field === 'latitude') delete raw.lat;
|
||||||
|
if (field === 'longitude') delete raw.lon;
|
||||||
|
raw[field] = value;
|
||||||
|
const parsed = parseStationInformation({
|
||||||
|
data: { stations: [raw, station({ station_id: 'good' })] },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[...parsed.keys()],
|
||||||
|
['good'],
|
||||||
|
`${field} = ${JSON.stringify(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('station information skips coordinates outside latitude and longitude bounds', () => {
|
||||||
|
for (const fields of [
|
||||||
|
{ lat: 90.001 },
|
||||||
|
{ lat: -90.001 },
|
||||||
|
{ lon: 180.001 },
|
||||||
|
{ lon: -180.001 },
|
||||||
|
{ lat: undefined, latitude: '91' },
|
||||||
|
{ lon: undefined, longitude: '-181' },
|
||||||
|
]) {
|
||||||
|
const parsed = parseStationInformation({
|
||||||
|
data: { stations: [station(fields)] },
|
||||||
|
});
|
||||||
|
assert.equal(parsed.size, 0, JSON.stringify(fields));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('station information preserves zero, boundary coordinates and numeric aliases', () => {
|
||||||
|
for (const [fields, expected] of [
|
||||||
|
[{ lat: 0, lon: 0 }, [0, 0]],
|
||||||
|
[{ lat: ' 0 ', lon: '0' }, [0, 0]],
|
||||||
|
[{ lat: 90, lon: 180 }, [90, 180]],
|
||||||
|
[{ lat: '-90', lon: '-180' }, [-90, -180]],
|
||||||
|
[
|
||||||
|
{ lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' },
|
||||||
|
[30.2681, -97.7469],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const parsed = parseStationInformation({
|
||||||
|
data: { stations: [station(fields)] },
|
||||||
|
}).get('s1');
|
||||||
|
assert.ok(parsed, JSON.stringify(fields));
|
||||||
|
assert.deepEqual([parsed.lat, parsed.lon], expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
|
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
|
||||||
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
|
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
|
||||||
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
|
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
|
||||||
|
|||||||
@@ -171,6 +171,46 @@ test('bike share reports uncovered areas and failed systems honestly', async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bike share excludes blank coordinates without losing stations on the prime meridian', async () => {
|
||||||
|
const stations = [
|
||||||
|
{ station_id: 'blank', lat: 51.5, lon: ' ' },
|
||||||
|
{ station_id: 'zero', lat: 51.5, lon: 0 },
|
||||||
|
];
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
bikeshare: {
|
||||||
|
systems: [system('fixture', 'Fixture Bikes', 51.5, 0)],
|
||||||
|
async getStations(url) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
stations: url.endsWith('station_information.json')
|
||||||
|
? stations
|
||||||
|
: stations.map(({ station_id }) => ({
|
||||||
|
station_id,
|
||||||
|
num_bikes_available: 4,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await catalog.call('get_bike_share', {
|
||||||
|
area: { lat: 51.5, lon: 0, radius_km: 2 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.rows.map((row) => row.station_id),
|
||||||
|
['zero'],
|
||||||
|
);
|
||||||
|
assert.equal(result.data.total, 1);
|
||||||
|
assert.equal(result.data.rows[0].lon, 0);
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'1 bike-share station in 2 km around 51.500, 0.000 with 4 bikes available.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const feed = {
|
const feed = {
|
||||||
id: 'capmetro',
|
id: 'capmetro',
|
||||||
name: 'CapMetro',
|
name: 'CapMetro',
|
||||||
|
|||||||
Reference in new issue
Block a user