From 62abcfdd16548a889dbec86ff49e94be0ed82cc6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 20 Sep 2026 17:11:27 -0700 Subject: [PATCH] fix: keep antimeridian traffic requests in range --- CHANGELOG.md | 4 ++++ src/data/trafficBounds.js | 37 ++++++++++++++++++++++------- src/data/trafficBounds.test.mjs | 38 ++++++++++-------------------- src/layers/traffic/source.test.mjs | 23 ++++++++++++++++++ 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde31f22e..fbd5353b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -267,6 +267,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Keep traffic-road bounds crossing the antimeridian monotonic and inside the + longitude range accepted by the Overpass request path, preserving the small + wrapped span instead of producing an inverted or rejected box (#392 — thanks + @Ashfaqbs). - Make `npm run doctor` report keyless anonymous OpenSky access for explicit `OPENSKY_AUTH_MODE=anon` and OAuth mode without a client pair, retain the existing OAuth-pair capability wording, and identify selected Basic or auto diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index fcfe1ba81..ff5142133 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -139,19 +139,35 @@ function longitudeSpanDeg(west, east) { return raw >= 0 ? raw : raw + 360; } +/** Keep a monotonic longitude range inside the traffic source's legal domain. */ +function boundedLongitudeRange(center, span) { + let west = center - span / 2; + let east = center + span / 2; + if (east > 180) { + west -= east - 180; + east = 180; + } + if (west < -180) { + east += -180 - west; + west = -180; + } + return { west, east }; +} + /** * Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`. * * Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km) * but centers the box on the derived look-at point instead of the view - * rectangle's midpoint. Idempotent when `center` is the box's own midpoint. + * rectangle's midpoint. At the antimeridian the whole box shifts just far + * enough to remain inside [-180, 180], because the road source accepts one + * monotonic Overpass bounding box. Idempotent when `center` is the box's own + * midpoint. * * Longitude is handled as a cyclic axis for the span calculation, so the - * input span wraps correctly across the antimeridian. The output west/east - * are deliberately left unnormalized (may fall outside [-180, 180)) so that - * `west <= east` always holds — callers such as `getBoundsCenter` and - * `boundsOverlap` rely on that invariant and would silently misbehave on a - * normalized-but-inverted box (see #392 review). + * input span wraps correctly across the antimeridian. Output remains + * monotonic because callers such as `getBoundsCenter` and `boundsOverlap` + * rely on `west <= east`. * * @param {{south:number, west:number, north:number, east:number}} bounds * Source bounds (span donor). @@ -161,11 +177,14 @@ function longitudeSpanDeg(west, east) { */ export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) { const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg); - const lonSpan = Math.min(longitudeSpanDeg(bounds.west, bounds.east), maxSpanDeg); + const lonSpan = Math.min( + longitudeSpanDeg(bounds.west, bounds.east), + maxSpanDeg, + ); + const longitude = boundedLongitudeRange(center.lon, lonSpan); return { south: center.lat - latSpan / 2, north: center.lat + latSpan / 2, - west: center.lon - lonSpan / 2, - east: center.lon + lonSpan / 2, + ...longitude, }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index 005123ddd..0ff38628d 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -109,30 +109,18 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re- assert.deepEqual(twice, once); }); -test('span clamp handles bounds crossing the antimeridian', () => { - const bounds = { - south: -0.02, - north: 0.02, - west: 179.98, - east: -179.98, - }; +test('span clamp keeps antimeridian bounds monotonic and request-safe', () => { + for (const centerLon of [179.99, -179.99]) { + const result = clampBoundsAroundCenter({ + south: -0.02, + north: 0.02, + west: 179.98, + east: -179.98, + }, { lat: 0, lon: centerLon }, 0.05); - const center = { - lat: 0, - lon: 179.99, - }; - - const result = clampBoundsAroundCenter(bounds, center, 0.05); - - // west/east are deliberately left unnormalized (may exceed 180) so that - // west <= east always holds for downstream consumers (getBoundsCenter, - // boundsOverlap) that assume a monotonic box. See #392 review. - assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`); - assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12); - - // The plain-mean center a caller like getBoundsCenter computes must land - // back on the antimeridian near the requested center, not ~180 degrees - // away on the opposite side of the planet. - const center2 = (result.west + result.east) / 2; - assert.ok(Math.abs(center2 - center.lon) < 1e-9, `center drifted: ${center2}`); + assert.ok(result.west >= -180 && result.east <= 180); + assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`); + assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12); + assert.ok(result.west <= centerLon && centerLon <= result.east); + } }); diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 763dfe7bf..8047ddbbd 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; import { createTrafficSource } from './source.js'; +import { clampBoundsAroundCenter } from '../../data/trafficBounds.js'; const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 }; const fixture = readFileSync( new URL( @@ -87,6 +88,28 @@ test('road requests have finite bounds and retain the two-pass query', async () /residential/, ); }); +test('antimeridian clamps produce road bounds accepted on either side', async () => { + const calls = []; + const source = createTrafficSource({ + fetchImpl: async (...args) => { + calls.push(args); + return new Response('{"elements":[]}'); + }, + }); + for (const centerLon of [179.99, -179.99]) { + const clamped = clampBoundsAroundCenter( + { + south: -0.02, + north: 0.02, + west: 179.98, + east: -179.98, + }, + { lat: 0, lon: centerLon }, + ); + await source.requestRoads(clamped); + } + assert.equal(calls.length, 2); +}); test('malformed availability is an unavailable source rather than a keyless response', async () => { const source = createTrafficSource({ fetchImpl: async () => new Response('{}'),