diff --git a/src/data/infrastructure.js b/src/data/infrastructure.js index bac9e81d5..511c6e23e 100644 --- a/src/data/infrastructure.js +++ b/src/data/infrastructure.js @@ -1,8 +1,12 @@ import { createLocalGeoJsonLayer } from './localGeojsonCore.js'; // 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 damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href; +const datacentersUrl = new URL( + './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. @@ -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. */ export function createInfrastructureLayers(services) { - const datacenters = createLocalGeoJsonLayer({ - id: 'local-datacenters', - url: datacentersUrl, - name: 'Datacenters', - color: '#00ffff', // Cyan - icon: '▣', - source: 'Local', - labels: true, - labelMax: 700, - labelGridPx: 138, - }, services); + const datacenters = createLocalGeoJsonLayer( + { + id: 'local-datacenters', + url: datacentersUrl, + name: 'Datacenters', + color: '#00ffff', // Cyan + icon: '▣', + source: 'Local', + labels: true, + labelMax: 700, + labelGridPx: 138, + }, + services, + ); - const dams = createLocalGeoJsonLayer({ - id: 'local-dams', - url: damsUrl, - name: 'Dams', - color: '#0088ff', // Blue - icon: '▰', - source: 'USACE', - labels: true, - labelMax: 900, - labelGridPx: 132, - }, services); + const dams = createLocalGeoJsonLayer( + { + id: 'local-dams', + url: damsUrl, + name: 'Dams', + color: '#0088ff', // Blue + icon: '▰', + source: 'USACE', + labels: true, + labelMax: 900, + labelGridPx: 132, + }, + services, + ); return [datacenters, dams]; } diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 416423636..d8da4c3d4 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -12,11 +12,18 @@ function services() { return { records, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, - registerEntityContext(entity, metadata) { records.set(metadata.id, { entity, ...metadata }); }, - selectEntityContext(entity) { selection = entity; }, - clearSelectedEntityContextForLayer() { selection = undefined; }, + registerEntityContext(entity, metadata) { + records.set(metadata.id, { entity, ...metadata }); + }, + selectEntityContext(entity) { + selection = entity; + }, + clearSelectedEntityContextForLayer() { + selection = undefined; + }, 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() {}, selection: () => selection, @@ -24,7 +31,12 @@ function services() { } 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.setTimeout = () => { throw new Error('unexpected timer'); }; 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/geojson'); 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); }); -test('infrastructure factory preserves identity and creates independent state without loading', t => { - t.mock.method(globalThis, 'fetch', () => { throw new Error('factory must not fetch'); }); +test('infrastructure factory preserves identity and creates independent state without loading', (t) => { + t.mock.method(globalThis, 'fetch', () => { + throw new Error('factory must not fetch'); + }); const first = createInfrastructureLayers(services()); const second = createInfrastructureLayers(services()); - assert.deepEqual(first.map(({ id, name, source }) => ({ id, name, source })), [ - { id: 'local-datacenters', name: 'Datacenters', source: 'Local' }, - { id: 'local-dams', name: 'Dams', source: 'USACE' }, - ]); + assert.deepEqual( + first.map(({ id, name, source }) => ({ id, name, source })), + [ + { id: 'local-datacenters', name: 'Datacenters', source: 'Local' }, + { id: 'local-dams', name: 'Dams', source: 'USACE' }, + ], + ); first.forEach((layer, index) => { assert.notEqual(layer, second[index]); 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(); }); }); test('dataset URLs still name the complete bundled sources', () => { - for (const [file, count] of [['datacenters', 4351], ['dams', 704]]) { - const lines = readFileSync(new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url), 'utf8') - .split('\n').filter(line => line.trim()); + for (const [file, count] of [ + ['datacenters', 4351], + ['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); } }); -test('two viewers use their supplied contexts and dispose independently', async t => { - t.mock.method(globalThis, 'fetch', async () => ({ ok: true, 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]]] }, - }) })); +test('two viewers use their supplied contexts and dispose independently', async (t) => { + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + 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 instances = hosts.map(host => { + const instances = hosts.map((host) => { const sources = new Cesium.DataSourceCollection(); let click; - const layer = createLocalGeoJsonLayer({ - id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl', - screenSpaceEventHandlerFactory: () => ({ setInputAction(fn) { click = fn; }, destroy() {} }), - }, host); + const layer = createLocalGeoJsonLayer( + { + id: 'local-dams', + name: 'Dams', + color: '#0088ff', + url: '/dams.geojsonl', + screenSpaceEventHandlerFactory: () => ({ + setInputAction(fn) { + click = fn; + }, + destroy() {}, + }), + }, + host, + ); const viewer = { dataSources: sources, scene: { - canvas: {}, preRender: new Cesium.Event(), requestRender() {}, + canvas: {}, + preRender: new Cesium.Event(), + requestRender() {}, screenSpaceCameraController: { enableInputs: true }, 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: {} }) }; }); - 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))); assert.equal(hosts[0].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, assetsInlineLimit: 0, rollupOptions: { - input: fileURLToPath(import.meta.resolve('gods-eye-view/infrastructure')), + input: fileURLToPath( + import.meta.resolve('gods-eye-view/infrastructure'), + ), external: ['cesium'], preserveEntrySignatures: 'strict', }, }, }); const output = result.output; - const entry = output.find(item => item.type === 'chunk' && item.isEntry); - const sources = Object.keys(entry.modules).filter(id => id.endsWith('.js')); - assert.deepEqual(sources.map(id => id.split('/').at(-1)).sort(), [ - 'infrastructure.js', 'localGeojsonCore.js', 'localGeojsonLod.js', + const entry = output.find((item) => item.type === 'chunk' && item.isEntry); + const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); + assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ + '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']) { - 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(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`, + ); } }); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index d18ce833f..b90d1b66b 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -15,7 +15,8 @@ const LOCAL_OVERLAY_COLLISION_CAPACITY = 96; const LOCAL_OVERLAY_CELL_SURPLUS = 2; const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000; 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 // (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 @@ -63,8 +64,12 @@ export function localInfrastructureOverlayCopy(properties, layerId) { props.capacity, ]); const line = [operator, capacity] - .filter((value, index, values) => value && values.indexOf(value) === index) - .filter((value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase()) + .filter( + (value, index, values) => value && values.indexOf(value) === index, + ) + .filter( + (value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase(), + ) .join(' · '); if (line) details.push(clampCardLine(line)); } else if (layerId === 'local-dams') { @@ -147,21 +152,29 @@ export function createLocalInfrastructureOverlayEntry({ * @param {number} [options.cohortLimit=Infinity] Host-safe materialization cap. * @returns {object[]} Bounded overlay entries for shared-host arbitration. */ -export function selectLocalInfrastructureOverlayCohort(records, { - maxEntries, - gridPx, - width, - height, - project, - cohortLimit = Number.POSITIVE_INFINITY, -}) { +export function selectLocalInfrastructureOverlayCohort( + records, + { + maxEntries, + gridPx, + width, + height, + project, + cohortLimit = Number.POSITIVE_INFINITY, + }, +) { const sourceCap = Math.max(0, Math.floor(Number(maxEntries) || 0)); const materializationCap = Number.isFinite(Number(cohortLimit)) ? Math.max(0, Math.floor(Number(cohortLimit))) : Number.POSITIVE_INFINITY; const cap = Math.min(sourceCap, materializationCap); 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 []; } @@ -170,8 +183,13 @@ export function selectLocalInfrastructureOverlayCohort(records, { for (const record of records) { const screen = project(record); if (!Number.isFinite(screen?.x) || !Number.isFinite(screen?.y)) continue; - if (screen.x < -padding || screen.x > width + padding - || screen.y < -padding || screen.y > height + padding) continue; + if ( + 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)}`; let contenders = cells.get(key); if (!contenders) { @@ -189,7 +207,8 @@ export function selectLocalInfrastructureOverlayCohort(records, { } primary.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)); 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. * @returns {{show:function():void,publish:function(object[]):void,hide:function():void,destroy:function():void}} */ -export function createLocalInfrastructureOverlayPublisher({ - sourceId, - host, -}) { +export function createLocalInfrastructureOverlayPublisher({ sourceId, host }) { let visible = false; let published = false; let destroyed = false; @@ -227,17 +243,26 @@ export function createLocalInfrastructureOverlayPublisher({ }, publish(entries) { if (destroyed || !visible) return; - if (lastPublication && entries.length === lastPublication.length - && entries.every((entry, index) => { + if ( + lastPublication && + entries.length === lastPublication.length && + entries.every((entry, index) => { const previous = lastPublication[index]; - return entry === previous.entry - && entry.position?.x === previous.x - && entry.position?.y === previous.y - && entry.position?.z === previous.z; - })) return; + return ( + entry === previous.entry && + entry.position?.x === previous.x && + entry.position?.y === previous.y && + entry.position?.z === previous.z + ); + }) + ) + return; host.setEntries(sourceId, entries, sourceOptions); - lastPublication = entries.map(entry => ({ - entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z, + lastPublication = entries.map((entry) => ({ + entry, + x: entry.position?.x, + y: entry.position?.y, + z: entry.position?.z, })); 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. * Destroy the previous instance before replacing it. Importing creates no layers. */ -export function createLocalGeoJsonLayer({ - id, - url, - name, - color, - icon = '📍', - source = 'Local JSONL', - labels = true, - labelMax = DEFAULT_LABEL_MAX, - labelGridPx = DEFAULT_LABEL_GRID_PX, - screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), - projectToWindow = (scene, position) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), -}, { - overlayHost, - registerEntityContext, - selectEntityContext, - clearSelectedEntityContextForLayer, - removeEntityContextsForLayer, - governorRequestRender, -}) { +export function createLocalGeoJsonLayer( + { + id, + url, + name, + color, + icon = '📍', + source = 'Local JSONL', + labels = true, + labelMax = DEFAULT_LABEL_MAX, + labelGridPx = DEFAULT_LABEL_GRID_PX, + screenSpaceEventHandlerFactory = (canvas) => + new Cesium.ScreenSpaceEventHandler(canvas), + projectToWindow = (scene, position) => + Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), + }, + { + overlayHost, + registerEntityContext, + selectEntityContext, + clearSelectedEntityContextForLayer, + removeEntityContextsForLayer, + governorRequestRender, + }, +) { let _dataSource = null; let _enabled = false; let _clickHandler = null; @@ -483,231 +513,279 @@ export function createLocalGeoJsonLayer({ // 1. Initialize data source if (!_dataSource) { - if (!_loadPromise) _loadPromise = (async () => { - _loadController = new AbortController(); - 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. - // The source is built into a local and committed to `_dataSource` - // only once setup finishes: a half-built source published early would - // make every later enable() skip this block, so the layer could never - // clear its error or retry. - _error = null; - let loaded = null; - // Whether the scene has actually accepted `loaded` — the two rollback - // windows (before vs after the add settles) need different cleanup. - let addedToScene = false; - try { - const response = await fetch(url, { signal: _loadController.signal }); - if (_destroyed) return; - // 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 (!response.ok) { - throw new Error(`HTTP ${response.status ?? '?'}`); - } - const text = await response.text(); - if (_destroyed) return; - const lines = text.split('\n').filter(l => l.trim().length > 0); + // Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection. + // The source is built into a local and committed to `_dataSource` + // only once setup finishes: a half-built source published early would + // make every later enable() skip this block, so the layer could never + // clear its error or retry. + _error = null; + let loaded = null; + // Whether the scene has actually accepted `loaded` — the two rollback + // windows (before vs after the add settles) need different cleanup. + let addedToScene = false; + try { + const response = await fetch(url, { + signal: _loadController.signal, + }); + if (_destroyed) return; + // 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 (!response.ok) { + throw new Error(`HTTP ${response.status ?? '?'}`); + } + 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 = { - type: 'FeatureCollection', - features - }; + const geojson = { + type: 'FeatureCollection', + features, + }; - // Natively parse into entities and use it as our _dataSource - loaded = await Cesium.GeoJsonDataSource.load(geojson, { - clampToGround: true, - stroke: baseColor, - fill: baseColor.withAlpha(0.3), - strokeWidth: 2, - markerSize: 8, - markerColor: baseColor, - }); + // Natively parse into entities and use it as our _dataSource + loaded = await Cesium.GeoJsonDataSource.load(geojson, { + clampToGround: true, + stroke: baseColor, + fill: baseColor.withAlpha(0.3), + strokeWidth: 2, + markerSize: 8, + markerColor: baseColor, + }); - if (_destroyed) return; - loaded.name = name; - loaded.show = false; - // Cesium's DataSourceCollection.add() returns a promise and only - // inserts on a later microtask. Without this await, a throw during - // post-processing would roll back a source the scene had not - // accepted yet — and Cesium would then insert the "removed" source - // anyway, leaving an orphan the retry would double up on. Awaiting - // also routes an add() rejection into the error path below instead - // of leaving it uncaught with healthy-looking stats. - await viewer.dataSources.add(loaded); - addedToScene = true; - if (_destroyed) { - viewer.dataSources.remove(loaded, true); - 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 (_destroyed) return; + loaded.name = name; + loaded.show = false; + // Cesium's DataSourceCollection.add() returns a promise and only + // inserts on a later microtask. Without this await, a throw during + // post-processing would roll back a source the scene had not + // accepted yet — and Cesium would then insert the "removed" source + // anyway, leaving an orphan the retry would double up on. Awaiting + // also routes an add() rejection into the error path below instead + // of leaving it uncaught with healthy-looking stats. + await viewer.dataSources.add(loaded); + addedToScene = true; + if (_destroyed) { + viewer.dataSources.remove(loaded, true); + return; } - 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); - const groundHeight = 0; // Ellipsoid surface until a scene sample lands - const tipHeight = 2000; // Initial Stem height + 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 - const base = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, groundHeight); - const tip = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, tipHeight); - const properties = propertyObject(feature); - const recordId = String(feature.id ?? i); + let pos = feature.position?.getValue(Cesium.JulianDate.now()); - // Store references for bounded stem scaling and native picking. - feature.__localBaseCarto = carto; - feature.__localBaseCartesian = base; - registerEntityContext(feature, { - id: `${id}:${recordId}`, - 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)), - }); + if (!pos) { + // It's a polygon or line + if (feature.polygon) { + feature.polygon.outline = true; + feature.polygon.outlineColor = baseColor; - // 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, - }); + // 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; + } + } + } - 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, - entry: labels ? createLocalInfrastructureOverlayEntry({ - id: recordId, + if (!pos) continue; + + const carto = Cesium.Cartographic.fromCartesian(pos); + const groundHeight = 0; // Ellipsoid surface until a scene sample lands + const tipHeight = 2000; // Initial Stem height + + const base = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + groundHeight, + ); + 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. + feature.__localBaseCarto = carto; + feature.__localBaseCartesian = base; + registerEntityContext(feature, { + id: `${id}:${recordId}`, layerId: id, - position: tip, + 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 + // 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, - accent: color, - }) : null, - }); - } - // 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; }, - }); + entry: labels + ? createLocalInfrastructureOverlayEntry({ + id: recordId, + layerId: id, + position: tip, + properties, + priority, + accent: color, + }) + : null, + }); + } + // 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 */ } } - }, 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 { await _loadPromise; } finally { @@ -741,7 +819,10 @@ export function createLocalGeoJsonLayer({ const probe = shouldRecomputeInfraLod({ nowMs: now, lastProbeMs: _lastLodProbeMs, - movedSqM: Cesium.Cartesian3.distanceSquared(cameraPos, _lastLodCameraPos), + movedSqM: Cesium.Cartesian3.distanceSquared( + cameraPos, + _lastLodCameraPos, + ), cameraHeightM: viewer.camera.positionCartographic?.height, }); _lastLodProbeMs = probe.lastProbeMs; @@ -750,7 +831,10 @@ export function createLocalGeoJsonLayer({ 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 refreshStemGeometry = _stemGeometryDirty; @@ -762,7 +846,8 @@ export function createLocalGeoJsonLayer({ // Capability can arrive late (WebGL context restore, a tileset that // finally supports sampling). A parked camera has no moveEnd to // 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; let groundRetryPending = false; let groundSampleProgress = false; @@ -814,7 +899,8 @@ export function createLocalGeoJsonLayer({ const record = _stemRecords[i]; const isActive = !_lodComputed || _activeLodIds.has(record.id); 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, // so skip every per-frame cost for them — geometry trig, the @@ -823,20 +909,32 @@ export function createLocalGeoJsonLayer({ if (!isActive) continue; const wasGroundSampled = record.groundSampled; - const terrainFloorChanged = refreshLocalTerrainFloor(viewer, record); + const terrainFloorChanged = refreshLocalTerrainFloor( + viewer, + record, + ); if (refreshStemGeometry || terrainFloorChanged) { updateLocalStemGeometry(viewer, record, now); - } else if (canSampleGround && !record.groundSampled - && now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS) { + } else if ( + canSampleGround && + !record.groundSampled && + now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS + ) { // Capability first: without it the distance below is pure waste, // once per ungrounded record per walk, forever. - const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); - if (distance < GROUND_SAMPLE_MAX_DISTANCE_M - && sampleLocalGroundHeight(viewer, record, now)) { + const distance = Cesium.Cartesian3.distance( + viewer.camera.positionWC, + record.base, + ); + if ( + distance < GROUND_SAMPLE_MAX_DISTANCE_M && + sampleLocalGroundHeight(viewer, record, now) + ) { 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 // layer has no hold and no periodic update, so under the idle // 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 // camera (or a keyless scene) stays fully idle; the distance is // only computed for still-unsampled stems. - if (canSampleGround && !record.groundSampled && !groundRetryPending - && Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) - < GROUND_SAMPLE_MAX_DISTANCE_M) { + if ( + canSampleGround && + !record.groundSampled && + !groundRetryPending && + Cesium.Cartesian3.distance( + viewer.camera.positionWC, + record.base, + ) < GROUND_SAMPLE_MAX_DISTANCE_M + ) { groundRetryPending = true; } if (isVisible && record.entry) visibleOverlayRecords.push(record); @@ -859,14 +963,17 @@ export function createLocalGeoJsonLayer({ if (groundRetryPending) scheduleGroundRetryRender(viewer); const canvas = viewer.scene.canvas; - const cohort = selectLocalInfrastructureOverlayCohort(visibleOverlayRecords, { - maxEntries: labelMax, - gridPx: labelGridPx, - width: canvas.clientWidth || canvas.width || 0, - height: canvas.clientHeight || canvas.height || 0, - cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, - project: (record) => projectToWindow(viewer.scene, record.tip), - }); + const cohort = selectLocalInfrastructureOverlayCohort( + visibleOverlayRecords, + { + maxEntries: labelMax, + gridPx: labelGridPx, + width: canvas.clientWidth || canvas.width || 0, + height: canvas.clientHeight || canvas.height || 0, + cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, + project: (record) => projectToWindow(viewer.scene, record.tip), + }, + ); _overlayPublisher.publish(cohort); }); } @@ -913,7 +1020,7 @@ export function createLocalGeoJsonLayer({ _count = 0; _lastUpdate = null; _error = null; - } + }, }; } @@ -923,24 +1030,36 @@ function compareLocalOverlayRecords(a, b) { function insertLocalCellContender(contenders, record) { let index = 0; - while (index < contenders.length && compareLocalOverlayRecords(contenders[index], record) <= 0) { + while ( + index < contenders.length && + compareLocalOverlayRecords(contenders[index], record) <= 0 + ) { index++; } 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) { const globe = viewer.scene.globe; - if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) return false; - if (Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) - >= GROUND_SAMPLE_MAX_DISTANCE_M) return false; + if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) + 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. // 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. const height = globe.getHeight?.(record.carto); - if (!Number.isFinite(height) || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M - || height <= record.groundHeight) return false; + if ( + !Number.isFinite(height) || + Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || + height <= record.groundHeight + ) + return false; setLocalGroundHeight(record, height); return true; } @@ -957,13 +1076,22 @@ function sampleLocalGroundHeight(viewer, record, now) { } catch { 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 // 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 // inactive terrain must not constrain their geometry. - const terrainHeight = globe?.show ? globe.getHeight?.(record.carto) : undefined; - if (Number.isFinite(terrainHeight) && Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M) { + const terrainHeight = globe?.show + ? globe.getHeight?.(record.carto) + : undefined; + if ( + Number.isFinite(terrainHeight) && + Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M + ) { sampled = Math.max(sampled, terrainHeight); } record.groundSampled = true; @@ -987,12 +1115,13 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { const distance = Number.isFinite(knownDistance) ? knownDistance : 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 // minimum made a marker hundreds of metres tall beside a nearby building. const effectiveDistance = Math.max(distance, 1); 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 fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight); const tipHeight = record.groundHeight + effectiveDistance * fovFactor; @@ -1003,12 +1132,16 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { Cesium.Ellipsoid.WGS84, 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; } Cesium.Cartesian3.clone(record.nextTip, record.tip); record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex; - const stemPositions = record.stemPositionBuffers[record.stemPositionBufferIndex]; + const stemPositions = + record.stemPositionBuffers[record.stemPositionBufferIndex]; stemPositions[0] = record.base; stemPositions[1] = record.tip; record.entity.position.setValue(record.tip); @@ -1050,9 +1183,10 @@ function labelPriorityFromProperties(props, layerId) { function propertyObject(entity) { const source = entity?.properties; - const raw = typeof source?.getValue === 'function' - ? source.getValue(Cesium.JulianDate.now()) - : source || {}; + const raw = + typeof source?.getValue === 'function' + ? source.getValue(Cesium.JulianDate.now()) + : source || {}; return unwrapProperties(raw); } @@ -1061,9 +1195,10 @@ function unwrapProperties(value) { if (Array.isArray(value)) return value.map(unwrapProperties); const out = {}; for (const [key, entry] of Object.entries(value)) { - out[key] = entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); + out[key] = + entry && typeof entry.getValue === 'function' + ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) + : unwrapProperties(entry); } return out; } diff --git a/src/data/localGeojsonLod.js b/src/data/localGeojsonLod.js index 5c3124855..cfd082c04 100644 --- a/src/data/localGeojsonLod.js +++ b/src/data/localGeojsonLod.js @@ -58,8 +58,10 @@ export function infraLodBudget(cameraHeightM) { const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : INFRA_LOD_GLOBAL_HEIGHT_M; - if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MIN }; - if (height >= INFRA_LOD_REGIONAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MID }; + if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) + 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 }; } @@ -104,24 +106,33 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200; * @param {number} [options.maxDistancePenalty] * @returns {number} */ -export function infraRankScore(priority, distanceM, isIncumbent, { - incumbentBonus = INFRA_LOD_INCUMBENT_BONUS, - farM = INFRA_LOD_FAR_M, - maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY, -} = {}) { +export function infraRankScore( + priority, + distanceM, + 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 far = Number.isFinite(farM) && farM > 0 ? farM : INFRA_LOD_FAR_M; const d = Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : far; - const penaltyCap = Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0 - ? maxDistancePenalty - : INFRA_LOD_MAX_DISTANCE_PENALTY; - const bonus = isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0; + const penaltyCap = + Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0 + ? maxDistancePenalty + : INFRA_LOD_MAX_DISTANCE_PENALTY; + const bonus = + isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0; return p + bonus - penaltyCap * Math.min(1, d / far); } /** Infinity-safe distance for a total sort order. */ 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|Set} [options.incumbentIds] Ids that currently hold an active stem. * @returns {{activeIds:string[], budget:{activeLimit:number}}} */ -export function selectInfraLod(candidates, { cameraHeightM, incumbentIds } = {}) { +export function selectInfraLod( + candidates, + { cameraHeightM, incumbentIds } = {}, +) { 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(); 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; - const distanceM = Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0 - ? candidate.distanceM - : Number.POSITIVE_INFINITY; + const distanceM = + Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0 + ? candidate.distanceM + : Number.POSITIVE_INFINITY; const normalized = { id: candidate.id, 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); - if (!current - || normalized.score > current.score - || (normalized.score === current.score && normalized.distanceM < current.distanceM)) { + if ( + !current || + normalized.score > current.score || + (normalized.score === current.score && + normalized.distanceM < current.distanceM) + ) { byId.set(normalized.id, normalized); } } - const ranked = [...byId.values()].sort((a, b) => ( - b.score - a.score - || sortableDistance(a.distanceM) - sortableDistance(b.distanceM) - || a.id.localeCompare(b.id) - )); + const ranked = [...byId.values()].sort( + (a, b) => + b.score - a.score || + sortableDistance(a.distanceM) - sortableDistance(b.distanceM) || + a.id.localeCompare(b.id), + ); return { activeIds: ranked.slice(0, budget.activeLimit).map((entry) => entry.id), @@ -220,7 +245,9 @@ export function applyInfraEvictionGrace({ graceMs = INFRA_LOD_GRACE_MS, } = {}) { 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 evictIds = []; @@ -241,8 +268,13 @@ export function applyInfraEvictionGrace({ // Under cap pressure, grace-period stems go first: oldest-in-grace first // (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)); - const cap = Number.isFinite(activeLimit) ? Math.max(0, Math.floor(activeLimit)) : INFRA_LOD_ACTIVE_MAX; + graced.sort( + (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 overflow = Math.max(0, graced.length - capacity); for (let i = 0; i < graced.length; i++) { @@ -251,7 +283,10 @@ export function applyInfraEvictionGrace({ continue; } 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 }; @@ -307,13 +342,21 @@ export const INFRA_LOD_MOTION_EPSILON_MIN_M = 250; * @param {number} [options.minM] * @returns {number} */ -export function infraLodMotionEpsilonM(cameraHeightM, { - ratio = INFRA_LOD_MOTION_EPSILON_RATIO, - minM = INFRA_LOD_MOTION_EPSILON_MIN_M, -} = {}) { - 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; +export function infraLodMotionEpsilonM( + cameraHeightM, + { + ratio = INFRA_LOD_MOTION_EPSILON_RATIO, + minM = INFRA_LOD_MOTION_EPSILON_MIN_M, + } = {}, +) { + 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); } @@ -355,15 +398,19 @@ export function shouldRecomputeInfraLod({ motionEpsilonM, } = {}) { const now = Number.isFinite(nowMs) ? nowMs : 0; - const last = Number.isFinite(lastProbeMs) ? lastProbeMs : Number.NEGATIVE_INFINITY; - const interval = Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0 - ? probeIntervalMs - : INFRA_LOD_MOTION_PROBE_INTERVAL_MS; + const last = Number.isFinite(lastProbeMs) + ? lastProbeMs + : Number.NEGATIVE_INFINITY; + const interval = + Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0 + ? probeIntervalMs + : INFRA_LOD_MOTION_PROBE_INTERVAL_MS; if (now - last < interval) return { recompute: false, lastProbeMs: last }; const movedSq = Number.isFinite(movedSqM) && movedSqM > 0 ? movedSqM : 0; - const epsilon = Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0 - ? motionEpsilonM - : infraLodMotionEpsilonM(cameraHeightM); + const epsilon = + Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0 + ? motionEpsilonM + : infraLodMotionEpsilonM(cameraHeightM); return { recompute: movedSq > epsilon * epsilon, lastProbeMs: now }; }