From c22ad6af32be8afa4ee495258fff736aef66d686 Mon Sep 17 00:00:00 2001 From: Ashfaq <105435085+Ashfaqbs@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:15:55 +0530 Subject: [PATCH 1/3] fix: handle antimeridian crossing in clampBoundsAroundCenter east - west assumed a linear longitude axis, so a viewport crossing the antimeridian (west=179.98, east=-179.98) produced a negative span and unnormalized output coordinates outside [-180, 180). Wrap the span through 360 and normalize the clamped west/east back into range. Fixes #352. --- src/data/trafficBounds.js | 37 ++++++++++++++++++++++++++++++--- src/data/trafficBounds.test.mjs | 27 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 3b0404c3b..9f303785e 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -113,6 +113,33 @@ export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullK return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' }; } +/** + * Wrapped longitude span between two meridians, in degrees. + * + * Longitude is cyclic, so a box crossing the antimeridian (±180°) has + * `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west` + * goes negative in that case; wrap through 360° instead. + * + * @param {number} west - West edge longitude (degrees). + * @param {number} east - East edge longitude (degrees). + * @returns {number} Span in degrees, always >= 0. + */ +function longitudeSpanDeg(west, east) { + const raw = east - west; + return raw >= 0 ? raw : raw + 360; +} + +/** + * Normalize a longitude into [-180, 180). Matches the wrap idiom already + * used by `destinationPoint()` above. + * + * @param {number} lon - Longitude in degrees. + * @returns {number} Longitude normalized to [-180, 180). + */ +function normalizeLonDeg(lon) { + return ((lon + 540) % 360) - 180; +} + /** * Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`. * @@ -120,6 +147,10 @@ export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullK * 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. * + * Longitude is handled as a cyclic axis: the input span wraps correctly + * across the antimeridian, and the output west/east are normalized back into + * [-180, 180) instead of drifting outside the normal coordinate range. + * * @param {{south:number, west:number, north:number, east:number}} bounds * Source bounds (span donor). * @param {{lat:number, lon:number}} center - Fetch center (degrees). @@ -128,11 +159,11 @@ export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullK */ export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) { const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg); - const lonSpan = Math.min(bounds.east - bounds.west, maxSpanDeg); + const lonSpan = Math.min(longitudeSpanDeg(bounds.west, bounds.east), maxSpanDeg); return { south: center.lat - latSpan / 2, north: center.lat + latSpan / 2, - west: center.lon - lonSpan / 2, - east: center.lon + lonSpan / 2, + west: normalizeLonDeg(center.lon - lonSpan / 2), + east: normalizeLonDeg(center.lon + lonSpan / 2), }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index ec68edba1..63450dbb9 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -108,3 +108,30 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re- const twice = clampBoundsAroundCenter(once, midpoint, 0.05); 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, + }; + + const center = { + lat: 0, + lon: 179.99, + }; + + const result = clampBoundsAroundCenter(bounds, center, 0.05); + + assert.ok(result.west >= -180 && result.west < 180); + assert.ok(result.east >= -180 && result.east < 180); + + // Wrapped longitude span should remain about 0.04 degrees. + const span = + result.east >= result.west + ? result.east - result.west + : result.east - result.west + 360; + + assert.ok(Math.abs(span - 0.04) < 1e-12); +}); From 830c372c12f98b641eb2cceb902496b57d07199d Mon Sep 17 00:00:00 2001 From: Ashfaq <105435085+Ashfaqbs@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:02:26 +0530 Subject: [PATCH 2/3] fix: keep clamped traffic bounds monotonic across the antimeridian The previous fix wrapped the span correctly but normalized the returned west/east back into [-180, 180), which re-inverts the box whenever it crosses the antimeridian (west > east again). Downstream consumers (getBoundsCenter, boundsOverlap) assume west <= east and silently misbehave on an inverted box: the fetch center lands on the opposite side of the planet, and the overlap gate never skips a re-fetch. Drop the normalization and leave west/east unnormalized instead, so the box stays monotonic. The only remaining edge case (coordinates outside the conventional range reaching the Overpass query builder) is a visible, separate problem rather than a silently wrong center. Addresses review feedback on #392. --- src/data/trafficBounds.js | 24 ++++++++---------------- src/data/trafficBounds.test.mjs | 19 ++++++++++--------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 9f303785e..aed0053b7 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -129,17 +129,6 @@ function longitudeSpanDeg(west, east) { return raw >= 0 ? raw : raw + 360; } -/** - * Normalize a longitude into [-180, 180). Matches the wrap idiom already - * used by `destinationPoint()` above. - * - * @param {number} lon - Longitude in degrees. - * @returns {number} Longitude normalized to [-180, 180). - */ -function normalizeLonDeg(lon) { - return ((lon + 540) % 360) - 180; -} - /** * Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`. * @@ -147,9 +136,12 @@ function normalizeLonDeg(lon) { * 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. * - * Longitude is handled as a cyclic axis: the input span wraps correctly - * across the antimeridian, and the output west/east are normalized back into - * [-180, 180) instead of drifting outside the normal coordinate range. + * 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). * * @param {{south:number, west:number, north:number, east:number}} bounds * Source bounds (span donor). @@ -163,7 +155,7 @@ export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) { return { south: center.lat - latSpan / 2, north: center.lat + latSpan / 2, - west: normalizeLonDeg(center.lon - lonSpan / 2), - east: normalizeLonDeg(center.lon + lonSpan / 2), + west: center.lon - lonSpan / 2, + east: center.lon + lonSpan / 2, }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index 63450dbb9..005123ddd 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -124,14 +124,15 @@ test('span clamp handles bounds crossing the antimeridian', () => { const result = clampBoundsAroundCenter(bounds, center, 0.05); - assert.ok(result.west >= -180 && result.west < 180); - assert.ok(result.east >= -180 && result.east < 180); + // 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); - // Wrapped longitude span should remain about 0.04 degrees. - const span = - result.east >= result.west - ? result.east - result.west - : result.east - result.west + 360; - - assert.ok(Math.abs(span - 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}`); }); From 62abcfdd16548a889dbec86ff49e94be0ed82cc6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 20 Sep 2026 17:11:27 -0700 Subject: [PATCH 3/3] 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('{}'),