refactor(geography): separate cable sources and bundled lookup modules

This commit is contained in:
Sameh Khamis committed 2026-09-13 18:36:33 -07:00
1 parent 2213c42200
commit 3ce5d2e454
21 files changed
+2423 -1437

No files matched your search

+56 -29
View File
@@ -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;
+45 -13
View File
@@ -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' });
});
+79
View File
@@ -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),
};
});
}
+53
View File
@@ -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';
+210
View File
@@ -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 };
}
+75
View File
@@ -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,
};
}
+156
View File
@@ -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,
};
},
};
}
+309
View File
@@ -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))}...`;
}
+109
View File
@@ -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();
}
+274
View File
@@ -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,
};
}
+107
View File
@@ -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;
}
+126
View File
@@ -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);
}