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 4bd80ff32..ff5142133 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -123,12 +123,51 @@ export function deriveFetchCenter({ 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; +} + +/** 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. 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). @@ -138,11 +177,14 @@ export function deriveFetchCenter({ */ 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, + ); + 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 ec68edba1..0ff38628d 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -108,3 +108,19 @@ 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 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); + + 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('{}'),