mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Split submarine cables at the antimeridian and keep their reference beside the crossing
This commit is contained in:
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();
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) };
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user