Compare commits

...
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 63c9e80a40 docs(changelog): add the #1722 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 18:03:35 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 1923547d43 perf(cables): draw with batched primitives instead of entities
The layer loaded two GeoJsonDataSources and a CustomDataSource: 6,459
entities. Every enable rebuilt them (a 100-250 ms task or three and
about 1 GB of heap that stayed until the next collection), every disable
removed them in a 130-150 ms task, and each horizon sweep that showed a
landing marker made Cesium create its billboard again and read the
terrain height under it once more.

The layer now makes the primitives Cesium built from those entities
itself: one GroundPolylinePrimitive for every line, one
BillboardCollection for the point markers, one PointPrimitiveCollection
for the reference tips and one static polyline Primitive for the stems
in view, rebuilt in a worker when a sweep moves a tip. A disable hides
them and an enable shows them again, so only the first enable builds
anything. A marker is clamped to the ground once, 48 per frame as
before; after that Cesium follows the terrain within its own time budget
and hiding or showing the marker reads nothing. A map-stack change
re-classifies the lines in place.

Lines keep the distance-display attribute the entity display gave them:
with a float attribute present Cesium keeps the 2.5 px width, and
without one it is stored as a byte and drawn 2 px.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:59:47 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 b6c241a68e docs(changelog): add the #1707 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:05:57 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 f42980a04d perf(cables): show landing markers a few per frame
Switching Submarine cables on after the globe had loaded held the page
for about 1.4 s in one task. 1.2 s of it was Cesium reading the terrain
height for each of the 1,917 ground-clamped landing markers as it created
them, all in the first frame, including the ones behind the globe that
the horizon sweep hid a moment later. The markers now start hidden and
the sweep shows the visible ones 48 per rendered frame.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 15:05:54 +01:00
15 changed files with 1350 additions and 1282 deletions

No files matched your search

@@ -0,0 +1,3 @@
- Shorten the freeze when Submarine cables is switched on from about 1.4 s to
about 0.2 s. Landing markers are now shown a few dozen per frame instead of
all at once (mazy1998, #1707).
@@ -0,0 +1,4 @@
- Submarine cables draw from a few batched primitives instead of thousands of
entities. Switching the layer off and back on, and zooming with it on, no
longer stall the page, and it holds about 900 MB less memory. Double-click
no longer starts Cesium's entity tracking on a cable (mazy1998, #1722).
+31 -9
View File
@@ -1541,12 +1541,14 @@ query. Movement within an accepted or pending query reuses it. Markers retain
their Cesium identity while their geometry is unchanged; refreshes preserve their Cesium identity while their geometry is unchanged; refreshes preserve
selection without replaying a click event. selection without replaying a click event.
Data Centers, Dams and Submarine Cables release their built Cesium data sources Data Centers and Dams release their built Cesium data sources
and record references when disabled. Parsed datasets remain cached for the layer and record references when disabled. Parsed datasets remain cached for the layer
lifetime, so re-enable rebuilds entities without downloading or parsing again; lifetime, so re-enable rebuilds entities without downloading or parsing again;
this can take longer than simply revealing hidden entities. Destruction clears this can take longer than simply revealing hidden entities. Destruction clears
the parsed cache as well. A disable during loading cannot leave a completed the parsed cache as well. A disable during loading cannot leave a completed
build hidden in the scene. build hidden in the scene. Submarine Cables draws with its own primitives
instead of entities and only hides them when disabled (see "Bundled geography
and submarine cable components").
CCTV exposes a factory through `./layers/cctv`. Catalog and health requests, CCTV exposes a factory through `./layers/cctv`. Catalog and health requests,
frame/media URLs, camera records, ground placement, geometry queues, playback, frame/media URLs, camera records, ground placement, geometry queues, playback,
@@ -8147,17 +8149,37 @@ an error. Without feeds it keeps the WebUSB statuses. See
Submarine cables use separate source, geometry, rendering, interaction and Submarine cables use separate source, geometry, rendering, interaction and
lifecycle modules. The layer factory accepts cable and landing-point GeoJSON lifecycle modules. The layer factory accepts cable and landing-point GeoJSON
collections from a source with `fetch(signal)` and a display label. The default collections from a source with `fetch(signal)` and a display label. The default
source loads the same bundled TeleGeography files. Disabling still removes all source loads the same bundled TeleGeography files. Load ownership prevents
three Cesium data sources; enabling rebuilds from the accepted parsed cache, cancelled work from adding anything to the scene after teardown: the fetch is
and destroying clears it. Load ownership prevents cancelled work from adding the load's only await, and everything is built in the turn after it.
entities after teardown.
Every Cesium entity from multipart cable or landing-point geometry stays tied The layer draws with four Cesium primitives of its own and creates no entity
to its source feature, including generated IDs and IDs that collide with or data source. Every line is one instance of a single
Cesium's multipart suffixes. Each segment gets its own feature's styling, click `GroundPolylinePrimitive` (per-line colour and show flag, rhumb lines,
tessellated in a worker; a map-stack change re-classifies it in place). Every
point is a billboard in one `BillboardCollection`. Reference tips are one
`PointPrimitiveCollection`, and the stems of the references in view are one
static polyline `Primitive`: a horizon sweep that moved a tip or changed which
references are in view builds a replacement in a worker, and it replaces the
shown stems once it is ready. Disabling hides the lines, markers and tips and
keeps them, so enabling again neither fetches nor builds them (only the stems
are redrawn, for the new view); destroying removes everything. These are
the same primitives Cesium built from the layer's entities, so the layer looks
the same. Polygon geometry in a custom source is not drawn, and GeoJSON
simplestyle properties (`marker-color`, `stroke` and the like) are not read.
Every line and marker from multipart cable or landing-point geometry stays tied
to its source feature. Each part gets its own feature's styling, click
destination and horizon-culling reference, and each source feature has one destination and horizon-culling reference, and each source feature has one
reference stem. The source collections are not modified. reference stem. The source collections are not modified.
Landing markers are ground-clamped billboards. A marker starts hidden and
unclamped; the horizon sweep clamps and shows the visible ones 48 per rendered
frame (`CABLE_LANDING_REVEALS_PER_FRAME`), so the terrain height reads are
never one task. A marker is clamped once: Cesium then keeps it on the surface
as terrain loads, and hiding or showing it again reads nothing; hiding is
immediate.
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
-1
View File
@@ -24,7 +24,6 @@ export {
createCableOverlayEntry, createCableOverlayEntry,
cableClassificationTypeForStack, cableClassificationTypeForStack,
cableClassificationTypeForScene, cableClassificationTypeForScene,
applyTranslucentMarkerBlend,
createCableReferenceSweepGate, createCableReferenceSweepGate,
updateCableReferenceStem, updateCableReferenceStem,
} from '../layers/submarineCables/index.js'; } from '../layers/submarineCables/index.js';
File diff suppressed because it is too large. Load diff
+55
View File
@@ -77,3 +77,58 @@ export function normalizeFeatures(json, kind) {
}; };
}); });
} }
function partPosition(value) {
if (!Array.isArray(value)) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
const height = Number(value[2]);
return Number.isFinite(height) ? [lon, lat, height] : [lon, lat];
}
function collectParts(geometry, out) {
const coordinates = geometry?.coordinates;
switch (geometry?.type) {
case 'Point': {
const point = partPosition(coordinates);
if (point) out.points.push(point);
break;
}
case 'MultiPoint':
for (const value of coordinates || []) {
const point = partPosition(value);
if (point) out.points.push(point);
}
break;
case 'LineString': {
const line = (coordinates || []).map(partPosition).filter(Boolean);
if (line.length >= 2) out.lines.push(line);
break;
}
case 'MultiLineString':
for (const part of coordinates || []) {
const line = (part || []).map(partPosition).filter(Boolean);
if (line.length >= 2) out.lines.push(line);
}
break;
case 'GeometryCollection':
for (const child of geometry.geometries || []) collectParts(child, out);
break;
default:
break;
}
}
/**
* The parts of a feature the layer draws: every line of a LineString or
* MultiLineString and every point of a Point or MultiPoint, also inside a
* GeometryCollection. Each is [lon, lat] or [lon, lat, height] in degrees
* and metres. Polygons are not drawn.
* @returns {{lines:number[][][],points:number[][]}}
*/
export function featureParts(feature) {
const out = { lines: [], points: [] };
collectParts(feature?.geometry, out);
return out;
}
+2 -1
View File
@@ -10,6 +10,7 @@ export function createSubmarineCableLayer({
screenSpaceEventHandlerFactory, screenSpaceEventHandlerFactory,
mapStackEventTarget = null, mapStackEventTarget = null,
sweepClock = defaultSweepClock, sweepClock = defaultSweepClock,
createMarkerImage,
}) { }) {
if (!source?.fetch || !source.label) if (!source?.fetch || !source.label)
throw new TypeError( throw new TypeError(
@@ -23,6 +24,7 @@ export function createSubmarineCableLayer({
source, source,
screenSpaceEventHandlerFactory, screenSpaceEventHandlerFactory,
mapStackEventTarget, mapStackEventTarget,
createMarkerImage,
}; };
parts.rendering = createRendering(context); parts.rendering = createRendering(context);
parts.ingestion = createIngestion(context); parts.ingestion = createIngestion(context);
@@ -40,7 +42,6 @@ export {
export { export {
cableClassificationTypeForStack, cableClassificationTypeForStack,
cableClassificationTypeForScene, cableClassificationTypeForScene,
applyTranslucentMarkerBlend,
} from './surface.js'; } from './surface.js';
export { export {
CABLE_REFERENCE_LABEL_WINNER_CAP, CABLE_REFERENCE_LABEL_WINNER_CAP,
+93 -174
View File
@@ -1,27 +1,82 @@
import { msg, t } from '../../i18n/index.js'; import { msg, t } from '../../i18n/index.js';
import { import {
featureLabel, featureLabel,
featureParts,
featureReference, featureReference,
normalizeFeatures, normalizeFeatures,
} from './geometry.js'; } from './geometry.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; const toCartesian = ([lon, lat, height]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, height ?? 0);
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 }) {
/**
* Turn the two feature collections into what rendering draws. Every part
* of a multipart feature is its own line or marker with its own pick
* record, all sharing the feature's reference; a feature gets one
* reference stem however many parts it has.
*/
function describeScene(cableFeatures, landingFeatures) {
const lines = [];
const markers = [];
const references = [];
const add = (features, kind, style) => {
for (const feature of features) {
const reference = featureReference(feature);
if (!reference) continue;
const label = featureLabel(feature);
const base = Cesium.Cartesian3.fromDegrees(
reference.lon,
reference.lat,
0,
);
const drawn = featureParts(feature);
const pick = () =>
parts.interaction.registerPick({ kind, reference, label });
for (const line of drawn.lines)
lines.push({
info: pick(),
base,
positions: line.map(toCartesian),
color: style.lineColor(feature),
width: style.lineWidth,
});
for (const point of drawn.points)
markers.push({
info: pick(),
base,
position: toCartesian(point),
hasHeight: point.length > 2,
color: style.color,
});
references.push({
reference,
label,
kind,
color: style.color,
feature,
});
}
};
add(cableFeatures, 'cable', {
color: state.cableColor,
lineWidth: 2.5,
lineColor: (feature) => {
const css = feature?.properties?.color;
const color = css && Cesium.Color.fromCssColorString(String(css));
return (color || state.cableColor).withAlpha(0.92);
},
});
const landingLine = state.landingColor.withAlpha(0.9);
add(landingFeatures, 'landing-point', {
color: state.landingColor,
lineWidth: 2,
lineColor: () => landingLine,
});
return { lines, markers, references };
}
async function load(viewer) { async function load(viewer) {
if (state._loading || state._loaded) return; if (state._loading || state._loaded) return;
@@ -30,10 +85,10 @@ export function createIngestion({ state, parts, source }) {
state._loadingLabel = t('loading...'); state._loadingLabel = t('loading...');
// Ownership token (the militaryAwareness activationId pattern): a // Ownership token (the militaryAwareness activationId pattern): a
// disable/abort followed by a fresh enable starts a NEWER load while this // 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 // one is still settling. The stale load must bail after its await and
// must never touch shared lifecycle state or the viewer it no longer // must never touch shared lifecycle state or the viewer it no longer
// owns — clearing `_loading`/`_abort` for the successor is how duplicate // owns — clearing `_loading`/`_abort` for the successor is how duplicate
// or post-destroy data sources got added. // or post-destroy primitives would get added.
const generation = ++state._loadGeneration; const generation = ++state._loadGeneration;
const abort = new AbortController(); const abort = new AbortController();
state._abort = abort; state._abort = abort;
@@ -41,170 +96,34 @@ export function createIngestion({ state, parts, source }) {
generation === state._loadGeneration && !abort.signal.aborted; generation === state._loadGeneration && !abort.signal.aborted;
try { try {
let cableJson = state._cachedCableJson; const { cables: cableJson, landingPoints: landingJson } =
let landingJson = state._cachedLandingJson; await source.fetch(abort.signal);
if (!cableJson || !landingJson) { // The fetch is the only await: everything after it runs in this one
({ cables: cableJson, landingPoints: landingJson } = await source.fetch( // turn, so no disable or destroy can land between the ownership check
abort.signal, // and the primitives reaching the scene.
)); if (!owns()) return;
if (!owns()) return; if (
if ( !Array.isArray(cableJson?.features) ||
!Array.isArray(cableJson?.features) || !Array.isArray(landingJson?.features)
!Array.isArray(landingJson?.features) ) {
) { throw new TypeError(
throw new TypeError( 'A cable source must return cables and landingPoints GeoJSON collections',
'A cable source must return cables and landingPoints GeoJSON collections', );
);
}
state._cachedCableJson = cableJson;
state._cachedLandingJson = landingJson;
} }
const cableFeatures = normalizeFeatures(cableJson, 'cable'); const cableFeatures = normalizeFeatures(cableJson, 'cable');
const landingFeatures = normalizeFeatures(landingJson, 'landing'); const landingFeatures = normalizeFeatures(landingJson, 'landing');
try {
const cableDataSource = await Cesium.GeoJsonDataSource.load( parts.rendering.buildScene(
indexedCollection(cableFeatures), viewer,
{ describeScene(cableFeatures, landingFeatures),
clampToGround: true, );
stroke: state.cableColor.withAlpha(0.95), } catch (error) {
fill: state.cableColor.withAlpha(0.18), // Leave nothing half built in the scene.
strokeWidth: 2, parts.rendering.destroyScene(viewer);
markerColor: state.cableColor, throw error;
markerSize: 6,
},
);
if (!owns()) return;
const landingDataSource = await Cesium.GeoJsonDataSource.load(
indexedCollection(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 = [];
// 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;
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),
});
if (cableReferences.has(index)) return;
cableReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
kind: 'cable',
color: state.cableColor,
feature,
});
});
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;
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),
});
if (landingReferences.has(index)) return;
landingReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
kind: 'landing-point',
color: state.landingColor,
feature,
});
});
state._count = cableFeatures.length + landingFeatures.length; state._count = cableFeatures.length + landingFeatures.length;
state._loaded = true; state._loaded = true;
state._lastUpdate = Date.now(); state._lastUpdate = Date.now();
+7 -30
View File
@@ -2,9 +2,10 @@ import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js'; import { isPointerFree } from '../../data/inputOwnership.js';
export function createInteraction({ state, screenSpaceEventHandlerFactory }) { export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
function registerPickEntity(entity, info) { /** Every drawn part carries its own record as the Cesium pick id. */
entity.__gevTeleGeography = info; function registerPick(info) {
state._pickByEntity.set(entity, info); state._pickRecords.add(info);
return info;
} }
function beginInteraction(viewer) { function beginInteraction(viewer) {
@@ -22,32 +23,8 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
} }
function resolvePickRecord(picked) { function resolvePickRecord(picked) {
if (!picked) return null; const info = picked?.id;
const primitive = picked.primitive; return info && state._pickRecords.has(info) ? info : null;
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) { function flyToReference(viewer, reference) {
@@ -70,7 +47,7 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
}); });
} }
return { return {
registerPickEntity, registerPick,
beginInteraction, beginInteraction,
resolvePickRecord, resolvePickRecord,
flyToReference, flyToReference,
+17 -23
View File
@@ -38,12 +38,13 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
// never emit moveEnd — reach the motion fallback. No render is // never emit moveEnd — reach the motion fallback. No render is
// requested for a fallback sweep: it only fires while the camera is // requested for a fallback sweep: it only fires while the camera is
// already moving, so frames are flowing by construction. // already moving, so frames are flowing by construction.
if ( if (!state._enabled || !state._loaded) return;
!state._enabled || // Markers left over from the last sweep, a few per frame; line
!state._loaded || // shows and rebuilt stems waiting for their geometry.
!state._referenceSweepGate.shouldRun(viewer.camera) parts.rendering.revealLandingMarkers();
) parts.rendering.syncLineShow();
return; parts.rendering.settleStems();
if (!state._referenceSweepGate.shouldRun(viewer.camera)) return;
parts.rendering.updateReferenceVisibility(); parts.rendering.updateReferenceVisibility();
}, },
); );
@@ -80,10 +81,15 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
state._loading = false; state._loading = false;
state._loadingLabel = ''; state._loadingLabel = '';
} }
// Free the entities rather than hide them; enable() rebuilds from the // Hide what is built and keep it: a hidden primitive is skipped by
// cached JSON. An in-flight load's own post-await ownership check // the scene, so it costs nothing per frame, and the next enable shows
// removes whatever that stale generation adds later. // it again without a rebuild. Only the stems go: they are sized for
parts.rendering.releaseDataSources(state._viewer); // the view and the next sweep redraws them in a worker. An in-flight load's own post-await
// ownership check stops that stale generation from building anything.
state._pendingLandingReveals.length = 0;
parts.rendering.dropStems();
parts.rendering.updateVisibility();
state._viewer?.scene?.requestRender?.();
}, },
update(viewer) { update(viewer) {
@@ -92,9 +98,7 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
destroy(viewer) { destroy(viewer) {
if (state._abort) state._abort.abort(); if (state._abort) state._abort.abort();
parts.rendering.releaseDataSources(viewer || state._viewer); parts.rendering.destroyScene(viewer || state._viewer);
state._cachedCableJson = null;
state._cachedLandingJson = null;
// hide() clears every published host entry and goes invisible while // hide() clears every published host entry and goes invisible while
// keeping the publisher reusable — the legacy layer supported re-init // keeping the publisher reusable — the legacy layer supported re-init
// after destroy, and hidden publishers already drop late publishes. // after destroy, and hidden publishers already drop late publishes.
@@ -128,16 +132,6 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
state._error = null; state._error = null;
state._lastUpdate = null; state._lastUpdate = null;
state._loadingLabel = ''; 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(); parts.rendering.resetPublishSignature();
state._referenceSweepGate.reset(); state._referenceSweepGate.reset();
}, },
+8 -15
View File
@@ -41,7 +41,7 @@ export function selectCableReferenceLabelWinners(
.sort( .sort(
(a, b) => (a, b) =>
a.distanceM - b.distanceM || a.distanceM - b.distanceM ||
String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')), String(a.id || '').localeCompare(String(b.id || '')),
) )
.slice(0, cap); .slice(0, cap);
} }
@@ -248,16 +248,15 @@ export function createCableReferenceSweepGate({
} }
/** /**
* Recompute one staticized stem on the sweep cadence. Replaces the former * Recompute one stem tip on the sweep cadence. The tip is moved in place, so
* per-frame `CallbackProperty` pair: constant properties are redefined only * the label entry keeps pointing at it, and only when the camera-scaled tip
* when the camera-scaled tip moved beyond the settle epsilon, and the two * moved beyond the settle epsilon; the tip point follows with one buffer
* preallocated position buffers alternate so each real change raises exactly * write, and the caller redraws the stems when any tip moved.
* one geometry notification (the localGeojson double-buffer pattern). * @param {object} record Reference record with point/base/tip/nextTip.
* @param {object} record Reference record with entity/base/tip/nextTip/buffers.
* @param {Cesium.Cartesian3} cameraPositionWC * @param {Cesium.Cartesian3} cameraPositionWC
* @param {number} canvasHeight CSS-pixel canvas height. * @param {number} canvasHeight CSS-pixel canvas height.
* @param {number} fov Camera frustum field of view (radians). * @param {number} fov Camera frustum field of view (radians).
* @returns {boolean} True when the stem geometry was redefined. * @returns {boolean} True when the tip moved.
*/ */
export function updateCableReferenceStem( export function updateCableReferenceStem(
@@ -289,13 +288,7 @@ export function updateCableReferenceStem(
return false; return false;
} }
Cesium.Cartesian3.clone(record.nextTip, record.tip); Cesium.Cartesian3.clone(record.nextTip, record.tip);
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex; record.point.position = record.tip;
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; return true;
} }
+16 -15
View File
@@ -1,5 +1,3 @@
import * as Cesium from 'cesium';
export const BASE_CABLE_COLOR = '#39d5ff'; export const BASE_CABLE_COLOR = '#39d5ff';
export const BASE_LANDING_COLOR = '#8fffd2'; export const BASE_LANDING_COLOR = '#8fffd2';
@@ -66,6 +64,22 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
depthTested: false, depthTested: false,
}); });
/**
* Landing markers clamped to the ground per rendered frame. A marker is put
* on the surface the first time it comes into view, and that reads the
* terrain height under it. Doing it for all 1,917 in the frame after the
* layer was switched on was a single task (measured 1.2 s on a loaded
* globe); a few dozen per frame keeps each frame short and the markers still
* fill in within a second. A marker is clamped once: after that Cesium moves
* it as terrain loads, and hiding or showing it reads nothing.
*/
export const CABLE_LANDING_REVEALS_PER_FRAME = 48;
/** Pixel size of the marker drawn for point geometry (GeoJSON's small pin). */
export const CABLE_MARKER_SIZE = 6;
/** /**
* `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion * `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion
* Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that * Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that
@@ -81,19 +95,6 @@ export const CABLE_GLOBE_STACK_IDS = Object.freeze(
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']), 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: * Monotonic clock for the sweep gate's motion probe. A hoisted declaration:
* the module's `export default createTeleGeographySubmarineCableLayer()` * the module's `export default createTeleGeographySubmarineCableLayer()`
+378 -162
View File
@@ -1,8 +1,8 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
probeTranslucentMarkerBlend, CABLE_LANDING_REVEALS_PER_FRAME,
commitTranslucentMarkerBlend, CABLE_MARKER_SIZE,
} from './surface.js'; } from './policy.js';
import { import {
clampLabel, clampLabel,
createCableOverlayEntry, createCableOverlayEntry,
@@ -11,7 +11,13 @@ import {
cableReferencePriority, cableReferencePriority,
} from './overlay.js'; } from './overlay.js';
export function createRendering({ state }) { const defaultMarkerImage = (color) =>
new Cesium.PinBuilder().fromColor(color, CABLE_MARKER_SIZE);
export function createRendering({
state,
createMarkerImage = defaultMarkerImage,
}) {
function resetPublishSignature() { function resetPublishSignature() {
state._lastPublishedIds.length = 0; state._lastPublishedIds.length = 0;
state._lastPublishedPriorities.length = 0; state._lastPublishedPriorities.length = 0;
@@ -19,129 +25,131 @@ export function createRendering({ state }) {
} }
/** /**
* Remove the three data sources from the viewer and forget the built * Build everything the layer draws, in one synchronous pass with no entity
* entities. Hiding them (`show = false`) is NOT enough: Cesium's * per feature. The layer used to load two GeoJsonDataSources and a
* DataSourceDisplay still walks every visualizer of a hidden source on * CustomDataSource: 6,459 entities whose creation held about 1 GB of
* every frame, and the thousands of hidden polylines/billboards keep * short-lived heap, whose visualizers ran 100–250 ms tasks whenever shows
* costing CPU and GC pressure long after the user toggled the layer off — * or stems changed, and whose removal on disable was a 150 ms task. These
* that is the "everything gets sluggish after I turn layers off" report. * are the primitives Cesium built from those entities, made directly:
* The fetched JSON stays cached, so the next enable rebuilds without a *
* network round trip or a re-parse. * - lines: one GroundPolylinePrimitive, a colour and a show flag per line
* (Cesium's own batch for clamped colour polylines), tessellated in a
* worker;
* - point markers: one BillboardCollection, each billboard hidden and
* unclamped until revealLandingMarkers puts it on the ground;
* - references: one PointPrimitiveCollection for the tips, whose positions
* and shows are plain buffer writes, and one Primitive of polylines for
* the stems (see rebuildStems).
*
* `lines`, `markers` and `references` come from ingestion; each line and
* marker carries the pick record Cesium returns as the pick id.
*/ */
function releaseDataSources(viewer) { function buildScene(viewer, { lines, markers, references }) {
const sources = [ const scene = viewer.scene;
state._cableDataSource, state._lineRecords = [];
state._landingDataSource, state._markerRecords = [];
state._referenceDataSource,
];
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
state._referenceRecords = []; state._referenceRecords = [];
state._surfaceRecords = []; state._pendingLandingReveals.length = 0;
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 (lines.length) {
if (state._cableDataSource) state._cableDataSource.show = state._enabled; // The entity display gave every line this attribute too. It is a float
if (state._landingDataSource) // attribute, and with one present Cesium keeps the per-line width as a
state._landingDataSource.show = state._enabled; // float; without it the 2.5 px width is stored as a byte and draws 2 px.
if (state._referenceDataSource) const always =
state._referenceDataSource.show = state._enabled; Cesium.DistanceDisplayConditionGeometryInstanceAttribute.fromDistanceDisplayCondition(
} new Cesium.DistanceDisplayCondition(),
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.",
); );
const instances = lines.map(
(line) =>
new Cesium.GeometryInstance({
id: line.info,
geometry: new Cesium.GroundPolylineGeometry({
positions: line.positions,
width: line.width,
// GeoJSON lines are rhumb lines (GeoJsonDataSource's choice).
arcType: Cesium.ArcType.RHUMB,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
line.color,
),
show: new Cesium.ShowGeometryInstanceAttribute(true),
distanceDisplayCondition: always,
},
}),
);
state._cablePrimitive = scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance(),
classificationType: state._classificationType,
asynchronous: true,
show: state._enabled,
}),
);
for (const line of lines)
state._lineRecords.push({
info: line.info,
base: line.base,
visible: true,
shown: true,
attributes: null,
});
}
if (markers.length) {
// Every marker is translucent, so one blend pass, not Cesium's two.
const billboards = scene.primitives.add(
new Cesium.BillboardCollection({
scene,
blendOption: Cesium.BlendOption.TRANSLUCENT,
show: state._enabled,
}),
);
state._markerBillboards = billboards;
const images = new Map();
for (const marker of markers) {
const key = marker.color.toCssHexString();
let image = images.get(key);
if (!image) {
image = {
id: `gev-cable-marker-${key}`,
source: createMarkerImage(marker.color),
};
images.set(key, image);
}
const billboard = billboards.add({
id: marker.info,
position: marker.position,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
show: false,
});
billboard.setImage(image.id, image.source);
state._markerRecords.push({
info: marker.info,
marker: billboard,
base: marker.base,
// A position with its own height is drawn there, as before.
clamped: marker.hasHeight,
visible: false,
});
} }
return;
} }
const landingBlend = commitTranslucentMarkerBlend(landing);
const referenceBlend = commitTranslucentMarkerBlend(reference);
if (landingBlend.applied > 0 && referenceBlend.applied > 0)
state._markerBlendDone = true;
}
/** if (references.length) {
* Re-classify every cable ground line for the active surface. One batched state._referencePoints = scene.primitives.add(
* ground-primitive rebuild per stack switch — never per frame. new Cesium.PointPrimitiveCollection({
* @param {Cesium.ClassificationType} next blendOption: Cesium.BlendOption.TRANSLUCENT,
*/ show: state._enabled,
}),
function applyCableClassification(next) { );
if (next === undefined || next === state._classificationType) return; for (const reference of references) addReferenceStem(reference);
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?.(); state._lineShowDirty = false;
}
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 }) { function addReferenceStem({ reference, label, kind, color, feature }) {
if (!state._referenceDataSource || !reference) return;
const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0); const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0);
const tip = Cesium.Cartesian3.fromDegrees( const tip = Cesium.Cartesian3.fromDegrees(
reference.lon, reference.lon,
@@ -154,42 +162,31 @@ export function createRendering({ state }) {
label, label,
featureId: feature?.id || feature?.properties?.id || null, featureId: feature?.id || feature?.properties?.id || null,
}; };
state._pickRecords.add(info);
const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94); const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94);
const stemColor = color.withAlpha(kind === 'cable' ? 0.58 : 0.68); // The tip is moved in place on the sweep cadence (overlay.js), never by a
// Constant properties on the sweep cadence, never per-frame callbacks: // per-frame callback.
// the sweep redefines them through the alternating buffers below. const point = state._referencePoints.add({
const stemPositionBuffers = [ id: info,
[base, tip],
[base, tip],
];
const entity = state._referenceDataSource.entities.add({
id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`,
position: tip, position: tip,
polyline: { pixelSize: kind === 'cable' ? 7 : 8,
positions: stemPositionBuffers[0], color: pointColor,
width: kind === 'cable' ? 2 : 2.4, outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
material: new Cesium.ColorMaterialProperty(stemColor), outlineWidth: 1,
}, disableDepthTestDistance: 0,
point: { show: false,
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 = { const record = {
id: entity.id, id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`,
entity, info,
point,
base, base,
tip, tip,
nextTip: Cesium.Cartesian3.clone(tip), nextTip: Cesium.Cartesian3.clone(tip),
stemPositionBuffers, stemColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
stemPositionBufferIndex: 0, color.withAlpha(kind === 'cable' ? 0.58 : 0.68),
),
stemWidth: kind === 'cable' ? 2 : 2.4,
reference, reference,
kind, kind,
label: clampLabel(label), label: clampLabel(label),
@@ -201,6 +198,212 @@ export function createRendering({ state }) {
state._referenceRecords.push(record); state._referenceRecords.push(record);
} }
/**
* Draw the stems of the references now in view, base to tip. Stems are one
* static polyline primitive, as the entity display batched them: nothing
* runs for it per frame, and a stem's length cannot change without new
* geometry. So a sweep that moved a tip or changed which references are
* in view builds a replacement in a worker, and settleStems swaps it in
* once it is ready; the previous stems stay until then. A sweep that
* changed nothing builds nothing.
*/
function rebuildStems() {
const primitives = state._viewer?.scene?.primitives;
if (!primitives) return;
if (state._stemPrimitivePending) {
// Superseded before it was ever shown.
primitives.remove(state._stemPrimitivePending);
state._stemPrimitivePending = null;
}
const instances = [];
for (const record of state._referenceRecords) {
if (!record.visible) continue;
instances.push(
new Cesium.GeometryInstance({
id: record.info,
geometry: new Cesium.PolylineGeometry({
positions: [record.base, record.tip],
width: record.stemWidth,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
attributes: { color: record.stemColor },
}),
);
}
state._stemsDirty = false;
if (!instances.length) {
if (state._stemPrimitive) primitives.remove(state._stemPrimitive);
state._stemPrimitive = null;
return;
}
state._stemPrimitivePending = primitives.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
// Hidden while it builds; settleStems shows it.
show: false,
}),
);
state._viewer.scene.requestRender?.();
}
/** Swap in rebuilt stems once their geometry is ready; called per frame. */
function settleStems() {
const pending = state._stemPrimitivePending;
if (!pending) return;
const scene = state._viewer?.scene;
if (!pending.ready) {
// Keep frames coming until the geometry is in.
scene?.requestRender?.();
return;
}
pending.show = state._enabled;
if (state._stemPrimitive) scene?.primitives?.remove(state._stemPrimitive);
state._stemPrimitive = pending;
state._stemPrimitivePending = null;
scene?.requestRender?.();
}
/** Remove and destroy everything buildScene made. Only destroy() does this;
* a disable hides the primitives and keeps them for the next enable. */
function destroyScene(viewer) {
const scene = viewer?.scene;
const owned = [
[scene?.groundPrimitives, state._cablePrimitive],
[scene?.primitives, state._markerBillboards],
[scene?.primitives, state._stemPrimitive],
[scene?.primitives, state._stemPrimitivePending],
[scene?.primitives, state._referencePoints],
];
state._cablePrimitive = null;
state._markerBillboards = null;
state._stemPrimitive = null;
state._stemPrimitivePending = null;
state._stemsDirty = false;
state._referencePoints = null;
state._referenceRecords = [];
state._lineRecords = [];
state._markerRecords = [];
state._lineShowDirty = false;
state._pendingLandingReveals.length = 0;
state._pickRecords = new WeakSet();
state._referenceLabelCount = 0;
state._publishScratch.length = 0;
state._loaded = false;
state._referenceSweepGate.reset();
for (const [collection, primitive] of owned) {
if (!primitive) continue;
try {
// A collection destroys what it removes; one that is already gone
// has destroyed it too.
if (!collection?.remove?.(primitive) && !primitive.isDestroyed())
primitive.destroy();
} catch {
/* collection gone */
}
}
}
/**
* Drop the stems, shown and building, so the next sweep draws them afresh.
* A disable does this: stems kept from the view the layer was switched off
* in would show at their old lengths until the rebuild for the new view
* was ready.
*/
function dropStems() {
const primitives = state._viewer?.scene?.primitives;
for (const primitive of [state._stemPrimitive, state._stemPrimitivePending])
if (primitive) primitives?.remove(primitive);
state._stemPrimitive = null;
state._stemPrimitivePending = null;
state._stemsDirty = true;
}
function updateVisibility() {
for (const primitive of [
state._cablePrimitive,
state._markerBillboards,
state._stemPrimitive,
state._referencePoints,
])
if (primitive) primitive.show = state._enabled;
}
/**
* Re-classify the cable ground lines for the active surface. The primitive
* reads its classification as it queues commands, so this is one
* assignment per stack switch and no rebuild.
* @param {Cesium.ClassificationType} next
*/
function applyCableClassification(next) {
if (next === undefined || next === state._classificationType) return;
state._classificationType = next;
if (!state._cablePrimitive) return;
state._cablePrimitive.classificationType = next;
state._viewer?.scene?.requestRender?.();
}
/**
* Write the line show states the last sweep worked out. The primitive
* builds its geometry in a worker and has no per-line attributes until it
* is ready, so the states wait in the records until then.
*/
function syncLineShow() {
if (!state._lineShowDirty) return;
const primitive = state._cablePrimitive;
if (!primitive?.ready) {
// Keep frames coming until the geometry is in.
if (primitive) state._viewer?.scene?.requestRender?.();
return;
}
for (const record of state._lineRecords) {
if (record.shown === record.visible) continue;
record.attributes ??= primitive.getGeometryInstanceAttributes(
record.info,
);
if (!record.attributes) continue;
record.attributes.show = Cesium.ShowGeometryInstanceAttribute.toValue(
record.visible,
record.attributes.show,
);
record.shown = record.visible;
}
state._lineShowDirty = false;
}
/**
* Put the next few markers the last sweep found visible on the ground and
* show them, and ask for another frame while any are left. Called from the
* sweep and then once per rendered frame, so a parked camera costs nothing
* once the list is empty. Clamping is what reads the terrain height, once
* per marker; Cesium then keeps the marker on the surface as terrain loads,
* within its own per-frame time budget.
*/
function revealLandingMarkers() {
const pending = state._pendingLandingReveals;
if (!pending.length) return;
let budget = CABLE_LANDING_REVEALS_PER_FRAME;
while (pending.length && budget > 0) {
const record = pending.pop();
if (!record.visible || record.marker.show) continue;
if (!record.clamped) {
record.marker.heightReference = Cesium.HeightReference.CLAMP_TO_GROUND;
record.clamped = true;
budget--;
}
record.marker.show = true;
}
if (pending.length) state._viewer?.scene?.requestRender?.();
}
function updateReferenceVisibility() { function updateReferenceVisibility() {
if (!state._enabled || !state._viewer?.camera) return; if (!state._enabled || !state._viewer?.camera) return;
const cameraPos = state._viewer.camera.positionWC; const cameraPos = state._viewer.camera.positionWC;
@@ -216,20 +419,35 @@ export function createRendering({ state }) {
const visible = occluder.isPointVisible(record.base); const visible = occluder.isPointVisible(record.base);
record.visible = visible; record.visible = visible;
record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base); record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base);
if (record.entity.show !== visible) { if (record.point.show !== visible) {
record.entity.show = visible; record.point.show = visible;
state._stemsDirty = true;
} }
// Hidden stems keep their last geometry; this same sweep refreshes them // Hidden stems keep their last tip; this same sweep refreshes them in
// in the pass where they turn visible again. // the pass where they turn visible again.
if (visible) if (
updateCableReferenceStem(record, cameraPos, canvasHeight, fov); visible &&
updateCableReferenceStem(record, cameraPos, canvasHeight, fov)
)
state._stemsDirty = true;
} }
for (const record of state._surfaceRecords) { if (state._stemsDirty) rebuildStems();
for (const record of state._lineRecords) {
record.visible = occluder.isPointVisible(record.base);
if (record.visible !== record.shown) state._lineShowDirty = true;
}
syncLineShow();
state._pendingLandingReveals.length = 0;
for (const record of state._markerRecords) {
const visible = occluder.isPointVisible(record.base); const visible = occluder.isPointVisible(record.base);
if (record.entity.show !== visible) { record.visible = visible;
record.entity.show = visible; if (record.marker.show === visible) continue;
} // A marker not yet on the ground waits its turn; one that already is
// shows at once, and hiding is immediate.
if (visible && !record.clamped) state._pendingLandingReveals.push(record);
else record.marker.show = visible;
} }
revealLandingMarkers();
const winners = selectCableReferenceLabelWinners(state._referenceRecords); const winners = selectCableReferenceLabelWinners(state._referenceRecords);
let changed = winners.length !== state._lastPublishedCount; let changed = winners.length !== state._lastPublishedCount;
for (let i = 0; i < winners.length; i++) { for (let i = 0; i < winners.length; i++) {
@@ -247,9 +465,6 @@ export function createRendering({ state }) {
} }
state._publishScratch.length = winners.length; state._publishScratch.length = winners.length;
state._referenceLabelCount = 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; if (!changed) return;
state._overlayPublisher.publish(state._publishScratch); state._overlayPublisher.publish(state._publishScratch);
state._lastPublishedCount = winners.length; state._lastPublishedCount = winners.length;
@@ -262,13 +477,14 @@ export function createRendering({ state }) {
} }
return { return {
resetPublishSignature, resetPublishSignature,
releaseDataSources, buildScene,
destroyScene,
updateVisibility, updateVisibility,
styleCableEntity,
applyMarkerBlendOnce,
applyCableClassification, applyCableClassification,
styleLandingEntity, syncLineShow,
addReferenceStem, settleStems,
dropStems,
revealLandingMarkers,
updateReferenceVisibility, updateReferenceVisibility,
}; };
} }
+37 -21
View File
@@ -30,26 +30,50 @@ export function createState({ sweepClock, overlayHost }) {
state._loadGeneration = 0; state._loadGeneration = 0;
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
/** /**
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES * What the layer draws, built once per load and kept across disable/enable
* the three data sources (see releaseDataSources) instead of hiding them, * (a disable only hides them, which costs nothing per frame):
* so a re-enable has to rebuild the entities; the network/parse half of * one ground polyline primitive for every line, one billboard collection
* that rebuild is the expensive part users would notice, so it is cached. * for every point marker, a point collection for the reference tips and
* one polyline primitive for the reference stems.
*/ */
state._cachedCableJson = null; state._cablePrimitive = null;
state._cachedLandingJson = null; state._markerBillboards = null;
state._referencePoints = null;
/** The stems now drawn, and their replacement while it builds. */
state._stemPrimitive = null;
state._stemPrimitivePending = null;
state._stemsDirty = false;
state._referenceRecords = []; state._referenceRecords = [];
state._surfaceRecords = []; /** One record per drawn line: its pick record, horizon base and show state. */
state._lineRecords = [];
/** Line show states still to be written once the primitive is ready. */
state._lineShowDirty = false;
/** One record per point marker: its billboard and horizon base. */
state._markerRecords = [];
/** Markers the last sweep found visible but not on the ground yet; drained
* a few per frame (CABLE_LANDING_REVEALS_PER_FRAME). */
state._pendingLandingReveals = [];
/** The pick records handed to Cesium as ids, so a pick is known to be ours. */
state._pickRecords = new WeakSet();
state._clickHandler = null; state._clickHandler = null;
@@ -63,14 +87,6 @@ export function createState({ sweepClock, overlayHost }) {
state._classificationType = Cesium.ClassificationType.BOTH; 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._referenceLabelCount = 0;
state._referenceSweepGate = createCableReferenceSweepGate({ state._referenceSweepGate = createCableReferenceSweepGate({
+2 -89
View File
@@ -1,8 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import { CABLE_GLOBE_STACK_IDS } from './policy.js';
CABLE_GLOBE_STACK_IDS,
MARKER_COLLECTION_EXPECTATIONS,
} from './policy.js';
/** /**
* Ground-line classification for one map stack. The photoreal stack renders * Ground-line classification for one map stack. The photoreal stack renders
@@ -10,7 +7,7 @@ import {
* need the 3D-tile classification pass there; every other known stack (the * 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 * bing stacks and osm) renders imagery on the shown globe, so only the
* terrain pass applies. * terrain pass applies.
* Classifying against just the active surface halves the batched * Classifying against just the active surface halves the
* GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback * GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback
* for an unknown stack — it renders on every surface, exactly the shipped * for an unknown stack — it renders on every surface, exactly the shipped
* pre-optimization behavior. * pre-optimization behavior.
@@ -40,87 +37,3 @@ export function cableClassificationTypeForScene(scene) {
? Cesium.ClassificationType.CESIUM_3D_TILE ? Cesium.ClassificationType.CESIUM_3D_TILE
: Cesium.ClassificationType.TERRAIN; : 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);
}