style: format the reusable infrastructure component

This commit is contained in:
Sameh Khamis committed 2026-09-11 16:07:53 -07:00
1 parent 04f4d54bfe
commit f70b5a3631
4 files changed
+671 -404

No files matched your search

+34 -24
View File
@@ -1,8 +1,12 @@
import { createLocalGeoJsonLayer } from './localGeojsonCore.js'; import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
// Resolved by Vite in builds and relative to this module in other consumers. // Resolved by Vite in builds and relative to this module in other consumers.
const datacentersUrl = new URL('./local_data/datacenters/datacenters.geojsonl', import.meta.url).href; const datacentersUrl = new URL(
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href; './local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href;
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
.href;
/** /**
* Create fresh datacenter and dam layers without starting or loading them. * Create fresh datacenter and dam layers without starting or loading them.
@@ -10,29 +14,35 @@ const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href
* @returns {object[]} Datacenters then dams, with stable standalone identities. * @returns {object[]} Datacenters then dams, with stable standalone identities.
*/ */
export function createInfrastructureLayers(services) { export function createInfrastructureLayers(services) {
const datacenters = createLocalGeoJsonLayer({ const datacenters = createLocalGeoJsonLayer(
id: 'local-datacenters', {
url: datacentersUrl, id: 'local-datacenters',
name: 'Datacenters', url: datacentersUrl,
color: '#00ffff', // Cyan name: 'Datacenters',
icon: '▣', color: '#00ffff', // Cyan
source: 'Local', icon: '▣',
labels: true, source: 'Local',
labelMax: 700, labels: true,
labelGridPx: 138, labelMax: 700,
}, services); labelGridPx: 138,
},
services,
);
const dams = createLocalGeoJsonLayer({ const dams = createLocalGeoJsonLayer(
id: 'local-dams', {
url: damsUrl, id: 'local-dams',
name: 'Dams', url: damsUrl,
color: '#0088ff', // Blue name: 'Dams',
icon: '▰', color: '#0088ff', // Blue
source: 'USACE', icon: '▰',
labels: true, source: 'USACE',
labelMax: 900, labels: true,
labelGridPx: 132, labelMax: 900,
}, services); labelGridPx: 132,
},
services,
);
return [datacenters, dams]; return [datacenters, dams];
} }
+111 -36
View File
@@ -12,11 +12,18 @@ function services() {
return { return {
records, records,
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
registerEntityContext(entity, metadata) { records.set(metadata.id, { entity, ...metadata }); }, registerEntityContext(entity, metadata) {
selectEntityContext(entity) { selection = entity; }, records.set(metadata.id, { entity, ...metadata });
clearSelectedEntityContextForLayer() { selection = undefined; }, },
selectEntityContext(entity) {
selection = entity;
},
clearSelectedEntityContextForLayer() {
selection = undefined;
},
removeEntityContextsForLayer(id) { removeEntityContextsForLayer(id) {
for (const [key, record] of records) if (record.layerId === id) records.delete(key); for (const [key, record] of records)
if (record.layerId === id) records.delete(key);
}, },
governorRequestRender() {}, governorRequestRender() {},
selection: () => selection, selection: () => selection,
@@ -24,7 +31,12 @@ function services() {
} }
test('package exports import without an application, DOM, fetch, or timers', () => { test('package exports import without an application, DOM, fetch, or timers', () => {
const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` const result = spawnSync(
process.execPath,
[
'--input-type=module',
'-e',
`
globalThis.fetch = () => { throw new Error('unexpected fetch'); }; globalThis.fetch = () => { throw new Error('unexpected fetch'); };
globalThis.setTimeout = () => { throw new Error('unexpected timer'); }; globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
for (const key of ['window', 'document']) { for (const key of ['window', 'document']) {
@@ -33,51 +45,99 @@ test('package exports import without an application, DOM, fetch, or timers', ()
await import('gods-eye-view/infrastructure'); await import('gods-eye-view/infrastructure');
await import('gods-eye-view/infrastructure/geojson'); await import('gods-eye-view/infrastructure/geojson');
await import('gods-eye-view/infrastructure/lod'); await import('gods-eye-view/infrastructure/lod');
`], { cwd: new URL('../..', import.meta.url), encoding: 'utf8' }); `,
],
{ cwd: new URL('../..', import.meta.url), encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
}); });
test('infrastructure factory preserves identity and creates independent state without loading', t => { test('infrastructure factory preserves identity and creates independent state without loading', (t) => {
t.mock.method(globalThis, 'fetch', () => { throw new Error('factory must not fetch'); }); t.mock.method(globalThis, 'fetch', () => {
throw new Error('factory must not fetch');
});
const first = createInfrastructureLayers(services()); const first = createInfrastructureLayers(services());
const second = createInfrastructureLayers(services()); const second = createInfrastructureLayers(services());
assert.deepEqual(first.map(({ id, name, source }) => ({ id, name, source })), [ assert.deepEqual(
{ id: 'local-datacenters', name: 'Datacenters', source: 'Local' }, first.map(({ id, name, source }) => ({ id, name, source })),
{ id: 'local-dams', name: 'Dams', source: 'USACE' }, [
]); { id: 'local-datacenters', name: 'Datacenters', source: 'Local' },
{ id: 'local-dams', name: 'Dams', source: 'USACE' },
],
);
first.forEach((layer, index) => { first.forEach((layer, index) => {
assert.notEqual(layer, second[index]); assert.notEqual(layer, second[index]);
layer.destroy(); layer.destroy();
assert.deepEqual(second[index].getStats(), { count: 0, lastUpdate: null, error: null }); assert.deepEqual(second[index].getStats(), {
count: 0,
lastUpdate: null,
error: null,
});
second[index].destroy(); second[index].destroy();
}); });
}); });
test('dataset URLs still name the complete bundled sources', () => { test('dataset URLs still name the complete bundled sources', () => {
for (const [file, count] of [['datacenters', 4351], ['dams', 704]]) { for (const [file, count] of [
const lines = readFileSync(new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url), 'utf8') ['datacenters', 4351],
.split('\n').filter(line => line.trim()); ['dams', 704],
]) {
const lines = readFileSync(
new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url),
'utf8',
)
.split('\n')
.filter((line) => line.trim());
assert.equal(lines.length, count); assert.equal(lines.length, count);
} }
}); });
test('two viewers use their supplied contexts and dispose independently', async t => { test('two viewers use their supplied contexts and dispose independently', async (t) => {
t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({ t.mock.method(globalThis, 'fetch', async () => ({
type: 'Feature', id: 'same-id', properties: { name: 'Dam' }, ok: true,
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, text: async () =>
}) })); JSON.stringify({
type: 'Feature',
id: 'same-id',
properties: { name: 'Dam' },
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[0.01, 0],
[0, 0.01],
[0, 0],
],
],
},
}),
}));
const hosts = [services(), services()]; const hosts = [services(), services()];
const instances = hosts.map(host => { const instances = hosts.map((host) => {
const sources = new Cesium.DataSourceCollection(); const sources = new Cesium.DataSourceCollection();
let click; let click;
const layer = createLocalGeoJsonLayer({ const layer = createLocalGeoJsonLayer(
id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl', {
screenSpaceEventHandlerFactory: () => ({ setInputAction(fn) { click = fn; }, destroy() {} }), id: 'local-dams',
}, host); name: 'Dams',
color: '#0088ff',
url: '/dams.geojsonl',
screenSpaceEventHandlerFactory: () => ({
setInputAction(fn) {
click = fn;
},
destroy() {},
}),
},
host,
);
const viewer = { const viewer = {
dataSources: sources, dataSources: sources,
scene: { scene: {
canvas: {}, preRender: new Cesium.Event(), requestRender() {}, canvas: {},
preRender: new Cesium.Event(),
requestRender() {},
screenSpaceCameraController: { enableInputs: true }, screenSpaceCameraController: { enableInputs: true },
pick: () => ({ id: sources.get(0).entities.values[0] }), pick: () => ({ id: sources.get(0).entities.values[0] }),
}, },
@@ -85,7 +145,9 @@ test('two viewers use their supplied contexts and dispose independently', async
}; };
return { layer, viewer, click: () => click({ position: {} }) }; return { layer, viewer, click: () => click({ position: {} }) };
}); });
t.after(() => instances.forEach(({ layer, viewer }) => layer.destroy(viewer))); t.after(() =>
instances.forEach(({ layer, viewer }) => layer.destroy(viewer)),
);
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer))); await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
assert.equal(hosts[0].records.size, 1); assert.equal(hosts[0].records.size, 1);
assert.equal(hosts[1].records.size, 1); assert.equal(hosts[1].records.size, 1);
@@ -109,22 +171,35 @@ test('consumer build includes only infrastructure code and resolves assets under
write: false, write: false,
assetsInlineLimit: 0, assetsInlineLimit: 0,
rollupOptions: { rollupOptions: {
input: fileURLToPath(import.meta.resolve('gods-eye-view/infrastructure')), input: fileURLToPath(
import.meta.resolve('gods-eye-view/infrastructure'),
),
external: ['cesium'], external: ['cesium'],
preserveEntrySignatures: 'strict', preserveEntrySignatures: 'strict',
}, },
}, },
}); });
const output = result.output; const output = result.output;
const entry = output.find(item => item.type === 'chunk' && item.isEntry); const entry = output.find((item) => item.type === 'chunk' && item.isEntry);
const sources = Object.keys(entry.modules).filter(id => id.endsWith('.js')); const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
assert.deepEqual(sources.map(id => id.split('/').at(-1)).sort(), [ assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
'infrastructure.js', 'localGeojsonCore.js', 'localGeojsonLod.js', 'infrastructure.js',
'localGeojsonCore.js',
'localGeojsonLod.js',
]); ]);
assert.deepEqual(entry.imports, ['cesium'], 'the viewer supplies the same Cesium dependency'); assert.deepEqual(
entry.imports,
['cesium'],
'the viewer supplies the same Cesium dependency',
);
for (const name of ['datacenters', 'dams']) { for (const name of ['datacenters', 'dams']) {
const asset = output.find(item => item.type === 'asset' && item.fileName.includes(name)); const asset = output.find(
(item) => item.type === 'asset' && item.fileName.includes(name),
);
assert.ok(asset, `${name} must be emitted`); assert.ok(asset, `${name} must be emitted`);
assert.ok(entry.code.includes(`/example/${asset.fileName}`), `${name} must retain the consumer base path`); assert.ok(
entry.code.includes(`/example/${asset.fileName}`),
`${name} must retain the consumer base path`,
);
} }
}); });
+434 -299
View File
@@ -15,7 +15,8 @@ const LOCAL_OVERLAY_COLLISION_CAPACITY = 96;
const LOCAL_OVERLAY_CELL_SURPLUS = 2; const LOCAL_OVERLAY_CELL_SURPLUS = 2;
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000; const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
const LOCAL_OVERLAY_FADE_START_M = 250000; const LOCAL_OVERLAY_FADE_START_M = 250000;
const LOCAL_OVERLAY_FADE_START_RATIO = LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M; const LOCAL_OVERLAY_FADE_START_RATIO =
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
// Stems are anchored at ellipsoid height 0, but high-elevation features // Stems are anchored at ellipsoid height 0, but high-elevation features
// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid, // (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid,
// burying the short close-in stem inside the photoreal mesh. Once the // burying the short close-in stem inside the photoreal mesh. Once the
@@ -63,8 +64,12 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
props.capacity, props.capacity,
]); ]);
const line = [operator, capacity] const line = [operator, capacity]
.filter((value, index, values) => value && values.indexOf(value) === index) .filter(
.filter((value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase()) (value, index, values) => value && values.indexOf(value) === index,
)
.filter(
(value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase(),
)
.join(' · '); .join(' · ');
if (line) details.push(clampCardLine(line)); if (line) details.push(clampCardLine(line));
} else if (layerId === 'local-dams') { } else if (layerId === 'local-dams') {
@@ -147,21 +152,29 @@ export function createLocalInfrastructureOverlayEntry({
* @param {number} [options.cohortLimit=Infinity] Host-safe materialization cap. * @param {number} [options.cohortLimit=Infinity] Host-safe materialization cap.
* @returns {object[]} Bounded overlay entries for shared-host arbitration. * @returns {object[]} Bounded overlay entries for shared-host arbitration.
*/ */
export function selectLocalInfrastructureOverlayCohort(records, { export function selectLocalInfrastructureOverlayCohort(
maxEntries, records,
gridPx, {
width, maxEntries,
height, gridPx,
project, width,
cohortLimit = Number.POSITIVE_INFINITY, height,
}) { project,
cohortLimit = Number.POSITIVE_INFINITY,
},
) {
const sourceCap = Math.max(0, Math.floor(Number(maxEntries) || 0)); const sourceCap = Math.max(0, Math.floor(Number(maxEntries) || 0));
const materializationCap = Number.isFinite(Number(cohortLimit)) const materializationCap = Number.isFinite(Number(cohortLimit))
? Math.max(0, Math.floor(Number(cohortLimit))) ? Math.max(0, Math.floor(Number(cohortLimit)))
: Number.POSITIVE_INFINITY; : Number.POSITIVE_INFINITY;
const cap = Math.min(sourceCap, materializationCap); const cap = Math.min(sourceCap, materializationCap);
const cellSize = Math.max(1, Number(gridPx) || 1); const cellSize = Math.max(1, Number(gridPx) || 1);
if (!Array.isArray(records) || records.length === 0 || cap === 0 || typeof project !== 'function') { if (
!Array.isArray(records) ||
records.length === 0 ||
cap === 0 ||
typeof project !== 'function'
) {
return []; return [];
} }
@@ -170,8 +183,13 @@ export function selectLocalInfrastructureOverlayCohort(records, {
for (const record of records) { for (const record of records) {
const screen = project(record); const screen = project(record);
if (!Number.isFinite(screen?.x) || !Number.isFinite(screen?.y)) continue; if (!Number.isFinite(screen?.x) || !Number.isFinite(screen?.y)) continue;
if (screen.x < -padding || screen.x > width + padding if (
|| screen.y < -padding || screen.y > height + padding) continue; screen.x < -padding ||
screen.x > width + padding ||
screen.y < -padding ||
screen.y > height + padding
)
continue;
const key = `${Math.floor(screen.x / cellSize)}:${Math.floor(screen.y / cellSize)}`; const key = `${Math.floor(screen.x / cellSize)}:${Math.floor(screen.y / cellSize)}`;
let contenders = cells.get(key); let contenders = cells.get(key);
if (!contenders) { if (!contenders) {
@@ -189,7 +207,8 @@ export function selectLocalInfrastructureOverlayCohort(records, {
} }
primary.sort(compareLocalOverlayRecords); primary.sort(compareLocalOverlayRecords);
surplus.sort(compareLocalOverlayRecords); surplus.sort(compareLocalOverlayRecords);
if (primary.length >= cap) return primary.slice(0, cap).map((record) => record.entry); if (primary.length >= cap)
return primary.slice(0, cap).map((record) => record.entry);
const candidates = primary.concat(surplus.slice(0, cap - primary.length)); const candidates = primary.concat(surplus.slice(0, cap - primary.length));
return candidates.map((record) => record.entry); return candidates.map((record) => record.entry);
} }
@@ -201,10 +220,7 @@ export function selectLocalInfrastructureOverlayCohort(records, {
* @param {object} [options.host] Test seam for the three host lifecycle calls. * @param {object} [options.host] Test seam for the three host lifecycle calls.
* @returns {{show:function():void,publish:function(object[]):void,hide:function():void,destroy:function():void}} * @returns {{show:function():void,publish:function(object[]):void,hide:function():void,destroy:function():void}}
*/ */
export function createLocalInfrastructureOverlayPublisher({ export function createLocalInfrastructureOverlayPublisher({ sourceId, host }) {
sourceId,
host,
}) {
let visible = false; let visible = false;
let published = false; let published = false;
let destroyed = false; let destroyed = false;
@@ -227,17 +243,26 @@ export function createLocalInfrastructureOverlayPublisher({
}, },
publish(entries) { publish(entries) {
if (destroyed || !visible) return; if (destroyed || !visible) return;
if (lastPublication && entries.length === lastPublication.length if (
&& entries.every((entry, index) => { lastPublication &&
entries.length === lastPublication.length &&
entries.every((entry, index) => {
const previous = lastPublication[index]; const previous = lastPublication[index];
return entry === previous.entry return (
&& entry.position?.x === previous.x entry === previous.entry &&
&& entry.position?.y === previous.y entry.position?.x === previous.x &&
&& entry.position?.z === previous.z; entry.position?.y === previous.y &&
})) return; entry.position?.z === previous.z
);
})
)
return;
host.setEntries(sourceId, entries, sourceOptions); host.setEntries(sourceId, entries, sourceOptions);
lastPublication = entries.map(entry => ({ lastPublication = entries.map((entry) => ({
entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z, entry,
x: entry.position?.x,
y: entry.position?.y,
z: entry.position?.z,
})); }));
published = entries.length > 0; published = entries.length > 0;
}, },
@@ -288,26 +313,31 @@ export function localDatasetError(error) {
* One live instance per layer id is allowed in a given viewer/context/overlay host. * One live instance per layer id is allowed in a given viewer/context/overlay host.
* Destroy the previous instance before replacing it. Importing creates no layers. * Destroy the previous instance before replacing it. Importing creates no layers.
*/ */
export function createLocalGeoJsonLayer({ export function createLocalGeoJsonLayer(
id, {
url, id,
name, url,
color, name,
icon = '📍', color,
source = 'Local JSONL', icon = '📍',
labels = true, source = 'Local JSONL',
labelMax = DEFAULT_LABEL_MAX, labels = true,
labelGridPx = DEFAULT_LABEL_GRID_PX, labelMax = DEFAULT_LABEL_MAX,
screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), labelGridPx = DEFAULT_LABEL_GRID_PX,
projectToWindow = (scene, position) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), screenSpaceEventHandlerFactory = (canvas) =>
}, { new Cesium.ScreenSpaceEventHandler(canvas),
overlayHost, projectToWindow = (scene, position) =>
registerEntityContext, Cesium.SceneTransforms.worldToWindowCoordinates(scene, position),
selectEntityContext, },
clearSelectedEntityContextForLayer, {
removeEntityContextsForLayer, overlayHost,
governorRequestRender, registerEntityContext,
}) { selectEntityContext,
clearSelectedEntityContextForLayer,
removeEntityContextsForLayer,
governorRequestRender,
},
) {
let _dataSource = null; let _dataSource = null;
let _enabled = false; let _enabled = false;
let _clickHandler = null; let _clickHandler = null;
@@ -483,231 +513,279 @@ export function createLocalGeoJsonLayer({
// 1. Initialize data source // 1. Initialize data source
if (!_dataSource) { if (!_dataSource) {
if (!_loadPromise) _loadPromise = (async () => { if (!_loadPromise)
_loadController = new AbortController(); _loadPromise = (async () => {
const baseColor = Cesium.Color.fromCssColorString(color); _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, { signal: _loadController.signal }); const response = await fetch(url, {
if (_destroyed) return; signal: _loadController.signal,
// A 404 returns an HTML body that would otherwise die in JSON.parse });
// one line later, reported as a parse error for a missing file. if (_destroyed) return;
if (!response.ok) { // A 404 returns an HTML body that would otherwise die in JSON.parse
throw new Error(`HTTP ${response.status ?? '?'}`); // one line later, reported as a parse error for a missing file.
} if (!response.ok) {
const text = await response.text(); throw new Error(`HTTP ${response.status ?? '?'}`);
if (_destroyed) return; }
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,
}); });
if (_destroyed) return; if (_destroyed) return;
loaded.name = name; loaded.name = name;
loaded.show = false; loaded.show = false;
// Cesium's DataSourceCollection.add() returns a promise and only // Cesium's DataSourceCollection.add() returns a promise and only
// inserts on a later microtask. Without this await, a throw during // inserts on a later microtask. Without this await, a throw during
// post-processing would roll back a source the scene had not // post-processing would roll back a source the scene had not
// accepted yet — and Cesium would then insert the "removed" source // accepted yet — and Cesium would then insert the "removed" source
// anyway, leaving an orphan the retry would double up on. Awaiting // anyway, leaving an orphan the retry would double up on. Awaiting
// also routes an add() rejection into the error path below instead // also routes an add() rejection into the error path below instead
// of leaving it uncaught with healthy-looking stats. // of leaving it uncaught with healthy-looking stats.
await viewer.dataSources.add(loaded); await viewer.dataSources.add(loaded);
addedToScene = true; addedToScene = true;
if (_destroyed) { if (_destroyed) {
viewer.dataSources.remove(loaded, true); viewer.dataSources.remove(loaded, true);
return; return;
}
// Convert parsed points into 3D stems or style polygons
const entities = loaded.entities.values;
_count = entities.length;
_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(
// every frame, while the point/stem pick surface stays native. Cesium.JulianDate.now(),
feature.position = tip; );
const stemPositionBuffers = [[base, tip], [base, tip]]; if (
feature.polyline = new Cesium.PolylineGraphics({ hierarchy &&
positions: stemPositionBuffers[0], hierarchy.positions &&
width: 3.5, hierarchy.positions.length > 0
material: new Cesium.ColorMaterialProperty(baseColor), ) {
}); pos = Cesium.BoundingSphere.fromPoints(
feature.point = new Cesium.PointGraphics({ hierarchy.positions,
pixelSize: 10, ).center;
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(
nextTip: Cesium.Cartesian3.clone(tip), carto.longitude,
stemPositionBuffers, carto.latitude,
stemPositionBufferIndex: 0, groundHeight,
groundHeight, );
groundSampled: false, const tip = Cesium.Cartesian3.fromRadians(
lastGroundSampleMs: 0, carto.longitude,
priority, carto.latitude,
entry: labels ? createLocalInfrastructureOverlayEntry({ tipHeight,
id: recordId, );
const properties = propertyObject(feature);
const recordId = String(feature.id ?? i);
// Store references for bounded stem scaling and native picking.
feature.__localBaseCarto = carto;
feature.__localBaseCartesian = base;
registerEntityContext(feature, {
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
}) : null, ? createLocalInfrastructureOverlayEntry({
}); id: recordId,
} layerId: id,
// Setup finished — publish it. position: tip,
_dataSource = loaded; properties,
_lastUpdate = Date.now(); priority,
} catch (e) { accent: color,
// 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. : null,
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 // Setup finished — publish it.
// post-add window has something in the scene to remove: a failure _dataSource = loaded;
// before (or inside) add() never reached the collection, and _lastUpdate = Date.now();
// removing then would race Cesium's pending insert. } catch (e) {
if (addedToScene) { // The dataset ships with the build, so this is a broken install,
try { viewer?.dataSources?.remove(loaded, true); } catch { /* already gone */ } // not a blip — it has to reach the chip, not just the console.
} if (!_destroyed) _error = localDatasetError(e);
if (_destroyed) return; // Roll the partial build back so a later enable() retries from
removeEntityContextsForLayer(id); // scratch instead of inheriting a half-populated source. Only the
_count = 0; // post-add window has something in the scene to remove: a failure
_stemRecords = []; // before (or inside) add() never reached the collection, and
console.error(`Failed to load ${id}:`, e); // removing then would race Cesium's pending insert.
} if (addedToScene) {
try {
if (_destroyed) return; viewer?.dataSources?.remove(loaded, true);
// 2. Install native global click handler } catch {
if (!_clickHandler) { /* already gone */
_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); 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 { try {
await _loadPromise; await _loadPromise;
} finally { } finally {
@@ -741,7 +819,10 @@ export function createLocalGeoJsonLayer({
const probe = shouldRecomputeInfraLod({ const probe = shouldRecomputeInfraLod({
nowMs: now, nowMs: now,
lastProbeMs: _lastLodProbeMs, lastProbeMs: _lastLodProbeMs,
movedSqM: Cesium.Cartesian3.distanceSquared(cameraPos, _lastLodCameraPos), movedSqM: Cesium.Cartesian3.distanceSquared(
cameraPos,
_lastLodCameraPos,
),
cameraHeightM: viewer.camera.positionCartographic?.height, cameraHeightM: viewer.camera.positionCartographic?.height,
}); });
_lastLodProbeMs = probe.lastProbeMs; _lastLodProbeMs = probe.lastProbeMs;
@@ -750,7 +831,10 @@ export function createLocalGeoJsonLayer({
if (probe.recompute) _stemGeometryDirty = true; if (probe.recompute) _stemGeometryDirty = true;
} }
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPos); const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
cameraPos,
);
const visibleOverlayRecords = []; const visibleOverlayRecords = [];
const refreshStemGeometry = _stemGeometryDirty; const refreshStemGeometry = _stemGeometryDirty;
@@ -762,7 +846,8 @@ export function createLocalGeoJsonLayer({
// Capability can arrive late (WebGL context restore, a tileset that // Capability can arrive late (WebGL context restore, a tileset that
// finally supports sampling). A parked camera has no moveEnd to // finally supports sampling). A parked camera has no moveEnd to
// re-open a spent budget, so the false→true edge does it. // re-open a spent budget, so the false→true edge does it.
if (canSampleGround && _lastGroundSampleCapability === false) _groundRetryArms = 0; if (canSampleGround && _lastGroundSampleCapability === false)
_groundRetryArms = 0;
_lastGroundSampleCapability = canSampleGround; _lastGroundSampleCapability = canSampleGround;
let groundRetryPending = false; let groundRetryPending = false;
let groundSampleProgress = false; let groundSampleProgress = false;
@@ -814,7 +899,8 @@ export function createLocalGeoJsonLayer({
const record = _stemRecords[i]; const record = _stemRecords[i];
const isActive = !_lodComputed || _activeLodIds.has(record.id); const isActive = !_lodComputed || _activeLodIds.has(record.id);
const isVisible = isActive && occluder.isPointVisible(record.base); const isVisible = isActive && occluder.isPointVisible(record.base);
if (record.entity.show !== isVisible) record.entity.show = isVisible; if (record.entity.show !== isVisible)
record.entity.show = isVisible;
// Records outside the globe-LOD active set carry no visible stem, // Records outside the globe-LOD active set carry no visible stem,
// so skip every per-frame cost for them — geometry trig, the // so skip every per-frame cost for them — geometry trig, the
@@ -823,20 +909,32 @@ export function createLocalGeoJsonLayer({
if (!isActive) continue; if (!isActive) continue;
const wasGroundSampled = record.groundSampled; const wasGroundSampled = record.groundSampled;
const terrainFloorChanged = refreshLocalTerrainFloor(viewer, record); const terrainFloorChanged = refreshLocalTerrainFloor(
viewer,
record,
);
if (refreshStemGeometry || terrainFloorChanged) { if (refreshStemGeometry || terrainFloorChanged) {
updateLocalStemGeometry(viewer, record, now); updateLocalStemGeometry(viewer, record, now);
} else if (canSampleGround && !record.groundSampled } else if (
&& now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS) { canSampleGround &&
!record.groundSampled &&
now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS
) {
// Capability first: without it the distance below is pure waste, // Capability first: without it the distance below is pure waste,
// once per ungrounded record per walk, forever. // once per ungrounded record per walk, forever.
const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); const distance = Cesium.Cartesian3.distance(
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M viewer.camera.positionWC,
&& sampleLocalGroundHeight(viewer, record, now)) { record.base,
);
if (
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
sampleLocalGroundHeight(viewer, record, now)
) {
updateLocalStemGeometry(viewer, record, now, distance); updateLocalStemGeometry(viewer, record, now, distance);
} }
} }
if (!wasGroundSampled && record.groundSampled) groundSampleProgress = true; if (!wasGroundSampled && record.groundSampled)
groundSampleProgress = true;
// Still unsampled AND close enough for a retry to succeed: this // Still unsampled AND close enough for a retry to succeed: this
// layer has no hold and no periodic update, so under the idle // layer has no hold and no periodic update, so under the idle
// governor the retry's preRender never arrives on a parked camera // governor the retry's preRender never arrives on a parked camera
@@ -845,9 +943,15 @@ export function createLocalGeoJsonLayer({
// Gated on a sampleable scene and in-range records only, so a far // Gated on a sampleable scene and in-range records only, so a far
// camera (or a keyless scene) stays fully idle; the distance is // camera (or a keyless scene) stays fully idle; the distance is
// only computed for still-unsampled stems. // only computed for still-unsampled stems.
if (canSampleGround && !record.groundSampled && !groundRetryPending if (
&& Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) canSampleGround &&
< GROUND_SAMPLE_MAX_DISTANCE_M) { !record.groundSampled &&
!groundRetryPending &&
Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M
) {
groundRetryPending = true; groundRetryPending = true;
} }
if (isVisible && record.entry) visibleOverlayRecords.push(record); if (isVisible && record.entry) visibleOverlayRecords.push(record);
@@ -859,14 +963,17 @@ export function createLocalGeoJsonLayer({
if (groundRetryPending) scheduleGroundRetryRender(viewer); if (groundRetryPending) scheduleGroundRetryRender(viewer);
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
const cohort = selectLocalInfrastructureOverlayCohort(visibleOverlayRecords, { const cohort = selectLocalInfrastructureOverlayCohort(
maxEntries: labelMax, visibleOverlayRecords,
gridPx: labelGridPx, {
width: canvas.clientWidth || canvas.width || 0, maxEntries: labelMax,
height: canvas.clientHeight || canvas.height || 0, gridPx: labelGridPx,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, width: canvas.clientWidth || canvas.width || 0,
project: (record) => projectToWindow(viewer.scene, record.tip), height: canvas.clientHeight || canvas.height || 0,
}); cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
project: (record) => projectToWindow(viewer.scene, record.tip),
},
);
_overlayPublisher.publish(cohort); _overlayPublisher.publish(cohort);
}); });
} }
@@ -913,7 +1020,7 @@ export function createLocalGeoJsonLayer({
_count = 0; _count = 0;
_lastUpdate = null; _lastUpdate = null;
_error = null; _error = null;
} },
}; };
} }
@@ -923,24 +1030,36 @@ function compareLocalOverlayRecords(a, b) {
function insertLocalCellContender(contenders, record) { function insertLocalCellContender(contenders, record) {
let index = 0; let index = 0;
while (index < contenders.length && compareLocalOverlayRecords(contenders[index], record) <= 0) { while (
index < contenders.length &&
compareLocalOverlayRecords(contenders[index], record) <= 0
) {
index++; index++;
} }
contenders.splice(index, 0, record); contenders.splice(index, 0, record);
if (contenders.length > LOCAL_OVERLAY_CELL_SURPLUS) contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; if (contenders.length > LOCAL_OVERLAY_CELL_SURPLUS)
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
} }
function refreshLocalTerrainFloor(viewer, record) { function refreshLocalTerrainFloor(viewer, record) {
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) return false; if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
if (Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) return false;
>= GROUND_SAMPLE_MAX_DISTANCE_M) return false; if (
Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) >=
GROUND_SAMPLE_MAX_DISTANCE_M
)
return false;
// Camera motion can refine terrain after the first successful depth sample. // Camera motion can refine terrain after the first successful depth sample.
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do // Reuse the loaded mesh on existing bounded walks; never arm a timer or do
// another GPU readback just to maintain this floor. Keep roof elevations. // another GPU readback just to maintain this floor. Keep roof elevations.
const height = globe.getHeight?.(record.carto); const height = globe.getHeight?.(record.carto);
if (!Number.isFinite(height) || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M if (
|| height <= record.groundHeight) return false; !Number.isFinite(height) ||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
height <= record.groundHeight
)
return false;
setLocalGroundHeight(record, height); setLocalGroundHeight(record, height);
return true; return true;
} }
@@ -957,13 +1076,22 @@ function sampleLocalGroundHeight(viewer, record, now) {
} catch { } catch {
return false; // tiles not ready; retry on a later bounded update return false; // tiles not ready; retry on a later bounded update
} }
if (!Number.isFinite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) return false; if (
!Number.isFinite(sampled) ||
Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M
)
return false;
// A coarse scene-depth sample can lie below the terrain already loaded by // A coarse scene-depth sample can lie below the terrain already loaded by
// the visible globe. Keep that terrain as a floor while allowing roofs and // the visible globe. Keep that terrain as a floor while allowing roofs and
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its // valid below-sea-level elevations. Photoreal scenes hide the globe, so its
// inactive terrain must not constrain their geometry. // inactive terrain must not constrain their geometry.
const terrainHeight = globe?.show ? globe.getHeight?.(record.carto) : undefined; const terrainHeight = globe?.show
if (Number.isFinite(terrainHeight) && Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M) { ? globe.getHeight?.(record.carto)
: undefined;
if (
Number.isFinite(terrainHeight) &&
Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M
) {
sampled = Math.max(sampled, terrainHeight); sampled = Math.max(sampled, terrainHeight);
} }
record.groundSampled = true; record.groundSampled = true;
@@ -987,12 +1115,13 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
const distance = Number.isFinite(knownDistance) const distance = Number.isFinite(knownDistance)
? knownDistance ? knownDistance
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleLocalGroundHeight(viewer, record, now); if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
sampleLocalGroundHeight(viewer, record, now);
// Keep the intended screen-size scaling in close-up views too. A 5 km // Keep the intended screen-size scaling in close-up views too. A 5 km
// minimum made a marker hundreds of metres tall beside a nearby building. // minimum made a marker hundreds of metres tall beside a nearby building.
const effectiveDistance = Math.max(distance, 1); const effectiveDistance = Math.max(distance, 1);
const canvasHeight = viewer.scene.canvas.clientHeight || 1080; const canvasHeight = viewer.scene.canvas.clientHeight || 1080;
const fov = viewer.camera.frustum.fov || (Math.PI / 3); const fov = viewer.camera.frustum.fov || Math.PI / 3;
const targetPx = 65; const targetPx = 65;
const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight); const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight);
const tipHeight = record.groundHeight + effectiveDistance * fovFactor; const tipHeight = record.groundHeight + effectiveDistance * fovFactor;
@@ -1003,12 +1132,16 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
Cesium.Ellipsoid.WGS84, Cesium.Ellipsoid.WGS84,
record.nextTip, record.nextTip,
); );
if (Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <= LOCAL_STEM_TIP_EPSILON_SQ) { if (
Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <=
LOCAL_STEM_TIP_EPSILON_SQ
) {
return false; return false;
} }
Cesium.Cartesian3.clone(record.nextTip, record.tip); Cesium.Cartesian3.clone(record.nextTip, record.tip);
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex; record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
const stemPositions = record.stemPositionBuffers[record.stemPositionBufferIndex]; const stemPositions =
record.stemPositionBuffers[record.stemPositionBufferIndex];
stemPositions[0] = record.base; stemPositions[0] = record.base;
stemPositions[1] = record.tip; stemPositions[1] = record.tip;
record.entity.position.setValue(record.tip); record.entity.position.setValue(record.tip);
@@ -1050,9 +1183,10 @@ function labelPriorityFromProperties(props, layerId) {
function propertyObject(entity) { function propertyObject(entity) {
const source = entity?.properties; const source = entity?.properties;
const raw = typeof source?.getValue === 'function' const raw =
? source.getValue(Cesium.JulianDate.now()) typeof source?.getValue === 'function'
: source || {}; ? source.getValue(Cesium.JulianDate.now())
: source || {};
return unwrapProperties(raw); return unwrapProperties(raw);
} }
@@ -1061,9 +1195,10 @@ function unwrapProperties(value) {
if (Array.isArray(value)) return value.map(unwrapProperties); if (Array.isArray(value)) return value.map(unwrapProperties);
const out = {}; const out = {};
for (const [key, entry] of Object.entries(value)) { for (const [key, entry] of Object.entries(value)) {
out[key] = entry && typeof entry.getValue === 'function' out[key] =
? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) entry && typeof entry.getValue === 'function'
: unwrapProperties(entry); ? unwrapProperties(entry.getValue(Cesium.JulianDate.now()))
: unwrapProperties(entry);
} }
return out; return out;
} }
+92 -45
View File
@@ -58,8 +58,10 @@ export function infraLodBudget(cameraHeightM) {
const height = Number.isFinite(cameraHeightM) const height = Number.isFinite(cameraHeightM)
? Math.max(0, cameraHeightM) ? Math.max(0, cameraHeightM)
: INFRA_LOD_GLOBAL_HEIGHT_M; : INFRA_LOD_GLOBAL_HEIGHT_M;
if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MIN }; if (height >= INFRA_LOD_GLOBAL_HEIGHT_M)
if (height >= INFRA_LOD_REGIONAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MID }; return { activeLimit: INFRA_LOD_ACTIVE_MIN };
if (height >= INFRA_LOD_REGIONAL_HEIGHT_M)
return { activeLimit: INFRA_LOD_ACTIVE_MID };
return { activeLimit: INFRA_LOD_ACTIVE_MAX }; return { activeLimit: INFRA_LOD_ACTIVE_MAX };
} }
@@ -104,24 +106,33 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200;
* @param {number} [options.maxDistancePenalty] * @param {number} [options.maxDistancePenalty]
* @returns {number} * @returns {number}
*/ */
export function infraRankScore(priority, distanceM, isIncumbent, { export function infraRankScore(
incumbentBonus = INFRA_LOD_INCUMBENT_BONUS, priority,
farM = INFRA_LOD_FAR_M, distanceM,
maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY, isIncumbent,
} = {}) { {
incumbentBonus = INFRA_LOD_INCUMBENT_BONUS,
farM = INFRA_LOD_FAR_M,
maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY,
} = {},
) {
const p = Number.isFinite(priority) ? priority : 0; const p = Number.isFinite(priority) ? priority : 0;
const far = Number.isFinite(farM) && farM > 0 ? farM : INFRA_LOD_FAR_M; const far = Number.isFinite(farM) && farM > 0 ? farM : INFRA_LOD_FAR_M;
const d = Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : far; const d = Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : far;
const penaltyCap = Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0 const penaltyCap =
? maxDistancePenalty Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0
: INFRA_LOD_MAX_DISTANCE_PENALTY; ? maxDistancePenalty
const bonus = isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0; : INFRA_LOD_MAX_DISTANCE_PENALTY;
const bonus =
isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0;
return p + bonus - penaltyCap * Math.min(1, d / far); return p + bonus - penaltyCap * Math.min(1, d / far);
} }
/** Infinity-safe distance for a total sort order. */ /** Infinity-safe distance for a total sort order. */
function sortableDistance(distanceM) { function sortableDistance(distanceM) {
return Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : Number.MAX_VALUE; return Number.isFinite(distanceM) && distanceM >= 0
? distanceM
: Number.MAX_VALUE;
} }
/** /**
@@ -142,35 +153,49 @@ function sortableDistance(distanceM) {
* @param {Iterable<string>|Set<string>} [options.incumbentIds] Ids that currently hold an active stem. * @param {Iterable<string>|Set<string>} [options.incumbentIds] Ids that currently hold an active stem.
* @returns {{activeIds:string[], budget:{activeLimit:number}}} * @returns {{activeIds:string[], budget:{activeLimit:number}}}
*/ */
export function selectInfraLod(candidates, { cameraHeightM, incumbentIds } = {}) { export function selectInfraLod(
candidates,
{ cameraHeightM, incumbentIds } = {},
) {
const budget = infraLodBudget(cameraHeightM); const budget = infraLodBudget(cameraHeightM);
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []); const incumbents =
incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
const byId = new Map(); const byId = new Map();
for (const candidate of Array.isArray(candidates) ? candidates : []) { for (const candidate of Array.isArray(candidates) ? candidates : []) {
if (!candidate || typeof candidate.id !== 'string' || !candidate.id) continue; if (!candidate || typeof candidate.id !== 'string' || !candidate.id)
continue;
if (candidate.inView !== true) continue; if (candidate.inView !== true) continue;
const distanceM = Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0 const distanceM =
? candidate.distanceM Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0
: Number.POSITIVE_INFINITY; ? candidate.distanceM
: Number.POSITIVE_INFINITY;
const normalized = { const normalized = {
id: candidate.id, id: candidate.id,
distanceM, distanceM,
score: infraRankScore(candidate.priority, candidate.distanceM, incumbents.has(candidate.id)), score: infraRankScore(
candidate.priority,
candidate.distanceM,
incumbents.has(candidate.id),
),
}; };
const current = byId.get(normalized.id); const current = byId.get(normalized.id);
if (!current if (
|| normalized.score > current.score !current ||
|| (normalized.score === current.score && normalized.distanceM < current.distanceM)) { normalized.score > current.score ||
(normalized.score === current.score &&
normalized.distanceM < current.distanceM)
) {
byId.set(normalized.id, normalized); byId.set(normalized.id, normalized);
} }
} }
const ranked = [...byId.values()].sort((a, b) => ( const ranked = [...byId.values()].sort(
b.score - a.score (a, b) =>
|| sortableDistance(a.distanceM) - sortableDistance(b.distanceM) b.score - a.score ||
|| a.id.localeCompare(b.id) sortableDistance(a.distanceM) - sortableDistance(b.distanceM) ||
)); a.id.localeCompare(b.id),
);
return { return {
activeIds: ranked.slice(0, budget.activeLimit).map((entry) => entry.id), activeIds: ranked.slice(0, budget.activeLimit).map((entry) => entry.id),
@@ -220,7 +245,9 @@ export function applyInfraEvictionGrace({
graceMs = INFRA_LOD_GRACE_MS, graceMs = INFRA_LOD_GRACE_MS,
} = {}) { } = {}) {
const selectedSet = new Set( const selectedSet = new Set(
(Array.isArray(selectedIds) ? selectedIds : []).filter((id) => typeof id === 'string' && id), (Array.isArray(selectedIds) ? selectedIds : []).filter(
(id) => typeof id === 'string' && id,
),
); );
const keepIds = [...selectedSet]; const keepIds = [...selectedSet];
const evictIds = []; const evictIds = [];
@@ -241,8 +268,13 @@ export function applyInfraEvictionGrace({
// Under cap pressure, grace-period stems go first: oldest-in-grace first // Under cap pressure, grace-period stems go first: oldest-in-grace first
// (longest chance to return), then more misses, then id for determinism. // (longest chance to return), then more misses, then id for determinism.
graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id)); graced.sort(
const cap = Number.isFinite(activeLimit) ? Math.max(0, Math.floor(activeLimit)) : INFRA_LOD_ACTIVE_MAX; (a, b) =>
a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id),
);
const cap = Number.isFinite(activeLimit)
? Math.max(0, Math.floor(activeLimit))
: INFRA_LOD_ACTIVE_MAX;
const capacity = Math.max(0, cap - keepIds.length); const capacity = Math.max(0, cap - keepIds.length);
const overflow = Math.max(0, graced.length - capacity); const overflow = Math.max(0, graced.length - capacity);
for (let i = 0; i < graced.length; i++) { for (let i = 0; i < graced.length; i++) {
@@ -251,7 +283,10 @@ export function applyInfraEvictionGrace({
continue; continue;
} }
keepIds.push(graced[i].id); keepIds.push(graced[i].id);
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since }); nextGrace.set(graced[i].id, {
misses: graced[i].misses,
since: graced[i].since,
});
} }
return { keepIds, evictIds, graceState: nextGrace }; return { keepIds, evictIds, graceState: nextGrace };
@@ -307,13 +342,21 @@ export const INFRA_LOD_MOTION_EPSILON_MIN_M = 250;
* @param {number} [options.minM] * @param {number} [options.minM]
* @returns {number} * @returns {number}
*/ */
export function infraLodMotionEpsilonM(cameraHeightM, { export function infraLodMotionEpsilonM(
ratio = INFRA_LOD_MOTION_EPSILON_RATIO, cameraHeightM,
minM = INFRA_LOD_MOTION_EPSILON_MIN_M, {
} = {}) { ratio = INFRA_LOD_MOTION_EPSILON_RATIO,
const floor = Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M; minM = INFRA_LOD_MOTION_EPSILON_MIN_M,
const scale = Number.isFinite(ratio) && ratio >= 0 ? ratio : INFRA_LOD_MOTION_EPSILON_RATIO; } = {},
const height = Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0; ) {
const floor =
Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M;
const scale =
Number.isFinite(ratio) && ratio >= 0
? ratio
: INFRA_LOD_MOTION_EPSILON_RATIO;
const height =
Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0;
return Math.max(floor, height * scale); return Math.max(floor, height * scale);
} }
@@ -355,15 +398,19 @@ export function shouldRecomputeInfraLod({
motionEpsilonM, motionEpsilonM,
} = {}) { } = {}) {
const now = Number.isFinite(nowMs) ? nowMs : 0; const now = Number.isFinite(nowMs) ? nowMs : 0;
const last = Number.isFinite(lastProbeMs) ? lastProbeMs : Number.NEGATIVE_INFINITY; const last = Number.isFinite(lastProbeMs)
const interval = Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0 ? lastProbeMs
? probeIntervalMs : Number.NEGATIVE_INFINITY;
: INFRA_LOD_MOTION_PROBE_INTERVAL_MS; const interval =
Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0
? probeIntervalMs
: INFRA_LOD_MOTION_PROBE_INTERVAL_MS;
if (now - last < interval) return { recompute: false, lastProbeMs: last }; if (now - last < interval) return { recompute: false, lastProbeMs: last };
const movedSq = Number.isFinite(movedSqM) && movedSqM > 0 ? movedSqM : 0; const movedSq = Number.isFinite(movedSqM) && movedSqM > 0 ? movedSqM : 0;
const epsilon = Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0 const epsilon =
? motionEpsilonM Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0
: infraLodMotionEpsilonM(cameraHeightM); ? motionEpsilonM
: infraLodMotionEpsilonM(cameraHeightM);
return { recompute: movedSq > epsilon * epsilon, lastProbeMs: now }; return { recompute: movedSq > epsilon * epsilon, lastProbeMs: now };
} }