mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
This commit is contained in:
1 parent
5b73b70d19
commit
6f56c84b0b
4 files changed
+201
-5
No files matched your search
@@ -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;
|
||||
}
|
||||
+78
-1
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user