mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Merge pull request #1093 from Aliciawque/fix/multipart-cable-feature-ownership
fix(cables): preserve feature ownership across multipart geometry
This commit is contained in:
4 files changed
+289
-4
No files matched your search
@@ -0,0 +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).
|
||||||
@@ -6373,6 +6373,12 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache,
|
|||||||
and destroying clears it. Load ownership prevents cancelled work from adding
|
and destroying clears it. Load ownership prevents cancelled work from adding
|
||||||
entities after teardown.
|
entities after teardown.
|
||||||
|
|
||||||
|
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.
|
Natural Earth regions and neighborhood polygon lookup are package exports.
|
||||||
Their existing lazy loaders, retry behavior, bundled datasets and attribution
|
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
|
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
|
||||||
|
|||||||
@@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept
|
|||||||
env.layer.destroy(env.viewer);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,6 +6,21 @@ import {
|
|||||||
} from './geometry.js';
|
} from './geometry.js';
|
||||||
import * as Cesium from 'cesium';
|
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 }) {
|
export function createIngestion({ state, parts, source }) {
|
||||||
async function load(viewer) {
|
async function load(viewer) {
|
||||||
if (state._loading || state._loaded) return;
|
if (state._loading || state._loaded) return;
|
||||||
@@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
||||||
|
|
||||||
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
||||||
{ type: 'FeatureCollection', features: cableFeatures },
|
indexedCollection(cableFeatures),
|
||||||
{
|
{
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
stroke: state.cableColor.withAlpha(0.95),
|
stroke: state.cableColor.withAlpha(0.95),
|
||||||
@@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
);
|
);
|
||||||
if (!owns()) return;
|
if (!owns()) return;
|
||||||
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
||||||
{ type: 'FeatureCollection', features: landingFeatures },
|
indexedCollection(landingFeatures),
|
||||||
{
|
{
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
stroke: state.landingColor.withAlpha(0.9),
|
stroke: state.landingColor.withAlpha(0.9),
|
||||||
@@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
state._referenceRecords = [];
|
state._referenceRecords = [];
|
||||||
state._surfaceRecords = [];
|
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 feature = cableFeatures[index];
|
||||||
const reference = featureReference(feature);
|
const reference = featureReference(feature);
|
||||||
if (!reference) return;
|
if (!reference) return;
|
||||||
@@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
entity,
|
entity,
|
||||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||||
});
|
});
|
||||||
|
if (cableReferences.has(index)) return;
|
||||||
|
cableReferences.add(index);
|
||||||
parts.rendering.addReferenceStem({
|
parts.rendering.addReferenceStem({
|
||||||
reference,
|
reference,
|
||||||
label: featureLabel(feature),
|
label: featureLabel(feature),
|
||||||
@@ -155,7 +177,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 feature = landingFeatures[index];
|
||||||
const reference = featureReference(feature);
|
const reference = featureReference(feature);
|
||||||
if (!reference) return;
|
if (!reference) return;
|
||||||
@@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
entity,
|
entity,
|
||||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||||
});
|
});
|
||||||
|
if (landingReferences.has(index)) return;
|
||||||
|
landingReferences.add(index);
|
||||||
parts.rendering.addReferenceStem({
|
parts.rendering.addReferenceStem({
|
||||||
reference,
|
reference,
|
||||||
label: featureLabel(feature),
|
label: featureLabel(feature),
|
||||||
|
|||||||
Reference in new issue
Block a user