diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb230427..aa76d97fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Datacenter and dam factories are available through scoped package exports with + explicit context, overlay and render callbacks. The standalone app uses the + same implementation and bundled datasets. + +- Local GeoJSON layers share concurrent loads, cancel pending fetches on destruction, + discard late results, and remove their entity-context records on teardown. + - Unchanged local infrastructure overlays no longer sustain idle rendering. Ground samples wait for visible terrain to settle and cannot place a marker below its loaded surface; roofs and valid below-sea-level heights are retained. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1332eaf49..7353449ad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2,6 +2,17 @@ ## Infrastructure marker visibility +Datacenter/dam registration uses fresh reusable factories with the application's +existing context, overlay and render functions. The scoped package exports do +not import standalone application globals; see +[the infrastructure interface](INFRASTRUCTURE-LAYERS.md) for lifecycle and asset +requirements. + +Local GeoJSON layers coalesce concurrent enables into one load. Disabling while +loading keeps the result hidden; destruction aborts the fetch and rejects late +parse/add results. Destruction and failed setup remove owned context records, +so replacing a layer cannot retain stale entities or listeners. + Datacenters and dams retain their full datasets while limiting active marker stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km, and 420 below 200 km. Selection favors existing label priority and nearby diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md new file mode 100644 index 000000000..d29e356f9 --- /dev/null +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -0,0 +1,62 @@ +# Reusing infrastructure layers + +`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`. +It returns fresh datacenter and dam layers in that order, with the existing IDs, +appearance, label budgets and bundled public datasets. Importing the module or +calling the factory does not load data, create a viewer, or start the application. +The asset URLs resolve relative to the module; Vite rewrites them for production +base paths. This browser source export expects a Vite-compatible asset build. + +Pass the application's existing functions: + +```js +import { createInfrastructureLayers } from 'gods-eye-view/infrastructure'; + +const layers = createInfrastructureLayers({ + overlayHost: { setEntries, setVisible, clearSource }, + registerEntityContext, + selectEntityContext, + clearSelectedEntityContextForLayer, + removeEntityContextsForLayer, + governorRequestRender, +}); + +// Register these layers with the existing data-layer manager and viewer. +``` + +| Function | Arguments / responsibility | +| --- | --- | +| `overlayHost.setEntries` | `(sourceId, entries, options)`; publish the existing infrastructure overlay records and paint budget | +| `overlayHost.setVisible` | `(sourceId, visible)`; toggle a source | +| `overlayHost.clearSource` | `(sourceId)`; remove that source's overlay entries | +| `registerEntityContext` | `(entity, metadata)`; register selectable feature metadata, including the owning layer and data source | +| `selectEntityContext` | `(entity)`; select that registered feature | +| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer | +| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup | +| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | + +Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, +`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`. +Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay +host permits one live instance per layer ID; destroy an old instance before +replacing it. Separate applications may use identical IDs with separate hosts. +Callbacks must be ready before enable and remain usable through destruction. + +Disable retains the loaded dataset for reuse and clears selection and listeners. +Concurrent enables share one load. Destroy is permanent, aborts pending fetches, +discards late results, and clears the owned data source, context records, overlays, +input handler and listeners. The layer never creates an application context store, +overlay host, viewer, or render scheduler. The consumer must resolve one compatible +Cesium installation shared with its viewer; it must not bundle separate copies. + +`gods-eye-view/infrastructure/geojson` exports the lower-level +`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay +helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers. +The standalone app's `src/data/localGeojson.js` keeps its original single-argument +factory and supplies its existing functions. Other consumers should use the package +exports, which do not import standalone application globals. + +Dataset files and source/license notices remain under `src/data/local_data/`. +Their input counts are 4,351 datacenter features and 704 dam features. Cesium may +expand multipart geometries into multiple entities; entity and stem counts are +different measurements. This change does not refresh or relicense the datasets. diff --git a/package.json b/package.json index 864d876ea..e2676c976 100644 --- a/package.json +++ b/package.json @@ -56,5 +56,10 @@ "vite": "^6.0.0", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" + }, + "exports": { + "./infrastructure": "./src/data/infrastructure.js", + "./infrastructure/geojson": "./src/data/localGeojsonCore.js", + "./infrastructure/lod": "./src/data/localGeojsonLod.js" } } diff --git a/src/data/infrastructure.js b/src/data/infrastructure.js new file mode 100644 index 000000000..bac9e81d5 --- /dev/null +++ b/src/data/infrastructure.js @@ -0,0 +1,38 @@ +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; + +/** + * Create fresh datacenter and dam layers without starting or loading them. + * @param {object} services Caller-owned context, overlay and render operations. + * @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 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 new file mode 100644 index 000000000..416423636 --- /dev/null +++ b/src/data/infrastructure.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import * as Cesium from 'cesium'; +import { createInfrastructureLayers } from 'gods-eye-view/infrastructure'; +import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson'; + +function services() { + const records = new Map(); + let selection; + return { + records, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + 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); + }, + governorRequestRender() {}, + selection: () => selection, + }; +} + +test('package exports import without an application, DOM, fetch, or timers', () => { + 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']) { + delete globalThis[key]; + } + 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' }); + 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'); }); + 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' }, + ]); + first.forEach((layer, index) => { + assert.notEqual(layer, second[index]); + layer.destroy(); + 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()); + 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]]] }, + }) })); + const hosts = [services(), services()]; + 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 viewer = { + dataSources: sources, + scene: { + canvas: {}, preRender: new Cesium.Event(), requestRender() {}, + screenSpaceCameraController: { enableInputs: true }, + pick: () => ({ id: sources.get(0).entities.values[0] }), + }, + camera: { moveEnd: new Cesium.Event(), flyTo() {} }, + }; + return { layer, viewer, click: () => click({ position: {} }) }; + }); + 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); + instances[0].click(); + assert.ok(hosts[0].selection()); + assert.equal(hosts[1].selection(), undefined); + instances[0].layer.destroy(instances[0].viewer); + assert.equal(hosts[0].records.size, 0); + assert.equal(hosts[1].records.size, 1); + assert.equal(instances[1].viewer.dataSources.length, 1); +}); + +test('consumer build includes only infrastructure code and resolves assets under a non-root base', async () => { + const { build } = await import('vite'); + const { fileURLToPath } = await import('node:url'); + const result = await build({ + configFile: false, + logLevel: 'silent', + base: '/example/', + build: { + write: false, + assetsInlineLimit: 0, + rollupOptions: { + 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', + ]); + 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)); + assert.ok(asset, `${name} must be emitted`); + assert.ok(entry.code.includes(`/example/${asset.fileName}`), `${name} must retain the consumer base path`); + } +}); diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js index 7cf7aeb5b..3798552e0 100644 --- a/src/data/localGeojson.js +++ b/src/data/localGeojson.js @@ -1,8 +1,8 @@ -import * as Cesium from 'cesium'; import { governorRequestRender } from '../renderGovernor.js'; import { clearSelectedEntityContextForLayer, registerEntityContext, + removeEntityContextsForLayer, selectEntityContext, } from './contextStore.js'; import { @@ -11,1067 +11,39 @@ import { setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { - selectInfraLod, - applyInfraEvictionGrace, - shouldRecomputeInfraLod, -} from './localGeojsonLod.js'; + createLocalGeoJsonLayer as createLayer, + createLocalInfrastructureOverlayPublisher as createPublisher, +} from './localGeojsonCore.js'; -const DEFAULT_LABEL_MAX = 900; -const DEFAULT_LABEL_GRID_PX = 132; -const VISIBILITY_UPDATE_MS = 450; -// Each source keeps its own bounded cohort; the host sums their ambient-card -// paint budgets only up to its 192-card shared-lane ceiling. -export const LOCAL_OVERLAY_COHORT_LIMIT = 160; -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; -// 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 -// camera is near enough for tiles to be loaded, sample the real surface -// height once per feature and lift the stem onto it. -const GROUND_SAMPLE_MAX_DISTANCE_M = 75000; -const GROUND_SAMPLE_RETRY_MS = 2000; -const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000; -/** - * Bounded give-up for the self-armed retry. Sampling can be SUPPORTED and still - * never succeed (no sampleable surface under the feature), in which case each - * requested frame would arm the next 2 s timer forever — an idle-governor leak - * dressed up as a retry. After this many consecutive armed retries with no - * record newly grounded, stop arming; camera motion (a frame we get for free) - * still retries through the normal preRender walk and re-opens the budget. - * 30 × 2 s ≈ 60 s, far longer than a tile stream-in. - */ -export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; -/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */ -export const LOCAL_STEM_TIP_EPSILON_M = 0.5; -const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; +export * from './localGeojsonCore.js'; -const DEFAULT_OVERLAY_HOST = Object.freeze({ - clearSource: clearOverlaySource, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, +/** Existing standalone application operations, shared by its local layers. */ +export const localGeoJsonServices = Object.freeze({ + overlayHost: Object.freeze({ + clearSource: clearOverlaySource, + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + }), + clearSelectedEntityContextForLayer, + registerEntityContext, + removeEntityContextsForLayer, + selectEntityContext, + governorRequestRender, }); -/** - * Build the owner-approved local-infrastructure card copy. - * @param {object} properties Unwrapped GeoJSON feature properties. - * @param {string} layerId Local layer id. - * @returns {{title:string,details:string[]}} - */ -export function localInfrastructureOverlayCopy(properties, layerId) { - const props = unwrapProperties(properties) || {}; - const tags = props.tags || {}; - const title = featureLabelFromProperties(props, layerId); - const details = []; - - if (layerId === 'local-datacenters') { - const operator = firstClean([ - tags.operator, - props.operator, - tags['operator:short'], - ]); - const capacity = firstClean([ - tags['capacity:it_load'], - tags.it_load, - tags.capacity, - props.capacity, - ]); - const line = [operator, capacity] - .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') { - const river = firstClean([ - tags.associated_river, - props.associated_river, - tags.river, - props.river, - tags['river:name'], - ]); - if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) { - details.push(clampCardLine(river)); - } - } - - return { title, details }; -} - -/** - * Produce one normalized-contract input owned by a local infrastructure layer. - * The host revalidates the authoritative `source` value while normalizing it. - * @param {object} options - * @param {string} options.id Stable id within the source. - * @param {string} options.layerId Local layer id. - * @param {Cesium.Cartesian3} options.position Current stem-tip position. - * @param {object} options.properties Unwrapped feature properties. - * @param {number} options.priority Source-owned importance score. - * @param {string} options.accent Source accent color. - * @returns {object} - */ -export function createLocalInfrastructureOverlayEntry({ - id, - layerId, - position, - properties, - priority, - accent, -}) { - const copy = localInfrastructureOverlayCopy(properties, layerId); - return { - id: String(id), - source: layerId, - position, - variant: 'card', - title: copy.title, - details: copy.details, - accent, - priority, - collisionGroup: 'ambient-card', - zIndex: 30, - interactive: false, - minDistance: 0, - maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M, - distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO, - distanceScale: { - near: 250000, - nearValue: 1, - far: 9000000, - farValue: 0.62, - }, - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - gapPx: 15, - placement: 'above', - }; -} - -/** - * Retain a bounded screen-grid surplus for the host's final rectangle arbiter. - * Two deterministic contenders per legacy grid cell preserve the old density - * while giving the shared solver an alternative when the first card collides. - * @param {object[]} records Local stem/entry records. - * @param {object} options - * @param {number} options.maxEntries Legacy source cap. - * @param {number} options.gridPx Legacy screen grid size. - * @param {number} options.width Viewport width in CSS pixels. - * @param {number} options.height Viewport height in CSS pixels. - * @param {function(object):({x:number,y:number}|null)} options.project Projection callback. - * @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, -}) { - 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') { - return []; - } - - const cells = new Map(); - const padding = cellSize; - 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; - const key = `${Math.floor(screen.x / cellSize)}:${Math.floor(screen.y / cellSize)}`; - let contenders = cells.get(key); - if (!contenders) { - contenders = []; - cells.set(key, contenders); - } - insertLocalCellContender(contenders, record); - } - - const primary = []; - const surplus = []; - for (const contenders of cells.values()) { - if (contenders[0]) primary.push(contenders[0]); - if (contenders[1]) surplus.push(contenders[1]); - } - primary.sort(compareLocalOverlayRecords); - surplus.sort(compareLocalOverlayRecords); - 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); -} - -/** - * Bind a local layer's visibility and entry lifecycle to the shared host. - * @param {object} options - * @param {string} options.sourceId Local layer id. - * @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 = DEFAULT_OVERLAY_HOST, -}) { - let visible = false; - let published = false; - let destroyed = false; - // Local entries have immutable metadata and a mutable Cartesian position. - // Snapshot coordinates: retaining only the entry reference would miss a - // stem tip moving in place. Republishing an unchanged cohort invalidates - // the host and can sustain a render loop when frames exceed the 450 ms walk. - let lastPublication = null; - const sourceOptions = { - cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, - collisionCapacity: LOCAL_OVERLAY_COLLISION_CAPACITY, - moving: false, - }; - - return { - show() { - if (destroyed || visible) return; - visible = true; - host.setVisible(sourceId, true); - }, - publish(entries) { - if (destroyed || !visible) return; - 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; - host.setEntries(sourceId, entries, sourceOptions); - lastPublication = entries.map(entry => ({ - entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z, - })); - published = entries.length > 0; - }, - hide() { - if (destroyed) return; - if (published) host.clearSource(sourceId); - if (visible) host.setVisible(sourceId, false); - visible = false; - published = false; - lastPublication = null; - }, - destroy() { - if (destroyed) return; - if (published) host.clearSource(sourceId); - if (visible) host.setVisible(sourceId, false); - visible = false; - published = false; - lastPublication = null; - destroyed = true; - }, - }; -} - -/** - * Reduce a bundled-dataset load failure to one short, honest stats string. - * - * These layers ship their data with the build, so a failure means the asset - * is missing (404 / bad path) or corrupt — never "the network is slow". Both - * must reach the user's chip; the raw parser message is console-only because - * a truncated JSON blob is not a status line. - * - * @param {Error|{name?:string, message?:string}|null|undefined} error - The thrown load failure. - * @returns {string} Short reason for getStats().error. - */ -export function localDatasetError(error) { - if (error?.name === 'SyntaxError') return 'dataset is malformed'; - const message = String(error?.message || '').trim(); - return message ? `dataset unavailable (${message})` : 'dataset unavailable'; -} - -/** - * A minimal, rock-solid native implementation for loading local GeoJSON Data. - * Draws 3D stems (polylines) attached to Point entities and ensures - * standard scene.pick natively clicks them. - */ -export function createLocalGeoJsonLayer({ - id, - url, - name, - color, - icon = '📍', - source = 'Local JSONL', - labels = true, - labelMax = DEFAULT_LABEL_MAX, - labelGridPx = DEFAULT_LABEL_GRID_PX, - overlayHost = DEFAULT_OVERLAY_HOST, - screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), - projectToWindow = (scene, position) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), -}) { - let _dataSource = null; - let _enabled = false; - let _clickHandler = null; - let _count = 0; - /** @type {number|null} Timestamp of the last successful dataset load. */ - let _lastUpdate = null; - /** @type {string|null} Short reason the bundled dataset failed to load. */ - let _error = null; - let _preRenderRemover = null; - let _cameraMoveEndRemover = null; - let _stemRecords = []; - let _stemGeometryDirty = true; - let _lastVisibilityUpdate = 0; - let _destroyed = false; - /** - * Globe-LOD active set: the record ids allowed to carry a live stem right - * now. This bounds geometry refreshes and ground-sample work to the - * camera-height budget in localGeojsonLod.js. Recomputed only - * when the camera moves (`_stemGeometryDirty`, raised by moveEnd and by the - * motion fallback below). Empty means "not computed yet" — the first - * post-enable walk fills it. - * @type {Set} - */ - let _activeLodIds = new Set(); - /** Eviction-grace bookkeeping for `_activeLodIds` (id -> {misses, since}). */ - let _lodGraceState = new Map(); - /** Budget cap the most recent camera-height band produced (0 until computed). */ - let _lastLodBudgetLimit = 0; - /** - * False until the first post-enable preRender walk has run the LOD - * selection. While false the walk treats every record as active (old - * behavior); once true, an EMPTY `_activeLodIds` legitimately means "nothing - * should carry a stem right now" (e.g. every feature occluded), not "not - * computed yet". - */ - let _lodComputed = false; - /** - * Camera position the last LOD selection ran from — the reference the motion - * fallback measures travel against. Preallocated: the walk clones into it, - * so a moving camera allocates nothing per pass. Only meaningful while - * `_lodComputed` is true. - */ - const _lastLodCameraPos = new Cesium.Cartesian3(); - /** Last time the motion-fallback probe window opened (or a selection ran). */ - let _lastLodProbeMs = Number.NEGATIVE_INFINITY; - let _groundRetryTimer = null; - /** Consecutive self-armed retries since the last grounding/camera motion. */ - let _groundRetryArms = 0; - /** Last observed scene.sampleHeightSupported; null until the first walk. */ - let _lastGroundSampleCapability = null; - - /** - * Coalesced one-shot: ask the governor for a frame once the retry window - * has elapsed, so the preRender ground-sample retry actually runs while the - * camera is parked. One timer for the whole layer (not per record) — the - * retry pass walks every record anyway. (perf rebase 2026-08-17) - * - * Two gates keep this from becoming an idle leak (review round 2): - * - CAPABILITY: without `scene.sampleHeightSupported` the sample can never - * succeed, so a timer here would re-arm on every requested frame, - * forever. Records simply stay at ellipsoid height — exactly the - * pre-perf keyless behavior. - * - BUDGET: sampling can be supported and still keep failing (no sampleable - * surface yet/ever). Give up after GROUND_SAMPLE_MAX_ARMED_RETRIES - * consecutive arms; free camera-motion frames still retry. - * @param {Cesium.Viewer} viewer - * @returns {void} - */ - function scheduleGroundRetryRender(viewer) { - if (_groundRetryTimer || !_enabled) return; - if (!viewer?.scene?.sampleHeightSupported) return; - if (_groundRetryArms >= GROUND_SAMPLE_MAX_ARMED_RETRIES) return; - _groundRetryArms += 1; - _groundRetryTimer = setTimeout(() => { - _groundRetryTimer = null; - if (!_enabled || _destroyed) return; - governorRequestRender(`local-ground-retry:${id}`); - }, GROUND_SAMPLE_RETRY_MS); - } - - function clearGroundRetryRender() { - _groundRetryArms = 0; - _lastGroundSampleCapability = null; - if (!_groundRetryTimer) return; - clearTimeout(_groundRetryTimer); - _groundRetryTimer = null; - } - const _overlayPublisher = createLocalInfrastructureOverlayPublisher({ - sourceId: id, - host: overlayHost, +/** Create a layer using the standalone app's operations; retain overlay overrides. */ +export function createLocalGeoJsonLayer(options) { + return createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost === undefined + ? localGeoJsonServices.overlayHost : options.overlayHost, }); - - const disableLayer = (viewer) => { - _enabled = false; - clearGroundRetryRender(); - _activeLodIds = new Set(); - _lodGraceState = new Map(); - _lastLodBudgetLimit = 0; - _lodComputed = false; - _lastLodProbeMs = Number.NEGATIVE_INFINITY; - if (_dataSource) _dataSource.show = false; - _overlayPublisher.hide(); - clearSelectedEntityContextForLayer(id); - if (viewer?.selectedEntity?.__localLayerId === id) { - viewer.selectedEntity = undefined; - } - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (_cameraMoveEndRemover) { - _cameraMoveEndRemover(); - _cameraMoveEndRemover = null; - } - }; - - return { - id, - name, - icon, - source, - updateInterval: 0, - statsRefreshInterval: 1000, - - init: async (viewer) => { - // DataLayerManager calls this once - }, - - update: async (viewer) => { - // DataLayerManager calls this when enabled - }, - - /** - * @returns {{count:number, lastUpdate:number|null, error:string|null}} - * A dead layer must be distinguishable from an empty one: a failed load - * surfaces `error` (manager chip → UNAVAILABLE) instead of reporting a - * silent zero count as nominal. - */ - getStats: () => { - return { count: _count, lastUpdate: _lastUpdate, error: _error }; - }, - - /** - * Globe-LOD state for QA harnesses (scripts/qa-infra-lod.mjs) and tests. - * `active` is how many records currently carry a live stem; `total` is the - * full materialized set. `active < total` means the declutter is engaged. - * `budgetLimit` is the cap the last camera-height band produced; `computed` - * is false until the first post-enable walk has run the selection. - * @returns {{total:number, active:number, budgetLimit:number, computed:boolean}} - */ - getLodDiagnostics: () => ({ - total: _stemRecords.length, - active: _activeLodIds.size, - budgetLimit: _lastLodBudgetLimit, - computed: _lodComputed, - }), - - enable: async (viewer) => { - if (_destroyed) return; - _enabled = true; - _stemGeometryDirty = true; - _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; - _groundRetryArms = 0; // fresh give-up budget per enable-cycle - _lastGroundSampleCapability = null; - _activeLodIds = new Set(); // fresh globe-LOD selection per enable-cycle - _lodGraceState = new Map(); - _lastLodBudgetLimit = 0; - _lodComputed = false; - _lastLodProbeMs = Number.NEGATIVE_INFINITY; - _overlayPublisher.show(); - - // 1. Initialize data source - if (!_dataSource) { - 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); - // 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(); - const lines = text.split('\n').filter(l => l.trim().length > 0); - - const features = lines.map(line => JSON.parse(line)); - - 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, - }); - - 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; - - // 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; - - 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, - 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, - 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. - _error = localDatasetError(e); - // Roll the partial build back so a later enable() retries from - // scratch instead of inheriting a half-populated source. Only the - // post-add window has something in the scene to remove: a failure - // before (or inside) add() never reached the collection, and - // removing then would race Cesium's pending insert. - if (addedToScene) { - try { viewer?.dataSources?.remove(loaded, true); } catch { /* already gone */ } - } - _count = 0; - _stemRecords = []; - console.error(`Failed to load ${id}:`, e); - } - - // 2. Install native global click handler - if (!_clickHandler) { - _clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!_enabled) return; - const picked = viewer.scene.pick(click.position); - - if (picked && picked.id && picked.id.__localLayerId === id) { - const entity = picked.id; - viewer.selectedEntity = entity; - selectEntityContext(entity); - - // We zoom to the surface base of the stem or the center of the polygon - let targetPos = null; - - if (entity.polyline) { - // If it's a stem, fly to the base - const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now()); - if (positions && positions.length > 0) { - targetPos = positions[0]; - } - } else if (entity.polygon && entity.polygon.hierarchy) { - // If it's a polygon, just fly to its center - const hierarchy = entity.polygon.hierarchy.getValue(Cesium.JulianDate.now()); - if (hierarchy && hierarchy.positions.length > 0) { - targetPos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center; - } - } - - if (targetPos) { - const carto = Cesium.Cartographic.fromCartesian(targetPos); - - // Disable interactions so Cesium doesn't magically cancel the flight - viewer.scene.screenSpaceCameraController.enableInputs = false; - - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, 5000), - duration: 1.5, - complete: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; }, - cancel: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; }, - }); - } - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - } - - // 3. Add an incredibly fast pre-render occluder to hide points behind the globe - if (_enabled && !_preRenderRemover) { - _preRenderRemover = viewer.scene.preRender.addEventListener(() => { - if (!_enabled || !_dataSource) return; - const now = performance.now(); - if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return; - _lastVisibilityUpdate = now; - - const cameraPos = viewer.camera.positionWC; - if (!cameraPos) return; - - // --- Globe-LOD motion fallback. --- - // `_stemGeometryDirty` is otherwise raised only by moveEnd, which a - // tracked-entity follow, Cockpit, route flight, or continuous orbit - // never emits. Left alone the selection stays pinned to the region - // the camera left: the walk below hides those records one by one as - // they pass behind the globe and admits nothing newly visible, so - // the layer bleeds down to sparse-or-empty until motion stops. - // Bounded: rate-limited to a probe window, and only when the camera - // has actually travelled since the last selection. - if (!_stemGeometryDirty && _lodComputed && _stemRecords.length > 0) { - const probe = shouldRecomputeInfraLod({ - nowMs: now, - lastProbeMs: _lastLodProbeMs, - movedSqM: Cesium.Cartesian3.distanceSquared(cameraPos, _lastLodCameraPos), - cameraHeightM: viewer.camera.positionCartographic?.height, - }); - _lastLodProbeMs = probe.lastProbeMs; - // Re-selecting demands fresh stem geometry: a record admitted - // mid-motion has never had its tip sized for this camera. - if (probe.recompute) _stemGeometryDirty = true; - } - - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPos); - const visibleOverlayRecords = []; - const refreshStemGeometry = _stemGeometryDirty; - - // A scene that cannot sample heights can never ground a record, so it - // must never arm a retry (the arm would re-arm on every requested - // frame, forever) and must not spend ANY per-record work trying. - // Read once per walk, not per record. - const canSampleGround = viewer.scene.sampleHeightSupported === true; - // 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; - _lastGroundSampleCapability = canSampleGround; - let groundRetryPending = false; - let groundSampleProgress = false; - - // --- Globe-LOD: recompute the active-stem set on camera moves. --- - // `_stemGeometryDirty` is set by moveEnd, the first enable, and the - // motion fallback above — exactly when the camera-height budget and - // per-record distances can have changed. Without this, all local - // infrastructure features run stem trig + Cesium property writes on - // every move; with it, only the ~80 (global) to ~420 (regional) - // budgeted records do. - // The occluder test is the same cheap dot product used below. - if (refreshStemGeometry && _stemRecords.length > 0) { - const cameraHeightM = viewer.camera.positionCartographic?.height; - const candidates = new Array(_stemRecords.length); - for (let i = 0; i < _stemRecords.length; i++) { - const record = _stemRecords[i]; - candidates[i] = { - id: record.id, - priority: record.priority, - distanceM: Cesium.Cartesian3.distance(cameraPos, record.base), - inView: occluder.isPointVisible(record.base), - }; - } - const selection = selectInfraLod(candidates, { - cameraHeightM, - incumbentIds: _activeLodIds, - }); - const grace = applyInfraEvictionGrace({ - selectedIds: selection.activeIds, - builtIds: [..._activeLodIds], - graceState: _lodGraceState, - nowMs: now, - activeLimit: selection.budget.activeLimit, - }); - _activeLodIds = new Set(grace.keepIds); - _lodGraceState = grace.graceState; - _lastLodBudgetLimit = selection.budget.activeLimit; - _lodComputed = true; - // Adopt this pass as the motion-fallback reference. Travel is - // measured from the last SELECTION, never the previous walk, so - // jitter around a parked camera never accumulates into a - // recompute while genuine slow travel eventually does. - Cesium.Cartesian3.clone(cameraPos, _lastLodCameraPos); - _lastLodProbeMs = now; - } - - for (let i = 0; i < _stemRecords.length; i++) { - 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; - - // Records outside the globe-LOD active set carry no visible stem, - // so skip every per-frame cost for them — geometry trig, the - // ground-sample GPU readback, overlay-label candidacy. They rejoin - // on the next camera move if the budget has room. - if (!isActive) continue; - - const wasGroundSampled = record.groundSampled; - const terrainFloorChanged = refreshLocalTerrainFloor(viewer, record); - if (refreshStemGeometry || terrainFloorChanged) { - updateLocalStemGeometry(viewer, record, now); - } 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)) { - updateLocalStemGeometry(viewer, record, now, distance); - } - } - 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 - // and the stem stays at ellipsoid height (buried/floating) until - // the user happens to move. Schedule the frame the retry needs. - // 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) { - groundRetryPending = true; - } - if (isVisible && record.entry) visibleOverlayRecords.push(record); - } - _stemGeometryDirty = false; - // Tiles ARE streaming in: real progress re-opens the give-up budget - // so the records still waiting get their own bounded run of retries. - if (groundSampleProgress) _groundRetryArms = 0; - 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), - }); - _overlayPublisher.publish(cohort); - }); - } - if (_enabled && !_cameraMoveEndRemover) { - _cameraMoveEndRemover = viewer.camera.moveEnd.addEventListener(() => { - if (!_enabled) return; - _stemGeometryDirty = true; - _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; - // Real camera motion is a fresh situation (new tiles, new distances) - // and its frames are free, so it re-opens the retry budget that a - // parked camera may have spent. - _groundRetryArms = 0; - viewer.scene.requestRender?.(); - }); - } - - // Honor a disable() that landed while we were awaiting the fetch/parse: - // disable() runs before _dataSource exists, so its show=false is a no-op — - // reading _enabled here (rather than forcing true) respects the toggle-off. - if (_dataSource) _dataSource.show = _enabled; - viewer.scene.requestRender?.(); - }, - - disable: disableLayer, - - destroy: (viewer) => { - if (_destroyed) return; - _destroyed = true; - // Defensively disable first so listeners and selection state are - // torn down even if destroy is called while the layer is enabled. - disableLayer(viewer); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_dataSource && viewer) { - viewer.dataSources.remove(_dataSource, true); - } - _overlayPublisher.destroy(); - _dataSource = null; - _stemRecords = []; - _count = 0; - _lastUpdate = null; - _error = null; - } - }; } -function compareLocalOverlayRecords(a, b) { - return b.priority - a.priority || String(a.id).localeCompare(String(b.id)); -} - -function insertLocalCellContender(contenders, record) { - let index = 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; -} - -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; - // 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; - setLocalGroundHeight(record, height); - return true; -} - -function sampleLocalGroundHeight(viewer, record, now) { - if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; - if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; - record.lastGroundSampleMs = now; - const globe = viewer.scene.globe; - if (globe?.show && globe.tilesLoaded === false) return false; - let sampled; - try { - sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); - } 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; - // 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) { - sampled = Math.max(sampled, terrainHeight); - } - record.groundSampled = true; - setLocalGroundHeight(record, sampled); - return true; -} - -function setLocalGroundHeight(record, height) { - record.groundHeight = height; - Cesium.Cartesian3.fromRadians( - record.carto.longitude, - record.carto.latitude, - record.groundHeight, - Cesium.Ellipsoid.WGS84, - record.base, - ); - record.entity.__localBaseCartesian = record.base; -} - -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); - // 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 targetPx = 65; - const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight); - const tipHeight = record.groundHeight + effectiveDistance * fovFactor; - Cesium.Cartesian3.fromRadians( - record.carto.longitude, - record.carto.latitude, - tipHeight, - Cesium.Ellipsoid.WGS84, - record.nextTip, - ); - 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]; - stemPositions[0] = record.base; - stemPositions[1] = record.tip; - record.entity.position.setValue(record.tip); - record.entity.polyline.positions.setValue(stemPositions); - return true; -} - -function featureLabelFromProperties(props, layerId) { - const tags = props.tags || {}; - - const candidates = [ - props.name, - tags.name, - tags['name:en'], - tags.official_name, - tags.operator, - tags['operator:short'], - props.operator, - props.output ? `${layerTitle(layerId)} ${props.output}` : '', - props.osm_id ? `${layerTitle(layerId)} ${props.osm_id}` : '', - ]; - - const text = candidates.map(cleanLabel).find(Boolean); - return clampLabel(text || layerTitle(layerId)); -} - -function labelPriorityFromProperties(props, layerId) { - const tags = props.tags || {}; - - let score = 0; - if (cleanLabel(props.name) || cleanLabel(tags.name)) score += 1000; - if (cleanLabel(tags['name:en'])) score += 700; - if (cleanLabel(tags.operator) || cleanLabel(props.operator)) score += 180; - if (props.output || tags['plant:output:electricity']) score += 120; - if (layerId === 'local-dams') score += 80; - if (layerId === 'local-datacenters') score += 60; - return score; -} - -function propertyObject(entity) { - const source = entity?.properties; - const raw = typeof source?.getValue === 'function' - ? source.getValue(Cesium.JulianDate.now()) - : source || {}; - return unwrapProperties(raw); -} - -function unwrapProperties(value) { - if (!value || typeof value !== 'object') return 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); - } - return out; -} - -function cleanLabel(value) { - const text = String(value || '').trim(); - if (!text || text === 'undefined' || text === 'null') return ''; - return text; -} - -function firstClean(values) { - return values.map(cleanLabel).find(Boolean) || ''; -} - -function clampLabel(value) { - const text = cleanLabel(value); - return text.length > 34 ? `${text.slice(0, 31)}...` : text; -} - -function clampCardLine(value) { - const text = cleanLabel(value); - return text.length > 48 ? `${text.slice(0, 45)}...` : text; -} - -function layerTitle(layerId) { - if (layerId === 'local-datacenters') return 'Datacenter'; - if (layerId === 'local-dams') return 'Dam'; - return 'Feature'; +/** Create an overlay publisher using the standalone app's host by default. */ +export function createLocalInfrastructureOverlayPublisher(options) { + return createPublisher({ + ...options, + host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host, + }); } diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 40f09b96d..6f6b3a27d 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -489,7 +489,7 @@ for (const [heightM, minStemM, maxStemM] of [[500, 45, 90], [10000, 1100, 1400]] } test('local infrastructure creates no native labels or per-frame geometry callbacks', () => { - const source = readFileSync(new URL('./localGeojson.js', import.meta.url), 'utf8'); + const source = readFileSync(new URL('./localGeojsonCore.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /new Cesium\.LabelGraphics/); assert.doesNotMatch(source, /new Cesium\.CallbackProperty/); assert.match(source, /feature\.position = tip/); @@ -1336,3 +1336,15 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE env.preRender.raise(); assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); }); + + +test('standalone publisher retains its default host for an undefined override', () => { + const publisher = createLocalInfrastructureOverlayPublisher({ + sourceId: 'local-default-host-test', host: undefined, + }); + assert.doesNotThrow(() => { + publisher.show(); + publisher.hide(); + publisher.destroy(); + }); +}); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js new file mode 100644 index 000000000..d18ce833f --- /dev/null +++ b/src/data/localGeojsonCore.js @@ -0,0 +1,1095 @@ +import * as Cesium from 'cesium'; +import { + selectInfraLod, + applyInfraEvictionGrace, + shouldRecomputeInfraLod, +} from './localGeojsonLod.js'; + +const DEFAULT_LABEL_MAX = 900; +const DEFAULT_LABEL_GRID_PX = 132; +const VISIBILITY_UPDATE_MS = 450; +// Each source keeps its own bounded cohort; the host sums their ambient-card +// paint budgets only up to its 192-card shared-lane ceiling. +export const LOCAL_OVERLAY_COHORT_LIMIT = 160; +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; +// 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 +// camera is near enough for tiles to be loaded, sample the real surface +// height once per feature and lift the stem onto it. +const GROUND_SAMPLE_MAX_DISTANCE_M = 75000; +const GROUND_SAMPLE_RETRY_MS = 2000; +const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000; +/** + * Bounded give-up for the self-armed retry. Sampling can be SUPPORTED and still + * never succeed (no sampleable surface under the feature), in which case each + * requested frame would arm the next 2 s timer forever — an idle-governor leak + * dressed up as a retry. After this many consecutive armed retries with no + * record newly grounded, stop arming; camera motion (a frame we get for free) + * still retries through the normal preRender walk and re-opens the budget. + * 30 × 2 s ≈ 60 s, far longer than a tile stream-in. + */ +export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; +/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */ +export const LOCAL_STEM_TIP_EPSILON_M = 0.5; +const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; + +/** + * Build the owner-approved local-infrastructure card copy. + * @param {object} properties Unwrapped GeoJSON feature properties. + * @param {string} layerId Local layer id. + * @returns {{title:string,details:string[]}} + */ +export function localInfrastructureOverlayCopy(properties, layerId) { + const props = unwrapProperties(properties) || {}; + const tags = props.tags || {}; + const title = featureLabelFromProperties(props, layerId); + const details = []; + + if (layerId === 'local-datacenters') { + const operator = firstClean([ + tags.operator, + props.operator, + tags['operator:short'], + ]); + const capacity = firstClean([ + tags['capacity:it_load'], + tags.it_load, + tags.capacity, + props.capacity, + ]); + const line = [operator, capacity] + .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') { + const river = firstClean([ + tags.associated_river, + props.associated_river, + tags.river, + props.river, + tags['river:name'], + ]); + if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) { + details.push(clampCardLine(river)); + } + } + + return { title, details }; +} + +/** + * Produce one normalized-contract input owned by a local infrastructure layer. + * The host revalidates the authoritative `source` value while normalizing it. + * @param {object} options + * @param {string} options.id Stable id within the source. + * @param {string} options.layerId Local layer id. + * @param {Cesium.Cartesian3} options.position Current stem-tip position. + * @param {object} options.properties Unwrapped feature properties. + * @param {number} options.priority Source-owned importance score. + * @param {string} options.accent Source accent color. + * @returns {object} + */ +export function createLocalInfrastructureOverlayEntry({ + id, + layerId, + position, + properties, + priority, + accent, +}) { + const copy = localInfrastructureOverlayCopy(properties, layerId); + return { + id: String(id), + source: layerId, + position, + variant: 'card', + title: copy.title, + details: copy.details, + accent, + priority, + collisionGroup: 'ambient-card', + zIndex: 30, + interactive: false, + minDistance: 0, + maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M, + distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO, + distanceScale: { + near: 250000, + nearValue: 1, + far: 9000000, + farValue: 0.62, + }, + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + gapPx: 15, + placement: 'above', + }; +} + +/** + * Retain a bounded screen-grid surplus for the host's final rectangle arbiter. + * Two deterministic contenders per legacy grid cell preserve the old density + * while giving the shared solver an alternative when the first card collides. + * @param {object[]} records Local stem/entry records. + * @param {object} options + * @param {number} options.maxEntries Legacy source cap. + * @param {number} options.gridPx Legacy screen grid size. + * @param {number} options.width Viewport width in CSS pixels. + * @param {number} options.height Viewport height in CSS pixels. + * @param {function(object):({x:number,y:number}|null)} options.project Projection callback. + * @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, +}) { + 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') { + return []; + } + + const cells = new Map(); + const padding = cellSize; + 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; + const key = `${Math.floor(screen.x / cellSize)}:${Math.floor(screen.y / cellSize)}`; + let contenders = cells.get(key); + if (!contenders) { + contenders = []; + cells.set(key, contenders); + } + insertLocalCellContender(contenders, record); + } + + const primary = []; + const surplus = []; + for (const contenders of cells.values()) { + if (contenders[0]) primary.push(contenders[0]); + if (contenders[1]) surplus.push(contenders[1]); + } + primary.sort(compareLocalOverlayRecords); + surplus.sort(compareLocalOverlayRecords); + 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); +} + +/** + * Bind a local layer's visibility and entry lifecycle to the shared host. + * @param {object} options + * @param {string} options.sourceId Local layer id. + * @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, +}) { + let visible = false; + let published = false; + let destroyed = false; + // Local entries have immutable metadata and a mutable Cartesian position. + // Snapshot coordinates: retaining only the entry reference would miss a + // stem tip moving in place. Republishing an unchanged cohort invalidates + // the host and can sustain a render loop when frames exceed the 450 ms walk. + let lastPublication = null; + const sourceOptions = { + cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, + collisionCapacity: LOCAL_OVERLAY_COLLISION_CAPACITY, + moving: false, + }; + + return { + show() { + if (destroyed || visible) return; + visible = true; + host.setVisible(sourceId, true); + }, + publish(entries) { + if (destroyed || !visible) return; + 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; + host.setEntries(sourceId, entries, sourceOptions); + lastPublication = entries.map(entry => ({ + entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z, + })); + published = entries.length > 0; + }, + hide() { + if (destroyed) return; + if (published) host.clearSource(sourceId); + if (visible) host.setVisible(sourceId, false); + visible = false; + published = false; + lastPublication = null; + }, + destroy() { + if (destroyed) return; + if (published) host.clearSource(sourceId); + if (visible) host.setVisible(sourceId, false); + visible = false; + published = false; + lastPublication = null; + destroyed = true; + }, + }; +} + +/** + * Reduce a bundled-dataset load failure to one short, honest stats string. + * + * These layers ship their data with the build, so a failure means the asset + * is missing (404 / bad path) or corrupt — never "the network is slow". Both + * must reach the user's chip; the raw parser message is console-only because + * a truncated JSON blob is not a status line. + * + * @param {Error|{name?:string, message?:string}|null|undefined} error - The thrown load failure. + * @returns {string} Short reason for getStats().error. + */ +export function localDatasetError(error) { + if (error?.name === 'SyntaxError') return 'dataset is malformed'; + const message = String(error?.message || '').trim(); + return message ? `dataset unavailable (${message})` : 'dataset unavailable'; +} + +/** + * A minimal, rock-solid native implementation for loading local GeoJSON Data. + * Draws 3D stems (polylines) attached to Point entities and ensures + * standard scene.pick natively clicks them. + * @param {object} options Dataset URL, identity, appearance and optional Cesium adapters. + * @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md. + * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats. + * 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, +}) { + let _dataSource = null; + let _enabled = false; + let _clickHandler = null; + let _count = 0; + /** @type {number|null} Timestamp of the last successful dataset load. */ + let _lastUpdate = null; + /** @type {string|null} Short reason the bundled dataset failed to load. */ + let _error = null; + let _preRenderRemover = null; + let _cameraMoveEndRemover = null; + let _stemRecords = []; + let _stemGeometryDirty = true; + let _lastVisibilityUpdate = 0; + let _destroyed = false; + let _loadPromise = null; + let _loadController = null; + /** + * Globe-LOD active set: the record ids allowed to carry a live stem right + * now. This bounds geometry refreshes and ground-sample work to the + * camera-height budget in localGeojsonLod.js. Recomputed only + * when the camera moves (`_stemGeometryDirty`, raised by moveEnd and by the + * motion fallback below). Empty means "not computed yet" — the first + * post-enable walk fills it. + * @type {Set} + */ + let _activeLodIds = new Set(); + /** Eviction-grace bookkeeping for `_activeLodIds` (id -> {misses, since}). */ + let _lodGraceState = new Map(); + /** Budget cap the most recent camera-height band produced (0 until computed). */ + let _lastLodBudgetLimit = 0; + /** + * False until the first post-enable preRender walk has run the LOD + * selection. While false the walk treats every record as active (old + * behavior); once true, an EMPTY `_activeLodIds` legitimately means "nothing + * should carry a stem right now" (e.g. every feature occluded), not "not + * computed yet". + */ + let _lodComputed = false; + /** + * Camera position the last LOD selection ran from — the reference the motion + * fallback measures travel against. Preallocated: the walk clones into it, + * so a moving camera allocates nothing per pass. Only meaningful while + * `_lodComputed` is true. + */ + const _lastLodCameraPos = new Cesium.Cartesian3(); + /** Last time the motion-fallback probe window opened (or a selection ran). */ + let _lastLodProbeMs = Number.NEGATIVE_INFINITY; + let _groundRetryTimer = null; + /** Consecutive self-armed retries since the last grounding/camera motion. */ + let _groundRetryArms = 0; + /** Last observed scene.sampleHeightSupported; null until the first walk. */ + let _lastGroundSampleCapability = null; + + /** + * Coalesced one-shot: ask the governor for a frame once the retry window + * has elapsed, so the preRender ground-sample retry actually runs while the + * camera is parked. One timer for the whole layer (not per record) — the + * retry pass walks every record anyway. (perf rebase 2026-08-17) + * + * Two gates keep this from becoming an idle leak (review round 2): + * - CAPABILITY: without `scene.sampleHeightSupported` the sample can never + * succeed, so a timer here would re-arm on every requested frame, + * forever. Records simply stay at ellipsoid height — exactly the + * pre-perf keyless behavior. + * - BUDGET: sampling can be supported and still keep failing (no sampleable + * surface yet/ever). Give up after GROUND_SAMPLE_MAX_ARMED_RETRIES + * consecutive arms; free camera-motion frames still retry. + * @param {Cesium.Viewer} viewer + * @returns {void} + */ + function scheduleGroundRetryRender(viewer) { + if (_groundRetryTimer || !_enabled) return; + if (!viewer?.scene?.sampleHeightSupported) return; + if (_groundRetryArms >= GROUND_SAMPLE_MAX_ARMED_RETRIES) return; + _groundRetryArms += 1; + _groundRetryTimer = setTimeout(() => { + _groundRetryTimer = null; + if (!_enabled || _destroyed) return; + governorRequestRender(`local-ground-retry:${id}`); + }, GROUND_SAMPLE_RETRY_MS); + } + + function clearGroundRetryRender() { + _groundRetryArms = 0; + _lastGroundSampleCapability = null; + if (!_groundRetryTimer) return; + clearTimeout(_groundRetryTimer); + _groundRetryTimer = null; + } + const _overlayPublisher = createLocalInfrastructureOverlayPublisher({ + sourceId: id, + host: overlayHost, + }); + + const disableLayer = (viewer) => { + _enabled = false; + clearGroundRetryRender(); + _activeLodIds = new Set(); + _lodGraceState = new Map(); + _lastLodBudgetLimit = 0; + _lodComputed = false; + _lastLodProbeMs = Number.NEGATIVE_INFINITY; + if (_dataSource) _dataSource.show = false; + _overlayPublisher.hide(); + clearSelectedEntityContextForLayer(id); + if (viewer?.selectedEntity?.__localLayerId === id) { + viewer.selectedEntity = undefined; + } + if (_preRenderRemover) { + _preRenderRemover(); + _preRenderRemover = null; + } + if (_cameraMoveEndRemover) { + _cameraMoveEndRemover(); + _cameraMoveEndRemover = null; + } + }; + + return { + id, + name, + icon, + source, + updateInterval: 0, + statsRefreshInterval: 1000, + + init: async (viewer) => { + // DataLayerManager calls this once + }, + + update: async (viewer) => { + // DataLayerManager calls this when enabled + }, + + /** + * @returns {{count:number, lastUpdate:number|null, error:string|null}} + * A dead layer must be distinguishable from an empty one: a failed load + * surfaces `error` (manager chip → UNAVAILABLE) instead of reporting a + * silent zero count as nominal. + */ + getStats: () => { + return { count: _count, lastUpdate: _lastUpdate, error: _error }; + }, + + /** + * Globe-LOD state for QA harnesses (scripts/qa-infra-lod.mjs) and tests. + * `active` is how many records currently carry a live stem; `total` is the + * full materialized set. `active < total` means the declutter is engaged. + * `budgetLimit` is the cap the last camera-height band produced; `computed` + * is false until the first post-enable walk has run the selection. + * @returns {{total:number, active:number, budgetLimit:number, computed:boolean}} + */ + getLodDiagnostics: () => ({ + total: _stemRecords.length, + active: _activeLodIds.size, + budgetLimit: _lastLodBudgetLimit, + computed: _lodComputed, + }), + + enable: async (viewer) => { + if (_destroyed) return; + _enabled = true; + _stemGeometryDirty = true; + _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; + _groundRetryArms = 0; // fresh give-up budget per enable-cycle + _lastGroundSampleCapability = null; + _activeLodIds = new Set(); // fresh globe-LOD selection per enable-cycle + _lodGraceState = new Map(); + _lastLodBudgetLimit = 0; + _lodComputed = false; + _lastLodProbeMs = Number.NEGATIVE_INFINITY; + _overlayPublisher.show(); + + // 1. Initialize data source + if (!_dataSource) { + 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); + + const features = lines.map(line => JSON.parse(line)); + + 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, + }); + + 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 (!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, + 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, + 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 */ } + } + if (_destroyed) return; + removeEntityContextsForLayer(id); + _count = 0; + _stemRecords = []; + console.error(`Failed to load ${id}:`, e); + } + + if (_destroyed) return; + // 2. Install native global click handler + if (!_clickHandler) { + _clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas); + _clickHandler.setInputAction((click) => { + if (!_enabled) return; + const picked = viewer.scene.pick(click.position); + + if (picked && picked.id && picked.id.__localLayerId === id) { + const entity = picked.id; + viewer.selectedEntity = entity; + selectEntityContext(entity); + + // We zoom to the surface base of the stem or the center of the polygon + let targetPos = null; + + if (entity.polyline) { + // If it's a stem, fly to the base + const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now()); + if (positions && positions.length > 0) { + targetPos = positions[0]; + } + } else if (entity.polygon && entity.polygon.hierarchy) { + // If it's a polygon, just fly to its center + const hierarchy = entity.polygon.hierarchy.getValue(Cesium.JulianDate.now()); + if (hierarchy && hierarchy.positions.length > 0) { + targetPos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center; + } + } + + if (targetPos) { + const carto = Cesium.Cartographic.fromCartesian(targetPos); + + // Disable interactions so Cesium doesn't magically cancel the flight + viewer.scene.screenSpaceCameraController.enableInputs = false; + + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, 5000), + duration: 1.5, + complete: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; }, + cancel: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; }, + }); + } + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + })(); + try { + await _loadPromise; + } finally { + _loadPromise = null; + _loadController = null; + } + } + + if (_destroyed) return; + // 3. Add an incredibly fast pre-render occluder to hide points behind the globe + if (_enabled && !_preRenderRemover) { + _preRenderRemover = viewer.scene.preRender.addEventListener(() => { + if (!_enabled || !_dataSource) return; + const now = performance.now(); + if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return; + _lastVisibilityUpdate = now; + + const cameraPos = viewer.camera.positionWC; + if (!cameraPos) return; + + // --- Globe-LOD motion fallback. --- + // `_stemGeometryDirty` is otherwise raised only by moveEnd, which a + // tracked-entity follow, Cockpit, route flight, or continuous orbit + // never emits. Left alone the selection stays pinned to the region + // the camera left: the walk below hides those records one by one as + // they pass behind the globe and admits nothing newly visible, so + // the layer bleeds down to sparse-or-empty until motion stops. + // Bounded: rate-limited to a probe window, and only when the camera + // has actually travelled since the last selection. + if (!_stemGeometryDirty && _lodComputed && _stemRecords.length > 0) { + const probe = shouldRecomputeInfraLod({ + nowMs: now, + lastProbeMs: _lastLodProbeMs, + movedSqM: Cesium.Cartesian3.distanceSquared(cameraPos, _lastLodCameraPos), + cameraHeightM: viewer.camera.positionCartographic?.height, + }); + _lastLodProbeMs = probe.lastProbeMs; + // Re-selecting demands fresh stem geometry: a record admitted + // mid-motion has never had its tip sized for this camera. + if (probe.recompute) _stemGeometryDirty = true; + } + + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPos); + const visibleOverlayRecords = []; + const refreshStemGeometry = _stemGeometryDirty; + + // A scene that cannot sample heights can never ground a record, so it + // must never arm a retry (the arm would re-arm on every requested + // frame, forever) and must not spend ANY per-record work trying. + // Read once per walk, not per record. + const canSampleGround = viewer.scene.sampleHeightSupported === true; + // 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; + _lastGroundSampleCapability = canSampleGround; + let groundRetryPending = false; + let groundSampleProgress = false; + + // --- Globe-LOD: recompute the active-stem set on camera moves. --- + // `_stemGeometryDirty` is set by moveEnd, the first enable, and the + // motion fallback above — exactly when the camera-height budget and + // per-record distances can have changed. Without this, all local + // infrastructure features run stem trig + Cesium property writes on + // every move; with it, only the ~80 (global) to ~420 (regional) + // budgeted records do. + // The occluder test is the same cheap dot product used below. + if (refreshStemGeometry && _stemRecords.length > 0) { + const cameraHeightM = viewer.camera.positionCartographic?.height; + const candidates = new Array(_stemRecords.length); + for (let i = 0; i < _stemRecords.length; i++) { + const record = _stemRecords[i]; + candidates[i] = { + id: record.id, + priority: record.priority, + distanceM: Cesium.Cartesian3.distance(cameraPos, record.base), + inView: occluder.isPointVisible(record.base), + }; + } + const selection = selectInfraLod(candidates, { + cameraHeightM, + incumbentIds: _activeLodIds, + }); + const grace = applyInfraEvictionGrace({ + selectedIds: selection.activeIds, + builtIds: [..._activeLodIds], + graceState: _lodGraceState, + nowMs: now, + activeLimit: selection.budget.activeLimit, + }); + _activeLodIds = new Set(grace.keepIds); + _lodGraceState = grace.graceState; + _lastLodBudgetLimit = selection.budget.activeLimit; + _lodComputed = true; + // Adopt this pass as the motion-fallback reference. Travel is + // measured from the last SELECTION, never the previous walk, so + // jitter around a parked camera never accumulates into a + // recompute while genuine slow travel eventually does. + Cesium.Cartesian3.clone(cameraPos, _lastLodCameraPos); + _lastLodProbeMs = now; + } + + for (let i = 0; i < _stemRecords.length; i++) { + 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; + + // Records outside the globe-LOD active set carry no visible stem, + // so skip every per-frame cost for them — geometry trig, the + // ground-sample GPU readback, overlay-label candidacy. They rejoin + // on the next camera move if the budget has room. + if (!isActive) continue; + + const wasGroundSampled = record.groundSampled; + const terrainFloorChanged = refreshLocalTerrainFloor(viewer, record); + if (refreshStemGeometry || terrainFloorChanged) { + updateLocalStemGeometry(viewer, record, now); + } 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)) { + updateLocalStemGeometry(viewer, record, now, distance); + } + } + 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 + // and the stem stays at ellipsoid height (buried/floating) until + // the user happens to move. Schedule the frame the retry needs. + // 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) { + groundRetryPending = true; + } + if (isVisible && record.entry) visibleOverlayRecords.push(record); + } + _stemGeometryDirty = false; + // Tiles ARE streaming in: real progress re-opens the give-up budget + // so the records still waiting get their own bounded run of retries. + if (groundSampleProgress) _groundRetryArms = 0; + 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), + }); + _overlayPublisher.publish(cohort); + }); + } + if (_enabled && !_cameraMoveEndRemover) { + _cameraMoveEndRemover = viewer.camera.moveEnd.addEventListener(() => { + if (!_enabled) return; + _stemGeometryDirty = true; + _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; + // Real camera motion is a fresh situation (new tiles, new distances) + // and its frames are free, so it re-opens the retry budget that a + // parked camera may have spent. + _groundRetryArms = 0; + viewer.scene.requestRender?.(); + }); + } + + // Honor a disable() that landed while we were awaiting the fetch/parse: + // disable() runs before _dataSource exists, so its show=false is a no-op — + // reading _enabled here (rather than forcing true) respects the toggle-off. + if (_dataSource) _dataSource.show = _enabled; + viewer.scene.requestRender?.(); + }, + + disable: disableLayer, + + destroy: (viewer) => { + if (_destroyed) return; + _destroyed = true; + _loadController?.abort(); + // Defensively disable first so listeners and selection state are + // torn down even if destroy is called while the layer is enabled. + disableLayer(viewer); + removeEntityContextsForLayer(id); + if (_clickHandler) { + _clickHandler.destroy(); + _clickHandler = null; + } + if (_dataSource && viewer) { + viewer.dataSources.remove(_dataSource, true); + } + _overlayPublisher.destroy(); + _dataSource = null; + _stemRecords = []; + _count = 0; + _lastUpdate = null; + _error = null; + } + }; +} + +function compareLocalOverlayRecords(a, b) { + return b.priority - a.priority || String(a.id).localeCompare(String(b.id)); +} + +function insertLocalCellContender(contenders, record) { + let index = 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; +} + +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; + // 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; + setLocalGroundHeight(record, height); + return true; +} + +function sampleLocalGroundHeight(viewer, record, now) { + if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; + if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; + record.lastGroundSampleMs = now; + const globe = viewer.scene.globe; + if (globe?.show && globe.tilesLoaded === false) return false; + let sampled; + try { + sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); + } 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; + // 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) { + sampled = Math.max(sampled, terrainHeight); + } + record.groundSampled = true; + setLocalGroundHeight(record, sampled); + return true; +} + +function setLocalGroundHeight(record, height) { + record.groundHeight = height; + Cesium.Cartesian3.fromRadians( + record.carto.longitude, + record.carto.latitude, + record.groundHeight, + Cesium.Ellipsoid.WGS84, + record.base, + ); + record.entity.__localBaseCartesian = record.base; +} + +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); + // 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 targetPx = 65; + const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight); + const tipHeight = record.groundHeight + effectiveDistance * fovFactor; + Cesium.Cartesian3.fromRadians( + record.carto.longitude, + record.carto.latitude, + tipHeight, + Cesium.Ellipsoid.WGS84, + record.nextTip, + ); + 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]; + stemPositions[0] = record.base; + stemPositions[1] = record.tip; + record.entity.position.setValue(record.tip); + record.entity.polyline.positions.setValue(stemPositions); + return true; +} + +function featureLabelFromProperties(props, layerId) { + const tags = props.tags || {}; + + const candidates = [ + props.name, + tags.name, + tags['name:en'], + tags.official_name, + tags.operator, + tags['operator:short'], + props.operator, + props.output ? `${layerTitle(layerId)} ${props.output}` : '', + props.osm_id ? `${layerTitle(layerId)} ${props.osm_id}` : '', + ]; + + const text = candidates.map(cleanLabel).find(Boolean); + return clampLabel(text || layerTitle(layerId)); +} + +function labelPriorityFromProperties(props, layerId) { + const tags = props.tags || {}; + + let score = 0; + if (cleanLabel(props.name) || cleanLabel(tags.name)) score += 1000; + if (cleanLabel(tags['name:en'])) score += 700; + if (cleanLabel(tags.operator) || cleanLabel(props.operator)) score += 180; + if (props.output || tags['plant:output:electricity']) score += 120; + if (layerId === 'local-dams') score += 80; + if (layerId === 'local-datacenters') score += 60; + return score; +} + +function propertyObject(entity) { + const source = entity?.properties; + const raw = typeof source?.getValue === 'function' + ? source.getValue(Cesium.JulianDate.now()) + : source || {}; + return unwrapProperties(raw); +} + +function unwrapProperties(value) { + if (!value || typeof value !== 'object') return 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); + } + return out; +} + +function cleanLabel(value) { + const text = String(value || '').trim(); + if (!text || text === 'undefined' || text === 'null') return ''; + return text; +} + +function firstClean(values) { + return values.map(cleanLabel).find(Boolean) || ''; +} + +function clampLabel(value) { + const text = cleanLabel(value); + return text.length > 34 ? `${text.slice(0, 31)}...` : text; +} + +function clampCardLine(value) { + const text = cleanLabel(value); + return text.length > 48 ? `${text.slice(0, 45)}...` : text; +} + +function layerTitle(layerId) { + if (layerId === 'local-datacenters') return 'Datacenter'; + if (layerId === 'local-dams') return 'Dam'; + return 'Feature'; +} diff --git a/src/data/localGeojsonLifecycle.test.mjs b/src/data/localGeojsonLifecycle.test.mjs new file mode 100644 index 000000000..984e6e4af --- /dev/null +++ b/src/data/localGeojsonLifecycle.test.mjs @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createLocalGeoJsonLayer } from './localGeojson.js'; +import { getContextStore } from './contextStore.js'; + +const dataset = JSON.stringify({ + type: 'Feature', id: 'dam', properties: { name: 'Test dam' }, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, +}); +const response = () => ({ ok: true, text: async () => dataset }); +const deferred = () => { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +}; + +function harness(t) { + const previousWindow = globalThis.window; + globalThis.window = { dispatchEvent() {} }; + t.after(() => { + layer.destroy(viewer); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + const sources = new Set(); + const listeners = new Set(); + const event = { addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn); } }; + let handlers = 0; + const viewer = { + dataSources: { + async add(source) { sources.add(source); return source; }, + remove(source) { return sources.delete(source); }, + }, + scene: { canvas: {}, preRender: event, requestRender() {} }, + camera: { moveEnd: event }, + }; + const create = () => createLocalGeoJsonLayer({ + id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl', + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + screenSpaceEventHandlerFactory() { + handlers++; + return { setInputAction() {}, destroy() { handlers--; } }; + }, + }); + const layer = create(); + return { layer, create, viewer, sources, listeners, handlers: () => handlers }; +} + +test('destroy removes context records and permits a fresh replacement', async t => { + const env = harness(t); + t.mock.method(globalThis, 'fetch', async () => response()); + await env.layer.enable(env.viewer); + const oldEntity = [...getContextStore().entities.values()][0].entity; + assert.equal(getContextStore().entities.size, 1); + env.layer.disable(env.viewer); + assert.equal(getContextStore().entities.size, 1, 'disabled cached source retains its records'); + env.layer.destroy(env.viewer); + assert.equal(getContextStore().entities.size, 0); + assert.equal(env.sources.size, 0); + assert.equal(env.listeners.size, 0); + assert.equal(env.handlers(), 0); + const replacement = env.create(); + await replacement.enable(env.viewer); + assert.equal(getContextStore().entities.size, 1); + assert.notEqual([...getContextStore().entities.values()][0].entity, oldEntity); + replacement.destroy(env.viewer); + await env.layer.enable(env.viewer); + assert.equal(getContextStore().entities.size, 0); +}); + +for (const phase of ['fetch', 'text', 'parse', 'add']) { + test(`destroy during ${phase} leaves no late objects or context records`, async t => { + const env = harness(t); + const entered = deferred(); + const release = deferred(); + const pause = async value => { entered.resolve(); await release.promise; return value; }; + let signal; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + signal = options.signal; + if (phase === 'fetch') return pause(response()); + if (phase === 'text') return { ok: true, text: () => pause(dataset) }; + return response(); + }); + if (phase === 'parse') { + const load = Cesium.GeoJsonDataSource.load; + t.mock.method(Cesium.GeoJsonDataSource, 'load', async (...args) => pause(await load(...args))); + } + if (phase === 'add') { + t.mock.method(env.viewer.dataSources, 'add', async source => { + await pause(); + env.sources.add(source); + return source; + }); + } + const loading = env.layer.enable(env.viewer); + await entered.promise; + env.layer.destroy(env.viewer); + assert.equal(signal.aborted, true); + release.resolve(); + await loading; + assert.equal(env.sources.size, 0); + assert.equal(env.listeners.size, 0); + assert.equal(env.handlers(), 0); + assert.equal(getContextStore().entities.size, 0); + assert.deepEqual(env.layer.getStats(), { count: 0, lastUpdate: null, error: null }); + }); +} + +test('concurrent enable and disable/re-enable share one pending dataset load', async t => { + const env = harness(t); + const release = deferred(); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { + await release.promise; + return response(); + }); + const first = env.layer.enable(env.viewer); + env.layer.disable(env.viewer); + const second = env.layer.enable(env.viewer); + release.resolve(); + await Promise.all([first, second]); + assert.equal(fetchMock.mock.callCount(), 1); + assert.equal(env.sources.size, 1); + assert.equal(env.handlers(), 1); + assert.equal(env.listeners.size, 2); + assert.equal([...env.sources][0].show, true); +}); + +test('disable during loading keeps the completed source hidden until re-enabled', async t => { + const env = harness(t); + const release = deferred(); + t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); }); + const loading = env.layer.enable(env.viewer); + env.layer.disable(env.viewer); + release.resolve(); + await loading; + assert.equal([...env.sources][0].show, false); + assert.equal(env.listeners.size, 0); + await env.layer.enable(env.viewer); + assert.equal([...env.sources][0].show, true); + assert.equal(env.sources.size, 1); +}); diff --git a/src/data/localLayers.js b/src/data/localLayers.js index 3128c0d11..8769910cc 100644 --- a/src/data/localLayers.js +++ b/src/data/localLayers.js @@ -1,38 +1,9 @@ -import { createLocalGeoJsonLayer } from './localGeojson.js'; +import { localGeoJsonServices } from './localGeojson.js'; +import { createInfrastructureLayers } from './infrastructure.js'; import { createFirmsHeatmapLayer } from './firmsHeatmap.js'; import submarineCablesLayer from './telegeographySubmarineCables.js'; -// Use Vite's ?url import to properly resolve these assets in dev and build -import datacentersUrl from './local_data/datacenters/datacenters.geojsonl?url'; -import damsUrl from './local_data/dams/dams.geojsonl?url'; - -/** - * Registry of local GeoJSON datasets. - * These are lazily loaded natively into Cesium when enabled. - */ -const datacenters = createLocalGeoJsonLayer({ - id: 'local-datacenters', - url: datacentersUrl, - name: 'Datacenters', - color: '#00ffff', // Cyan - icon: '▣', - source: 'Local', - labels: true, - labelMax: 700, - labelGridPx: 138, -}); - -const dams = createLocalGeoJsonLayer({ - id: 'local-dams', - url: damsUrl, - name: 'Dams', - color: '#0088ff', // Blue - icon: '▰', - source: 'USACE', - labels: true, - labelMax: 900, - labelGridPx: 132, -}); +const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices); // Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps // the historical `local-` prefix for persistence + voice-tool-enum compat,