From 4cfca57cfbacaaced36aaad356013aa25925efcc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 16:19:28 -0700 Subject: [PATCH] fix: bound large boxes by their farthest point on the sphere A box's radius was the distance to its farthest corner, which falls short for large boxes: a whole-world box measured about 10,000 km, so bike-share and transit selection never considered systems beyond it. The radius now checks whether the box holds the point opposite its center and otherwise samples every edge. --- src/tools/area.js | 40 +++++++++++++++++++++++------ src/tools/area.test.mjs | 17 ++++++++++++ src/tools/queries/mobility.test.mjs | 15 +++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/tools/area.js b/src/tools/area.js index dbd3aabb6..4af62a03c 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -142,14 +142,38 @@ export async function resolvePoint(point, { services, signal } = {}) { export function areaRadiusKm(area) { if (area.center) return area.center.radiusKm; const center = areaCenter(area); - return Math.max( - ...[ - { lat: area.north, lon: area.west }, - { lat: area.north, lon: area.east }, - { lat: area.south, lon: area.west }, - { lat: area.south, lon: area.east }, - ].map((corner) => distanceKm(center, corner)), - ); + // A box holding the point opposite its center reaches half the globe. + const antipode = { + lat: -center.lat, + lon: center.lon > 0 ? center.lon - 180 : center.lon + 180, + }; + if (areaContains(area, antipode)) return Math.PI * EARTH_RADIUS_KM; + // Otherwise the farthest point is on the boundary: sample each edge, and + // include where each parallel edge comes nearest the antipode's longitude. + const width = + area.west <= area.east + ? area.east - area.west + : 360 - (area.west - area.east); + const lonAt = (t) => area.west + width * t; + const steps = 64; + const points = []; + for (let step = 0; step <= steps; step += 1) { + const t = step / steps; + const lat = area.south + (area.north - area.south) * t; + points.push( + { lat, lon: area.west }, + { lat, lon: area.east }, + { lat: area.north, lon: lonAt(t) }, + { lat: area.south, lon: lonAt(t) }, + ); + } + const offset = (((antipode.lon - area.west) % 360) + 360) % 360; + if (offset <= width) + points.push( + { lat: area.north, lon: antipode.lon }, + { lat: area.south, lon: antipode.lon }, + ); + return Math.max(...points.map((point) => distanceKm(center, point))); } /** Great-circle distance in kilometers. */ diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index d901813b5..7ec551aae 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import { areaCenter, areaContains, + areaRadiusKm, distanceKm, lineTouchesArea, polygonsTouchArea, @@ -245,3 +246,19 @@ test('polygons touch an area by vertex, crossing edge or enclosing it', async () const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 20 }); assert.ok(polygonsTouchArea([square(5)], circle)); }); + +test('a box radius reaches every point of the box, however large', async () => { + const world = await resolveArea({ bbox: [-180, -90, 180, 90] }); + assert.ok(Math.abs(areaRadiusKm(world) - Math.PI * 6371.0088) < 1); + const wide = await resolveArea({ bbox: [-60, -70, 100, 60] }); + const radius = areaRadiusKm(wide); + const center = areaCenter(wide); + for (let lat = -70; lat <= 60; lat += 2.5) + for (let lon = -60; lon <= 100; lon += 2.5) + assert.ok( + distanceKm(center, { lat, lon }) <= radius + 0.5, + `${lat},${lon}`, + ); + const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] }); + assert.ok(areaRadiusKm(small) < 30); +}); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 1d3212ff7..af8142305 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -481,3 +481,18 @@ test('traffic counts only roads inside a radius area and reports missing tiles', assert.equal(partial.data.partial, true); assert.match(partial.summary, /Some map tiles did not load/); }); + +test('a whole-world bike-share search names every system it skipped', async () => { + const empty = { data: { stations: [] } }; + const result = await composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: async () => empty, + }, + }, + }).call('get_bike_share', { area: { bbox: [-180, -90, 180, 90] } }); + const named = [...result.data.systems, ...result.data.systems_not_searched]; + assert.equal(named.length, GBFS_CITY_REGISTRY.length); +});