fix: dispose pending infrastructure loads and entity contexts

This commit is contained in:
Sameh Khamis committed 2026-09-11 13:18:59 -07:00
1 parent a547e99a7b
commit e465197cee
4 files changed
+370 -195

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased] ## [Unreleased]
- Local GeoJSON layers share concurrent loads, cancel pending fetches on destruction,
discard late results, and remove their entity-context records on teardown.
- Unchanged local infrastructure overlays no longer sustain idle rendering. - Unchanged local infrastructure overlays no longer sustain idle rendering.
Ground samples wait for visible terrain to settle and cannot place a marker Ground samples wait for visible terrain to settle and cannot place a marker
below its loaded surface; roofs and valid below-sea-level heights are retained. below its loaded surface; roofs and valid below-sea-level heights are retained.
+5
View File
@@ -2,6 +2,11 @@
## Infrastructure marker visibility ## Infrastructure marker visibility
Local GeoJSON layers coalesce concurrent enables into one load. Disabling while
loading keeps the result hidden; destruction aborts the fetch and rejects late
parse/add results. Destruction and failed setup remove owned context records,
so replacing a layer cannot retain stale entities or listeners.
Datacenters and dams retain their full datasets while limiting active marker Datacenters and dams retain their full datasets while limiting active marker
stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km, stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km,
and 420 below 200 km. Selection favors existing label priority and nearby and 420 below 200 km. Selection favors existing label priority and nearby
+220 -195
View File
@@ -3,6 +3,7 @@ import { governorRequestRender } from '../renderGovernor.js';
import { import {
clearSelectedEntityContextForLayer, clearSelectedEntityContextForLayer,
registerEntityContext, registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext, selectEntityContext,
} from './contextStore.js'; } from './contextStore.js';
import { import {
@@ -328,6 +329,8 @@ export function createLocalGeoJsonLayer({
let _stemGeometryDirty = true; let _stemGeometryDirty = true;
let _lastVisibilityUpdate = 0; let _lastVisibilityUpdate = 0;
let _destroyed = false; let _destroyed = false;
let _loadPromise = null;
let _loadController = null;
/** /**
* Globe-LOD active set: the record ids allowed to carry a live stem right * Globe-LOD active set: the record ids allowed to carry a live stem right
* now. This bounds geometry refreshes and ground-sample work to the * now. This bounds geometry refreshes and ground-sample work to the
@@ -487,220 +490,240 @@ export function createLocalGeoJsonLayer({
// 1. Initialize data source // 1. Initialize data source
if (!_dataSource) { if (!_dataSource) {
const baseColor = Cesium.Color.fromCssColorString(color); if (!_loadPromise) _loadPromise = (async () => {
_loadController = new AbortController();
const baseColor = Cesium.Color.fromCssColorString(color);
// Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection. // Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection.
// The source is built into a local and committed to `_dataSource` // The source is built into a local and committed to `_dataSource`
// only once setup finishes: a half-built source published early would // only once setup finishes: a half-built source published early would
// make every later enable() skip this block, so the layer could never // make every later enable() skip this block, so the layer could never
// clear its error or retry. // clear its error or retry.
_error = null; _error = null;
let loaded = null; let loaded = null;
// Whether the scene has actually accepted `loaded` — the two rollback // Whether the scene has actually accepted `loaded` — the two rollback
// windows (before vs after the add settles) need different cleanup. // windows (before vs after the add settles) need different cleanup.
let addedToScene = false; let addedToScene = false;
try { try {
const response = await fetch(url); const response = await fetch(url, { signal: _loadController.signal });
// A 404 returns an HTML body that would otherwise die in JSON.parse if (_destroyed) return;
// one line later, reported as a parse error for a missing file. // A 404 returns an HTML body that would otherwise die in JSON.parse
if (!response.ok) { // one line later, reported as a parse error for a missing file.
throw new Error(`HTTP ${response.status ?? '?'}`); if (!response.ok) {
} throw new Error(`HTTP ${response.status ?? '?'}`);
const text = await response.text(); }
const lines = text.split('\n').filter(l => l.trim().length > 0); const text = await response.text();
if (_destroyed) return;
const lines = text.split('\n').filter(l => l.trim().length > 0);
const features = lines.map(line => JSON.parse(line)); const features = lines.map(line => JSON.parse(line));
const geojson = { const geojson = {
type: 'FeatureCollection', type: 'FeatureCollection',
features features
}; };
// Natively parse into entities and use it as our _dataSource // Natively parse into entities and use it as our _dataSource
loaded = await Cesium.GeoJsonDataSource.load(geojson, { loaded = await Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true, clampToGround: true,
stroke: baseColor, stroke: baseColor,
fill: baseColor.withAlpha(0.3), fill: baseColor.withAlpha(0.3),
strokeWidth: 2, strokeWidth: 2,
markerSize: 8, markerSize: 8,
markerColor: baseColor, markerColor: baseColor,
}); });
loaded.name = name; if (_destroyed) return;
loaded.show = false; loaded.name = name;
// Cesium's DataSourceCollection.add() returns a promise and only loaded.show = false;
// inserts on a later microtask. Without this await, a throw during // Cesium's DataSourceCollection.add() returns a promise and only
// post-processing would roll back a source the scene had not // inserts on a later microtask. Without this await, a throw during
// accepted yet — and Cesium would then insert the "removed" source // post-processing would roll back a source the scene had not
// anyway, leaving an orphan the retry would double up on. Awaiting // accepted yet — and Cesium would then insert the "removed" source
// also routes an add() rejection into the error path below instead // anyway, leaving an orphan the retry would double up on. Awaiting
// of leaving it uncaught with healthy-looking stats. // also routes an add() rejection into the error path below instead
await viewer.dataSources.add(loaded); // of leaving it uncaught with healthy-looking stats.
addedToScene = true; await viewer.dataSources.add(loaded);
addedToScene = true;
// Convert parsed points into 3D stems or style polygons if (_destroyed) {
const entities = loaded.entities.values; viewer.dataSources.remove(loaded, true);
_count = entities.length; return;
_stemRecords = [];
_stemGeometryDirty = true;
for (let i = 0; i < entities.length; i++) {
const feature = entities[i];
feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer
let pos = feature.position?.getValue(Cesium.JulianDate.now());
if (!pos) {
// It's a polygon or line
if (feature.polygon) {
feature.polygon.outline = true;
feature.polygon.outlineColor = baseColor;
// Calculate center point for the stem
const hierarchy = feature.polygon.hierarchy?.getValue(Cesium.JulianDate.now());
if (hierarchy && hierarchy.positions && hierarchy.positions.length > 0) {
pos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
}
}
} }
if (!pos) continue; // Convert parsed points into 3D stems or style polygons
const entities = loaded.entities.values;
_count = entities.length;
_stemRecords = [];
_stemGeometryDirty = true;
const carto = Cesium.Cartographic.fromCartesian(pos); for (let i = 0; i < entities.length; i++) {
const groundHeight = 0; // Ellipsoid surface until a scene sample lands const feature = entities[i];
const tipHeight = 2000; // Initial Stem height feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer
const base = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, groundHeight); let pos = feature.position?.getValue(Cesium.JulianDate.now());
const tip = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, tipHeight);
const properties = propertyObject(feature);
const recordId = String(feature.id ?? i);
// Store references for bounded stem scaling and native picking. if (!pos) {
feature.__localBaseCarto = carto; // It's a polygon or line
feature.__localBaseCartesian = base; if (feature.polygon) {
registerEntityContext(feature, { feature.polygon.outline = true;
id: `${id}:${recordId}`, feature.polygon.outlineColor = baseColor;
layerId: id,
layerName: name,
source,
dataSource: loaded,
label: featureLabelFromProperties(properties, id),
properties,
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)),
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)),
});
// Constant properties are refreshed on the existing 450 ms source // Calculate center point for the stem
// cadence. Cesium no longer evaluates 2-3 callbacks per entity on const hierarchy = feature.polygon.hierarchy?.getValue(Cesium.JulianDate.now());
// every frame, while the point/stem pick surface stays native. if (hierarchy && hierarchy.positions && hierarchy.positions.length > 0) {
feature.position = tip; pos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
const stemPositionBuffers = [[base, tip], [base, tip]]; }
feature.polyline = new Cesium.PolylineGraphics({ }
positions: stemPositionBuffers[0], }
width: 3.5,
material: new Cesium.ColorMaterialProperty(baseColor),
});
feature.point = new Cesium.PointGraphics({
pixelSize: 10,
color: baseColor,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
// Never depth-cull the anchor against the photoreal mesh —
// globe-horizon culling is handled by the pre-render occluder.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const priority = labelPriorityFromProperties(properties, id); if (!pos) continue;
_stemRecords.push({
id: recordId, const carto = Cesium.Cartographic.fromCartesian(pos);
entity: feature, const groundHeight = 0; // Ellipsoid surface until a scene sample lands
carto, const tipHeight = 2000; // Initial Stem height
base,
tip, const base = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, groundHeight);
nextTip: Cesium.Cartesian3.clone(tip), const tip = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, tipHeight);
stemPositionBuffers, const properties = propertyObject(feature);
stemPositionBufferIndex: 0, const recordId = String(feature.id ?? i);
groundHeight,
groundSampled: false, // Store references for bounded stem scaling and native picking.
lastGroundSampleMs: 0, feature.__localBaseCarto = carto;
priority, feature.__localBaseCartesian = base;
entry: labels ? createLocalInfrastructureOverlayEntry({ registerEntityContext(feature, {
id: recordId, id: `${id}:${recordId}`,
layerId: id, layerId: id,
position: tip, layerName: name,
source,
dataSource: loaded,
label: featureLabelFromProperties(properties, id),
properties, properties,
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)),
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)),
});
// Constant properties are refreshed on the existing 450 ms source
// cadence. Cesium no longer evaluates 2-3 callbacks per entity on
// every frame, while the point/stem pick surface stays native.
feature.position = tip;
const stemPositionBuffers = [[base, tip], [base, tip]];
feature.polyline = new Cesium.PolylineGraphics({
positions: stemPositionBuffers[0],
width: 3.5,
material: new Cesium.ColorMaterialProperty(baseColor),
});
feature.point = new Cesium.PointGraphics({
pixelSize: 10,
color: baseColor,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
// Never depth-cull the anchor against the photoreal mesh —
// globe-horizon culling is handled by the pre-render occluder.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const priority = labelPriorityFromProperties(properties, id);
_stemRecords.push({
id: recordId,
entity: feature,
carto,
base,
tip,
nextTip: Cesium.Cartesian3.clone(tip),
stemPositionBuffers,
stemPositionBufferIndex: 0,
groundHeight,
groundSampled: false,
lastGroundSampleMs: 0,
priority, priority,
accent: color, entry: labels ? createLocalInfrastructureOverlayEntry({
}) : null, id: recordId,
}); layerId: id,
} position: tip,
// Setup finished — publish it. properties,
_dataSource = loaded; priority,
_lastUpdate = Date.now(); accent: color,
} catch (e) { }) : null,
// The dataset ships with the build, so this is a broken install, });
// not a blip — it has to reach the chip, not just the console.
_error = localDatasetError(e);
// Roll the partial build back so a later enable() retries from
// scratch instead of inheriting a half-populated source. Only the
// post-add window has something in the scene to remove: a failure
// before (or inside) add() never reached the collection, and
// removing then would race Cesium's pending insert.
if (addedToScene) {
try { viewer?.dataSources?.remove(loaded, true); } catch { /* already gone */ }
}
_count = 0;
_stemRecords = [];
console.error(`Failed to load ${id}:`, e);
}
// 2. Install native global click handler
if (!_clickHandler) {
_clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
_clickHandler.setInputAction((click) => {
if (!_enabled) return;
const picked = viewer.scene.pick(click.position);
if (picked && picked.id && picked.id.__localLayerId === id) {
const entity = picked.id;
viewer.selectedEntity = entity;
selectEntityContext(entity);
// We zoom to the surface base of the stem or the center of the polygon
let targetPos = null;
if (entity.polyline) {
// If it's a stem, fly to the base
const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now());
if (positions && positions.length > 0) {
targetPos = positions[0];
}
} else if (entity.polygon && entity.polygon.hierarchy) {
// If it's a polygon, just fly to its center
const hierarchy = entity.polygon.hierarchy.getValue(Cesium.JulianDate.now());
if (hierarchy && hierarchy.positions.length > 0) {
targetPos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
}
}
if (targetPos) {
const carto = Cesium.Cartographic.fromCartesian(targetPos);
// Disable interactions so Cesium doesn't magically cancel the flight
viewer.scene.screenSpaceCameraController.enableInputs = false;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, 5000),
duration: 1.5,
complete: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
cancel: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
});
}
} }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); // Setup finished — publish it.
_dataSource = loaded;
_lastUpdate = Date.now();
} catch (e) {
// The dataset ships with the build, so this is a broken install,
// not a blip — it has to reach the chip, not just the console.
if (!_destroyed) _error = localDatasetError(e);
// Roll the partial build back so a later enable() retries from
// scratch instead of inheriting a half-populated source. Only the
// post-add window has something in the scene to remove: a failure
// before (or inside) add() never reached the collection, and
// removing then would race Cesium's pending insert.
if (addedToScene) {
try { viewer?.dataSources?.remove(loaded, true); } catch { /* already gone */ }
}
if (_destroyed) return;
removeEntityContextsForLayer(id);
_count = 0;
_stemRecords = [];
console.error(`Failed to load ${id}:`, e);
}
if (_destroyed) return;
// 2. Install native global click handler
if (!_clickHandler) {
_clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
_clickHandler.setInputAction((click) => {
if (!_enabled) return;
const picked = viewer.scene.pick(click.position);
if (picked && picked.id && picked.id.__localLayerId === id) {
const entity = picked.id;
viewer.selectedEntity = entity;
selectEntityContext(entity);
// We zoom to the surface base of the stem or the center of the polygon
let targetPos = null;
if (entity.polyline) {
// If it's a stem, fly to the base
const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now());
if (positions && positions.length > 0) {
targetPos = positions[0];
}
} else if (entity.polygon && entity.polygon.hierarchy) {
// If it's a polygon, just fly to its center
const hierarchy = entity.polygon.hierarchy.getValue(Cesium.JulianDate.now());
if (hierarchy && hierarchy.positions.length > 0) {
targetPos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
}
}
if (targetPos) {
const carto = Cesium.Cartographic.fromCartesian(targetPos);
// Disable interactions so Cesium doesn't magically cancel the flight
viewer.scene.screenSpaceCameraController.enableInputs = false;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, 5000),
duration: 1.5,
complete: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
cancel: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
});
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
})();
try {
await _loadPromise;
} finally {
_loadPromise = null;
_loadController = null;
} }
} }
if (_destroyed) return;
// 3. Add an incredibly fast pre-render occluder to hide points behind the globe // 3. Add an incredibly fast pre-render occluder to hide points behind the globe
if (_enabled && !_preRenderRemover) { if (_enabled && !_preRenderRemover) {
_preRenderRemover = viewer.scene.preRender.addEventListener(() => { _preRenderRemover = viewer.scene.preRender.addEventListener(() => {
@@ -879,9 +902,11 @@ export function createLocalGeoJsonLayer({
destroy: (viewer) => { destroy: (viewer) => {
if (_destroyed) return; if (_destroyed) return;
_destroyed = true; _destroyed = true;
_loadController?.abort();
// Defensively disable first so listeners and selection state are // Defensively disable first so listeners and selection state are
// torn down even if destroy is called while the layer is enabled. // torn down even if destroy is called while the layer is enabled.
disableLayer(viewer); disableLayer(viewer);
removeEntityContextsForLayer(id);
if (_clickHandler) { if (_clickHandler) {
_clickHandler.destroy(); _clickHandler.destroy();
_clickHandler = null; _clickHandler = null;
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer } from './localGeojson.js';
import { getContextStore } from './contextStore.js';
const dataset = JSON.stringify({
type: 'Feature', id: 'dam', properties: { name: 'Test dam' },
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
});
const response = () => ({ ok: true, text: async () => dataset });
const deferred = () => {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
};
function harness(t) {
const previousWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
t.after(() => {
layer.destroy(viewer);
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const sources = new Set();
const listeners = new Set();
const event = { addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn); } };
let handlers = 0;
const viewer = {
dataSources: {
async add(source) { sources.add(source); return source; },
remove(source) { return sources.delete(source); },
},
scene: { canvas: {}, preRender: event, requestRender() {} },
camera: { moveEnd: event },
};
const create = () => createLocalGeoJsonLayer({
id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl',
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
screenSpaceEventHandlerFactory() {
handlers++;
return { setInputAction() {}, destroy() { handlers--; } };
},
});
const layer = create();
return { layer, create, viewer, sources, listeners, handlers: () => handlers };
}
test('destroy removes context records and permits a fresh replacement', async t => {
const env = harness(t);
t.mock.method(globalThis, 'fetch', async () => response());
await env.layer.enable(env.viewer);
const oldEntity = [...getContextStore().entities.values()][0].entity;
assert.equal(getContextStore().entities.size, 1);
env.layer.disable(env.viewer);
assert.equal(getContextStore().entities.size, 1, 'disabled cached source retains its records');
env.layer.destroy(env.viewer);
assert.equal(getContextStore().entities.size, 0);
assert.equal(env.sources.size, 0);
assert.equal(env.listeners.size, 0);
assert.equal(env.handlers(), 0);
const replacement = env.create();
await replacement.enable(env.viewer);
assert.equal(getContextStore().entities.size, 1);
assert.notEqual([...getContextStore().entities.values()][0].entity, oldEntity);
replacement.destroy(env.viewer);
await env.layer.enable(env.viewer);
assert.equal(getContextStore().entities.size, 0);
});
for (const phase of ['fetch', 'text', 'parse', 'add']) {
test(`destroy during ${phase} leaves no late objects or context records`, async t => {
const env = harness(t);
const entered = deferred();
const release = deferred();
const pause = async value => { entered.resolve(); await release.promise; return value; };
let signal;
t.mock.method(globalThis, 'fetch', async (_url, options) => {
signal = options.signal;
if (phase === 'fetch') return pause(response());
if (phase === 'text') return { ok: true, text: () => pause(dataset) };
return response();
});
if (phase === 'parse') {
const load = Cesium.GeoJsonDataSource.load;
t.mock.method(Cesium.GeoJsonDataSource, 'load', async (...args) => pause(await load(...args)));
}
if (phase === 'add') {
t.mock.method(env.viewer.dataSources, 'add', async source => {
await pause();
env.sources.add(source);
return source;
});
}
const loading = env.layer.enable(env.viewer);
await entered.promise;
env.layer.destroy(env.viewer);
assert.equal(signal.aborted, true);
release.resolve();
await loading;
assert.equal(env.sources.size, 0);
assert.equal(env.listeners.size, 0);
assert.equal(env.handlers(), 0);
assert.equal(getContextStore().entities.size, 0);
assert.deepEqual(env.layer.getStats(), { count: 0, lastUpdate: null, error: null });
});
}
test('concurrent enable and disable/re-enable share one pending dataset load', async t => {
const env = harness(t);
const release = deferred();
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
await release.promise;
return response();
});
const first = env.layer.enable(env.viewer);
env.layer.disable(env.viewer);
const second = env.layer.enable(env.viewer);
release.resolve();
await Promise.all([first, second]);
assert.equal(fetchMock.mock.callCount(), 1);
assert.equal(env.sources.size, 1);
assert.equal(env.handlers(), 1);
assert.equal(env.listeners.size, 2);
assert.equal([...env.sources][0].show, true);
});
test('disable during loading keeps the completed source hidden until re-enabled', async t => {
const env = harness(t);
const release = deferred();
t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); });
const loading = env.layer.enable(env.viewer);
env.layer.disable(env.viewer);
release.resolve();
await loading;
assert.equal([...env.sources][0].show, false);
assert.equal(env.listeners.size, 0);
await env.layer.enable(env.viewer);
assert.equal([...env.sources][0].show, true);
assert.equal(env.sources.size, 1);
});