diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..b1d6cec0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Flights' ground floor works across the antimeridian. The viewer-distance + check behind the 150 km floor radius and its copy in the photoreal mesh-floor + sampler subtracted longitudes without wrapping, so two points 21 km apart + across 180° measured about 38,000 km and low or grounded aircraft just across + the line from the viewer were never floored. A contact's display corridor + walked a segment across 180° the long way round, spending its cell budget on + ground behind the contact, and a contact on the seam lost its cell hysteresis. + SEARCH NEARBY SITES on a view across 180° searched Google Places around + longitude 0, on the far side of the globe, with a radius sized from the view's + height alone; it now searches the view. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/src/data/groundFloor.test.mjs b/src/data/groundFloor.test.mjs index e118549c3..4644f57ca 100644 --- a/src/data/groundFloor.test.mjs +++ b/src/data/groundFloor.test.mjs @@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => { assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 }); }); +// --- Antimeridian ------------------------------------------------------------ +// Raw longitude arithmetic walks a segment across 180° the long way round and +// reads a contact on the seam as half a world from its previous cell. + +test('corridorFloorCells walks across the antimeridian, not the long way round', () => { + for (const [from, to, across, behind] of [ + [179.999, -179.998, -179.999, 179.998], + [-179.999, 179.998, 179.999, -179.998], + ]) { + const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]); + const lons = cells.map((c) => c.lon); + assert.equal(lons[0], from); + assert.equal(lons.at(-1), to); + assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`); + assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`); + assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`); + } +}); + +test('stickyFloorCell holds across the antimeridian', () => { + for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) { + const prev = { lat: -16.69, lon: prevLon }; + assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev); + } +}); + // --- F2: corridor budget fairness ------------------------------------------ // The first cut spent the budget in Map insertion order and decremented it // BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs index 8c629e50f..f651d72f8 100644 --- a/src/data/meshFloorSampler.test.mjs +++ b/src/data/meshFloorSampler.test.mjs @@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => { setMeshFloorPreferred(true); assert.equal(cachedMeshFloor(cell.lat, cell.lon), null); }); + +test('sampler: a cell just across the antimeridian is still near the viewer', () => { + reset(); + let calls = 0; + const scene = fakeScene(() => { calls += 1; return undefined; }); + sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 }); + sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 }); + assert.equal(calls, 2, 'about 200 m apart, so both cells are probed'); +}); diff --git a/src/layers/flights/recordPolicy.js b/src/layers/flights/recordPolicy.js index ea284cb11..2172ffdc1 100644 --- a/src/layers/flights/recordPolicy.js +++ b/src/layers/flights/recordPolicy.js @@ -15,7 +15,10 @@ export const ERROR_BACKOFF_INTERVAL = 20000; /** Local equirectangular distance used for viewer-proximate floor sampling. */ export function approxDistanceKm(lat1, lon1, lat2, lon2) { const dLat = (lat2 - lat1) * 111.32; + // The short way round, so points either side of 180° stay close. + let dLonDeg = lon2 - lon1; + dLonDeg -= 360 * Math.round(dLonDeg / 360); const dLon = - (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); return Math.hypot(dLat, dLon); } diff --git a/src/layers/flights/recordPolicy.test.mjs b/src/layers/flights/recordPolicy.test.mjs new file mode 100644 index 000000000..4cf74d3cf --- /dev/null +++ b/src/layers/flights/recordPolicy.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + approxDistanceKm, + GROUND_FLOOR_CLAMP_RADIUS_KM, +} from './recordPolicy.js'; + +test('approximate distance takes the short way across the antimeridian', () => { + // 0.2° of longitude at Taveuni, Fiji (16.8° S) is about 21 km. + for (const [lon1, lon2] of [ + [179.9, -179.9], + [-179.9, 179.9], + ]) { + const km = approxDistanceKm(-16.8, lon1, -16.8, lon2); + assert.ok(Math.abs(km - 21.31) < 0.01, `${lon1} → ${lon2}: ${km} km`); + assert.ok(km <= GROUND_FLOOR_CLAMP_RADIUS_KM); + } +}); diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index 57cda2710..c1d3101c7 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -133,15 +133,16 @@ export function createIngestion({ let placesError = null; if (layerState.googleSearchRequested) { layerState.googleSearchRequested = false; + // A view across 180° has west > east; its width runs through 180°. + const lonSpan = box.east - box.west + (box.east < box.west ? 360 : 0); const latitude = (box.south + box.north) / 2; - const longitude = (box.west + box.east) / 2; + let longitude = box.west + lonSpan / 2; + if (longitude > 180) longitude -= 360; const radiusM = Math.min( 50000, Math.max( 1000, - Math.round( - Math.max(box.north - box.south, box.east - box.west) * 55_000, - ), + Math.round(Math.max(box.north - box.south, lonSpan) * 55_000), ), ); try { diff --git a/src/layers/installations/ingestion.test.mjs b/src/layers/installations/ingestion.test.mjs new file mode 100644 index 000000000..fbb90534c --- /dev/null +++ b/src/layers/installations/ingestion.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createIngestion } from './ingestion.js'; + +// Taveuni, Fiji: Cesium reports a view across 180° with west > east. Each view +// is 0.4° wide and 0.2° tall; the last one does not cross 180°. +for (const [west, east, centre] of [ + [179.7, -179.9, 179.9], + [179.9, -179.7, -179.9], + [179.5, 179.9, 179.7], +]) + test(`a nearby search is centred on the view at ${centre}`, async () => { + const box = { south: -17, west, north: -16.8, east }; + const searches = []; + const state = { + enabled: true, + viewer: {}, + records: [], + googleSearchRequested: true, + }; + const ingestion = createIngestion({ + state, + source: { + getMappedSites: async () => ({ records: [], wide: true }), + searchNearby: async (query) => { + searches.push(query); + return { places: [] }; + }, + }, + services: { + render: { governorRequestRender() {} }, + ground: { resolveGroundFloorCellsBounded: async () => {} }, + }, + parts: { + viewport: { + loadArea: () => ({ box, coverage: {} }), + clearUnavailableRetry() {}, + scheduleUnavailableRetry() {}, + }, + model: { installationWithinViewport: () => true }, + rendering: { renderRecords() {}, warmInstallationFloors() {} }, + }, + }); + await ingestion.loadInstallations(); + assert.equal(searches.length, 1); + const { latitude, longitude, radiusM } = searches[0]; + assert.ok(Math.abs(latitude + 16.9) < 1e-9); + assert.ok(Math.abs(longitude - centre) < 1e-9, `longitude ${longitude}`); + // The width, not the height, sets the radius. + assert.equal(radiusM, 22000); + }); diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js index 950c1b94e..27b7dc5e4 100644 --- a/src/services/groundFloor.js +++ b/src/services/groundFloor.js @@ -204,12 +204,16 @@ export function createGroundFloor({ terrain, signal }) { * @returns {{lat: number, lon: number}} The cell to read. */ function stickyFloorCell(lat, lon, previousCell) { - if ( - previousCell && - Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG && - Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG - ) { - return previousCell; + if (previousCell) { + // The short way round, so a contact on the 180° seam keeps its cell. + let dLon = lon - previousCell.lon; + dLon -= 360 * Math.round(dLon / 360); + if ( + Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG && + Math.abs(dLon) <= 0.0005 + CELL_HYSTERESIS_DEG + ) { + return previousCell; + } } return coarseFloorCoord(lat, lon); } @@ -269,17 +273,20 @@ export function createGroundFloor({ terrain, signal }) { const a = points[s]; const b = points[s + 1]; if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue; + // The short way round, so a segment across 180° walks across it. + let dLon = b.lon - a.lon; + dLon -= 360 * Math.round(dLon / 360); const steps = Math.ceil( - Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) / + Math.max(Math.abs(b.lat - a.lat), Math.abs(dLon)) / CORRIDOR_WALK_STEP_DEG, ); for (let i = 1; i <= steps; i++) { if (out.length >= prefixLimit) break walk; const t = i / steps; - const cell = coarseFloorCoord( - a.lat + (b.lat - a.lat) * t, - a.lon + (b.lon - a.lon) * t, - ); + let lon = a.lon + dLon * t; + if (lon > 180) lon -= 360; + else if (lon < -180) lon += 360; + const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, lon); const key = `${cell.lat},${cell.lon}`; if (seen.has(key) || key === destKey) continue; seen.add(key); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index b1cf65f29..34a62fd27 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -84,8 +84,11 @@ export function createMeshFloorSampler({ */ function _approxKm(lat1, lon1, lat2, lon2) { const dLat = (lat2 - lat1) * 111.32; + // The short way round, so points either side of 180° stay close. + let dLonDeg = lon2 - lon1; + dLonDeg -= 360 * Math.round(dLonDeg / 360); const dLon = - (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); return Math.hypot(dLat, dLon); }