mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
|
? distanceKm(area.center, point) <= area.center.radiusKm
|
||||||
: true;
|
: 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 assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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';
|
import { placeFromGeocodeResult } from './places.js';
|
||||||
|
|
||||||
test('an area needs exactly one form', async () => {
|
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}`);
|
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. */
|
/** Submarine cables and their landing points. */
|
||||||
|
|
||||||
import { defineTool, ToolError } from '../catalog.js';
|
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';
|
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
||||||
|
|
||||||
const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)';
|
const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)';
|
||||||
@@ -58,9 +63,7 @@ export const findSubmarineCables = defineTool({
|
|||||||
return false;
|
return false;
|
||||||
return (
|
return (
|
||||||
!area ||
|
!area ||
|
||||||
cableLines(feature).some((line) =>
|
cableLines(feature).some((line) => lineTouchesArea(line, area))
|
||||||
line.some(([lon, lat]) => areaContains(area, { lat, lon })),
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((feature) => ({
|
.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', {}),
|
catalog.call('find_submarine_cables', {}),
|
||||||
/area, a name or both/,
|
/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 () => {
|
test('the local fetch serves bundled data files and nothing outside them', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user