Split submarine cables at the antimeridian and keep their reference beside the crossing

This commit is contained in:
German Khokhlov committed 2026-11-03 19:44:33 +04:00
1 parent 293ffb2bd3
commit b977ef29e3
3 files changed
+198 -4

No files matched your search

@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createCpuPicker } from '../../globe/cpuPicks.js';
import { createNavaraCables } from './render/navara.js';
import { CABLE_OVERLAY_SOURCE_ID } from './policy.js';
const FIJI = [178.44, -18.12];
// Cable A leaves Fiji eastwards across the antimeridian as ONE unsplit path
// and has no reference coordinates, so its reference comes from its geometry.
const cableA = {
type: 'Feature',
properties: { id: 'cable-a', name: 'Cable A' },
geometry: {
type: 'LineString',
coordinates: [FIJI, [-170, -10]],
},
};
// Cable B ends at the same landing point and runs south-west.
const cableB = {
type: 'Feature',
properties: { id: 'cable-b', name: 'Cable B', coordinates: [174, -24] },
geometry: {
type: 'LineString',
coordinates: [FIJI, [170, -30]],
},
};
const landing = {
type: 'Feature',
properties: { id: 'suva', name: 'Suva' },
geometry: { type: 'Point', coordinates: FIJI },
};
function fixture() {
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.setPose({
longitude: geo.math.toRadians(FIJI[0]),
latitude: geo.math.toRadians(FIJI[1]),
height: 400000,
});
// The fake has no mesh pickers: arbitrate them as Navara's picking does.
const meshPickers = new Set();
engine.picker.registerMeshPicker = (pick) => {
meshPickers.add(pick);
return () => meshPickers.delete(pick);
};
const drill = createCpuPicker({
projector: engine.projector,
camera: engine.camera,
movers: () => [],
lines: () => [],
meshes: (at) => [...meshPickers].flatMap((pick) => pick(at)),
});
engine.fake.script('pick', (at) => drill(at, 1)[0]);
const sources = [];
const native = {
view: {
addSource(options) {
sources.push(options);
return { delete() {} };
},
addLayer() {
return { on() {}, forceUpdate() {}, delete() {} };
},
registerMesh() {},
addMesh: () => ({ delete() {} }),
},
MeshDesc: class {},
NavaraColor: class {
setStyle() {
return this;
}
},
};
const host = { setVisible() {}, setEntries() {}, clearSource() {} };
const cables = createNavaraCables(engine, native, host);
cables.show({
cables: { type: 'FeatureCollection', features: [cableA, cableB] },
landingPoints: { type: 'FeatureCollection', features: [landing] },
});
return { engine, cables, sources };
}
const screen = (engine, [lon, lat]) =>
engine.projector.worldToWindow(geo.cartesian3.fromDegrees(lon, lat), {});
test('edge:telegeography-submarine-cables: an antimeridian crossing draws no segment across the whole map', () => {
const { cables, sources } = fixture();
try {
const drawn = sources.at(-1).data.features;
const paths = drawn.flatMap(({ geometry }) =>
geometry.type === 'MultiLineString'
? geometry.coordinates
: [geometry.coordinates],
);
assert.ok(paths.length >= 3, 'the crossing cable is split in two');
for (const path of paths)
for (let index = 1; index < path.length; index++)
assert.ok(
Math.abs(path[index][0] - path[index - 1][0]) <= 180,
`segment ${JSON.stringify([path[index - 1], path[index]])} spans the map`,
);
// Both halves meet at the antimeridian at the same latitude.
const [west, east] = drawn[0].geometry.coordinates;
assert.equal(Math.abs(west.at(-1)[0]), 180);
assert.equal(Math.abs(east[0][0]), 180);
assert.equal(west.at(-1)[1], east[0][1]);
} finally {
cables.destroy();
}
});
test('edge:telegeography-submarine-cables: a click on a cable beside its landing point returns that cable and flies to its own reference', () => {
const { engine, cables } = fixture();
try {
const edgeLat = -18.12 + ((180 - 178.44) / (190 - 178.44)) * (-10 + 18.12);
const from = screen(engine, FIJI);
const to = screen(engine, [180, edgeLat]);
// Halfway along cable A's first drawn segment, well clear of the landing point.
const at = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
assert.ok(Math.hypot(at.x - from.x, at.y - from.y) > 20);
const picked = engine.picker.pick(at);
assert.equal(picked?.id, `${CABLE_OVERLAY_SOURCE_ID}:cable:0`);
// The landing point itself still answers as the landing point.
assert.equal(
engine.picker.pick(from)?.id,
`${CABLE_OVERLAY_SOURCE_ID}:landing-point:0`,
);
engine.fake.resetCalls();
engine.fake.input('click', { position: at });
const [flight] = engine.fake.callsOf('camera.flyTo');
assert.ok(flight, 'the click flies to the cable');
const target = geo.cartographic.fromCartesian(flight.args[0].destination);
// The reference of a cable across the antimeridian stays beside it, not at 0°.
assert.ok(
Math.abs(geo.math.toDegrees(target.longitude)) > 170,
`reference at ${geo.math.toDegrees(target.longitude)}°`,
);
} finally {
cables.destroy();
}
});
+49 -2
View File
@@ -21,18 +21,65 @@ export function featureReference(feature) {
collectLonLat(geometry.coordinates, coords);
if (!coords.length) return null;
// Longitudes are averaged around the first one, so a cable split at the
// antimeridian (179.9 / -179.9) keeps its reference there, not at 0.
const west = coords[0][0];
let lonSum = 0;
let latSum = 0;
for (const [lon, lat] of coords) {
lonSum += lon;
lonSum += lon - 360 * Math.round((lon - west) / 360);
latSum += lat;
}
const lon = lonSum / coords.length;
return {
lon: lonSum / coords.length,
lon: lon - 360 * Math.round(lon / 360),
lat: latSum / coords.length,
};
}
/**
* Split a [lon, lat] path where a segment crosses the antimeridian, so no
* drawn segment runs the long way across the whole map.
*/
export function splitAtAntimeridian(path) {
const parts = [];
let current = [];
for (let index = 0; index < path.length; index++) {
const point = path[index];
const previous = path[index - 1];
if (previous && Math.abs(point[0] - previous[0]) > 180) {
const edge = previous[0] > point[0] ? 180 : -180;
const unwrapped = point[0] + 2 * edge;
const t = (edge - previous[0]) / (unwrapped - previous[0]);
const lat = previous[1] + t * (point[1] - previous[1]);
current.push([edge, lat]);
parts.push(current);
current = [[-edge, lat]];
}
current.push(point);
}
parts.push(current);
return parts.filter((part) => part.length > 1);
}
/** A line or multi-line feature whose paths are split at the antimeridian. */
export function splitFeatureAtAntimeridian(feature) {
const geometry = feature?.geometry;
if (geometry?.type !== 'LineString' && geometry?.type !== 'MultiLineString')
return feature;
const paths =
geometry.type === 'LineString'
? [geometry.coordinates]
: geometry.coordinates;
return {
...feature,
geometry: {
type: 'MultiLineString',
coordinates: paths.flatMap((path) => splitAtAntimeridian(path)),
},
};
}
function coordsFromProperty(value) {
if (!Array.isArray(value) || value.length < 2) return null;
const lon = Number(value[0]);
+6 -2
View File
@@ -4,6 +4,7 @@ import {
featureLabel,
featureReference,
normalizeFeatures,
splitFeatureAtAntimeridian,
} from '../geometry.js';
import {
BASE_CABLE_COLOR,
@@ -97,7 +98,9 @@ export function createNavaraCables(engine, native, host) {
if (next === dataset) return;
if (dataset) hide();
try {
const cables = normalizeFeatures(next.cables, 'cable'),
const cables = normalizeFeatures(next.cables, 'cable').map(
splitFeatureAtAntimeridian,
),
landings = normalizeFeatures(next.landingPoints, 'landing');
const shapes = [],
colors = new Map();
@@ -200,7 +203,8 @@ export function createNavaraCables(engine, native, host) {
layer.on('featureUpdated', ({ evaluator }) =>
evaluator.evaluate(({ properties }) => {
const css = properties?.color ?? BASE_CABLE_COLOR;
if (!colors.has(css)) colors.set(css, new native.NavaraColor().setStyle(css));
if (!colors.has(css))
colors.set(css, new native.NavaraColor().setStyle(css));
return { color: colors.get(css) };
}),
);