From 3ce5d2e454b5be29f3fa0f2a3340910aadd3e30d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 17:56:14 -0700 Subject: [PATCH] refactor(geography): separate cable sources and bundled lookup modules --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 15 + package.json | 6 +- scripts/format-scope.json | 18 +- scripts/package-boundaries.json | 37 + src/data/naturalEarthRegions.js | 85 +- src/data/neighborhoodPolygons.js | 58 +- src/data/telegeographySubmarineCables.js | 1250 +---------------- .../telegeographySubmarineCables.test.mjs | 815 ++++++++--- src/layers/submarineCables/bundledSource.js | 42 + .../submarineCables/bundledSource.test.mjs | 34 + src/layers/submarineCables/geometry.js | 79 ++ src/layers/submarineCables/index.js | 53 + src/layers/submarineCables/ingestion.js | 210 +++ src/layers/submarineCables/interaction.js | 75 + src/layers/submarineCables/lifecycle.js | 156 ++ src/layers/submarineCables/overlay.js | 309 ++++ src/layers/submarineCables/policy.js | 109 ++ src/layers/submarineCables/rendering.js | 274 ++++ src/layers/submarineCables/state.js | 107 ++ src/layers/submarineCables/surface.js | 126 ++ 21 files changed, 2423 insertions(+), 1437 deletions(-) create mode 100644 src/layers/submarineCables/bundledSource.js create mode 100644 src/layers/submarineCables/bundledSource.test.mjs create mode 100644 src/layers/submarineCables/geometry.js create mode 100644 src/layers/submarineCables/index.js create mode 100644 src/layers/submarineCables/ingestion.js create mode 100644 src/layers/submarineCables/interaction.js create mode 100644 src/layers/submarineCables/lifecycle.js create mode 100644 src/layers/submarineCables/overlay.js create mode 100644 src/layers/submarineCables/policy.js create mode 100644 src/layers/submarineCables/rendering.js create mode 100644 src/layers/submarineCables/state.js create mode 100644 src/layers/submarineCables/surface.js diff --git a/CHANGELOG.md b/CHANGELOG.md index dc297d5f1..32f8245b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. +- Separate submarine cable sources and rendering components, and export bundled geography lookup modules. + ### Fixed - Traffic now retries a failed destination after city navigation without a layer diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6b60bde92..f8d8d7bd3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3303,3 +3303,18 @@ explicitly. Each constructed layer owns its catalog, audio and lifecycle state. Existing catalog validation, category filters, tuning and voice playback behavior remain unchanged. Audio connects directly to the broadcaster after an explicit play action; the source does not relay or record streams. + +## Bundled geography and submarine cable components + +Submarine cables use separate source, geometry, rendering, interaction and +lifecycle modules. The layer factory accepts cable and landing-point GeoJSON +collections from a source with `fetch(signal)` and a display label. The default +source loads the same bundled TeleGeography files. Disabling still removes all +three Cesium data sources; enabling rebuilds from the accepted parsed cache, +and destroying clears it. Load ownership prevents cancelled work from adding +entities after teardown. + +Natural Earth regions and neighborhood polygon lookup are package exports. +Their existing lazy loaders, retry behavior, bundled datasets and attribution +are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by +the project's MIT license; see `DATA_SOURCES.md`. diff --git a/package.json b/package.json index aacbf0140..2452b819a 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,10 @@ "./layers/traffic": "./src/layers/traffic/index.js", "./layers/bikeshare": "./src/layers/bikeshare/index.js", "./layers/cctv": "./src/layers/cctv/index.js", - "./layers/radio": "./src/layers/radio/index.js" + "./layers/radio": "./src/layers/radio/index.js", + "./layers/submarine-cables": "./src/layers/submarineCables/index.js", + "./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js", + "./geography/natural-earth": "./src/data/naturalEarthRegions.js", + "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b96788889..ffbeac327 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -479,5 +479,21 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/data/radio.js", - "src/layers/radio/source.test.mjs" + "src/layers/radio/source.test.mjs", + "src/layers/submarineCables/bundledSource.js", + "src/layers/submarineCables/geometry.js", + "src/layers/submarineCables/index.js", + "src/layers/submarineCables/ingestion.js", + "src/layers/submarineCables/interaction.js", + "src/layers/submarineCables/lifecycle.js", + "src/layers/submarineCables/overlay.js", + "src/layers/submarineCables/policy.js", + "src/layers/submarineCables/rendering.js", + "src/layers/submarineCables/state.js", + "src/layers/submarineCables/surface.js", + "src/data/telegeographySubmarineCables.js", + "src/data/telegeographySubmarineCables.test.mjs", + "src/data/naturalEarthRegions.js", + "src/data/neighborhoodPolygons.js", + "src/layers/submarineCables/bundledSource.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4218a8a10..ebee63435 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -925,5 +925,42 @@ "src/ui/radioTunerModel.js" ], "external": ["cesium"] + }, + "submarine-cables": { + "exports": ["./layers/submarine-cables"], + "modules": [ + "src/layers/submarineCables/geometry.js", + "src/layers/submarineCables/index.js", + "src/layers/submarineCables/ingestion.js", + "src/layers/submarineCables/interaction.js", + "src/layers/submarineCables/lifecycle.js", + "src/layers/submarineCables/overlay.js", + "src/layers/submarineCables/policy.js", + "src/layers/submarineCables/rendering.js", + "src/layers/submarineCables/state.js", + "src/layers/submarineCables/surface.js" + ], + "external": ["cesium"] + }, + "bundled-cables": { + "exports": ["./sources/bundled-cables"], + "modules": [ + "src/layers/submarineCables/bundledSource.js", + "src/data/local_data/telegeography_submarine_cables/cable-geo.json", + "src/data/local_data/telegeography_submarine_cables/landing-point-geo.json" + ], + "external": [] + }, + "geography": { + "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "modules": [ + "src/data/naturalEarthRegions.js", + "src/data/neighborhoodPolygons.js", + "src/data/retryableLoad.js", + "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/natural_earth/marine.json" + ], + "external": [] } } diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 5929a8921..891e7e9a1 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -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; diff --git a/src/data/neighborhoodPolygons.js b/src/data/neighborhoodPolygons.js index 9ec672d38..2861f9318 100644 --- a/src/data/neighborhoodPolygons.js +++ b/src/data/neighborhoodPolygons.js @@ -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; } } diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 20d6e02ff..998829f5c 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -1,1225 +1,47 @@ import * as Cesium from 'cesium'; +import { + createSubmarineCableLayer, + createCableOverlayPublisher as createPublisher, +} from '../layers/submarineCables/index.js'; +import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; import { clearOverlaySource, setOverlayEntries, setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; - -// TeleGeography submarine-cable data is bundled for an out-of-the-box -// experience. IMPORTANT: it is CC BY-NC-SA 3.0 (NonCommercial + ShareAlike), -// NOT covered by this project's MIT license — see DATA_SOURCES.md. Commercial -// users must remove this dataset or obtain a license from TeleGeography. -const cableUrl = new URL( - './local_data/telegeography_submarine_cables/cable-geo.json', - import.meta.url, -).href; -const landingPointUrl = new URL( - './local_data/telegeography_submarine_cables/landing-point-geo.json', - import.meta.url, -).href; - -const BASE_CABLE_COLOR = '#39d5ff'; -const BASE_LANDING_COLOR = '#8fffd2'; -const STEM_TARGET_PX = 66; -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; -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, -}); - -const DEFAULT_OVERLAY_HOST = Object.freeze({ +const overlayHost = Object.freeze({ clearSource: clearOverlaySource, setEntries: setOverlayEntries, setVisible: setOverlaySourceVisible, }); - +export function createTeleGeographySubmarineCableLayer(options = {}) { + return createSubmarineCableLayer({ + source: createBundledCableSource(), + overlayHost, + screenSpaceEventHandlerFactory: (canvas) => + new Cesium.ScreenSpaceEventHandler(canvas), + mapStackEventTarget: typeof window !== 'undefined' ? window : null, + ...options, + }); +} +export function createCableOverlayPublisher(options = {}) { + return createPublisher({ host: overlayHost, ...options }); +} +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, + selectCableReferenceLabelWinners, + cableReferencePriority, + createCableOverlayEntry, + cableClassificationTypeForStack, + cableClassificationTypeForScene, + applyTranslucentMarkerBlend, + createCableReferenceSweepGate, + updateCableReferenceStem, +} from '../layers/submarineCables/index.js'; export default createTeleGeographySubmarineCableLayer(); - -/** - * 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 = DEFAULT_OVERLAY_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; - }, - }; -} - -/** - * `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. - */ -const CABLE_GLOBE_STACK_IDS = Object.freeze( - new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']), -); - -/** - * 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; -} - -/** EntityCluster's private marker collections and their required types. */ -const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([ - Object.freeze({ key: '_billboardCollection', type: Cesium.BillboardCollection }), - Object.freeze({ key: '_pointCollection', type: Cesium.PointPrimitiveCollection }), -]); - -/** - * 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}} - */ -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}} - */ -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); -} - -/** - * 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} - */ -function defaultSweepClock() { - return (typeof performance === 'object' && typeof performance.now === 'function') - ? performance.now() - : Date.now(); -} - -/** - * 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 createTeleGeographySubmarineCableLayer({ - overlayHost = DEFAULT_OVERLAY_HOST, - screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), - mapStackEventTarget = typeof window !== 'undefined' ? window : null, - // Test seam: the sweep gate's motion-fallback clock. Tests freeze it so the - // fallback window is driven explicitly instead of by wall-clock timing. - sweepClock = defaultSweepClock, -} = {}) { - let _viewer = null; - let _enabled = false; - let _loading = false; - let _loaded = false; - let _count = 0; - let _error = null; - let _lastUpdate = null; - let _loadingLabel = ''; - let _abort = null; - /** Monotonic load-ownership token; every load() start claims a new one. */ - let _loadGeneration = 0; - let _cableDataSource = null; - let _landingDataSource = null; - let _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. - */ - let _cachedCableJson = null; - let _cachedLandingJson = null; - let _referenceRecords = []; - let _surfaceRecords = []; - let _clickHandler = null; - let _preRenderRemover = null; - let _moveEndRemover = null; - let _mapStackListener = null; - /** Active ground-line classification; refined from live scene state at init. */ - let _classificationType = Cesium.ClassificationType.BOTH; - /** True once both marker collections blend translucent (or the shape probe failed). */ - let _markerBlendDone = false; - let _markerBlendInvariantWarned = false; - let _pickByEntity = new WeakMap(); - let _referenceLabelCount = 0; - const _referenceSweepGate = createCableReferenceSweepGate({ now: sweepClock }); - const _overlayPublisher = createCableOverlayPublisher({ host: overlayHost }); - /** Reused winner→entry scratch so a 2 Hz sweep allocates no arrays. */ - const _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. - */ - const _lastPublishedIds = []; - const _lastPublishedPriorities = []; - let _lastPublishedCount = -1; - - function resetPublishSignature() { - _lastPublishedIds.length = 0; - _lastPublishedPriorities.length = 0; - _lastPublishedCount = -1; - } - - const cableColor = Cesium.Color.fromCssColorString(BASE_CABLE_COLOR); - const cableOutline = Cesium.Color.BLACK.withAlpha(0.45); - const landingColor = Cesium.Color.fromCssColorString(BASE_LANDING_COLOR); - - async function load(viewer) { - if (_loading || _loaded) return; - - _loading = true; - _error = null; - _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 = ++_loadGeneration; - const abort = new AbortController(); - _abort = abort; - const owns = () => generation === _loadGeneration && !abort.signal.aborted; - - try { - let cableJson = _cachedCableJson; - let landingJson = _cachedLandingJson; - if (!cableJson || !landingJson) { - [cableJson, landingJson] = await Promise.all([ - fetchJson(cableUrl, abort.signal), - fetchJson(landingPointUrl, abort.signal), - ]); - if (!owns()) return; - _cachedCableJson = cableJson; - _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: cableColor.withAlpha(0.95), - fill: cableColor.withAlpha(0.18), - strokeWidth: 2, - markerColor: cableColor, - markerSize: 6, - } - ); - if (!owns()) return; - const landingDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: landingFeatures }, - { - clampToGround: true, - stroke: landingColor.withAlpha(0.9), - fill: landingColor.withAlpha(0.35), - strokeWidth: 2, - markerColor: landingColor, - markerSize: 6, - } - ); - if (!owns()) return; - - cableDataSource.name = 'TeleGeography Submarine Cables'; - landingDataSource.name = 'TeleGeography Landing Points'; - const referenceDataSource = new Cesium.CustomDataSource('TeleGeography 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. - _cableDataSource = cableDataSource; - _landingDataSource = landingDataSource; - _referenceDataSource = referenceDataSource; - - const cableEntities = _cableDataSource.entities.values; - const landingEntities = _landingDataSource.entities.values; - _pickByEntity = new WeakMap(); - _referenceRecords = []; - _surfaceRecords = []; - - cableEntities.forEach((entity, index) => { - const feature = cableFeatures[index]; - const reference = featureReference(feature); - if (!reference) return; - - styleCableEntity(entity, feature); - registerPickEntity(entity, { - kind: 'cable', - reference, - label: featureLabel(feature), - }); - _surfaceRecords.push({ - entity, - base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), - }); - addReferenceStem({ - reference, - label: featureLabel(feature), - kind: 'cable', - color: cableColor, - feature, - }); - }); - - landingEntities.forEach((entity, index) => { - const feature = landingFeatures[index]; - const reference = featureReference(feature); - if (!reference) return; - - styleLandingEntity(entity, feature); - registerPickEntity(entity, { - kind: 'landing-point', - reference, - label: featureLabel(feature), - }); - _surfaceRecords.push({ - entity, - base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), - }); - addReferenceStem({ - reference, - label: featureLabel(feature), - kind: 'landing-point', - color: landingColor, - feature, - }); - }); - - _count = cableFeatures.length + landingFeatures.length; - _loaded = true; - _lastUpdate = Date.now(); - _loadingLabel = ''; - updateVisibility(); - _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') { - _error = error?.message || 'TeleGeography load failed'; - console.warn('[Data:telegeography-submarine-cables]', _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 === _loadGeneration) { - _loading = false; - if (_abort === abort) _abort = null; - } - } - } - - /** - * 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 = [_cableDataSource, _landingDataSource, _referenceDataSource]; - _cableDataSource = null; - _landingDataSource = null; - _referenceDataSource = null; - _referenceRecords = []; - _surfaceRecords = []; - _pickByEntity = new WeakMap(); - _referenceLabelCount = 0; - _publishScratch.length = 0; - // The blend pass targets the collections being dropped; the rebuilt - // sources need their own pass. - _markerBlendDone = false; - _loaded = false; - _referenceSweepGate.reset(); - for (const source of sources) { - if (!source) continue; - try { viewer?.dataSources?.remove(source, true); } catch { /* collection gone */ } - } - } - - function updateVisibility() { - if (_cableDataSource) _cableDataSource.show = _enabled; - if (_landingDataSource) _landingDataSource.show = _enabled; - if (_referenceDataSource) _referenceDataSource.show = _enabled; - } - - function registerPickEntity(entity, info) { - entity.__gevTeleGeography = info; - _pickByEntity.set(entity, info); - } - - function styleCableEntity(entity, feature) { - if (!entity?.polyline) return; - const color = feature?.properties?.color - ? Cesium.Color.fromCssColorString(String(feature.properties.color)) - : cableColor; - entity.polyline.material = color.withAlpha(0.92); - entity.polyline.width = 2.5; - entity.polyline.clampToGround = true; - entity.polyline.classificationType = _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(_landingDataSource); - const reference = probeTranslucentMarkerBlend(_referenceDataSource); - if (landing.invariantFailed || reference.invariantFailed) { - _markerBlendDone = true; - if (!_markerBlendInvariantWarned && import.meta.env?.DEV === true) { - _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) _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 === _classificationType) return; - _classificationType = next; - if (!_cableDataSource) return; - const entities = _cableDataSource.entities.values; - for (let i = 0; i < entities.length; i++) { - const polyline = entities[i].polyline; - if (polyline) polyline.classificationType = next; - } - _viewer?.scene?.requestRender?.(); - } - - function styleLandingEntity(entity, feature) { - if (!entity?.point) return; - entity.point.color = landingColor.withAlpha(0.92); - entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7; - entity.point.outlineColor = cableOutline; - entity.point.outlineWidth = 1; - entity.point.disableDepthTestDistance = 0; - entity.show = true; - } - - function addReferenceStem({ reference, label, kind, color, feature }) { - if (!_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 = _referenceDataSource.entities.add({ - id: `${kind}-reference-${_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; - _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); - _referenceRecords.push(record); - } - - function updateReferenceVisibility() { - if (!_enabled || !_viewer?.camera) return; - const cameraPos = _viewer.camera.positionWC; - if (!cameraPos) return; - - const canvasHeight = _viewer.scene?.canvas?.clientHeight || 1080; - const fov = _viewer.camera.frustum?.fov || (Math.PI / 3); - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPos); - for (const record of _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 _surfaceRecords) { - const visible = occluder.isPointVisible(record.base); - if (record.entity.show !== visible) { - record.entity.show = visible; - } - } - const winners = selectCableReferenceLabelWinners(_referenceRecords); - let changed = winners.length !== _lastPublishedCount; - for (let i = 0; i < winners.length; i++) { - const record = winners[i]; - const priority = cableReferencePriority(record.distanceM); - record.entry.priority = priority; - _publishScratch[i] = record.entry; - if (!changed - && (_lastPublishedIds[i] !== record.entry.id - || _lastPublishedPriorities[i] !== priority)) { - changed = true; - } - } - _publishScratch.length = winners.length; - _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 (!_markerBlendDone) applyMarkerBlendOnce(); - if (!changed) return; - _overlayPublisher.publish(_publishScratch); - _lastPublishedCount = winners.length; - for (let i = 0; i < winners.length; i++) { - _lastPublishedIds[i] = _publishScratch[i].id; - _lastPublishedPriorities[i] = _publishScratch[i].priority; - } - _lastPublishedIds.length = winners.length; - _lastPublishedPriorities.length = winners.length; - } - - function beginInteraction(viewer) { - if (_clickHandler) return; - _clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!_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 = _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 = _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, - }); - } - - 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, - }; - } - - 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]; - } - - 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]; - } - - 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); - } - - function featureLabel(feature) { - const props = feature?.properties || {}; - return String(props.name || props.id || feature?.id || '').trim(); - } - - 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), - }; - }); - } - - async function fetchJson(url, signal) { - const response = await fetch(url, { signal, cache: 'force-cache' }); - if (!response.ok) { - throw new Error(`HTTP ${response.status} for ${url}`); - } - return response.json(); - } - - return { - id: 'telegeography-submarine-cables', - name: 'Submarine Cables', - icon: '≋', - source: 'TeleGeography', - updateInterval: 0, - statsRefreshInterval: 500, - - init(viewer) { - _viewer = viewer; - _classificationType = cableClassificationTypeForScene(viewer?.scene); - if (!_mapStackListener && mapStackEventTarget?.addEventListener) { - _mapStackListener = (event) => { - applyCableClassification(event?.detail?.activeId - ? cableClassificationTypeForStack(event.detail.activeId) - : cableClassificationTypeForScene(_viewer?.scene)); - }; - mapStackEventTarget.addEventListener('gev:map-stack-changed', _mapStackListener); - } - beginInteraction(viewer); - if (!_preRenderRemover) { - _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 (!_enabled || !_loaded - || !_referenceSweepGate.shouldRun(viewer.camera)) return; - updateReferenceVisibility(); - }); - } - if (!_moveEndRemover) { - _moveEndRemover = viewer.camera.moveEnd.addEventListener(() => { - if (!_enabled) return; - _referenceSweepGate.markDirty(); - viewer.scene.requestRender?.(); - }); - } - }, - - enable(viewer) { - _enabled = true; - updateVisibility(); - _overlayPublisher.show(); - // A hide() cleared the host source, so an identical cohort must still - // republish on the next sweep. - resetPublishSignature(); - _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 || _viewer)?.scene?.requestRender?.(); - void load(viewer || _viewer); - }, - - disable() { - _enabled = false; - _overlayPublisher.hide(); - resetPublishSignature(); - if (_loading && _abort) { - _abort.abort(); - _loading = false; - _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. - releaseDataSources(_viewer); - }, - - update(viewer) { - return load(viewer || _viewer); - }, - - destroy(viewer) { - if (_abort) _abort.abort(); - releaseDataSources(viewer || _viewer); - _cachedCableJson = null; - _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. - _overlayPublisher.hide(); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (_moveEndRemover) { - _moveEndRemover(); - _moveEndRemover = null; - } - if (_mapStackListener && mapStackEventTarget?.removeEventListener) { - mapStackEventTarget.removeEventListener('gev:map-stack-changed', _mapStackListener); - _mapStackListener = null; - } - _viewer = null; - _enabled = false; - _loading = false; - _loaded = false; - _count = 0; - _error = null; - _lastUpdate = null; - _loadingLabel = ''; - _cableDataSource = null; - _landingDataSource = null; - _referenceDataSource = null; - _referenceRecords = []; - _surfaceRecords = []; - _pickByEntity = new WeakMap(); - _referenceLabelCount = 0; - _publishScratch.length = 0; - _markerBlendDone = false; - _markerBlendInvariantWarned = false; - resetPublishSignature(); - _referenceSweepGate.reset(); - }, - - getStats() { - return { - count: _count, - lastUpdate: _lastUpdate, - loading: _loading, - loadingLabel: _loadingLabel, - error: _error, - referenceLabelCount: _referenceLabelCount, - }; - }, - }; -} - -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))}...`; -} diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 58bfaa87d..675c6a3b9 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -39,11 +39,18 @@ test('cable reference winners remain nearest-first and hard-capped', () => { for (let index = 0; index < 80; index++) records[index].visible = false; winners = selectCableReferenceLabelWinners(records); assert.equal(winners.length, CABLE_REFERENCE_LABEL_WINNER_CAP); - assert.equal(winners[0], records[80], 'hidden references release their slots to the next nearest'); + assert.equal( + winners[0], + records[80], + 'hidden references release their slots to the next nearest', + ); records[90].distanceM = 10_000_000; // beyond the 9,000 km reference range winners = selectCableReferenceLabelWinners(records); - assert.ok(!winners.includes(records[90]), 'out-of-range references never reach the host'); + assert.ok( + !winners.includes(records[90]), + 'out-of-range references never reach the host', + ); }); test('cable overlay entries satisfy the shared presentation contract', () => { @@ -58,21 +65,33 @@ test('cable overlay entries satisfy the shared presentation contract', () => { const entry = createCableOverlayEntry(record); assert.equal(entry.id, 'cable-reference-0-atlantic-1'); - assert.equal(entry.position, tip, 'entry stays attached to the mutable stem-tip Cartesian'); + assert.equal( + entry.position, + tip, + 'entry stays attached to the mutable stem-tip Cartesian', + ); assert.equal(entry.variant, 'label'); assert.equal(entry.title, 'Atlantic Crossing-1'); assert.equal(entry.accent, '#39d5ff'); assert.equal(entry.collisionGroup, 'ambient-label'); assert.equal(entry.paintLane, 'ambient-label'); - assert.equal(entry.interactive, false, 'point/stem/line picking remains Cesium-native'); + assert.equal( + entry.interactive, + false, + 'point/stem/line picking remains Cesium-native', + ); assert.equal(entry.maxDistance, 9_000_000); assert.equal(entry.distanceFadeStartRatio, 0.7); - assert.deepEqual(entry.distanceScale, { - near: 250_000, - nearValue: 1, - far: 9_000_000, - farValue: 0.62, - }, 'former native scaleByDistance curve is preserved'); + assert.deepEqual( + entry.distanceScale, + { + near: 250_000, + nearValue: 1, + far: 9_000_000, + farValue: 0.62, + }, + 'former native scaleByDistance curve is preserved', + ); assert.equal(entry.edgeFade, 'keyhole'); assert.equal(entry.horizonCull, true); assert.equal(entry.terrainOcclusion, false); @@ -86,13 +105,19 @@ test('cable overlay entries satisfy the shared presentation contract', () => { tip, distanceM: 100_000, }); - assert.equal(landing.accent, '#8fffd2', 'landing points keep their mint accent'); + assert.equal( + landing.accent, + '#8fffd2', + 'landing points keep their mint accent', + ); assert.equal(landing.interactive, false); }); test('cable reference priority is nearest-first and sweep-stable', () => { assert.ok(cableReferencePriority(1_000) > cableReferencePriority(1_000_000)); - assert.ok(cableReferencePriority(1_000_000) > cableReferencePriority(8_000_000)); + assert.ok( + cableReferencePriority(1_000_000) > cableReferencePriority(8_000_000), + ); assert.equal( cableReferencePriority(500_000), cableReferencePriority(500_000 + 10_000), @@ -124,7 +149,11 @@ test('cable overlay publisher owns the production show/publish/hide lifecycle', publisher.hide(); assert.deepEqual(calls[0], ['visible', CABLE_OVERLAY_SOURCE_ID, true]); - assert.deepEqual(calls[1].slice(0, 3), ['entries', CABLE_OVERLAY_SOURCE_ID, [{ id: 'cable-1' }]]); + assert.deepEqual(calls[1].slice(0, 3), [ + 'entries', + CABLE_OVERLAY_SOURCE_ID, + [{ id: 'cable-1' }], + ]); assert.deepEqual(calls[1][3], { cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP, collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY, @@ -132,7 +161,11 @@ test('cable overlay publisher owns the production show/publish/hide lifecycle', }); assert.deepEqual(calls[2], ['clear', CABLE_OVERLAY_SOURCE_ID]); assert.deepEqual(calls[3], ['visible', CABLE_OVERLAY_SOURCE_ID, false]); - assert.equal(calls.length, 4, 'hidden publishers drop late publishes; repeat hide is a no-op'); + assert.equal( + calls.length, + 4, + 'hidden publishers drop late publishes; repeat hide is a no-op', + ); // The production teardown (layer.destroy) is hide(): the publisher stays // reusable for the layer's supported re-init-after-destroy contract. There @@ -141,7 +174,11 @@ test('cable overlay publisher owns the production show/publish/hide lifecycle', publisher.show(); publisher.publish([{ id: 'cable-2' }]); assert.deepEqual(calls[4], ['visible', CABLE_OVERLAY_SOURCE_ID, true]); - assert.deepEqual(calls[5].slice(0, 3), ['entries', CABLE_OVERLAY_SOURCE_ID, [{ id: 'cable-2' }]]); + assert.deepEqual(calls[5].slice(0, 3), [ + 'entries', + CABLE_OVERLAY_SOURCE_ID, + [{ id: 'cable-2' }], + ]); }); test('cable ground lines classify against exactly the active surface on every stack', () => { @@ -163,11 +200,26 @@ test('cable ground lines classify against exactly the active surface on every st // claim as "renders on the globe", and asserting TERRAIN for an id this // module has never heard of would hide the cables on a future 3D-tile // stack instead of degrading to the documented safe behavior. - assert.equal(cableClassificationTypeForStack('some-future-stack'), Cesium.ClassificationType.BOTH); - assert.equal(cableClassificationTypeForStack('photoreal-v2'), Cesium.ClassificationType.BOTH); - assert.equal(cableClassificationTypeForStack(''), Cesium.ClassificationType.BOTH); - assert.equal(cableClassificationTypeForStack(undefined), Cesium.ClassificationType.BOTH); - assert.equal(cableClassificationTypeForStack(null), Cesium.ClassificationType.BOTH); + assert.equal( + cableClassificationTypeForStack('some-future-stack'), + Cesium.ClassificationType.BOTH, + ); + assert.equal( + cableClassificationTypeForStack('photoreal-v2'), + Cesium.ClassificationType.BOTH, + ); + assert.equal( + cableClassificationTypeForStack(''), + Cesium.ClassificationType.BOTH, + ); + assert.equal( + cableClassificationTypeForStack(undefined), + Cesium.ClassificationType.BOTH, + ); + assert.equal( + cableClassificationTypeForStack(null), + Cesium.ClassificationType.BOTH, + ); // Every id that actually ships must be a KNOWN id: a stack added to // MAP_STACKS without a mapping here silently loses its halved command set, @@ -190,7 +242,10 @@ test('cable ground lines classify against exactly the active surface on every st cableClassificationTypeForScene({ globe: { show: true } }), Cesium.ClassificationType.TERRAIN, ); - assert.equal(cableClassificationTypeForScene(null), Cesium.ClassificationType.BOTH); + assert.equal( + cableClassificationTypeForScene(null), + Cesium.ClassificationType.BOTH, + ); }); test('a map-stack change re-classifies every cable line once, and destroy detaches the listener', async () => { @@ -201,49 +256,84 @@ test('a map-stack change re-classifies every cable line once, and destroy detach if (listeners.get(name) === fn) listeners.delete(name); }, }; - const env = await createRealCableLayerHarness({ mapStackEventTarget: eventTarget }); - const cableSource = env.dataSources.find((ds) => /Submarine Cables/.test(ds.name || '')); - const classificationOf = (entity) => entity.polyline.classificationType.getValue(); + const env = await createRealCableLayerHarness({ + mapStackEventTarget: eventTarget, + }); + const cableSource = env.dataSources.find((ds) => + /Submarine Cables/.test(ds.name || ''), + ); + const classificationOf = (entity) => + entity.polyline.classificationType.getValue(); // The harness scene has no globe → BOTH at init (safe unknown fallback). const cableEntity = cableSource.entities.values[0]; assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.BOTH); const listener = listeners.get('gev:map-stack-changed'); - assert.equal(typeof listener, 'function', 'init must subscribe to the stack event'); + assert.equal( + typeof listener, + 'function', + 'init must subscribe to the stack event', + ); listener({ detail: { activeId: 'photoreal', status: 'ready' } }); - assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.CESIUM_3D_TILE); + assert.equal( + classificationOf(cableEntity), + Cesium.ClassificationType.CESIUM_3D_TILE, + ); listener({ detail: { activeId: 'osm', status: 'ready' } }); - assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.TERRAIN); + assert.equal( + classificationOf(cableEntity), + Cesium.ClassificationType.TERRAIN, + ); env.layer.destroy(env.viewer); - assert.equal(listeners.has('gev:map-stack-changed'), false, 'destroy must detach the stack listener'); + assert.equal( + listeners.has('gev:map-stack-changed'), + false, + 'destroy must detach the stack listener', + ); }); test('marker collections blend in a single translucent pass behind a loud shape invariant', async () => { // Helper contract on a REAL Cesium cluster shape. const ds = new Cesium.CustomDataSource('blend-probe'); let result = applyTranslucentMarkerBlend(ds); - assert.deepEqual(result, { applied: 0, pending: 2, invariantFailed: false }, - 'pre-visualizer clusters are pending, never a shape failure'); + assert.deepEqual( + result, + { applied: 0, pending: 2, invariantFailed: false }, + 'pre-visualizer clusters are pending, never a shape failure', + ); ds.clustering._billboardCollection = new Cesium.BillboardCollection(); ds.clustering._pointCollection = new Cesium.PointPrimitiveCollection(); result = applyTranslucentMarkerBlend(ds); assert.deepEqual(result, { applied: 2, pending: 0, invariantFailed: false }); - assert.equal(ds.clustering._billboardCollection.blendOption, Cesium.BlendOption.TRANSLUCENT); - assert.equal(ds.clustering._pointCollection.blendOption, Cesium.BlendOption.TRANSLUCENT); + assert.equal( + ds.clustering._billboardCollection.blendOption, + Cesium.BlendOption.TRANSLUCENT, + ); + assert.equal( + ds.clustering._pointCollection.blendOption, + Cesium.BlendOption.TRANSLUCENT, + ); // Shape drift fails LOUDLY-detectably instead of applying blindly: a // renamed field or a foreign object type must never be touched. - assert.equal(applyTranslucentMarkerBlend({ clustering: {} }).invariantFailed, true, - 'missing private fields mean Cesium changed shape'); + assert.equal( + applyTranslucentMarkerBlend({ clustering: {} }).invariantFailed, + true, + 'missing private fields mean Cesium changed shape', + ); const wrongType = new Cesium.CustomDataSource('wrong-type'); wrongType.clustering._billboardCollection = { blendOption: 0 }; const wrongResult = applyTranslucentMarkerBlend(wrongType); assert.equal(wrongResult.invariantFailed, true); - assert.equal(wrongType.clustering._billboardCollection.blendOption, 0, 'foreign objects are left untouched'); + assert.equal( + wrongType.clustering._billboardCollection.blendOption, + 0, + 'foreign objects are left untouched', + ); assert.equal(applyTranslucentMarkerBlend(null).invariantFailed, true); // ATOMICITY: a shape failure on the SECOND collection must not leave the @@ -255,24 +345,40 @@ test('marker collections blend in a single translucent pass behind a loud shape partial.clustering._pointCollection = { blendOption: 0 }; // renamed/reshaped const partialResult = applyTranslucentMarkerBlend(partial); assert.equal(partialResult.invariantFailed, true); - assert.equal(partialResult.applied, 0, 'a failed probe reports zero applications'); + assert.equal( + partialResult.applied, + 0, + 'a failed probe reports zero applications', + ); assert.equal( partial.clustering._billboardCollection.blendOption, Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT, - 'the valid first collection keeps Cesium\'s default blend when the second fails', + "the valid first collection keeps Cesium's default blend when the second fails", + ); + assert.equal( + partial.clustering._pointCollection.blendOption, + 0, + 'the malformed collection is never touched', ); - assert.equal(partial.clustering._pointCollection.blendOption, 0, 'the malformed collection is never touched'); // Real-layer wiring: once the visualizers have created the collections, // the next sweep applies the single-pass blend to both marker sources. const env = await createRealCableLayerHarness(); env.raiseSweep(); - const landingSource = env.dataSources.find((source) => /Landing Points/.test(source.name || '')); - const referenceSource = env.dataSources.find((source) => /References/.test(source.name || '')); - landingSource.clustering._billboardCollection = new Cesium.BillboardCollection(); - landingSource.clustering._pointCollection = new Cesium.PointPrimitiveCollection(); - referenceSource.clustering._billboardCollection = new Cesium.BillboardCollection(); - referenceSource.clustering._pointCollection = new Cesium.PointPrimitiveCollection(); + const landingSource = env.dataSources.find((source) => + /Landing Points/.test(source.name || ''), + ); + const referenceSource = env.dataSources.find((source) => + /References/.test(source.name || ''), + ); + landingSource.clustering._billboardCollection = + new Cesium.BillboardCollection(); + landingSource.clustering._pointCollection = + new Cesium.PointPrimitiveCollection(); + referenceSource.clustering._billboardCollection = + new Cesium.BillboardCollection(); + referenceSource.clustering._pointCollection = + new Cesium.PointPrimitiveCollection(); for (const fn of env.listeners.moveEnd) fn(); env.raiseSweep(); assert.equal( @@ -291,10 +397,16 @@ test('marker collections blend in a single translucent pass behind a loud shape // both before committing either, so a reshaped reference source can never // leave the landing source already forced to TRANSLUCENT. const failEnv = await createRealCableLayerHarness(); - const failLanding = failEnv.dataSources.find((source) => /Landing Points/.test(source.name || '')); - const failReference = failEnv.dataSources.find((source) => /References/.test(source.name || '')); - failLanding.clustering._billboardCollection = new Cesium.BillboardCollection(); - failLanding.clustering._pointCollection = new Cesium.PointPrimitiveCollection(); + const failLanding = failEnv.dataSources.find((source) => + /Landing Points/.test(source.name || ''), + ); + const failReference = failEnv.dataSources.find((source) => + /References/.test(source.name || ''), + ); + failLanding.clustering._billboardCollection = + new Cesium.BillboardCollection(); + failLanding.clustering._pointCollection = + new Cesium.PointPrimitiveCollection(); delete failReference.clustering._pointCollection; // Cesium reshaped failEnv.raiseSweep(); assert.equal( @@ -336,18 +448,30 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov return sweeps; }; - assert.equal(gate.shouldRun(camera), true, 'initial dirtiness still covers enable/load'); + assert.equal( + gate.shouldRun(camera), + true, + 'initial dirtiness still covers enable/load', + ); // PARKED: 100 s of frames on a camera that never moves must cost ZERO // sweeps — the fallback is motion-aware, not a reinstated timer. - assert.equal(runFrames(6000), 0, 'a parked camera costs zero sweeps however long frames flow'); + assert.equal( + runFrames(6000), + 0, + 'a parked camera costs zero sweeps however long frames flow', + ); // Sub-epsilon jitter is not motion either: the probe compares against the // last SWEPT position, so numeric noise can never accumulate into a sweep. const jitter = runFrames(6000, (frame) => { camera.positionWC = Cesium.Cartesian3.add( origin, - new Cesium.Cartesian3(0, 0, (frame % 2) * (CABLE_SWEEP_MOTION_EPSILON_M / 10)), + new Cesium.Cartesian3( + 0, + 0, + (frame % 2) * (CABLE_SWEEP_MOTION_EPSILON_M / 10), + ), new Cesium.Cartesian3(), ); }); @@ -369,7 +493,11 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov ); if (timedGate.shouldRun(timedCamera)) sweepAt = clock; } - assert.notEqual(sweepAt, null, 'a continuously moving camera must not starve the sweep'); + assert.notEqual( + sweepAt, + null, + 'a continuously moving camera must not starve the sweep', + ); assert.ok( sweepAt - trackingStart <= CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS + frameMs, `the fallback sweep must land inside one probe window (landed at ${sweepAt - trackingStart} ms)`, @@ -382,25 +510,44 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov // moveEnd still wins immediately: an event-dirtied gate does not wait for // the probe window it is sitting inside. timedGate.markDirty(); - assert.equal(timedGate.shouldRun(timedCamera), true, 'moveEnd dirtiness bypasses the probe window'); - assert.equal(timedGate.shouldRun(timedCamera), false, 'and still runs exactly once'); + assert.equal( + timedGate.shouldRun(timedCamera), + true, + 'moveEnd dirtiness bypasses the probe window', + ); + assert.equal( + timedGate.shouldRun(timedCamera), + false, + 'and still runs exactly once', + ); // A gate called without a camera keeps the pure dirty-only contract. const cameraless = createCableReferenceSweepGate({ now: () => clock }); assert.equal(cameraless.shouldRun(), true); clock += CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS * 10; - assert.equal(cameraless.shouldRun(), false, 'no camera state means no motion fallback'); + assert.equal( + cameraless.shouldRun(), + false, + 'no camera state means no motion fallback', + ); }); function makeStemRecord(lon = -40, lat = 35) { const base = Cesium.Cartesian3.fromDegrees(lon, lat, 0); const tip = Cesium.Cartesian3.fromDegrees(lon, lat, 2500); - const stemPositionBuffers = [[base, tip], [base, tip]]; + const stemPositionBuffers = [ + [base, tip], + [base, tip], + ]; const setCalls = { position: 0, polyline: 0, polylineArrays: [] }; return { record: { entity: { - position: { setValue: () => { setCalls.position++; } }, + position: { + setValue: () => { + setCalls.position++; + }, + }, polyline: { positions: { setValue: (value) => { @@ -428,10 +575,17 @@ test('staticized stems redefine constants only on real tip changes, alternating const fov = Math.PI / 3; const camera = Cesium.Cartesian3.fromDegrees(-40, 35, 500_000); - assert.equal(updateCableReferenceStem(record, camera, canvasHeight, fov), true); + assert.equal( + updateCableReferenceStem(record, camera, canvasHeight, fov), + true, + ); assert.equal(setCalls.position, 1); assert.equal(setCalls.polyline, 1); - assert.equal(setCalls.polylineArrays[0], stemPositionBuffers[1], 'first real update selects the alternate buffer'); + assert.equal( + setCalls.polylineArrays[0], + stemPositionBuffers[1], + 'first real update selects the alternate buffer', + ); // Sub-epsilon camera noise must not redefine anything. const jitter = Cesium.Cartesian3.add( @@ -439,7 +593,10 @@ test('staticized stems redefine constants only on real tip changes, alternating new Cesium.Cartesian3(CABLE_STEM_TIP_EPSILON_M / 10, 0, 0), new Cesium.Cartesian3(), ); - assert.equal(updateCableReferenceStem(record, jitter, canvasHeight, fov), false); + assert.equal( + updateCableReferenceStem(record, jitter, canvasHeight, fov), + false, + ); assert.equal(setCalls.position, 1); assert.equal(setCalls.polyline, 1); @@ -450,7 +607,10 @@ test('staticized stems redefine constants only on real tip changes, alternating new Cesium.Cartesian3(0, 0, 400_000), new Cesium.Cartesian3(), ); - assert.equal(updateCableReferenceStem(record, moved, canvasHeight, fov), true); + assert.equal( + updateCableReferenceStem(record, moved, canvasHeight, fov), + true, + ); assert.equal(setCalls.polylineArrays[1], stemPositionBuffers[0]); assert.equal(new Set(setCalls.polylineArrays).size, 2); @@ -469,13 +629,47 @@ test('the depth decision is the dated Option-2 host migration, superseding Optio }); test('cables create no native labels and no per-frame geometry callbacks', () => { - const source = readFileSync(new URL('./telegeographySubmarineCables.js', import.meta.url), 'utf8'); - assert.doesNotMatch(source, /new Cesium\.LabelGraphics/, 'reverting to native labels must fail this pin'); - assert.doesNotMatch(source, /new Cesium\.CallbackProperty/, 'reverting to per-frame stem callbacks must fail this pin'); - assert.match(source, /setOverlayEntries/, 'labels must flow through the shared world-overlay host'); + const source = [ + '../../data/telegeographySubmarineCables.js', + 'rendering.js', + 'overlay.js', + 'surface.js', + 'ingestion.js', + 'interaction.js', + 'geometry.js', + 'lifecycle.js', + ] + .map((file) => + readFileSync( + new URL('../layers/submarineCables/' + file, import.meta.url), + 'utf8', + ), + ) + .join('\n'); + assert.doesNotMatch( + source, + /new Cesium\.LabelGraphics/, + 'reverting to native labels must fail this pin', + ); + assert.doesNotMatch( + source, + /new Cesium\.CallbackProperty/, + 'reverting to per-frame stem callbacks must fail this pin', + ); + assert.match( + source, + /setOverlayEntries/, + 'labels must flow through the shared world-overlay host', + ); assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/); - assert.match(source, /const stemPositionBuffers = \[\[base, tip\], \[base, tip\]\]/); - assert.match(source, /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/); + assert.match( + source, + /const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/, + ); + assert.match( + source, + /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/, + ); }); const CABLE_FIXTURE = { @@ -485,7 +679,13 @@ const CABLE_FIXTURE = { type: 'Feature', id: 'ac1', properties: { id: 'ac1', name: 'Atlantic Crossing-1' }, - geometry: { type: 'LineString', coordinates: [[-40, 35], [-30, 40]] }, + geometry: { + type: 'LineString', + coordinates: [ + [-40, 35], + [-30, 40], + ], + }, }, ], }; @@ -498,17 +698,34 @@ const LANDING_FIXTURE = { { type: 'Feature', id: 'lisbon', - properties: { id: 'lisbon', name: 'Lisbon, Portugal', coordinates: [-9.1, 38.7] }, - geometry: { type: 'LineString', coordinates: [[-9.1, 38.7], [-9.05, 38.72]] }, + properties: { + id: 'lisbon', + name: 'Lisbon, Portugal', + coordinates: [-9.1, 38.7], + }, + geometry: { + type: 'LineString', + coordinates: [ + [-9.1, 38.7], + [-9.05, 38.72], + ], + }, }, ], }; function fixtureForUrl(url) { - return String(url).includes('landing-point') ? LANDING_FIXTURE : CABLE_FIXTURE; + return String(url).includes('landing-point') + ? LANDING_FIXTURE + : CABLE_FIXTURE; } -function makeStubViewer(listeners, dataSources, addCalls = { count: 0 }, addControl = null) { +function makeStubViewer( + listeners, + dataSources, + addCalls = { count: 0 }, + addControl = null, +) { return { dataSources: { // Mirror Cesium's real DataSourceCollection.add(): the returned promise @@ -527,7 +744,11 @@ function makeStubViewer(listeners, dataSources, addCalls = { count: 0 }, addCont const settle = () => { dataSources.push(source); if (addControl?.rejectAfterPush?.has(callIndex)) { - reject(new Error('dataSourceAdded listener threw after the push landed')); + reject( + new Error( + 'dataSourceAdded listener threw after the push landed', + ), + ); return; } resolve(source); @@ -558,7 +779,9 @@ function makeStubViewer(listeners, dataSources, addCalls = { count: 0 }, addCont scene: { canvas: { clientHeight: 900 }, requestRender() {}, - pick() { return null; }, + pick() { + return null; + }, preRender: { addEventListener: (fn) => { listeners.preRender.add(fn); @@ -581,33 +804,41 @@ function createDeferredCableLayerHarness() { const addCalls = { count: 0 }; const addControl = { held: false, pending: [], rejectAfterPush: new Set() }; const pendingFetches = []; - const abortError = () => Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }); + const abortError = () => + Object.assign(new Error('The operation was aborted'), { + name: 'AbortError', + }); const originalFetch = globalThis.fetch; - globalThis.fetch = (url, options = {}) => new Promise((resolve, reject) => { - const entry = { - url: String(url), - settled: false, - release() { - if (entry.settled) return; - entry.settled = true; - resolve({ ok: true, json: async () => fixtureForUrl(url) }); - }, - }; - const signal = options?.signal; - if (signal) { - if (signal.aborted) { - entry.settled = true; - reject(abortError()); - return; + globalThis.fetch = (url, options = {}) => + new Promise((resolve, reject) => { + const entry = { + url: String(url), + settled: false, + release() { + if (entry.settled) return; + entry.settled = true; + resolve({ ok: true, json: async () => fixtureForUrl(url) }); + }, + }; + const signal = options?.signal; + if (signal) { + if (signal.aborted) { + entry.settled = true; + reject(abortError()); + return; + } + signal.addEventListener( + 'abort', + () => { + if (entry.settled) return; + entry.settled = true; + reject(abortError()); + }, + { once: true }, + ); } - signal.addEventListener('abort', () => { - if (entry.settled) return; - entry.settled = true; - reject(abortError()); - }, { once: true }); - } - pendingFetches.push(entry); - }); + pendingFetches.push(entry); + }); const layer = createTeleGeographySubmarineCableLayer({ overlayHost: { @@ -615,7 +846,10 @@ function createDeferredCableLayerHarness() { setEntries: (...args) => hostCalls.push(['entries', ...args]), clearSource: (...args) => hostCalls.push(['clear', ...args]), }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + screenSpaceEventHandlerFactory: () => ({ + setInputAction() {}, + destroy() {}, + }), }); const viewer = makeStubViewer(listeners, dataSources, addCalls, addControl); layer.init(viewer); @@ -651,7 +885,8 @@ function createDeferredCableLayerHarness() { }, async settle() { // Drain the microtask/timer interleave the load path spans. - for (let i = 0; i < 8; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 0)); }, cleanup() { globalThis.fetch = originalFetch; @@ -672,9 +907,12 @@ function installGeoJsonLoadGate() { Cesium.GeoJsonDataSource.load = function gatedLoad(...args) { calls.push(args); let open; - const opened = new Promise((resolve) => { open = resolve; }); + const opened = new Promise((resolve) => { + open = resolve; + }); gates.push({ open }); - return original.apply(Cesium.GeoJsonDataSource, args) + return original + .apply(Cesium.GeoJsonDataSource, args) .then((result) => opened.then(() => result)); }; return { @@ -711,12 +949,20 @@ test('an abort landing at the post-fetch boundary stops the stale load before Ge env.layer.disable(); env.layer.enable(env.viewer); await env.settle(); - assert.equal(geojson.calls.length, 0, 'stale load A must not reach GeoJsonDataSource.load'); + assert.equal( + geojson.calls.length, + 0, + 'stale load A must not reach GeoJsonDataSource.load', + ); env.releaseAll(); await env.settle(); await geojson.drainOpen(() => env.settle()); - assert.equal(geojson.calls.length, 2, 'load B alone performs the two GeoJson loads'); + assert.equal( + geojson.calls.length, + 2, + 'load B alone performs the two GeoJson loads', + ); assert.equal(env.addCalls.count, 3); assert.equal(env.dataSources.length, 3); assert.equal(env.layer.getStats().count, 2); @@ -735,7 +981,11 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b env.releaseAll(); await env.settle(); assert.equal(env.pendingFetches.length, 0); - assert.equal(geojson.calls.length, 1, 'load A is parked on the gated cable GeoJson build'); + assert.equal( + geojson.calls.length, + 1, + 'load A is parked on the gated cable GeoJson build', + ); // Open the cable gate and abort in the same frame: A resumes at the // post-cable owns() check with ownership lost. @@ -746,17 +996,32 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b // A already cached the fetched JSON, so load B skips the network and goes // straight to its own CABLE build; the only new GeoJson call must be that // one — a landing build here would mean stale A ran on past its check. - assert.equal(env.pendingFetches.length, 0, 'load B rebuilds from the cached JSON without refetching'); - assert.equal(geojson.calls.length, 2, 'stale load A must not start the landing GeoJson build'); + assert.equal( + env.pendingFetches.length, + 0, + 'load B rebuilds from the cached JSON without refetching', + ); + assert.equal( + geojson.calls.length, + 2, + 'stale load A must not start the landing GeoJson build', + ); assert.ok( - Cesium.Color.equals(geojson.calls[1][1].stroke, geojson.calls[0][1].stroke), - 'the second GeoJson call is load B\'s cable build, not A\'s landing build', + Cesium.Color.equals( + geojson.calls[1][1].stroke, + geojson.calls[0][1].stroke, + ), + "the second GeoJson call is load B's cable build, not A's landing build", ); env.releaseAll(); await env.settle(); await geojson.drainOpen(() => env.settle()); - assert.equal(geojson.calls.length, 3, 'load B adds its own two GeoJson builds'); + assert.equal( + geojson.calls.length, + 3, + 'load B adds its own two GeoJson builds', + ); assert.equal(env.addCalls.count, 3); assert.equal(env.dataSources.length, 3); assert.equal(env.layer.getStats().count, 2); @@ -776,7 +1041,11 @@ test('an abort landing at the pre-add boundary stops the stale load before any d await env.settle(); geojson.open(0); await env.settle(); - assert.equal(geojson.calls.length, 2, 'load A is parked on the gated landing GeoJson build'); + assert.equal( + geojson.calls.length, + 2, + 'load A is parked on the gated landing GeoJson build', + ); // Open the landing gate and abort in the same frame: A resumes at the // pre-add owns() check with ownership lost. @@ -784,7 +1053,11 @@ test('an abort landing at the pre-add boundary stops the stale load before any d env.layer.disable(); env.layer.enable(env.viewer); await env.settle(); - assert.equal(env.addCalls.count, 0, 'stale load A must never call dataSources.add'); + assert.equal( + env.addCalls.count, + 0, + 'stale load A must never call dataSources.add', + ); env.releaseAll(); await env.settle(); @@ -812,8 +1085,16 @@ test('a destroy inside the deferred add window leaves zero post-destroy data sou while (env.addCalls.count < 3 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 0)); } - assert.equal(env.addCalls.count, 3, 'all three adds are in their deferred window'); - assert.equal(env.dataSources.length, 0, 'nothing has settled into the collection yet'); + assert.equal( + env.addCalls.count, + 3, + 'all three adds are in their deferred window', + ); + assert.equal( + env.dataSources.length, + 0, + 'nothing has settled into the collection yet', + ); // Destroy lands in the window: it can remove nothing, so the stale // load's own post-add ownership check must compensate once the deferred @@ -831,7 +1112,11 @@ test('a destroy inside the deferred add window leaves zero post-destroy data sou env.layer.enable(env.viewer); env.releaseAll(); await env.settle(); - assert.equal(env.dataSources.length, 3, 're-enable after the raced destroy loads cleanly'); + assert.equal( + env.dataSources.length, + 3, + 're-enable after the raced destroy loads cleanly', + ); assert.equal(env.layer.getStats().count, 2); } finally { env.layer.destroy(env.viewer); @@ -854,8 +1139,16 @@ test('a destroy in the deferred window with a rejected-after-push add still leav while (env.addCalls.count < 3 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 0)); } - assert.equal(env.addCalls.count, 3, 'all three adds are in their deferred window'); - assert.equal(env.dataSources.length, 0, 'nothing has settled into the collection yet'); + assert.equal( + env.addCalls.count, + 3, + 'all three adds are in their deferred window', + ); + assert.equal( + env.dataSources.length, + 0, + 'nothing has settled into the collection yet', + ); // Destroy lands in the window; the adds then settle as // [rejected-after-push, fulfilled, fulfilled]. Compensation that removes @@ -895,7 +1188,9 @@ test('a direct destroy of a published layer clears and hides its host source', a // a visible overlay source with orphan labels. env.layer.destroy(env.viewer); assert.ok( - env.hostCalls.some(([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID), + env.hostCalls.some( + ([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID, + ), 'direct destroy must clear the published host source', ); assert.deepEqual( @@ -908,12 +1203,16 @@ test('a direct destroy of a published layer clears and hides its host source', a } }); -test('a stale aborted load never clobbers the next load\'s lifecycle (A/B/C toggle race)', async () => { +test("a stale aborted load never clobbers the next load's lifecycle (A/B/C toggle race)", async () => { const env = createDeferredCableLayerHarness(); try { // A: enable starts a load whose fetches stay pending. env.layer.enable(env.viewer); - assert.equal(env.pendingFetches.length, 2, 'load A has two fetches in flight'); + assert.equal( + env.pendingFetches.length, + 2, + 'load A has two fetches in flight', + ); // Abort A and immediately start B before A\'s rejection lands. env.layer.disable(); @@ -925,13 +1224,23 @@ test('a stale aborted load never clobbers the next load\'s lifecycle (A/B/C togg // third load. void env.layer.update(env.viewer); await env.settle(); - const inFlight = env.pendingFetches.filter((entry) => !entry.settled).length; - assert.equal(inFlight, 2, 'only load B\'s two fetches remain in flight — no duplicate load C'); + const inFlight = env.pendingFetches.filter( + (entry) => !entry.settled, + ).length; + assert.equal( + inFlight, + 2, + "only load B's two fetches remain in flight — no duplicate load C", + ); // Releasing everything must produce exactly one set of scene adds. env.releaseAll(); await env.settle(); - assert.equal(env.addCalls.count, 3, 'exactly one cable/landing/reference data-source trio is added'); + assert.equal( + env.addCalls.count, + 3, + 'exactly one cable/landing/reference data-source trio is added', + ); assert.equal(env.dataSources.length, 3); const stats = env.layer.getStats(); assert.equal(stats.error, null); @@ -959,12 +1268,18 @@ test('destroy mid-load stays clean and a re-enabled layer reloads exactly once', // update() must not start a duplicate load E. void env.layer.update(env.viewer); await env.settle(); - const inFlight = env.pendingFetches.filter((entry) => !entry.settled).length; - assert.equal(inFlight, 2, 'only load D\'s two fetches remain in flight'); + const inFlight = env.pendingFetches.filter( + (entry) => !entry.settled, + ).length; + assert.equal(inFlight, 2, "only load D's two fetches remain in flight"); env.releaseAll(); await env.settle(); - assert.equal(env.addCalls.count, 3, 'the destroyed load added nothing; the re-enable added once'); + assert.equal( + env.addCalls.count, + 3, + 'the destroyed load added nothing; the re-enable added once', + ); assert.equal(env.dataSources.length, 3); assert.equal(env.layer.getStats().count, 2); } finally { @@ -973,7 +1288,10 @@ test('destroy mid-load stays clean and a re-enabled layer reloads exactly once', } }); -async function createRealCableLayerHarness({ mapStackEventTarget = null } = {}) { +async function createRealCableLayerHarness({ + source, + mapStackEventTarget = null, +} = {}) { const hostCalls = []; // The sweep gate's motion-fallback clock is FROZEN here and advanced only by // the tests that exercise it, so every other harness test observes the pure @@ -985,7 +1303,11 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {}) setEntries: (...args) => hostCalls.push(['entries', ...args]), clearSource: (...args) => hostCalls.push(['clear', ...args]), }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + screenSpaceEventHandlerFactory: () => ({ + setInputAction() {}, + destroy() {}, + }), + ...(source ? { source } : {}), mapStackEventTarget, sweepClock: () => clockMs, }); @@ -994,7 +1316,10 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {}) const dataSources = []; const viewer = { dataSources: { - add: (source) => { dataSources.push(source); return Promise.resolve(source); }, + add: (source) => { + dataSources.push(source); + return Promise.resolve(source); + }, remove: (source) => { const index = dataSources.indexOf(source); if (index >= 0) dataSources.splice(index, 1); @@ -1017,7 +1342,9 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {}) scene: { canvas: { clientHeight: 900 }, requestRender() {}, - pick() { return null; }, + pick() { + return null; + }, preRender: { addEventListener: (fn) => { listeners.preRender.add(fn); @@ -1033,29 +1360,46 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {}) // `properties.coordinates`, so a line geometry exercises the same path. globalThis.fetch = async (url) => ({ ok: true, - json: async () => (String(url).includes('landing-point') - ? { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'lisbon', - properties: { id: 'lisbon', name: 'Lisbon, Portugal', coordinates: [-9.1, 38.7] }, - geometry: { type: 'LineString', coordinates: [[-9.1, 38.7], [-9.05, 38.72]] }, + json: async () => + String(url).includes('landing-point') + ? { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'lisbon', + properties: { + id: 'lisbon', + name: 'Lisbon, Portugal', + coordinates: [-9.1, 38.7], + }, + geometry: { + type: 'LineString', + coordinates: [ + [-9.1, 38.7], + [-9.05, 38.72], + ], + }, + }, + ], + } + : { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'ac1', + properties: { id: 'ac1', name: 'Atlantic Crossing-1' }, + geometry: { + type: 'LineString', + coordinates: [ + [-40, 35], + [-30, 40], + ], + }, + }, + ], }, - ], - } - : { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'ac1', - properties: { id: 'ac1', name: 'Atlantic Crossing-1' }, - geometry: { type: 'LineString', coordinates: [[-40, 35], [-30, 40]] }, - }, - ], - }), }); try { layer.init(viewer); @@ -1075,8 +1419,18 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {}) const raiseSweep = () => { for (const fn of listeners.preRender) fn(); }; - const advanceClock = (ms) => { clockMs += ms; }; - return { layer, viewer, hostCalls, dataSources, raiseSweep, listeners, advanceClock }; + const advanceClock = (ms) => { + clockMs += ms; + }; + return { + layer, + viewer, + hostCalls, + dataSources, + raiseSweep, + listeners, + advanceClock, + }; } test('a real enabled cable layer publishes host entries and has zero native labels', async () => { @@ -1095,7 +1449,9 @@ test('a real enabled cable layer publishes host entries and has zero native labe moving: false, }); - const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || '')); + const referenceSource = env.dataSources.find((ds) => + /References/.test(ds.name || ''), + ); assert.ok(referenceSource, 'reference data source must exist'); const referenceEntities = referenceSource.entities.values; assert.ok(referenceEntities.length > 0); @@ -1104,8 +1460,11 @@ test('a real enabled cable layer publishes host entries and has zero native labe 'no reference entity may carry a native LabelGraphics', ); assert.ok( - referenceEntities.every((entity) => entity.position?.isConstant === true - && entity.polyline?.positions?.isConstant === true), + referenceEntities.every( + (entity) => + entity.position?.isConstant === true && + entity.polyline?.positions?.isConstant === true, + ), 'stem properties must be constants, never per-frame callbacks', ); @@ -1114,7 +1473,9 @@ test('a real enabled cable layer publishes host entries and has zero native labe env.layer.disable(); assert.ok( - env.hostCalls.some(([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID), + env.hostCalls.some( + ([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID, + ), 'disable must clear the host source', ); assert.deepEqual( @@ -1124,17 +1485,25 @@ test('a real enabled cable layer publishes host entries and has zero native labe ); env.layer.destroy(env.viewer); - assert.equal(env.dataSources.length, 0, 'destroy must remove every data source'); + assert.equal( + env.dataSources.length, + 0, + 'destroy must remove every data source', + ); }); test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async () => { const env = await createRealCableLayerHarness(); env.raiseSweep(); // initial sizing at the parked camera - const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || '')); + const referenceSource = env.dataSources.find((ds) => + /References/.test(ds.name || ''), + ); const entity = referenceSource.entities.values[0]; let stemRebuilds = 0; - const originalSet = entity.polyline.positions.setValue.bind(entity.polyline.positions); + const originalSet = entity.polyline.positions.setValue.bind( + entity.polyline.positions, + ); entity.polyline.positions.setValue = (...args) => { stemRebuilds++; return originalSet(...args); @@ -1154,12 +1523,20 @@ test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async env.raiseSweep(); // preRender fires every frame during a drag await new Promise((resolve) => setTimeout(resolve, 25)); } - assert.equal(stemRebuilds, 0, 'render ticks without moveEnd must never redefine a stem'); + assert.equal( + stemRebuilds, + 0, + 'render ticks without moveEnd must never redefine a stem', + ); // The gesture ends close over the cable's reference point (inside the // 700 m..85 km stem clamp band, so the re-size is a real tip change): // one moveEnd, one sweep, exactly one redefinition. - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-35, 37.5, 400_000); + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -35, + 37.5, + 400_000, + ); for (const fn of env.listeners.moveEnd) fn(); env.raiseSweep(); assert.equal(stemRebuilds, 1, 'moveEnd re-sizes the stem exactly once'); @@ -1175,13 +1552,21 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' // inside the 700 m..85 km clamp band, so a sweep is observable as a real // tip re-size (at the harness's 4,500 km parking altitude every stem sits // pinned at the 85 km clamp and no sweep could be distinguished). - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-35, 37.5, 400_000); + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -35, + 37.5, + 400_000, + ); env.raiseSweep(); // initial sizing at the tracking handoff - const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || '')); + const referenceSource = env.dataSources.find((ds) => + /References/.test(ds.name || ''), + ); const entity = referenceSource.entities.values[0]; let stemRebuilds = 0; - const originalSet = entity.polyline.positions.setValue.bind(entity.polyline.positions); + const originalSet = entity.polyline.positions.setValue.bind( + entity.polyline.positions, + ); entity.polyline.positions.setValue = (...args) => { stemRebuilds++; return originalSet(...args); @@ -1206,11 +1591,19 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' // Inside the first probe window the sweep stays event-dirty-only, so the // batched stem primitive is not rebuilt mid-motion (the shipped tradeoff). trackFrames(0, 60); - assert.equal(stemRebuilds, 0, 'inside the probe window a tracked camera still costs no rebuilds'); + assert.equal( + stemRebuilds, + 0, + 'inside the probe window a tracked camera still costs no rebuilds', + ); // Past the window the fallback fires — exactly once, not per frame. trackFrames(60, 130); - assert.equal(stemRebuilds, 1, 'the tracked camera earns exactly one fallback sweep per window'); + assert.equal( + stemRebuilds, + 1, + 'the tracked camera earns exactly one fallback sweep per window', + ); // Tracking stops but frames keep rendering. The motion accumulated since // the last fallback sweep settles in exactly ONE further sweep... @@ -1221,18 +1614,27 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback' } }; parkFrames(240); // 4 s parked: two full probe windows - assert.equal(stemRebuilds, 2, 'a stopped camera settles its residual motion in one further sweep'); + assert.equal( + stemRebuilds, + 2, + 'a stopped camera settles its residual motion in one further sweep', + ); // ...and the parked camera then costs zero sweeps for as long as it sits. parkFrames(3600); // 60 s parked - assert.equal(stemRebuilds, 2, 'a settled parked camera never spends another sweep'); + assert.equal( + stemRebuilds, + 2, + 'a settled parked camera never spends another sweep', + ); env.layer.destroy(env.viewer); }); test('an unchanged cohort never republishes, so a parked camera stays governor-idle', async () => { const env = await createRealCableLayerHarness(); - const publishCount = () => env.hostCalls.filter(([type]) => type === 'entries').length; + const publishCount = () => + env.hostCalls.filter(([type]) => type === 'entries').length; env.raiseSweep(); assert.equal(publishCount(), 1, 'first sweep publishes the cohort'); @@ -1247,7 +1649,11 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i // A real camera move changes distances beyond the 50 km priority quantum, // so the next sweep publishes again. - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-40, 35, 6_500_000); + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -40, + 35, + 6_500_000, + ); for (const fn of env.listeners.moveEnd) fn(); env.raiseSweep(); assert.equal(publishCount(), 2, 'a changed cohort publishes'); @@ -1258,24 +1664,39 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i env.layer.enable(env.viewer); await new Promise((resolve) => setTimeout(resolve, 10)); env.raiseSweep(); - assert.equal(publishCount(), 3, 're-enable republishes after the hide cleared the source'); + assert.equal( + publishCount(), + 3, + 're-enable republishes after the hide cleared the source', + ); env.layer.destroy(env.viewer); }); test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => { const env = await createRealCableLayerHarness(); - assert.equal(env.dataSources.length, 3, 'cable + landing + reference sources while on'); + assert.equal( + env.dataSources.length, + 3, + 'cable + landing + reference sources while on', + ); const before = env.layer.getStats().count; assert.equal(before, 2); // Any fetch after this point is a bug: the rebuild must come from the cache. const originalFetch = globalThis.fetch; let fetches = 0; - globalThis.fetch = async () => { fetches += 1; throw new Error('unexpected refetch'); }; + globalThis.fetch = async () => { + fetches += 1; + throw new Error('unexpected refetch'); + }; try { env.layer.disable(); - assert.equal(env.dataSources.length, 0, 'hidden entities are not free; disable must remove the sources'); + assert.equal( + env.dataSources.length, + 0, + 'hidden entities are not free; disable must remove the sources', + ); env.layer.enable(env.viewer); const deadline = Date.now() + 2_000; @@ -1286,8 +1707,13 @@ test('a toggle-off releases every data source and a toggle-on rebuilds them with assert.equal(env.layer.getStats().error, null); assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio'); assert.equal(env.layer.getStats().count, before); - const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || '')); - assert.ok(referenceSource?.entities.values.length > 0, 'reference stems are rebuilt'); + const referenceSource = env.dataSources.find((ds) => + /References/.test(ds.name || ''), + ); + assert.ok( + referenceSource?.entities.values.length > 0, + 'reference stems are rebuilt', + ); env.layer.destroy(env.viewer); assert.equal(env.dataSources.length, 0); @@ -1295,3 +1721,34 @@ test('a toggle-off releases every data source and a toggle-on rebuilds them with globalThis.fetch = originalFetch; } }); + +test('another GeoJSON source renders with its own label and rebuilds from accepted cache', async () => { + let reads = 0; + const source = { + label: 'Test cable directory', + async fetch(signal) { + signal.throwIfAborted(); + reads += 1; + return { cables: CABLE_FIXTURE, landingPoints: LANDING_FIXTURE }; + }, + }; + const env = await createRealCableLayerHarness({ source }); + try { + assert.equal(env.layer.source, source.label); + assert.equal(reads, 1); + assert.equal(env.dataSources.length, 3); + assert.ok( + env.dataSources.every((item) => item.name.startsWith(source.label)), + ); + env.layer.disable(); + assert.equal(env.dataSources.length, 0); + env.layer.enable(env.viewer); + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(env.layer.getStats().count, 2); + assert.equal(env.dataSources.length, 3); + assert.equal(reads, 1); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/layers/submarineCables/bundledSource.js b/src/layers/submarineCables/bundledSource.js new file mode 100644 index 000000000..6aa0b70bb --- /dev/null +++ b/src/layers/submarineCables/bundledSource.js @@ -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 }; + }, + }; +} diff --git a/src/layers/submarineCables/bundledSource.test.mjs b/src/layers/submarineCables/bundledSource.test.mjs new file mode 100644 index 000000000..d229254ae --- /dev/null +++ b/src/layers/submarineCables/bundledSource.test.mjs @@ -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' }); +}); diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js new file mode 100644 index 000000000..08366dba4 --- /dev/null +++ b/src/layers/submarineCables/geometry.js @@ -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), + }; + }); +} diff --git a/src/layers/submarineCables/index.js b/src/layers/submarineCables/index.js new file mode 100644 index 000000000..5de4fe6e2 --- /dev/null +++ b/src/layers/submarineCables/index.js @@ -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'; diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js new file mode 100644 index 000000000..eaefbeb39 --- /dev/null +++ b/src/layers/submarineCables/ingestion.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 }; +} diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js new file mode 100644 index 000000000..08e3384ed --- /dev/null +++ b/src/layers/submarineCables/interaction.js @@ -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, + }; +} diff --git a/src/layers/submarineCables/lifecycle.js b/src/layers/submarineCables/lifecycle.js new file mode 100644 index 000000000..39e41f630 --- /dev/null +++ b/src/layers/submarineCables/lifecycle.js @@ -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, + }; + }, + }; +} diff --git a/src/layers/submarineCables/overlay.js b/src/layers/submarineCables/overlay.js new file mode 100644 index 000000000..a51d2769b --- /dev/null +++ b/src/layers/submarineCables/overlay.js @@ -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))}...`; +} diff --git a/src/layers/submarineCables/policy.js b/src/layers/submarineCables/policy.js new file mode 100644 index 000000000..68009af2e --- /dev/null +++ b/src/layers/submarineCables/policy.js @@ -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(); +} diff --git a/src/layers/submarineCables/rendering.js b/src/layers/submarineCables/rendering.js new file mode 100644 index 000000000..91936573f --- /dev/null +++ b/src/layers/submarineCables/rendering.js @@ -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, + }; +} diff --git a/src/layers/submarineCables/state.js b/src/layers/submarineCables/state.js new file mode 100644 index 000000000..0761688aa --- /dev/null +++ b/src/layers/submarineCables/state.js @@ -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; +} diff --git a/src/layers/submarineCables/surface.js b/src/layers/submarineCables/surface.js new file mode 100644 index 000000000..36556bbb3 --- /dev/null +++ b/src/layers/submarineCables/surface.js @@ -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); +}