mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +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);
|
collectLonLat(geometry.coordinates, coords);
|
||||||
if (!coords.length) return null;
|
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 lonSum = 0;
|
||||||
let latSum = 0;
|
let latSum = 0;
|
||||||
for (const [lon, lat] of coords) {
|
for (const [lon, lat] of coords) {
|
||||||
lonSum += lon;
|
lonSum += lon - 360 * Math.round((lon - west) / 360);
|
||||||
latSum += lat;
|
latSum += lat;
|
||||||
}
|
}
|
||||||
|
const lon = lonSum / coords.length;
|
||||||
return {
|
return {
|
||||||
lon: lonSum / coords.length,
|
lon: lon - 360 * Math.round(lon / 360),
|
||||||
lat: latSum / coords.length,
|
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) {
|
function coordsFromProperty(value) {
|
||||||
if (!Array.isArray(value) || value.length < 2) return null;
|
if (!Array.isArray(value) || value.length < 2) return null;
|
||||||
const lon = Number(value[0]);
|
const lon = Number(value[0]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
featureLabel,
|
featureLabel,
|
||||||
featureReference,
|
featureReference,
|
||||||
normalizeFeatures,
|
normalizeFeatures,
|
||||||
|
splitFeatureAtAntimeridian,
|
||||||
} from '../geometry.js';
|
} from '../geometry.js';
|
||||||
import {
|
import {
|
||||||
BASE_CABLE_COLOR,
|
BASE_CABLE_COLOR,
|
||||||
@@ -97,7 +98,9 @@ export function createNavaraCables(engine, native, host) {
|
|||||||
if (next === dataset) return;
|
if (next === dataset) return;
|
||||||
if (dataset) hide();
|
if (dataset) hide();
|
||||||
try {
|
try {
|
||||||
const cables = normalizeFeatures(next.cables, 'cable'),
|
const cables = normalizeFeatures(next.cables, 'cable').map(
|
||||||
|
splitFeatureAtAntimeridian,
|
||||||
|
),
|
||||||
landings = normalizeFeatures(next.landingPoints, 'landing');
|
landings = normalizeFeatures(next.landingPoints, 'landing');
|
||||||
const shapes = [],
|
const shapes = [],
|
||||||
colors = new Map();
|
colors = new Map();
|
||||||
@@ -200,7 +203,8 @@ export function createNavaraCables(engine, native, host) {
|
|||||||
layer.on('featureUpdated', ({ evaluator }) =>
|
layer.on('featureUpdated', ({ evaluator }) =>
|
||||||
evaluator.evaluate(({ properties }) => {
|
evaluator.evaluate(({ properties }) => {
|
||||||
const css = properties?.color ?? BASE_CABLE_COLOR;
|
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) };
|
return { color: colors.get(css) };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user