mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor(geography): separate cable sources and bundled lookup modules
This commit is contained in:
1 parent
2213c42200
commit
3ce5d2e454
21 files changed
+2423
-1437
No files matched your search
@@ -29,7 +29,8 @@ function ringAreaKm2(ring) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum += toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
@@ -37,8 +38,9 @@ function ringAreaKm2(ring) {
|
||||
function haversineKm(lon1, lat1, lon2, lat2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
@@ -62,34 +64,34 @@ function normalizeName(s) {
|
||||
* Keys and values are both in normalizeName() form.
|
||||
*/
|
||||
const ALIASES = {
|
||||
'rockies': 'rocky mountains',
|
||||
'himalaya': 'himalayas',
|
||||
rockies: 'rocky mountains',
|
||||
himalaya: 'himalayas',
|
||||
'the himalaya': 'himalayas',
|
||||
'alps mountains': 'alps',
|
||||
'sahara desert': 'sahara',
|
||||
'gobi': 'gobi desert',
|
||||
'kalahari': 'kalahari desert',
|
||||
'atacama': 'desierto de atacama',
|
||||
gobi: 'gobi desert',
|
||||
kalahari: 'kalahari desert',
|
||||
atacama: 'desierto de atacama',
|
||||
'atacama desert': 'desierto de atacama',
|
||||
'tibetan plateau': 'plateau of tibet',
|
||||
'tibet plateau': 'plateau of tibet',
|
||||
'appalachians': 'appalachian mts',
|
||||
appalachians: 'appalachian mts',
|
||||
'appalachian mountains': 'appalachian mts',
|
||||
'caucasus': 'caucasus mts',
|
||||
caucasus: 'caucasus mts',
|
||||
'caucasus mountains': 'caucasus mts',
|
||||
'balkans': 'balkan pen',
|
||||
balkans: 'balkan pen',
|
||||
'balkan peninsula': 'balkan pen',
|
||||
'andes mountains': 'andes',
|
||||
'urals': 'ural mountains',
|
||||
urals: 'ural mountains',
|
||||
'pyrenees mountains': 'pyrenees',
|
||||
'arabian gulf': 'persian gulf',
|
||||
'gulf of arabia': 'persian gulf',
|
||||
'mediterranean': 'mediterranean sea',
|
||||
'caribbean': 'caribbean sea',
|
||||
'baja': 'baja california',
|
||||
'yucatan': 'pen de yucatan',
|
||||
mediterranean: 'mediterranean sea',
|
||||
caribbean: 'caribbean sea',
|
||||
baja: 'baja california',
|
||||
yucatan: 'pen de yucatan',
|
||||
'yucatan peninsula': 'pen de yucatan',
|
||||
'kamchatka': 'kamchatka peninsula',
|
||||
kamchatka: 'kamchatka peninsula',
|
||||
'sierra nevada mountains': 'sierra nevada',
|
||||
};
|
||||
|
||||
@@ -97,8 +99,13 @@ const ALIASES = {
|
||||
function suffixVariants(norm) {
|
||||
const v = [];
|
||||
// "x mountains" ↔ "x mts" (pack uses "Mts."; normalization strips the dot)
|
||||
if (norm.endsWith(' mountains')) v.push(norm.replace(/ mountains$/, ' mts'), norm.replace(/ mountains$/, ''));
|
||||
if (norm.endsWith(' mts')) v.push(norm.replace(/ mts$/, ' mountains'), norm.replace(/ mts$/, ''));
|
||||
if (norm.endsWith(' mountains'))
|
||||
v.push(
|
||||
norm.replace(/ mountains$/, ' mts'),
|
||||
norm.replace(/ mountains$/, ''),
|
||||
);
|
||||
if (norm.endsWith(' mts'))
|
||||
v.push(norm.replace(/ mts$/, ' mountains'), norm.replace(/ mts$/, ''));
|
||||
// "x desert" ↔ "x"
|
||||
if (norm.endsWith(' desert')) v.push(norm.replace(/ desert$/, ''));
|
||||
else v.push(norm + ' desert');
|
||||
@@ -117,9 +124,14 @@ async function loadPackFile(base) {
|
||||
// Vite bundles these JSON files as modules; the import attribute is what Node
|
||||
// needs to load the same files under node:test (same pattern as
|
||||
// neighborhoodPolygons.js). One path, so no node: import reaches the browser.
|
||||
const mod = base === 'regions'
|
||||
? await import('./local_data/natural_earth/regions.json', { with: { type: 'json' } })
|
||||
: await import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
|
||||
const mod =
|
||||
base === 'regions'
|
||||
? await import('./local_data/natural_earth/regions.json', {
|
||||
with: { type: 'json' },
|
||||
})
|
||||
: await import('./local_data/natural_earth/marine.json', {
|
||||
with: { type: 'json' },
|
||||
});
|
||||
return mod.default || mod;
|
||||
}
|
||||
|
||||
@@ -129,7 +141,10 @@ function buildEntries(pack, kind) {
|
||||
const polygons = ft.polygons || [];
|
||||
if (!polygons.length) continue;
|
||||
let areaKm2 = 0;
|
||||
let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
|
||||
let minLon = Infinity,
|
||||
minLat = Infinity,
|
||||
maxLon = -Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const ring of polygons) {
|
||||
areaKm2 += ringAreaKm2(ring);
|
||||
for (const [lon, lat] of ring) {
|
||||
@@ -169,10 +184,14 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
];
|
||||
const index = new Map();
|
||||
for (const entry of _entries) {
|
||||
for (const key of new Set([normalizeName(entry.name), normalizeName(entry.namealt)])) {
|
||||
for (const key of new Set([
|
||||
normalizeName(entry.name),
|
||||
normalizeName(entry.namealt),
|
||||
])) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
if (list) list.push(entry); else index.set(key, [entry]);
|
||||
if (list) list.push(entry);
|
||||
else index.set(key, [entry]);
|
||||
}
|
||||
}
|
||||
// duplicate names exist in Natural Earth (e.g. two "Cordillera Oriental",
|
||||
@@ -207,7 +226,11 @@ export async function findNaturalRegion(query) {
|
||||
const norm = normalizeName(query);
|
||||
if (!norm) return null;
|
||||
const index = await loadIndex();
|
||||
const candidates = [norm, ALIASES[norm], ...suffixVariants(ALIASES[norm] || norm)];
|
||||
const candidates = [
|
||||
norm,
|
||||
ALIASES[norm],
|
||||
...suffixVariants(ALIASES[norm] || norm),
|
||||
];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
@@ -248,8 +271,8 @@ export function pointInRing(ring, lat, lon) {
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
const intersects = (yi > lat) !== (yj > lat)
|
||||
&& lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
|
||||
const intersects =
|
||||
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
@@ -274,7 +297,11 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
const norm = normalizeName(query);
|
||||
if (!norm || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const index = await loadIndex();
|
||||
const candidates = [norm, ALIASES[norm], ...suffixVariants(ALIASES[norm] || norm)];
|
||||
const candidates = [
|
||||
norm,
|
||||
ALIASES[norm],
|
||||
...suffixVariants(ALIASES[norm] || norm),
|
||||
];
|
||||
const seen = new Set();
|
||||
for (const key of candidates) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
|
||||
@@ -14,7 +14,14 @@ import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
// bbox = [west, south, east, north]; only load a city file when the point falls in its box.
|
||||
const CITY_FILES = [
|
||||
{ id: 'san-francisco', bbox: [-122.55, 37.70, -122.35, 37.84], loader: () => import('./local_data/neighborhoods/san-francisco.json', { with: { type: 'json' } }) },
|
||||
{
|
||||
id: 'san-francisco',
|
||||
bbox: [-122.55, 37.7, -122.35, 37.84],
|
||||
loader: () =>
|
||||
import('./local_data/neighborhoods/san-francisco.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -27,7 +34,11 @@ const CITY_FILES = [
|
||||
const _cityLoaders = new Map();
|
||||
|
||||
function normalize(s) {
|
||||
return String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return String(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9 ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,10 +56,12 @@ const NAME_ALIASES = new Map([
|
||||
function pointInRing(lon, lat, ring) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0]; const yi = ring[i][1];
|
||||
const xj = ring[j][0]; const yj = ring[j][1];
|
||||
const intersect = ((yi > lat) !== (yj > lat))
|
||||
&& (lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi);
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
const intersect =
|
||||
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
@@ -70,7 +83,10 @@ function containingOuterRing(feature, lon, lat) {
|
||||
if (!pointInRing(lon, lat, outer)) continue;
|
||||
let inHole = false;
|
||||
for (let h = 1; h < poly.length; h++) {
|
||||
if (pointInRing(lon, lat, poly[h])) { inHole = true; break; }
|
||||
if (pointInRing(lon, lat, poly[h])) {
|
||||
inHole = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inHole) return outer;
|
||||
}
|
||||
@@ -108,7 +124,10 @@ async function loadCity(city) {
|
||||
} catch (error) {
|
||||
// The caller falls back to the live resolver ladder, which looks exactly
|
||||
// like "no bundled coverage" — say so once, out loud, instead.
|
||||
console.warn(`[neighborhoods] ${city.id} pack unavailable:`, error?.message || error);
|
||||
console.warn(
|
||||
`[neighborhoods] ${city.id} pack unavailable:`,
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -122,7 +141,13 @@ async function loadCity(city) {
|
||||
* neighborhood name, or null when the point isn't in a covered city / no match.
|
||||
*/
|
||||
export async function lookupNeighborhoodRing(lat, lon, matchName) {
|
||||
const city = CITY_FILES.find((c) => lon >= c.bbox[0] && lat >= c.bbox[1] && lon <= c.bbox[2] && lat <= c.bbox[3]);
|
||||
const city = CITY_FILES.find(
|
||||
(c) =>
|
||||
lon >= c.bbox[0] &&
|
||||
lat >= c.bbox[1] &&
|
||||
lon <= c.bbox[2] &&
|
||||
lat <= c.bbox[3],
|
||||
);
|
||||
if (!city) return null;
|
||||
const feats = await loadCity(city);
|
||||
if (!feats.length) return null;
|
||||
@@ -153,11 +178,18 @@ export async function lookupNeighborhoodRing(lat, lon, matchName) {
|
||||
const containRing = containingOuterRing(f, lon, lat);
|
||||
const ring = containRing || largestOuterRing(f.geometry);
|
||||
if (!ring) continue;
|
||||
const cand = { ring, name: f.properties.name, contains: !!containRing, fwordCount: fWords.length };
|
||||
const cand = {
|
||||
ring,
|
||||
name: f.properties.name,
|
||||
contains: !!containRing,
|
||||
fwordCount: fWords.length,
|
||||
};
|
||||
// Prefer a feature that CONTAINS the point, then the most specific name (most words).
|
||||
if (!best
|
||||
|| (cand.contains && !best.contains)
|
||||
|| (cand.contains === best.contains && cand.fwordCount > best.fwordCount)) {
|
||||
if (
|
||||
!best ||
|
||||
(cand.contains && !best.contains) ||
|
||||
(cand.contains === best.contains && cand.fwordCount > best.fwordCount)
|
||||
) {
|
||||
best = cand;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
|
||||
// Commercial users must remove it or obtain a TeleGeography license.
|
||||
// See DATA_SOURCES.md and the bundled dataset's source.json.
|
||||
const cableUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const landingPointUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
|
||||
export function createBundledCableSource({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
} = {}) {
|
||||
async function read(url, signal) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(url, { signal, cache: 'force-cache' });
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
return json;
|
||||
}
|
||||
return {
|
||||
label: 'TeleGeography',
|
||||
async fetch(signal) {
|
||||
const [cables, landingPoints] = await Promise.all([
|
||||
read(cableUrl, signal),
|
||||
read(landingPointUrl, signal),
|
||||
]);
|
||||
return { cables, landingPoints };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBundledCableSource } from './bundledSource.js';
|
||||
|
||||
test('a failed bundled response releases its body', async () => {
|
||||
let released = 0;
|
||||
const source = createBundledCableSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
body: {
|
||||
cancel: async () => {
|
||||
released++;
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(), /HTTP 503/);
|
||||
assert.equal(released, 2);
|
||||
});
|
||||
|
||||
test('cancellation during JSON parsing cannot return a late snapshot', async () => {
|
||||
const controller = new AbortController();
|
||||
const source = createBundledCableSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
controller.abort();
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(controller.signal), { name: 'AbortError' });
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
export function featureReference(feature) {
|
||||
const geometry = feature?.geometry;
|
||||
if (!geometry) return null;
|
||||
|
||||
const props = feature?.properties || {};
|
||||
const propertyCoords = coordsFromProperty(props.coordinates);
|
||||
if (propertyCoords) {
|
||||
return {
|
||||
lon: propertyCoords[0],
|
||||
lat: propertyCoords[1],
|
||||
};
|
||||
}
|
||||
|
||||
if (geometry.type === 'Point') {
|
||||
const coords = coordsFromPoint(geometry.coordinates);
|
||||
if (!coords) return null;
|
||||
return { lon: coords[0], lat: coords[1] };
|
||||
}
|
||||
|
||||
const coords = [];
|
||||
collectLonLat(geometry.coordinates, coords);
|
||||
if (!coords.length) return null;
|
||||
|
||||
let lonSum = 0;
|
||||
let latSum = 0;
|
||||
for (const [lon, lat] of coords) {
|
||||
lonSum += lon;
|
||||
latSum += lat;
|
||||
}
|
||||
return {
|
||||
lon: lonSum / coords.length,
|
||||
lat: latSum / coords.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function coordsFromProperty(value) {
|
||||
if (!Array.isArray(value) || value.length < 2) return null;
|
||||
const lon = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
export function coordsFromPoint(value) {
|
||||
if (!Array.isArray(value) || value.length < 2) return null;
|
||||
const lon = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
export function collectLonLat(value, out) {
|
||||
if (!Array.isArray(value)) return;
|
||||
if (typeof value[0] === 'number' && typeof value[1] === 'number') {
|
||||
const lon = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (Number.isFinite(lon) && Number.isFinite(lat)) {
|
||||
out.push([lon, lat]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of value) collectLonLat(child, out);
|
||||
}
|
||||
|
||||
export function featureLabel(feature) {
|
||||
const props = feature?.properties || {};
|
||||
return String(props.name || props.id || feature?.id || '').trim();
|
||||
}
|
||||
|
||||
export function normalizeFeatures(json, kind) {
|
||||
const features = Array.isArray(json?.features) ? json.features : [];
|
||||
return features.map((feature, index) => {
|
||||
const id = feature?.properties?.id || feature?.id || `${kind}-${index}`;
|
||||
return {
|
||||
...feature,
|
||||
id: String(id),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createState } from './state.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { defaultSweepClock } from './policy.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
export function createSubmarineCableLayer({
|
||||
source,
|
||||
overlayHost,
|
||||
screenSpaceEventHandlerFactory,
|
||||
mapStackEventTarget = null,
|
||||
sweepClock = defaultSweepClock,
|
||||
}) {
|
||||
if (!source?.fetch || !source.label)
|
||||
throw new TypeError(
|
||||
'A cable source with a label and fetch(signal) is required',
|
||||
);
|
||||
const state = createState({ overlayHost, sweepClock });
|
||||
const parts = {};
|
||||
const context = {
|
||||
state,
|
||||
parts,
|
||||
source,
|
||||
screenSpaceEventHandlerFactory,
|
||||
mapStackEventTarget,
|
||||
};
|
||||
parts.rendering = createRendering(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.interaction = createInteraction(context);
|
||||
return createLifecycle(context);
|
||||
}
|
||||
export {
|
||||
selectCableReferenceLabelWinners,
|
||||
cableReferencePriority,
|
||||
createCableOverlayEntry,
|
||||
createCableOverlayPublisher,
|
||||
createCableReferenceSweepGate,
|
||||
updateCableReferenceStem,
|
||||
} from './overlay.js';
|
||||
export {
|
||||
cableClassificationTypeForStack,
|
||||
cableClassificationTypeForScene,
|
||||
applyTranslucentMarkerBlend,
|
||||
} from './surface.js';
|
||||
export {
|
||||
CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
CABLE_OVERLAY_SOURCE_ID,
|
||||
CABLE_OVERLAY_COLLISION_CAPACITY,
|
||||
CABLE_STEM_TIP_EPSILON_M,
|
||||
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
||||
CABLE_SWEEP_MOTION_EPSILON_M,
|
||||
CABLE_LABEL_DEPTH_DECISION,
|
||||
} from './policy.js';
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
featureLabel,
|
||||
featureReference,
|
||||
normalizeFeatures,
|
||||
} from './geometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createIngestion({ state, parts, source }) {
|
||||
async function load(viewer) {
|
||||
if (state._loading || state._loaded) return;
|
||||
|
||||
state._loading = true;
|
||||
state._error = null;
|
||||
state._loadingLabel = 'loading...';
|
||||
// Ownership token (the militaryAwareness activationId pattern): a
|
||||
// disable/abort followed by a fresh enable starts a NEWER load while this
|
||||
// one is still settling. The stale load must bail after every await and
|
||||
// must never touch shared lifecycle state or the viewer it no longer
|
||||
// owns — clearing `_loading`/`_abort` for the successor is how duplicate
|
||||
// or post-destroy data sources got added.
|
||||
const generation = ++state._loadGeneration;
|
||||
const abort = new AbortController();
|
||||
state._abort = abort;
|
||||
const owns = () =>
|
||||
generation === state._loadGeneration && !abort.signal.aborted;
|
||||
|
||||
try {
|
||||
let cableJson = state._cachedCableJson;
|
||||
let landingJson = state._cachedLandingJson;
|
||||
if (!cableJson || !landingJson) {
|
||||
({ cables: cableJson, landingPoints: landingJson } = await source.fetch(
|
||||
abort.signal,
|
||||
));
|
||||
if (!owns()) return;
|
||||
if (
|
||||
!Array.isArray(cableJson?.features) ||
|
||||
!Array.isArray(landingJson?.features)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'A cable source must return cables and landingPoints GeoJSON collections',
|
||||
);
|
||||
}
|
||||
state._cachedCableJson = cableJson;
|
||||
state._cachedLandingJson = landingJson;
|
||||
}
|
||||
|
||||
const cableFeatures = normalizeFeatures(cableJson, 'cable');
|
||||
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
||||
|
||||
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
{ type: 'FeatureCollection', features: cableFeatures },
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.cableColor.withAlpha(0.95),
|
||||
fill: state.cableColor.withAlpha(0.18),
|
||||
strokeWidth: 2,
|
||||
markerColor: state.cableColor,
|
||||
markerSize: 6,
|
||||
},
|
||||
);
|
||||
if (!owns()) return;
|
||||
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
{ type: 'FeatureCollection', features: landingFeatures },
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.landingColor.withAlpha(0.9),
|
||||
fill: state.landingColor.withAlpha(0.35),
|
||||
strokeWidth: 2,
|
||||
markerColor: state.landingColor,
|
||||
markerSize: 6,
|
||||
},
|
||||
);
|
||||
if (!owns()) return;
|
||||
|
||||
cableDataSource.name = `${source.label} Submarine Cables`;
|
||||
landingDataSource.name = `${source.label} Landing Points`;
|
||||
const referenceDataSource = new Cesium.CustomDataSource(
|
||||
`${source.label} Cable References`,
|
||||
);
|
||||
const addedSources = [
|
||||
cableDataSource,
|
||||
landingDataSource,
|
||||
referenceDataSource,
|
||||
];
|
||||
|
||||
// dataSources.add() is itself an await point: Cesium mutates the
|
||||
// collection on a DEFERRED tick, so a disable/destroy racing this
|
||||
// window finds nothing to remove and the adds still materialize
|
||||
// afterwards. Await every add to settlement (allSettled so a partial
|
||||
// failure cannot leave an unawaited add materializing later), then
|
||||
// re-check ownership. Pinned teardown mechanism: disable/destroy never
|
||||
// wait for in-flight adds — the stale generation's own post-await
|
||||
// check here guarantees cleanup by removing exactly the sources THIS
|
||||
// generation added.
|
||||
const addResults = await Promise.allSettled([
|
||||
viewer.dataSources.add(cableDataSource),
|
||||
viewer.dataSources.add(landingDataSource),
|
||||
viewer.dataSources.add(referenceDataSource),
|
||||
]);
|
||||
const rejectedAdd = addResults.find(
|
||||
(result) => result.status === 'rejected',
|
||||
);
|
||||
if (!owns() || rejectedAdd) {
|
||||
// Compensate: every add has settled by now, so removal is effective
|
||||
// (a remove inside the deferred window would have been a no-op).
|
||||
// Remove EVERY generation-local source regardless of settle status:
|
||||
// Cesium's add() pushes into the collection BEFORE raising
|
||||
// dataSourceAdded, so a REJECTED add may still have landed its
|
||||
// mutation. remove() of a never-added source is a harmless no-op.
|
||||
for (const source of addedSources) {
|
||||
try {
|
||||
viewer.dataSources.remove(source, true);
|
||||
} catch {
|
||||
/* collection gone */
|
||||
}
|
||||
}
|
||||
if (owns() && rejectedAdd) throw rejectedAdd.reason;
|
||||
return;
|
||||
}
|
||||
|
||||
// Await-free commit: the viewer accepted all three sources and this
|
||||
// load still owns the lifecycle.
|
||||
state._cableDataSource = cableDataSource;
|
||||
state._landingDataSource = landingDataSource;
|
||||
state._referenceDataSource = referenceDataSource;
|
||||
|
||||
const cableEntities = state._cableDataSource.entities.values;
|
||||
const landingEntities = state._landingDataSource.entities.values;
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
|
||||
cableEntities.forEach((entity, index) => {
|
||||
const feature = cableFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
|
||||
parts.rendering.styleCableEntity(entity, feature);
|
||||
parts.interaction.registerPickEntity(entity, {
|
||||
kind: 'cable',
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
});
|
||||
state._surfaceRecords.push({
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
kind: 'cable',
|
||||
color: state.cableColor,
|
||||
feature,
|
||||
});
|
||||
});
|
||||
|
||||
landingEntities.forEach((entity, index) => {
|
||||
const feature = landingFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
|
||||
parts.rendering.styleLandingEntity(entity, feature);
|
||||
parts.interaction.registerPickEntity(entity, {
|
||||
kind: 'landing-point',
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
});
|
||||
state._surfaceRecords.push({
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
kind: 'landing-point',
|
||||
color: state.landingColor,
|
||||
feature,
|
||||
});
|
||||
});
|
||||
|
||||
state._count = cableFeatures.length + landingFeatures.length;
|
||||
state._loaded = true;
|
||||
state._lastUpdate = Date.now();
|
||||
state._loadingLabel = '';
|
||||
parts.rendering.updateVisibility();
|
||||
state._referenceSweepGate.markDirty();
|
||||
viewer.scene.requestRender?.();
|
||||
} catch (error) {
|
||||
// A stale or aborted load reports nothing: its failure belongs to a
|
||||
// lifecycle the user already left.
|
||||
if (owns() && error?.name !== 'AbortError') {
|
||||
state._error = error?.message || 'TeleGeography load failed';
|
||||
console.warn(
|
||||
'[Data:telegeography-submarine-cables]',
|
||||
state._error,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Only the OWNING load may clear the shared lifecycle. A stale load
|
||||
// clearing `_loading`/`_abort` would hand a later enable/update a
|
||||
// duplicate load while the real one is still in flight.
|
||||
if (generation === state._loadGeneration) {
|
||||
state._loading = false;
|
||||
if (state._abort === abort) state._abort = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { load };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
|
||||
function registerPickEntity(entity, info) {
|
||||
entity.__gevTeleGeography = info;
|
||||
state._pickByEntity.set(entity, info);
|
||||
}
|
||||
|
||||
function beginInteraction(viewer) {
|
||||
if (state._clickHandler) return;
|
||||
state._clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
|
||||
state._clickHandler.setInputAction((click) => {
|
||||
if (!state._enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const record = resolvePickRecord(picked);
|
||||
if (!record?.reference) return;
|
||||
flyToReference(viewer, record.reference);
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function resolvePickRecord(picked) {
|
||||
if (!picked) return null;
|
||||
const primitive = picked.primitive;
|
||||
if (primitive) {
|
||||
const primitiveInfo = state._pickByEntity.get(primitive);
|
||||
if (primitiveInfo) return primitiveInfo;
|
||||
if (primitive.__gevTeleGeography) return primitive.__gevTeleGeography;
|
||||
if (
|
||||
primitive.id &&
|
||||
typeof primitive.id === 'object' &&
|
||||
primitive.id.reference
|
||||
) {
|
||||
return primitive.id;
|
||||
}
|
||||
}
|
||||
|
||||
const entity = picked.id;
|
||||
if (entity) {
|
||||
const entityInfo = state._pickByEntity.get(entity);
|
||||
if (entityInfo) return entityInfo;
|
||||
if (entity.__gevTeleGeography) return entity.__gevTeleGeography;
|
||||
if (entity.id && typeof entity.id === 'object' && entity.id.reference) {
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function flyToReference(viewer, reference) {
|
||||
if (!viewer || !reference) return;
|
||||
const destination = Cesium.Cartesian3.fromDegrees(
|
||||
reference.lon,
|
||||
reference.lat,
|
||||
6500,
|
||||
);
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.flyTo({
|
||||
destination,
|
||||
orientation: {
|
||||
heading: viewer.camera.heading || 0,
|
||||
pitch: Cesium.Math.toRadians(-52),
|
||||
roll: 0,
|
||||
},
|
||||
duration: 1.35,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
}
|
||||
return {
|
||||
registerPickEntity,
|
||||
beginInteraction,
|
||||
resolvePickRecord,
|
||||
flyToReference,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
cableClassificationTypeForScene,
|
||||
cableClassificationTypeForStack,
|
||||
} from './surface.js';
|
||||
|
||||
export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
|
||||
return {
|
||||
id: 'telegeography-submarine-cables',
|
||||
name: 'Submarine Cables',
|
||||
icon: '≋',
|
||||
source: source.label,
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 500,
|
||||
|
||||
init(viewer) {
|
||||
state._viewer = viewer;
|
||||
state._classificationType = cableClassificationTypeForScene(
|
||||
viewer?.scene,
|
||||
);
|
||||
if (!state._mapStackListener && mapStackEventTarget?.addEventListener) {
|
||||
state._mapStackListener = (event) => {
|
||||
parts.rendering.applyCableClassification(
|
||||
event?.detail?.activeId
|
||||
? cableClassificationTypeForStack(event.detail.activeId)
|
||||
: cableClassificationTypeForScene(state._viewer?.scene),
|
||||
);
|
||||
};
|
||||
mapStackEventTarget.addEventListener(
|
||||
'gev:map-stack-changed',
|
||||
state._mapStackListener,
|
||||
);
|
||||
}
|
||||
parts.interaction.beginInteraction(viewer);
|
||||
if (!state._preRenderRemover) {
|
||||
state._preRenderRemover = viewer.scene.preRender.addEventListener(
|
||||
() => {
|
||||
// The camera is handed to the gate so tracked/orbit cameras — which
|
||||
// never emit moveEnd — reach the motion fallback. No render is
|
||||
// requested for a fallback sweep: it only fires while the camera is
|
||||
// already moving, so frames are flowing by construction.
|
||||
if (
|
||||
!state._enabled ||
|
||||
!state._loaded ||
|
||||
!state._referenceSweepGate.shouldRun(viewer.camera)
|
||||
)
|
||||
return;
|
||||
parts.rendering.updateReferenceVisibility();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!state._moveEndRemover) {
|
||||
state._moveEndRemover = viewer.camera.moveEnd.addEventListener(() => {
|
||||
if (!state._enabled) return;
|
||||
state._referenceSweepGate.markDirty();
|
||||
viewer.scene.requestRender?.();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
enable(viewer) {
|
||||
state._enabled = true;
|
||||
parts.rendering.updateVisibility();
|
||||
state._overlayPublisher.show();
|
||||
// A hide() cleared the host source, so an identical cohort must still
|
||||
// republish on the next sweep.
|
||||
parts.rendering.resetPublishSignature();
|
||||
state._referenceSweepGate.markDirty();
|
||||
// The gate is dirty-only; ask for the frame its sweep needs in case the
|
||||
// camera is parked and nothing else is rendering.
|
||||
(viewer || state._viewer)?.scene?.requestRender?.();
|
||||
void parts.ingestion.load(viewer || state._viewer);
|
||||
},
|
||||
|
||||
disable() {
|
||||
state._enabled = false;
|
||||
state._overlayPublisher.hide();
|
||||
parts.rendering.resetPublishSignature();
|
||||
if (state._loading && state._abort) {
|
||||
state._abort.abort();
|
||||
state._loading = false;
|
||||
state._loadingLabel = '';
|
||||
}
|
||||
// Free the entities rather than hide them; enable() rebuilds from the
|
||||
// cached JSON. An in-flight load's own post-await ownership check
|
||||
// removes whatever that stale generation adds later.
|
||||
parts.rendering.releaseDataSources(state._viewer);
|
||||
},
|
||||
|
||||
update(viewer) {
|
||||
return parts.ingestion.load(viewer || state._viewer);
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
if (state._abort) state._abort.abort();
|
||||
parts.rendering.releaseDataSources(viewer || state._viewer);
|
||||
state._cachedCableJson = null;
|
||||
state._cachedLandingJson = null;
|
||||
// hide() clears every published host entry and goes invisible while
|
||||
// keeping the publisher reusable — the legacy layer supported re-init
|
||||
// after destroy, and hidden publishers already drop late publishes.
|
||||
// Pinned by the direct-destroy host test: removing this line leaves a
|
||||
// visible overlay source with orphan labels.
|
||||
state._overlayPublisher.hide();
|
||||
if (state._clickHandler) {
|
||||
state._clickHandler.destroy();
|
||||
state._clickHandler = null;
|
||||
}
|
||||
if (state._preRenderRemover) {
|
||||
state._preRenderRemover();
|
||||
state._preRenderRemover = null;
|
||||
}
|
||||
if (state._moveEndRemover) {
|
||||
state._moveEndRemover();
|
||||
state._moveEndRemover = null;
|
||||
}
|
||||
if (state._mapStackListener && mapStackEventTarget?.removeEventListener) {
|
||||
mapStackEventTarget.removeEventListener(
|
||||
'gev:map-stack-changed',
|
||||
state._mapStackListener,
|
||||
);
|
||||
state._mapStackListener = null;
|
||||
}
|
||||
state._viewer = null;
|
||||
state._enabled = false;
|
||||
state._loading = false;
|
||||
state._loaded = false;
|
||||
state._count = 0;
|
||||
state._error = null;
|
||||
state._lastUpdate = null;
|
||||
state._loadingLabel = '';
|
||||
state._cableDataSource = null;
|
||||
state._landingDataSource = null;
|
||||
state._referenceDataSource = null;
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceLabelCount = 0;
|
||||
state._publishScratch.length = 0;
|
||||
state._markerBlendDone = false;
|
||||
state._markerBlendInvariantWarned = false;
|
||||
parts.rendering.resetPublishSignature();
|
||||
state._referenceSweepGate.reset();
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: state._count,
|
||||
lastUpdate: state._lastUpdate,
|
||||
loading: state._loading,
|
||||
loadingLabel: state._loadingLabel,
|
||||
error: state._error,
|
||||
referenceLabelCount: state._referenceLabelCount,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
|
||||
BASE_CABLE_COLOR,
|
||||
BASE_LANDING_COLOR,
|
||||
CABLE_OVERLAY_SOURCE_ID,
|
||||
CABLE_OVERLAY_COLLISION_CAPACITY,
|
||||
defaultSweepClock,
|
||||
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
||||
CABLE_SWEEP_MOTION_EPSILON_M,
|
||||
STEM_TARGET_PX,
|
||||
CABLE_STEM_TIP_EPSILON_SQ,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Select the nearest visible cable references without exceeding the bounded
|
||||
* cohort cap the shared host is offered.
|
||||
* @param {object[]} records Records annotated with `visible` and `distanceM`.
|
||||
* @param {number} [limit]
|
||||
* @returns {object[]}
|
||||
*/
|
||||
|
||||
export function selectCableReferenceLabelWinners(
|
||||
records,
|
||||
limit = CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
) {
|
||||
const cap = Math.max(
|
||||
0,
|
||||
Math.min(CABLE_REFERENCE_LABEL_WINNER_CAP, Math.floor(Number(limit) || 0)),
|
||||
);
|
||||
if (!Array.isArray(records) || cap === 0) return [];
|
||||
return records
|
||||
.filter(
|
||||
(record) =>
|
||||
record?.visible === true &&
|
||||
record.label &&
|
||||
Number.isFinite(record.distanceM) &&
|
||||
record.distanceM <= CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.distanceM - b.distanceM ||
|
||||
String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')),
|
||||
)
|
||||
.slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance-derived arbiter priority. Quantized to 50 km buckets so a slowly
|
||||
* moving camera does not reshuffle equal-rank labels on every sweep; nearer
|
||||
* references still win collisions, matching the shipped nearest-first feel.
|
||||
* @param {number} distanceM
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function cableReferencePriority(distanceM) {
|
||||
const distance = Number.isFinite(distanceM)
|
||||
? Math.max(0, distanceM)
|
||||
: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M;
|
||||
return 1000 - Math.round(distance / 50000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one shared-host ambient label for a cable or landing-point reference.
|
||||
* The entry stays attached to the record's mutable stem-tip Cartesian and is
|
||||
* created once per record; only `priority` is refreshed per sweep.
|
||||
* `interactive: false` follows the infrastructure precedent — the depth-tested
|
||||
* native point/stem/line remains the click surface, and a non-interactive
|
||||
* label keeps the host's per-frame accessibility/hit sync allocation-free.
|
||||
* @param {object} record Reference record ({ id, kind, label, tip }).
|
||||
* @returns {object}
|
||||
*/
|
||||
|
||||
export function createCableOverlayEntry(record) {
|
||||
const kind = record?.kind === 'landing-point' ? 'landing-point' : 'cable';
|
||||
return {
|
||||
id: String(record?.id || ''),
|
||||
position: record?.tip,
|
||||
variant: 'label',
|
||||
title: String(record?.label || ''),
|
||||
accent: kind === 'cable' ? BASE_CABLE_COLOR : BASE_LANDING_COLOR,
|
||||
priority: cableReferencePriority(record?.distanceM),
|
||||
collisionGroup: 'ambient-label',
|
||||
paintLane: 'ambient-label',
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: 0.7,
|
||||
// Former native scaleByDistance curve, unchanged: 1.0× at 250 km → 0.62×
|
||||
// at 9,000 km.
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 14,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the cable layer's host visibility and entry lifecycle to the shared
|
||||
* world overlay (same contract as the infrastructure publisher). The layer
|
||||
* supports re-init after destroy (legacy contract), so its teardown path is
|
||||
* hide() — clear the published source and go invisible while staying
|
||||
* reusable. There is deliberately no permanent-destroy method: a hidden
|
||||
* publisher already drops late publishes until the next show().
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.sourceId]
|
||||
* @param {object} [options.host] Test seam for the three host lifecycle calls.
|
||||
* @returns {{show:function():void,publish:function(object[]):void,hide:function():void}}
|
||||
*/
|
||||
|
||||
export function createCableOverlayPublisher({
|
||||
sourceId = CABLE_OVERLAY_SOURCE_ID,
|
||||
host,
|
||||
} = {}) {
|
||||
let visible = false;
|
||||
let published = false;
|
||||
const sourceOptions = {
|
||||
cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
|
||||
moving: false,
|
||||
};
|
||||
|
||||
return {
|
||||
show() {
|
||||
if (visible) return;
|
||||
visible = true;
|
||||
host.setVisible(sourceId, true);
|
||||
},
|
||||
publish(entries) {
|
||||
if (!visible) return;
|
||||
host.setEntries(sourceId, entries, sourceOptions);
|
||||
published = entries.length > 0;
|
||||
},
|
||||
hide() {
|
||||
if (published) host.clearSource(sourceId);
|
||||
if (visible) host.setVisible(sourceId, false);
|
||||
visible = false;
|
||||
published = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty gate for the reference sweep. TWO dirty conditions, no timer:
|
||||
*
|
||||
* 1. EVENT (primary): the sweep runs on the first frame after a camera
|
||||
* `moveEnd`, layer enable, or load completion. The former 500 ms timer
|
||||
* path re-sized stems while frames were flowing, and every re-size
|
||||
* rebuilt the 2,629-instance batched translucent stem primitive
|
||||
* (measured: 12 rebuilds in 6 s with the replacement primitive unready
|
||||
* for 46/360 frames — the felt hitch). Stem lengths are therefore
|
||||
* deliberately stale mid-drag/mid-flight and settle on release;
|
||||
* reference visibility and label winners go stale the same way, and the
|
||||
* shared host keeps reprojecting the published positions per frame.
|
||||
* 2. MOTION FALLBACK: `moveEnd` covers drags, one-shot `setView`, and voice
|
||||
* `flyTo`, but a TRACKED-entity follow camera or an orbit never emits it,
|
||||
* so condition 1 alone starves those sessions forever (frozen horizon
|
||||
* visibility, frozen stems, stale label winners for as long as tracking
|
||||
* lasts). While frames render, the gate therefore samples the camera at
|
||||
* most once per `CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS` and re-arms itself
|
||||
* only when the camera has actually travelled past
|
||||
* `CABLE_SWEEP_MOTION_EPSILON_M` since the last sweep.
|
||||
*
|
||||
* A PARKED camera still costs exactly zero sweeps: the probe compares against
|
||||
* the last swept position (never the previous frame), so neither a frozen
|
||||
* camera nor sub-epsilon numeric jitter ever accumulates into a sweep, and
|
||||
* the per-frame cost of the fallback is one clock read plus one subtraction.
|
||||
* Position is the only motion channel because it is the only camera input the
|
||||
* sweep consumes — the horizon occluder, the reference distances, the stem
|
||||
* sizing, and the winner ranks all derive from `positionWC` alone, so a
|
||||
* heading-only rotation cannot change a single sweep output and deliberately
|
||||
* does not spend one.
|
||||
* @param {object} [options]
|
||||
* @param {function():number} [options.now] Monotonic clock (test seam).
|
||||
* @param {number} [options.probeIntervalMs]
|
||||
* @param {number} [options.motionEpsilonM]
|
||||
*/
|
||||
|
||||
export function createCableReferenceSweepGate({
|
||||
now = defaultSweepClock,
|
||||
probeIntervalMs = CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
||||
motionEpsilonM = CABLE_SWEEP_MOTION_EPSILON_M,
|
||||
} = {}) {
|
||||
const motionEpsilonSq = motionEpsilonM ** 2;
|
||||
// Preallocated: the probe clones into it, so a moving camera allocates
|
||||
// nothing per frame.
|
||||
const lastSweptPosition = new Cesium.Cartesian3();
|
||||
let hasSweptPosition = false;
|
||||
let dirty = true;
|
||||
let lastProbeAt = -Infinity;
|
||||
|
||||
/** Arm the next probe window and adopt the camera position being swept. */
|
||||
function accept(camera, time) {
|
||||
lastProbeAt = time;
|
||||
const position = camera?.positionWC;
|
||||
hasSweptPosition = Boolean(position);
|
||||
if (position) Cesium.Cartesian3.clone(position, lastSweptPosition);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
markDirty() {
|
||||
dirty = true;
|
||||
},
|
||||
/**
|
||||
* @param {Cesium.Camera} [camera] Live camera; omit to disable the motion
|
||||
* fallback entirely (pure dirty-only gate).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldRun(camera) {
|
||||
if (dirty) {
|
||||
dirty = false;
|
||||
return accept(camera, now());
|
||||
}
|
||||
if (!camera) return false;
|
||||
// Cheapest possible per-frame path: a clock read and a subtraction. The
|
||||
// camera getter is only touched once the probe window has actually
|
||||
// opened, and the window re-arms whether or not the probe sweeps.
|
||||
const time = now();
|
||||
if (time - lastProbeAt < probeIntervalMs) return false;
|
||||
lastProbeAt = time;
|
||||
const position = camera.positionWC;
|
||||
if (!position) return false;
|
||||
if (
|
||||
hasSweptPosition &&
|
||||
Cesium.Cartesian3.distanceSquared(position, lastSweptPosition) <=
|
||||
motionEpsilonSq
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return accept(camera, time);
|
||||
},
|
||||
reset() {
|
||||
dirty = true;
|
||||
lastProbeAt = -Infinity;
|
||||
hasSweptPosition = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute one staticized stem on the sweep cadence. Replaces the former
|
||||
* per-frame `CallbackProperty` pair: constant properties are redefined only
|
||||
* when the camera-scaled tip moved beyond the settle epsilon, and the two
|
||||
* preallocated position buffers alternate so each real change raises exactly
|
||||
* one geometry notification (the localGeojson double-buffer pattern).
|
||||
* @param {object} record Reference record with entity/base/tip/nextTip/buffers.
|
||||
* @param {Cesium.Cartesian3} cameraPositionWC
|
||||
* @param {number} canvasHeight CSS-pixel canvas height.
|
||||
* @param {number} fov Camera frustum field of view (radians).
|
||||
* @returns {boolean} True when the stem geometry was redefined.
|
||||
*/
|
||||
|
||||
export function updateCableReferenceStem(
|
||||
record,
|
||||
cameraPositionWC,
|
||||
canvasHeight,
|
||||
fov,
|
||||
) {
|
||||
const distance = Cesium.Cartesian3.distance(cameraPositionWC, record.base);
|
||||
const effectiveDistance = Math.max(distance, 5000);
|
||||
const height = canvasHeight || 1080;
|
||||
const fieldOfView = fov || Math.PI / 3;
|
||||
const metersPerPixelFactor = (2 * Math.tan(fieldOfView / 2)) / height;
|
||||
const tipHeight = Math.max(
|
||||
700,
|
||||
Math.min(85000, effectiveDistance * metersPerPixelFactor * STEM_TARGET_PX),
|
||||
);
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.reference.lon,
|
||||
record.reference.lat,
|
||||
tipHeight,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
record.nextTip,
|
||||
);
|
||||
if (
|
||||
Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <=
|
||||
CABLE_STEM_TIP_EPSILON_SQ
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
Cesium.Cartesian3.clone(record.nextTip, record.tip);
|
||||
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
|
||||
const stemPositions =
|
||||
record.stemPositionBuffers[record.stemPositionBufferIndex];
|
||||
stemPositions[0] = record.base;
|
||||
stemPositions[1] = record.tip;
|
||||
record.entity.position.setValue(record.tip);
|
||||
record.entity.polyline.positions.setValue(stemPositions);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clampLabel(value, maxLength = 34) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!text) return '';
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, Math.max(1, maxLength - 3))}...`;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export const BASE_CABLE_COLOR = '#39d5ff';
|
||||
|
||||
export const BASE_LANDING_COLOR = '#8fffd2';
|
||||
|
||||
export const STEM_TARGET_PX = 66;
|
||||
|
||||
export const CABLE_REFERENCE_LABEL_MAX_DISTANCE_M = 9000000;
|
||||
|
||||
/** Bounded nearest-visible cohort the layer offers the shared host. */
|
||||
|
||||
export const CABLE_REFERENCE_LABEL_WINNER_CAP = 160;
|
||||
|
||||
/** Shared world-overlay source id (matches the layer id). */
|
||||
|
||||
export const CABLE_OVERLAY_SOURCE_ID = 'telegeography-submarine-cables';
|
||||
|
||||
/** Shared ambient-label paint budget, matching the infrastructure sources. */
|
||||
|
||||
export const CABLE_OVERLAY_COLLISION_CAPACITY = 96;
|
||||
|
||||
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
||||
|
||||
export const CABLE_STEM_TIP_EPSILON_M = 0.5;
|
||||
|
||||
export const CABLE_STEM_TIP_EPSILON_SQ = CABLE_STEM_TIP_EPSILON_M ** 2;
|
||||
|
||||
/**
|
||||
* Motion-fallback probe window. Cameras that never emit `moveEnd` (tracked
|
||||
* entities, orbits) would otherwise starve the dirty-only sweep forever, so
|
||||
* the gate samples camera motion at most this often while frames render.
|
||||
*/
|
||||
|
||||
export const CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS = 2000;
|
||||
|
||||
/**
|
||||
* Camera displacement that counts as meaningful motion for that probe. Stem
|
||||
* tip height is ~8.5% of camera distance at the shipped 66 px target, so
|
||||
* 250 m of camera travel moves a tip ~21 m; below that the frozen sweep is
|
||||
* visually indistinguishable and `moveEnd` settles it exactly.
|
||||
*/
|
||||
|
||||
export const CABLE_SWEEP_MOTION_EPSILON_M = 250;
|
||||
|
||||
/**
|
||||
* Phase-5 depth decision, REVISED 2026-08-18: Option 2. Cable reference text
|
||||
* now renders in the shared world-overlay host (horizon-cull + keyhole fade,
|
||||
* no per-label tile depth test), superseding the 2026-08-02 Option-1 native
|
||||
* `LabelGraphics` exception. The native path evaluated 2 `CallbackProperty`
|
||||
* channels per reference entity per frame (5,258 across the 2,629-reference
|
||||
* dataset) and re-batched a 160-label `LabelCollection` on every sweep — the
|
||||
* layer alone cost ~9.5 ms/frame during camera motion. The depth cue this
|
||||
* trades away (photoreal tiles occluding label TEXT at low, city-level
|
||||
* cameras) matches the sibling dams/datacenters sources, which shipped
|
||||
* host-composited under the same ruling; the anchor points/stems remain
|
||||
* Cesium-native and depth-tested. See
|
||||
* the world-overlay consolidation design notes ("Depth-testing
|
||||
* decision") for both dated decisions.
|
||||
*/
|
||||
|
||||
export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
|
||||
option: 2,
|
||||
decidedAt: '2026-08-18',
|
||||
supersedes: '2026-08-02',
|
||||
depthTested: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion
|
||||
* Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that
|
||||
* is not photoreal": an id this module has never heard of is UNKNOWN, and
|
||||
* unknown must reach the documented BOTH fallback rather than being asserted
|
||||
* onto the terrain surface. A stack added to `MAP_STACKS` without being added
|
||||
* here therefore degrades to the safe pre-optimization behavior (visible on
|
||||
* every surface) instead of vanishing — and the per-stack unit test walks the
|
||||
* real `MAP_STACKS` so the omission is caught loudly.
|
||||
*/
|
||||
|
||||
export const CABLE_GLOBE_STACK_IDS = Object.freeze(
|
||||
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
|
||||
);
|
||||
|
||||
/** EntityCluster's private marker collections and their required types. */
|
||||
|
||||
export const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([
|
||||
Object.freeze({
|
||||
key: '_billboardCollection',
|
||||
type: Cesium.BillboardCollection,
|
||||
}),
|
||||
Object.freeze({
|
||||
key: '_pointCollection',
|
||||
type: Cesium.PointPrimitiveCollection,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Monotonic clock for the sweep gate's motion probe. A hoisted declaration:
|
||||
* the module's `export default createTeleGeographySubmarineCableLayer()`
|
||||
* runs before this point in source order.
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function defaultSweepClock() {
|
||||
return typeof performance === 'object' &&
|
||||
typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
probeTranslucentMarkerBlend,
|
||||
commitTranslucentMarkerBlend,
|
||||
} from './surface.js';
|
||||
import {
|
||||
clampLabel,
|
||||
createCableOverlayEntry,
|
||||
updateCableReferenceStem,
|
||||
selectCableReferenceLabelWinners,
|
||||
cableReferencePriority,
|
||||
} from './overlay.js';
|
||||
|
||||
export function createRendering({ state }) {
|
||||
function resetPublishSignature() {
|
||||
state._lastPublishedIds.length = 0;
|
||||
state._lastPublishedPriorities.length = 0;
|
||||
state._lastPublishedCount = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the three data sources from the viewer and forget the built
|
||||
* entities. Hiding them (`show = false`) is NOT enough: Cesium's
|
||||
* DataSourceDisplay still walks every visualizer of a hidden source on
|
||||
* every frame, and the thousands of hidden polylines/billboards keep
|
||||
* costing CPU and GC pressure long after the user toggled the layer off —
|
||||
* that is the "everything gets sluggish after I turn layers off" report.
|
||||
* The fetched JSON stays cached, so the next enable rebuilds without a
|
||||
* network round trip or a re-parse.
|
||||
*/
|
||||
|
||||
function releaseDataSources(viewer) {
|
||||
const sources = [
|
||||
state._cableDataSource,
|
||||
state._landingDataSource,
|
||||
state._referenceDataSource,
|
||||
];
|
||||
state._cableDataSource = null;
|
||||
state._landingDataSource = null;
|
||||
state._referenceDataSource = null;
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceLabelCount = 0;
|
||||
state._publishScratch.length = 0;
|
||||
// The blend pass targets the collections being dropped; the rebuilt
|
||||
// sources need their own pass.
|
||||
state._markerBlendDone = false;
|
||||
state._loaded = false;
|
||||
state._referenceSweepGate.reset();
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
try {
|
||||
viewer?.dataSources?.remove(source, true);
|
||||
} catch {
|
||||
/* collection gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
if (state._cableDataSource) state._cableDataSource.show = state._enabled;
|
||||
if (state._landingDataSource)
|
||||
state._landingDataSource.show = state._enabled;
|
||||
if (state._referenceDataSource)
|
||||
state._referenceDataSource.show = state._enabled;
|
||||
}
|
||||
|
||||
function styleCableEntity(entity, feature) {
|
||||
if (!entity?.polyline) return;
|
||||
const color = feature?.properties?.color
|
||||
? Cesium.Color.fromCssColorString(String(feature.properties.color))
|
||||
: state.cableColor;
|
||||
entity.polyline.material = color.withAlpha(0.92);
|
||||
entity.polyline.width = 2.5;
|
||||
entity.polyline.clampToGround = true;
|
||||
entity.polyline.classificationType = state._classificationType;
|
||||
entity.show = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the single translucent blend pass to the landing-billboard and
|
||||
* reference-point collections. Landing entities carry only billboards and
|
||||
* reference entities only points, so completion is one applied collection
|
||||
* per source; a failed shape probe disables the optimization permanently
|
||||
* for this session and says so loudly in dev builds.
|
||||
* Both sources are probed BEFORE either is committed, so the helper's
|
||||
* "defaults untouched on shape drift" fallback holds across the pair too —
|
||||
* a reference-source failure can never leave the landing source already
|
||||
* forced to TRANSLUCENT.
|
||||
*/
|
||||
|
||||
function applyMarkerBlendOnce() {
|
||||
const landing = probeTranslucentMarkerBlend(state._landingDataSource);
|
||||
const reference = probeTranslucentMarkerBlend(state._referenceDataSource);
|
||||
if (landing.invariantFailed || reference.invariantFailed) {
|
||||
state._markerBlendDone = true;
|
||||
if (!state._markerBlendInvariantWarned && import.meta.env?.DEV === true) {
|
||||
state._markerBlendInvariantWarned = true;
|
||||
console.error(
|
||||
'[Data:telegeography-submarine-cables] EntityCluster marker-collection ' +
|
||||
'shape changed — single-pass translucent blend skipped; markers fall ' +
|
||||
"back to Cesium's default two-pass blend.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const landingBlend = commitTranslucentMarkerBlend(landing);
|
||||
const referenceBlend = commitTranslucentMarkerBlend(reference);
|
||||
if (landingBlend.applied > 0 && referenceBlend.applied > 0)
|
||||
state._markerBlendDone = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-classify every cable ground line for the active surface. One batched
|
||||
* ground-primitive rebuild per stack switch — never per frame.
|
||||
* @param {Cesium.ClassificationType} next
|
||||
*/
|
||||
|
||||
function applyCableClassification(next) {
|
||||
if (next === undefined || next === state._classificationType) return;
|
||||
state._classificationType = next;
|
||||
if (!state._cableDataSource) return;
|
||||
const entities = state._cableDataSource.entities.values;
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const polyline = entities[i].polyline;
|
||||
if (polyline) polyline.classificationType = next;
|
||||
}
|
||||
state._viewer?.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
function styleLandingEntity(entity, feature) {
|
||||
if (!entity?.point) return;
|
||||
entity.point.color = state.landingColor.withAlpha(0.92);
|
||||
entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7;
|
||||
entity.point.outlineColor = state.cableOutline;
|
||||
entity.point.outlineWidth = 1;
|
||||
entity.point.disableDepthTestDistance = 0;
|
||||
entity.show = true;
|
||||
}
|
||||
|
||||
function addReferenceStem({ reference, label, kind, color, feature }) {
|
||||
if (!state._referenceDataSource || !reference) return;
|
||||
|
||||
const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0);
|
||||
const tip = Cesium.Cartesian3.fromDegrees(
|
||||
reference.lon,
|
||||
reference.lat,
|
||||
2500,
|
||||
);
|
||||
const info = {
|
||||
kind,
|
||||
reference,
|
||||
label,
|
||||
featureId: feature?.id || feature?.properties?.id || null,
|
||||
};
|
||||
const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94);
|
||||
const stemColor = color.withAlpha(kind === 'cable' ? 0.58 : 0.68);
|
||||
// Constant properties on the sweep cadence, never per-frame callbacks:
|
||||
// the sweep redefines them through the alternating buffers below.
|
||||
const stemPositionBuffers = [
|
||||
[base, tip],
|
||||
[base, tip],
|
||||
];
|
||||
|
||||
const entity = state._referenceDataSource.entities.add({
|
||||
id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`,
|
||||
position: tip,
|
||||
polyline: {
|
||||
positions: stemPositionBuffers[0],
|
||||
width: kind === 'cable' ? 2 : 2.4,
|
||||
material: new Cesium.ColorMaterialProperty(stemColor),
|
||||
},
|
||||
point: {
|
||||
pixelSize: kind === 'cable' ? 7 : 8,
|
||||
color: pointColor,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: 0,
|
||||
},
|
||||
});
|
||||
|
||||
entity.__gevTeleGeography = info;
|
||||
state._pickByEntity.set(entity, info);
|
||||
const record = {
|
||||
id: entity.id,
|
||||
entity,
|
||||
base,
|
||||
tip,
|
||||
nextTip: Cesium.Cartesian3.clone(tip),
|
||||
stemPositionBuffers,
|
||||
stemPositionBufferIndex: 0,
|
||||
reference,
|
||||
kind,
|
||||
label: clampLabel(label),
|
||||
visible: false,
|
||||
distanceM: Infinity,
|
||||
entry: null,
|
||||
};
|
||||
record.entry = createCableOverlayEntry(record);
|
||||
state._referenceRecords.push(record);
|
||||
}
|
||||
|
||||
function updateReferenceVisibility() {
|
||||
if (!state._enabled || !state._viewer?.camera) return;
|
||||
const cameraPos = state._viewer.camera.positionWC;
|
||||
if (!cameraPos) return;
|
||||
|
||||
const canvasHeight = state._viewer.scene?.canvas?.clientHeight || 1080;
|
||||
const fov = state._viewer.camera.frustum?.fov || Math.PI / 3;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
cameraPos,
|
||||
);
|
||||
for (const record of state._referenceRecords) {
|
||||
const visible = occluder.isPointVisible(record.base);
|
||||
record.visible = visible;
|
||||
record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base);
|
||||
if (record.entity.show !== visible) {
|
||||
record.entity.show = visible;
|
||||
}
|
||||
// Hidden stems keep their last geometry; this same sweep refreshes them
|
||||
// in the pass where they turn visible again.
|
||||
if (visible)
|
||||
updateCableReferenceStem(record, cameraPos, canvasHeight, fov);
|
||||
}
|
||||
for (const record of state._surfaceRecords) {
|
||||
const visible = occluder.isPointVisible(record.base);
|
||||
if (record.entity.show !== visible) {
|
||||
record.entity.show = visible;
|
||||
}
|
||||
}
|
||||
const winners = selectCableReferenceLabelWinners(state._referenceRecords);
|
||||
let changed = winners.length !== state._lastPublishedCount;
|
||||
for (let i = 0; i < winners.length; i++) {
|
||||
const record = winners[i];
|
||||
const priority = cableReferencePriority(record.distanceM);
|
||||
record.entry.priority = priority;
|
||||
state._publishScratch[i] = record.entry;
|
||||
if (
|
||||
!changed &&
|
||||
(state._lastPublishedIds[i] !== record.entry.id ||
|
||||
state._lastPublishedPriorities[i] !== priority)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
state._publishScratch.length = winners.length;
|
||||
state._referenceLabelCount = winners.length;
|
||||
// The marker collections are created lazily by the visualizers, so the
|
||||
// single-pass blend is applied on the sweep cadence until both landed.
|
||||
if (!state._markerBlendDone) applyMarkerBlendOnce();
|
||||
if (!changed) return;
|
||||
state._overlayPublisher.publish(state._publishScratch);
|
||||
state._lastPublishedCount = winners.length;
|
||||
for (let i = 0; i < winners.length; i++) {
|
||||
state._lastPublishedIds[i] = state._publishScratch[i].id;
|
||||
state._lastPublishedPriorities[i] = state._publishScratch[i].priority;
|
||||
}
|
||||
state._lastPublishedIds.length = winners.length;
|
||||
state._lastPublishedPriorities.length = winners.length;
|
||||
}
|
||||
return {
|
||||
resetPublishSignature,
|
||||
releaseDataSources,
|
||||
updateVisibility,
|
||||
styleCableEntity,
|
||||
applyMarkerBlendOnce,
|
||||
applyCableClassification,
|
||||
styleLandingEntity,
|
||||
addReferenceStem,
|
||||
updateReferenceVisibility,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCableReferenceSweepGate,
|
||||
createCableOverlayPublisher,
|
||||
} from './overlay.js';
|
||||
import { BASE_CABLE_COLOR, BASE_LANDING_COLOR } from './policy.js';
|
||||
|
||||
export function createState({ sweepClock, overlayHost }) {
|
||||
const state = {};
|
||||
|
||||
state._viewer = null;
|
||||
|
||||
state._enabled = false;
|
||||
|
||||
state._loading = false;
|
||||
|
||||
state._loaded = false;
|
||||
|
||||
state._count = 0;
|
||||
|
||||
state._error = null;
|
||||
|
||||
state._lastUpdate = null;
|
||||
|
||||
state._loadingLabel = '';
|
||||
|
||||
state._abort = null;
|
||||
|
||||
/** Monotonic load-ownership token; every load() start claims a new one. */
|
||||
|
||||
state._loadGeneration = 0;
|
||||
|
||||
state._cableDataSource = null;
|
||||
|
||||
state._landingDataSource = null;
|
||||
|
||||
state._referenceDataSource = null;
|
||||
|
||||
/**
|
||||
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES
|
||||
* the three data sources (see releaseDataSources) instead of hiding them,
|
||||
* so a re-enable has to rebuild the entities; the network/parse half of
|
||||
* that rebuild is the expensive part users would notice, so it is cached.
|
||||
*/
|
||||
|
||||
state._cachedCableJson = null;
|
||||
|
||||
state._cachedLandingJson = null;
|
||||
|
||||
state._referenceRecords = [];
|
||||
|
||||
state._surfaceRecords = [];
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
state._preRenderRemover = null;
|
||||
|
||||
state._moveEndRemover = null;
|
||||
|
||||
state._mapStackListener = null;
|
||||
|
||||
/** Active ground-line classification; refined from live scene state at init. */
|
||||
|
||||
state._classificationType = Cesium.ClassificationType.BOTH;
|
||||
|
||||
/** True once both marker collections blend translucent (or the shape probe failed). */
|
||||
|
||||
state._markerBlendDone = false;
|
||||
|
||||
state._markerBlendInvariantWarned = false;
|
||||
|
||||
state._pickByEntity = new WeakMap();
|
||||
|
||||
state._referenceLabelCount = 0;
|
||||
|
||||
state._referenceSweepGate = createCableReferenceSweepGate({
|
||||
now: sweepClock,
|
||||
});
|
||||
|
||||
state._overlayPublisher = createCableOverlayPublisher({ host: overlayHost });
|
||||
|
||||
/** Reused winner→entry scratch so a 2 Hz sweep allocates no arrays. */
|
||||
|
||||
state._publishScratch = [];
|
||||
|
||||
/**
|
||||
* Last published cohort signature (ids + quantized priorities). A parked
|
||||
* camera reproduces the same winners every sweep; skipping the identical
|
||||
* republish keeps the host from requesting a render per publish, so the
|
||||
* layer stays render-governor idle exactly like its native predecessor.
|
||||
* Tip positions need no republish either: the host reprojects the same
|
||||
* mutable Cartesians on every painted frame.
|
||||
*/
|
||||
|
||||
state._lastPublishedIds = [];
|
||||
|
||||
state._lastPublishedPriorities = [];
|
||||
|
||||
state._lastPublishedCount = -1;
|
||||
|
||||
state.cableColor = Cesium.Color.fromCssColorString(BASE_CABLE_COLOR);
|
||||
|
||||
state.cableOutline = Cesium.Color.BLACK.withAlpha(0.45);
|
||||
|
||||
state.landingColor = Cesium.Color.fromCssColorString(BASE_LANDING_COLOR);
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CABLE_GLOBE_STACK_IDS,
|
||||
MARKER_COLLECTION_EXPECTATIONS,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Ground-line classification for one map stack. The photoreal stack renders
|
||||
* Google 3D tiles with the Cesium globe HIDDEN, so cable ground lines only
|
||||
* need the 3D-tile classification pass there; every other known stack (the
|
||||
* bing stacks and osm) renders imagery on the shown globe, so only the
|
||||
* terrain pass applies.
|
||||
* Classifying against just the active surface halves the batched
|
||||
* GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback
|
||||
* for an unknown stack — it renders on every surface, exactly the shipped
|
||||
* pre-optimization behavior.
|
||||
* @param {string|null|undefined} activeId MapStackController stack id.
|
||||
* @returns {Cesium.ClassificationType}
|
||||
*/
|
||||
|
||||
export function cableClassificationTypeForStack(activeId) {
|
||||
if (activeId === 'photoreal') return Cesium.ClassificationType.CESIUM_3D_TILE;
|
||||
if (CABLE_GLOBE_STACK_IDS.has(activeId))
|
||||
return Cesium.ClassificationType.TERRAIN;
|
||||
return Cesium.ClassificationType.BOTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the active surface from live scene state. The boot-time
|
||||
* `setStack(..., { silent: true })` fires no 'gev:map-stack-changed' event,
|
||||
* so the initial classification reads the scene the way the height-datum
|
||||
* listeners do: the photoreal regime is exactly "globe hidden".
|
||||
* @param {Cesium.Scene|null|undefined} scene
|
||||
* @returns {Cesium.ClassificationType}
|
||||
*/
|
||||
|
||||
export function cableClassificationTypeForScene(scene) {
|
||||
if (!scene?.globe) return Cesium.ClassificationType.BOTH;
|
||||
return scene.globe.show === false
|
||||
? Cesium.ClassificationType.CESIUM_3D_TILE
|
||||
: Cesium.ClassificationType.TERRAIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one data source's marker collections WITHOUT mutating anything.
|
||||
* Splitting validation from mutation is what makes the fallback honest: a
|
||||
* shape failure on the SECOND collection (or the second data source) must
|
||||
* not leave the first one already forced to TRANSLUCENT.
|
||||
* @param {Cesium.DataSource|null|undefined} dataSource
|
||||
* @returns {{ready:object[],pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function probeTranslucentMarkerBlend(dataSource) {
|
||||
const cluster = dataSource?.clustering;
|
||||
if (!cluster) return { ready: [], pending: 0, invariantFailed: true };
|
||||
const ready = [];
|
||||
let pending = 0;
|
||||
let invariantFailed = false;
|
||||
for (const { key, type } of MARKER_COLLECTION_EXPECTATIONS) {
|
||||
if (!(key in cluster)) {
|
||||
invariantFailed = true;
|
||||
continue;
|
||||
}
|
||||
const collection = cluster[key];
|
||||
if (collection === undefined || collection === null) {
|
||||
pending++;
|
||||
continue;
|
||||
}
|
||||
if (!(collection instanceof type) || !('blendOption' in collection)) {
|
||||
invariantFailed = true;
|
||||
continue;
|
||||
}
|
||||
ready.push(collection);
|
||||
}
|
||||
// Any failure discards every collected target: nothing is mutated at all.
|
||||
if (invariantFailed) return { ready: [], pending: 0, invariantFailed: true };
|
||||
return { ready, pending, invariantFailed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a clean probe. Only ever called once every probe in the batch has
|
||||
* passed, so this cannot land a partial application.
|
||||
* @param {{ready:object[],pending:number}} probe
|
||||
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function commitTranslucentMarkerBlend(probe) {
|
||||
for (const collection of probe.ready) {
|
||||
if (collection.blendOption !== Cesium.BlendOption.TRANSLUCENT) {
|
||||
collection.blendOption = Cesium.BlendOption.TRANSLUCENT;
|
||||
}
|
||||
}
|
||||
return {
|
||||
applied: probe.ready.length,
|
||||
pending: probe.pending,
|
||||
invariantFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force one translucent draw pass for a data source's marker collections.
|
||||
* The default `OPAQUE_AND_TRANSLUCENT` blend emits two draw commands per
|
||||
* collection, and every cable marker is translucent (alpha < 1), so the
|
||||
* opaque pass is pure overhead across 1,917 landing billboards + 2,629
|
||||
* reference points. Cesium has no public accessor for the entity-backed
|
||||
* collections, so this reaches through EntityCluster's private fields under
|
||||
* a SHAPE INVARIANT the caller must honor loudly: the constructor
|
||||
* pre-assigns `_billboardCollection`/`_pointCollection` (= undefined until a
|
||||
* visualizer lazily creates them), so a missing PROPERTY means Cesium's
|
||||
* shape changed — report `invariantFailed` and never touch anything —
|
||||
* while a present-but-undefined value only means "not created yet"
|
||||
* (`pending`). Wrong instance types also fail the invariant. The cables
|
||||
* sources never enable clustering, so created collections persist.
|
||||
* VALIDATE-ALL-THEN-MUTATE-ALL: every field/type check runs before the first
|
||||
* assignment, so a failure anywhere leaves BOTH collections on Cesium's
|
||||
* default blend — the fallback this helper promises.
|
||||
* @param {Cesium.DataSource|null|undefined} dataSource
|
||||
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function applyTranslucentMarkerBlend(dataSource) {
|
||||
const probe = probeTranslucentMarkerBlend(dataSource);
|
||||
if (probe.invariantFailed)
|
||||
return { applied: 0, pending: 0, invariantFailed: true };
|
||||
return commitTranslucentMarkerBlend(probe);
|
||||
}
|
||||
Reference in new issue
Block a user