From 8e68a29c5079a58fe133dd4589ea0ec764d4b701 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:36:55 -0700 Subject: [PATCH 1/3] fix(cables): preserve feature ownership across multipart geometry Associate expanded cable and landing entities through a copied source index, retain one reference stem per feature, and cover duplicate IDs, bundled segments and frozen provider inputs with real Cesium regressions. --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 8 + .../telegeographySubmarineCables.test.mjs | 249 ++++++++++++++++++ src/layers/submarineCables/ingestion.js | 34 ++- 4 files changed, 291 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..f45d71d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Keep every submarine-cable segment associated with its source feature after + Cesium expands multipart geometry, correcting segment colors, click destinations + and horizon culling while retaining one reference stem per feature. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..33e4c36b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5002,6 +5002,14 @@ 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. +Multipart cable and landing-point geometry retains its source feature on every +Cesium entity, including generated IDs and IDs that collide with Cesium's +multipart suffixes. Each segment receives its own feature's styling, click +destination and horizon-culling reference; each source feature has one reference +stem. Source collections and their properties are not modified. The copied input +adds `__gevSourceFeatureIndex`, which remains observable in Cesium properties +and default descriptions. + 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 diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 3850748dc..110f65f18 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept env.layer.destroy(env.viewer); } }); + +function multipartCableFixture() { + return [ + { + type: 'Feature', + properties: { + id: 'alpha', + name: 'Alpha cable', + color: '#ff0000', + coordinates: [-40, 35], + }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-40, 35], + [-39, 36], + ], + [ + [-38, 37], + [-37, 38], + ], + [ + [-36, 39], + [-35, 40], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + id: 'beta', + name: 'Beta cable', + color: '#0000ff', + coordinates: [140, -35], + }, + geometry: { + type: 'LineString', + coordinates: [ + [140, -35], + [141, -34], + ], + }, + }, + ]; +} + +function geometrySource(cables, landingPoints = []) { + return { + label: 'Multipart test source', + async fetch() { + return { + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landingPoints }, + }; + }, + }; +} + +test('multipart association accepts frozen provider collections without modifying properties', async () => { + const payload = { + cables: { type: 'FeatureCollection', features: multipartCableFixture() }, + landingPoints: { + type: 'FeatureCollection', + features: multipartCableFixture(), + }, + }; + payload.cables.features[0].properties.__gevSourceFeatureIndex = + 'provider value'; + const before = structuredClone(payload); + const freeze = (value) => { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; + }; + freeze(payload); + const env = await createRealCableLayerHarness({ + source: { label: 'Frozen multipart source', fetch: async () => payload }, + }); + try { + for (const dataSource of env.dataSources.slice(0, 2)) { + assert.deepEqual( + dataSource.entities.values.map( + (entity) => entity.__gevTeleGeography?.label, + ), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + } + assert.equal(env.dataSources[2].entities.values.length, 4); + assert.deepEqual(payload, before); + const entity = env.dataSources[0].entities.values[0]; + assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); + assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart cables retain their own styling, picking and culling on every segment', async () => { + const features = multipartCableFixture(); + const before = JSON.stringify(features); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, 4); + for (const [index, entity] of entities.entries()) { + const feature = features[index < 3 ? 0 : 1]; + const info = entity.__gevTeleGeography; + assert.equal(info?.label, feature.properties.name, entity.id); + assert.deepEqual(info.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + assert.deepEqual( + entity.polyline.material.color.getValue(), + Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( + 0.92, + ), + ); + assert.equal(entity.polyline.width.getValue(), 2.5); + } + env.raiseSweep(); + assert.deepEqual( + entities.map((entity) => entity.show), + [true, true, true, false], + ); + assert.equal( + env.dataSources[2].entities.values.length, + 2, + 'one reference per feature', + ); + assert.equal( + JSON.stringify(features), + before, + 'source features stay unchanged', + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { + const features = multipartCableFixture(); + features[1].id = 'alpha_2'; + delete features[1].properties.id; + const generated = structuredClone(features[1]); + delete generated.id; + generated.properties.name = 'Generated cable'; + features.push(generated); + const repeated = structuredClone(features[1]); + repeated.properties.id = 'alpha'; + repeated.properties.name = 'Repeated ID cable'; + features.push(repeated); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.deepEqual( + entities.map((entity) => entity.id), + ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], + ); + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ], + ); + assert.deepEqual( + env.dataSources[2].entities.values.map( + (entity) => entity.__gevTeleGeography.featureId, + ), + ['alpha', 'alpha_2', 'cable-2', 'alpha'], + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart landing geometry keeps every pick with one reference per feature', async () => { + const features = multipartCableFixture(); + const env = await createRealCableLayerHarness({ + source: geometrySource([], features), + }); + try { + const entities = env.dataSources[1].entities.values; + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + assert.ok( + entities.every( + (entity) => entity.__gevTeleGeography.kind === 'landing-point', + ), + ); + assert.equal(env.dataSources[2].entities.values.length, 2); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('every bundled cable segment is associated with its source feature', async () => { + const { features } = JSON.parse( + readFileSync( + new URL( + './local_data/telegeography_submarine_cables/cable-geo.json', + import.meta.url, + ), + 'utf8', + ), + ); + // Some bundled features share a cable ID but have different references. + const expandedFeatures = features.flatMap((feature) => + feature.geometry.coordinates.map(() => feature), + ); + assert.ok(expandedFeatures.length > features.length); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, expandedFeatures.length); + for (const [index, entity] of entities.entries()) { + const feature = expandedFeatures[index]; + assert.equal( + entity.__gevTeleGeography?.label, + feature.properties.name, + entity.id, + ); + assert.deepEqual(entity.__gevTeleGeography.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + } + assert.equal(env.dataSources[2].entities.values.length, features.length); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index eaefbeb39..3779af9c9 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -5,6 +5,21 @@ import { } from './geometry.js'; import * as Cesium from 'cesium'; +const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; + +function indexedCollection(features) { + return { + type: 'FeatureCollection', + features: features.map((feature, index) => ({ + ...feature, + properties: { + ...feature.properties, + [SOURCE_FEATURE_INDEX]: index, + }, + })), + }; +} + export function createIngestion({ state, parts, source }) { async function load(viewer) { if (state._loading || state._loaded) return; @@ -48,7 +63,7 @@ export function createIngestion({ state, parts, source }) { const landingFeatures = normalizeFeatures(landingJson, 'landing'); const cableDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: cableFeatures }, + indexedCollection(cableFeatures), { clampToGround: true, stroke: state.cableColor.withAlpha(0.95), @@ -60,7 +75,7 @@ export function createIngestion({ state, parts, source }) { ); if (!owns()) return; const landingDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: landingFeatures }, + indexedCollection(landingFeatures), { clampToGround: true, stroke: state.landingColor.withAlpha(0.9), @@ -130,7 +145,12 @@ export function createIngestion({ state, parts, source }) { state._referenceRecords = []; state._surfaceRecords = []; - cableEntities.forEach((entity, index) => { + // Cesium expands multipart geometry into several entities and suffixes + // colliding IDs. Its copied properties retain this source index even + // when a provider has no ID, so every segment keeps the right owner. + const cableReferences = new Set(); + cableEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = cableFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -145,6 +165,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (cableReferences.has(index)) return; + cableReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), @@ -154,7 +176,9 @@ export function createIngestion({ state, parts, source }) { }); }); - landingEntities.forEach((entity, index) => { + const landingReferences = new Set(); + landingEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = landingFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -169,6 +193,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (landingReferences.has(index)) return; + landingReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), From 58a41759b52e795268e1be6cb2d1fa84547b2630 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:59 +0800 Subject: [PATCH 2/3] test: resolve sky texture file URLs on Windows --- src/tooling/skyAssets.paintOut.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs index ddb57589a..78a91519e 100644 --- a/src/tooling/skyAssets.paintOut.test.mjs +++ b/src/tooling/skyAssets.paintOut.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { CUBE_FACES, @@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn })); for (const face of CUBE_FACES) { const { data, info } = await sharp( - new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, + fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)), ) .raw() .toBuffer({ resolveWithObject: true }); From 8a23328ed13319c91f07295feba76f6a06961a4d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:00:48 -0800 Subject: [PATCH 3/3] Describe multipart cable ownership by behaviour and credit the author's handle --- .../1093-multipart-cable-feature-ownership.md | 2 +- docs/CURRENT-STATE.md | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/changelog.d/1093-multipart-cable-feature-ownership.md b/changelog.d/1093-multipart-cable-feature-ownership.md index 65892e9a9..777a159d0 100644 --- a/changelog.d/1093-multipart-cable-feature-ownership.md +++ b/changelog.d/1093-multipart-cable-feature-ownership.md @@ -1,4 +1,4 @@ - Keep every submarine-cable segment associated with its source feature after Cesium expands multipart geometry, correcting segment colors, click destinations and horizon culling while retaining one reference stem per feature - (Aliciawque, #1093). + (@Aliciawque, #1093). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6d6a52f09..3b66fc24d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6371,14 +6371,11 @@ 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. -Multipart cable and landing-point geometry retains its source feature on every -Cesium entity, including generated IDs and IDs that collide with Cesium's -multipart suffixes. Each segment receives its own feature's styling, click -destination and horizon-culling reference; each source feature has one reference -stem. Source collections and their properties are not modified. The copied input -adds `__gevSourceFeatureIndex`, which remains observable in Cesium properties -and default descriptions. The application viewer disables the Cesium info box, -so those default descriptions are not shown in the app. +Every Cesium entity from multipart cable or landing-point geometry stays tied +to its source feature, including generated IDs and IDs that collide with +Cesium's multipart suffixes. Each segment gets its own feature's styling, click +destination and horizon-culling reference, and each source feature has one +reference stem. The source collections are not modified. Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution