From 6f56c84b0bf61e69b1099dfec387613435f16de2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:57:55 -0700 Subject: [PATCH] fix: find cables that cross an area between vertices Cable searches only tested vertices, so a cable crossing an area with no vertex inside it was missed. Lines now test each segment against the area's bounds, across the antimeridian too, and against a radius area's great-circle distance. --- src/tools/area.js | 101 ++++++++++++++++++++++++++++++ src/tools/area.test.mjs | 79 ++++++++++++++++++++++- src/tools/queries/cables.js | 11 ++-- src/tools/queries/cables.test.mjs | 15 +++++ 4 files changed, 201 insertions(+), 5 deletions(-) diff --git a/src/tools/area.js b/src/tools/area.js index 41b417482..b8eef612b 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -195,3 +195,104 @@ export function areaContains(area, point) { ? distanceKm(area.center, point) <= area.center.radiusKm : true; } + +/** Whether a straight lon/lat segment meets a plain box (Liang–Barsky). */ +function segmentMeetsBox(a, b, west, south, east, north) { + const dx = b.lon - a.lon; + const dy = b.lat - a.lat; + let low = 0; + let high = 1; + for (const [p, q] of [ + [-dx, a.lon - west], + [dx, east - a.lon], + [-dy, a.lat - south], + [dy, north - a.lat], + ]) { + if (p === 0) { + if (q < 0) return false; + } else { + const t = q / p; + if (p < 0) low = Math.max(low, t); + else high = Math.min(high, t); + if (low > high) return false; + } + } + return true; +} + +/** Whether a segment meets an area's bounding box, across the antimeridian too. */ +function segmentMeetsBounds(a, b, area) { + // A segment longer than half the globe in longitude crosses the antimeridian. + const end = + Math.abs(b.lon - a.lon) > 180 + ? { lat: b.lat, lon: b.lon + (b.lon < a.lon ? 360 : -360) } + : b; + const boxes = + area.west <= area.east + ? [[area.west, area.east]] + : [ + [area.west, 180], + [-180, area.east], + ]; + return boxes.some(([west, east]) => + [-360, 0, 360].some((shift) => + segmentMeetsBox( + a, + end, + west + shift, + area.south, + east + shift, + area.north, + ), + ), + ); +} + +/** Closest great-circle distance in km from `point` to the segment a–b. */ +function segmentDistanceKm(point, a, b) { + const toRad = Math.PI / 180; + const bearing = (from, to) => { + const lat1 = from.lat * toRad; + const lat2 = to.lat * toRad; + const dLon = (to.lon - from.lon) * toRad; + return Math.atan2( + Math.sin(dLon) * Math.cos(lat2), + Math.cos(lat1) * Math.sin(lat2) - + Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon), + ); + }; + const toPoint = distanceKm(a, point) / EARTH_RADIUS_KM; + const length = distanceKm(a, b) / EARTH_RADIUS_KM; + const angle = bearing(a, point) - bearing(a, b); + if (length === 0 || Math.cos(angle) <= 0) return distanceKm(a, point); + const cross = Math.asin(Math.sin(toPoint) * Math.sin(angle)); + const along = Math.acos( + Math.min(1, Math.cos(toPoint) / Math.max(1e-12, Math.cos(cross))), + ); + if (along >= length) return distanceKm(b, point); + return Math.abs(cross) * EARTH_RADIUS_KM; +} + +/** + * Whether a line of `[lon, lat]` vertices passes through an area, including + * segments that cross it with no vertex inside. + */ +export function lineTouchesArea(coords, area) { + const points = coords + .map(([lon, lat]) => ({ lat, lon })) + .filter( + (point) => Number.isFinite(point.lat) && Number.isFinite(point.lon), + ); + if (points.some((point) => areaContains(area, point))) return true; + for (let index = 1; index < points.length; index += 1) { + const a = points[index - 1]; + const b = points[index]; + if (!segmentMeetsBounds(a, b, area)) continue; + if ( + !area.center || + segmentDistanceKm(area.center, a, b) <= area.center.radiusKm + ) + return true; + } + return false; +} diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index d22611282..dee34f402 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { areaCenter, areaContains, distanceKm, resolveArea } from './area.js'; +import { + areaCenter, + areaContains, + distanceKm, + lineTouchesArea, + resolveArea, +} from './area.js'; import { placeFromGeocodeResult } from './places.js'; test('an area needs exactly one form', async () => { @@ -147,3 +153,74 @@ test('radius areas include every point within the radius, near the poles too', a assert.ok(areaContains(wide, point), `bearing ${bearing}`); } }); + +test('lines touch an area when a segment crosses it with no vertex inside', async () => { + const box = await resolveArea({ bbox: [-1, -1, 1, 1] }); + assert.ok( + lineTouchesArea( + [ + [-2, 0], + [2, 0], + ], + box, + ), + ); + assert.ok( + !lineTouchesArea( + [ + [-2, 2], + [2, 2], + ], + box, + ), + ); + const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 50 }); + assert.ok( + lineTouchesArea( + [ + [-2, 0.3], + [2, 0.3], + ], + circle, + ), + ); + // Inside the circle's box but beyond its radius. + assert.ok( + !lineTouchesArea( + [ + [0.3, 0.42], + [0.6, 0.42], + ], + circle, + ), + ); + const dateline = await resolveArea({ bbox: [179, -1, -179, 1] }); + assert.ok( + lineTouchesArea( + [ + [178, 0], + [-178, 0], + ], + dateline, + ), + ); + const nearDateline = await resolveArea({ bbox: [179.5, -1, 179.9, 1] }); + assert.ok( + lineTouchesArea( + [ + [179, 0], + [-179, 0], + ], + nearDateline, + ), + ); + assert.ok( + !lineTouchesArea( + [ + [170, 0], + [175, 0], + ], + nearDateline, + ), + ); +}); diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js index e7f5ec6f7..ddf69f33d 100644 --- a/src/tools/queries/cables.js +++ b/src/tools/queries/cables.js @@ -1,7 +1,12 @@ /** Submarine cables and their landing points. */ import { defineTool, ToolError } from '../catalog.js'; -import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; +import { + AREA_SCHEMA, + areaContains, + lineTouchesArea, + resolveArea, +} from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)'; @@ -58,9 +63,7 @@ export const findSubmarineCables = defineTool({ return false; return ( !area || - cableLines(feature).some((line) => - line.some(([lon, lat]) => areaContains(area, { lat, lon })), - ) + cableLines(feature).some((line) => lineTouchesArea(line, area)) ); }) .map((feature) => ({ diff --git a/src/tools/queries/cables.test.mjs b/src/tools/queries/cables.test.mjs index 754032b6e..f74457087 100644 --- a/src/tools/queries/cables.test.mjs +++ b/src/tools/queries/cables.test.mjs @@ -72,6 +72,21 @@ test('cables are found by area and name, with landing points and attribution', a catalog.call('find_submarine_cables', {}), /area, a name or both/, ); + // Areas a cable crosses between vertices, including at the antimeridian. + const atlantic = await catalog.call('find_submarine_cables', { + area: { bbox: [-30, 40, -20, 43] }, + }); + assert.deepEqual( + atlantic.data.rows.map((row) => row.id), + ['marea'], + ); + const pacific = await catalog.call('find_submarine_cables', { + area: { bbox: [179, 34, -179, 35.5] }, + }); + assert.deepEqual( + pacific.data.rows.map((row) => row.id), + ['unity'], + ); }); test('the local fetch serves bundled data files and nothing outside them', async () => {