diff --git a/CHANGELOG.md b/CHANGELOG.md index 931c492b1..02af2c6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Datacenter and dam marker stems use bounded, zoom-dependent active sets with + stable selection during camera motion. Close-up stems scale to the actual + camera distance; source totals and submarine cables remain unchanged. + - Keyboard focus rings now survive active/selected button styles across the interface. Visual Styles, Location cities and points of interest, search, Context/mission actions, Cockpit utilities, and sliders retain a distinct diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 640dfe805..a4f1fe706 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,16 @@ # God's Eye View Current State +## Infrastructure marker visibility + +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 +features, retains stable choices across small camera movements, and refreshes +during continuous motion. Disable/re-enable resets selection cleanly. +Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. +The submarine-cable renderer is unchanged. These limits bound active stem work; +they do not reduce the materialized entity count or establish an FPS gain. + ## Keyboard interaction and focus - Enter on the map-source disclosure opens immediately. A short Space press diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs new file mode 100644 index 000000000..be78b2c89 --- /dev/null +++ b/scripts/qa-infra-lod.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node +/** + * qa-infra-lod — browser checks for the local-infrastructure LOD declutter. + * + * Datacenters and dams use a bounded active-stem set. Cables are loaded for + * a representative combined view but use their own unchanged renderer. + * + * Two jobs: + * 1. GATE — with datacenters + dams enabled: + * a. at a full-earth camera, each layer's getLodDiagnostics() reports + * computed===true, active<=budgetLimit, budgetLimit===INFRA_LOD_ACTIVE_MIN, + * and active { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:5180'); +const CONTROL = argv.includes('--control'); +const HEADFUL = argv.includes('--headful'); + +const INFRA_LAYER_IDS = ['local-datacenters', 'local-dams']; +const CABLE_LAYER_ID = 'telegeography-submarine-cables'; + +const results = []; +function check(name, pass, detail) { + results.push({ name, pass }); + const tag = pass ? 'PASS' : 'FAIL'; + console.log(` [${tag}] ${name}${detail !== undefined ? ` — ${JSON.stringify(detail)}` : ''}`); +} +function report(name, detail) { + console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`); +} + +const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, + protocolTimeout: 300_000, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--window-size=1440,900', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-background-timer-throttling', + ], +}); + +try { + const page = await browser.newPage(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(String(error.message).replace(/https?:\/\/\S+/g, '[URL]'))); + await page.setViewport({ width: 1440, height: 860 }); + const testUrl = new URL(APP_URL); + testUrl.searchParams.set('welcome', '0'); + await page.goto(testUrl.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.styleManager + && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90_000 }); + check('first-run chooser does not cover the test surface', await page.evaluate(() => ( + !document.querySelector('#first-run-launcher:not([hidden])') + ))); + report('renderer', await page.evaluate(() => { + const gl = window.__godsEyeView.viewer.scene.context._gl; + const debug = gl.getExtension('WEBGL_debug_renderer_info'); + return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); + })); + + // Park at a full-earth view and disable every layer so infra is measured + // in isolation. + await page.evaluate(async () => { + const gev = window.__godsEyeView; + const v = gev.viewer; + v.camera.cancelFlight(); + const ell = v.scene.globe.ellipsoid; + v.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + for (const [id, entry] of gev.dataManager.layers) { + if (entry.enabled) { + try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } + } + } + }); + await new Promise((r) => setTimeout(r, 4_000)); + + // Enable the infrastructure layers (skipped in --control so the orbit cost + // measures the empty scene for attribution). + if (!CONTROL) { + const loaded = await page.evaluate(async (infraIds, cableId) => { + const gev = window.__godsEyeView; + const ids = [...infraIds, cableId]; + for (const id of ids) { + try { await gev.dataManager.setEnabled(id, true, { origin: 'user' }); } catch { /* reported below */ } + } + const deadline = performance.now() + 60_000; + const stat = (id) => gev.dataManager.layers.get(id)?.module?.getStats?.() || {}; + while (performance.now() < deadline) { + // Every id, cables included. The cable layer's enable() only starts + // `void load()`, and the manager's immediate update() returns early + // because `_loading` is already true — so waiting on datacenters and + // dams alone releases the measurement before ~2,600 cable references + // exist, and the advertised three-layer frame cost underreports. + const done = ids.every((id) => { + const s = stat(id); + return (s.count || 0) > 0 || s.error; + }); + if (done) break; + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 200)); + } + const out = {}; + for (const id of ids) out[id] = stat(id); + return out; + }, INFRA_LAYER_IDS, CABLE_LAYER_ID); + report('layer load stats', loaded); + for (const id of [...INFRA_LAYER_IDS, CABLE_LAYER_ID]) { + check(`${id} loaded its bundled dataset`, (loaded[id]?.count || 0) > 0, loaded[id]); + } + // Nudge a render pass so the first post-enable LOD walk runs. + await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.()); + await new Promise((r) => setTimeout(r, 2_000)); + } + + // ── GATE a: full-earth budget engaged ───────────────────────────────── + const readLod = (label) => page.evaluate((infraIds) => { + const gev = window.__godsEyeView; + const out = {}; + for (const id of infraIds) { + out[id] = gev.dataManager.layers.get(id)?.module?.getLodDiagnostics?.() || null; + } + return out; + }, INFRA_LAYER_IDS).then((lod) => { report(label, lod); return lod; }); + + if (!CONTROL) { + const globalLod = await readLod('getLodDiagnostics @ full-earth'); + for (const id of INFRA_LAYER_IDS) { + const d = globalLod[id]; + check(`${id}: LOD selection has run`, !!d && d.computed === true, d); + check(`${id}: active stems within the band budget`, !!d && d.active <= d.budgetLimit, d); + check(`${id}: full-earth band budget is INFRA_LOD_ACTIVE_MIN`, + !!d && d.budgetLimit === INFRA_LOD_ACTIVE_MIN, { got: d?.budgetLimit, want: INFRA_LOD_ACTIVE_MIN }); + // Every bound above is satisfied by an EMPTY active set: `computed` is + // true, `0 <= budgetLimit`, and `0 < total`. A regressed candidate build + // or occlusion test would therefore pass the gate while both layers + // render nothing, so assert the selection is actually populated. + check(`${id}: active set is non-empty`, !!d && d.active > 0, d); + // "declutter engaged" only asserts when the dataset actually exceeds the + // cap — a tiny dataset legitimately shows every feature. This fixture is + // a full-earth view of a global dataset an order of magnitude past the + // global band, so the only correct outcome is a cap-filled set. + if (d && d.total > d.budgetLimit) { + check(`${id}: declutter is engaged (active < total)`, d.active < d.total, d); + check(`${id}: full-earth active set fills the band budget`, + d.active === d.budgetLimit, { active: d.active, budgetLimit: d.budgetLimit }); + } + } + + // ── GATE b: zoom in widens the budget ─────────────────────────────── + await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const ell = v.scene.globe.ellipsoid; + v.camera.cancelFlight(); + v.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 55_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, + }); + // setView does not fire moveEnd; raise the LOD recompute trigger. + v.camera.moveEnd.raiseEvent?.(); + v.scene.requestRender?.(); + }); + await new Promise((r) => setTimeout(r, 3_000)); + const regionalLod = await readLod('getLodDiagnostics @ city'); + for (const id of INFRA_LAYER_IDS) { + const g = globalLod[id]; + const r = regionalLod[id]; + check(`${id}: city band lifts the budget to INFRA_LOD_ACTIVE_MAX`, + !!r && r.budgetLimit === INFRA_LOD_ACTIVE_MAX, { got: r?.budgetLimit, want: INFRA_LOD_ACTIVE_MAX }); + // `>=` alone accepts 0 >= 0, so the non-shrink check needs a floor too. + check(`${id}: active set did not shrink when zooming in`, + !!r && !!g && r.active > 0 && r.active >= Math.min(g.active, r.total), + { global: g?.active, regional: r?.active }); + } + + // ── GATE c: no churn between camera moves ─────────────────────────── + const churn = await page.evaluate(async (infraIds) => { + const gev = window.__godsEyeView; + const snap = () => infraIds.map((id) => { + const visibleIds = []; + for (let i = 0; i < gev.viewer.dataSources.length; i++) { + for (const entity of gev.viewer.dataSources.get(i).entities.values) { + if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id)); + } + } + return visibleIds.sort(); + }); + const before = snap(); + for (let i = 0; i < 5; i++) { + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 120)); + } + return { before, after: snap() }; + }, INFRA_LAYER_IDS); + check('the same visible IDs remain stable without a camera move', + churn.before.every((ids) => ids.length > 0) + && JSON.stringify(churn.before) === JSON.stringify(churn.after), + { beforeCounts: churn.before.map((ids) => ids.length), afterCounts: churn.after.map((ids) => ids.length) }); + + // Back to full-earth for the frame-cost measurement. + await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const ell = v.scene.globe.ellipsoid; + v.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + v.camera.moveEnd.raiseEvent?.(); + v.scene.requestRender?.(); + }); + await new Promise((r) => setTimeout(r, 3_000)); + } + + // Count actual rendered frames: scene.render() can return without drawing. + const frameCost = await page.evaluate(() => new Promise((resolve) => { + const v = window.__godsEyeView.viewer; + const scene = v.scene; + const durations = []; + let frames = 0; + let previous; + const remove = scene.postRender.addEventListener(() => { + const now = performance.now(); + if (previous !== undefined) durations.push(now - previous); + previous = now; + frames++; + }); + const t0 = performance.now(); + const tick = () => { + v.camera.rotateRight(0.0004); + scene.requestRender(); + if (performance.now() - t0 < 10_000) requestAnimationFrame(tick); + else { + remove(); + const elapsedMs = performance.now() - t0; + durations.sort((a, b) => a - b); + const n = durations.length; + const sum = durations.reduce((s, d) => s + d, 0); + resolve({ + frames, + elapsedMs: +elapsedMs.toFixed(1), + meanMs: +(sum / Math.max(1, n)).toFixed(2), + p50Ms: +(durations[Math.floor(n * 0.5)] || 0).toFixed(2), + p95Ms: +(durations[Math.floor(n * 0.95)] || 0).toFixed(2), + maxMs: +(durations[n - 1] || 0).toFixed(2), + effectiveFps: +(frames * 1000 / elapsedMs).toFixed(1), + }); + } + }; + requestAnimationFrame(tick); + })); + report(CONTROL + ? 'rendered-frame intervals, control (infra OFF), 10s orbit' + : 'rendered-frame intervals, infra ON, 10s orbit', frameCost); + + // ── parked-idle honesty: the LOD walk must not force continuous render ─ + await new Promise((r) => setTimeout(r, 4_000)); + const idle = await page.evaluate(() => new Promise((resolve) => { + const scene = window.__godsEyeView.viewer.scene; + let renders = 0; + const remove = scene.postRender.addEventListener(() => { renders += 1; }); + setTimeout(() => { remove(); resolve({ renders }); }, 5_000); + })); + report(CONTROL + ? 'parked idle, control (postRender fires / 5s)' + : 'parked idle with infra ON (postRender fires / 5s)', idle); + if (!CONTROL) { + check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle); + } + check('no uncaught application errors', pageErrors.length === 0, pageErrors); + + try { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const shot = path.join(SHOTS_DIR, CONTROL ? 'infra-lod-control.png' : 'infra-lod.png'); + await page.screenshot({ path: shot }); + console.log(` [SHOT] ${shot}`); + } catch (e) { + console.log(` [SHOT] skipped — ${e?.message || e}`); + } +} finally { + await browser.close(); +} + +const passed = results.filter((r) => r.pass).length; +console.log(`\nqa-infra-lod: ${passed}/${results.length} passed${CONTROL ? ' (control mode — gates skipped)' : ''}`); +console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`); +process.exit(passed === results.length ? 0 : 1); diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js index aad3eaa2e..aba4b7ee5 100644 --- a/src/data/localGeojson.js +++ b/src/data/localGeojson.js @@ -10,6 +10,11 @@ import { setOverlayEntries, setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; +import { + selectInfraLod, + applyInfraEvictionGrace, + shouldRecomputeInfraLod, +} from './localGeojsonLod.js'; const DEFAULT_LABEL_MAX = 900; const DEFAULT_LABEL_GRID_PX = 132; @@ -305,6 +310,37 @@ export function createLocalGeoJsonLayer({ 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; @@ -355,6 +391,11 @@ export function createLocalGeoJsonLayer({ 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); @@ -397,6 +438,21 @@ export function createLocalGeoJsonLayer({ 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; @@ -404,6 +460,11 @@ export function createLocalGeoJsonLayer({ _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 @@ -632,7 +693,29 @@ export function createLocalGeoJsonLayer({ 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; @@ -649,8 +732,62 @@ export function createLocalGeoJsonLayer({ _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; if (refreshStemGeometry) { updateLocalStemGeometry(viewer, record, now); @@ -678,8 +815,6 @@ export function createLocalGeoJsonLayer({ < GROUND_SAMPLE_MAX_DISTANCE_M) { groundRetryPending = true; } - const isVisible = occluder.isPointVisible(record.base); - if (record.entity.show !== isVisible) record.entity.show = isVisible; if (isVisible && record.entry) visibleOverlayRecords.push(record); } _stemGeometryDirty = false; @@ -787,7 +922,9 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { ? knownDistance : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleLocalGroundHeight(viewer, record, now); - const effectiveDistance = Math.max(distance, 5000); + // 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; diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 4727f16f5..3b543954a 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -14,6 +14,7 @@ import { selectLocalInfrastructureOverlayCohort, } from './localGeojson.js'; import { layerFeedState } from './manager.js'; +import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js'; import { installRenderGovernor, getRenderGovernorDiagnostics, @@ -445,6 +446,21 @@ test('a real enabled local layer has no native label graphics at runtime', async env.cleanup(); }); +for (const [heightM, minStemM, maxStemM] of [[500, 45, 90], [10000, 1100, 1400]]) { + test(`local infrastructure keeps stems proportional at ${heightM} m`, async (t) => { + const env = await createRealLocalLayerHarness(); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + const entity = env.dataSources[0].entities.values[0]; + const carto = entity.__localBaseCarto; + env.viewer.camera.positionWC = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, heightM); + env.preRender.raise(env.viewer.scene, Cesium.JulianDate.now()); + const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now()); + const stemM = Cesium.Cartesian3.distance(positions[0], positions[1]); + assert.ok(stemM >= minStemM && stemM <= maxStemM, + `a ${heightM} m close-up must not inherit a globe-sized stem: ${stemM.toFixed(1)} m`); + }); +} + test('local infrastructure creates no native labels or per-frame geometry callbacks', () => { const source = readFileSync(new URL('./localGeojson.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /new Cesium\.LabelGraphics/); @@ -963,3 +979,260 @@ test('after the cap a camera-motion frame still samples, and re-opens the budget 'a grounded record asks for no further frames', ); }); + +// ── Globe-LOD: bound the active-stem set by camera height ──────────────────── +// +// createLocalGeoJsonLayer used to give every feature a live stem and walk all +// of them (geometry trig + Cesium property writes) on every camera move. With +// three bundled-infra layers that is ~5,700 records on a full-earth view — the +// frame-rate cliff that got the INFRASTRUCTURE first-run tile cut. The walk +// now asks src/data/localGeojsonLod.js which records may carry a stem, sized to +// the camera-height budget, and skips all per-frame cost for the rest. + +/** + * Harness with N in-view point features and a controllable camera height. + * Even-indexed features are named (high label priority), odd ones anonymous. + */ +async function createMultiFeatureLodHarness({ featureCount = 150, cameraHeightM = 9_000_000 } = {}) { + const originalFetch = globalThis.fetch; + const originalWindow = globalThis.window; + const preRender = new MockLayerEvent(); + const moveEnd = new MockLayerEvent(); + const dataSources = []; + const centerLon = -97.7; + const centerLat = 30.2; + // Polygons, not Points: Cesium's GeoJsonDataSource builds a PinBuilder + // billboard for a Point, which needs a DOM canvas the node test env lacks. + // The layer takes the polygon's bounding-sphere centre as the stem anchor. + const lines = Array.from({ length: featureCount }, (_, i) => { + const lon = centerLon + (i % 12) * 0.02; + const lat = centerLat + Math.floor(i / 12) * 0.02; + return JSON.stringify({ + type: 'Feature', + id: `dc-${i}`, + properties: i % 2 === 0 + ? { name: `Datacenter ${i}`, tags: { name: `Datacenter ${i}`, operator: 'Example Cloud' } } + : { tags: {} }, + geometry: { + type: 'Polygon', + coordinates: [[ + [lon, lat], + [lon + 0.004, lat], + [lon + 0.004, lat + 0.004], + [lon, lat], + ]], + }, + }); + }); + globalThis.fetch = async () => ({ ok: true, status: 200, text: async () => lines.join('\n') }); + globalThis.window = { dispatchEvent() {} }; + + const setPos = (m) => { + const p = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, m); + viewer.camera.positionWC = p; + viewer.camera.positionCartographic = Cesium.Cartographic.fromCartesian(p); + }; + const viewer = { + selectedEntity: undefined, + dataSources: { + add(ds) { dataSources.push(ds); return ds; }, + remove(ds) { + const i = dataSources.indexOf(ds); + if (i >= 0) dataSources.splice(i, 1); + return i >= 0; + }, + }, + camera: { + positionWC: null, + positionCartographic: null, + frustum: { fov: Math.PI / 3 }, + moveEnd, + flyTo() {}, + }, + scene: { + canvas: { clientWidth: 1440, clientHeight: 900 }, + preRender, + sampleHeightSupported: false, + screenSpaceCameraController: { enableInputs: true }, + pick() { return null; }, + requestRender() {}, + }, + }; + setPos(cameraHeightM); + const layer = createLocalGeoJsonLayer({ + id: 'local-datacenters', + url: '/lod-fixture.geojsonl', + name: 'LOD Datacenters', + color: '#00ffff', + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + projectToWindow: () => ({ x: 700, y: 450 }), + screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + }); + try { + await layer.enable(viewer); + } finally { + globalThis.fetch = originalFetch; + } + return { + layer, + viewer, + dataSources, + preRender, + moveEnd, + setCameraHeight: setPos, + shownCount() { + return dataSources[0].entities.values.filter((entity) => entity.show === true).length; + }, + cleanup() { + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + }, + }; +} + +test('globe-LOD caps live stems at the camera-height budget and widens as you zoom in', async (t) => { + const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); + const clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + }); + + // Full-earth framing: 150 in-view features, but the global band allows 80. + env.preRender.raise(); + assert.equal(env.dataSources[0].entities.values.length, 150, 'all features are materialized'); + assert.equal(env.shownCount(), INFRA_LOD_ACTIVE_MIN, 'only the global-band budget carries a stem'); + + // Named features (label priority ~1240) outrank anonymous nodes (~60), so + // every one of the 75 named features keeps a stem; the 5 remaining budget + // slots go to unnamed ones. + const shownIds = new Set( + env.dataSources[0].entities.values.filter((entity) => entity.show === true).map((entity) => entity.id), + ); + const namedIds = Array.from({ length: 150 }, (_, i) => i).filter((i) => i % 2 === 0).map((i) => `dc-${i}`); + assert.ok(namedIds.every((id) => shownIds.has(id)), 'every named feature wins a stem before any unnamed one'); + + // Zoom to continental framing: the budget opens to MID (200), so all 150 + // in-view features now get a stem. + env.setCameraHeight(1_000_000); + env.moveEnd.raise(); + clock.advance(500); // clear the 450 ms visibility gate + env.preRender.raise(); + assert.equal(env.shownCount(), 150, 'a closer camera lifts the cap above the in-view count'); +}); + +test('globe-LOD selection is stable between camera moves (no per-frame churn)', async (t) => { + const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); + const clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + }); + + env.preRender.raise(); + const firstSet = env.dataSources[0].entities.values + .filter((entity) => entity.show === true) + .map((entity) => entity.id) + .sort(); + assert.equal(firstSet.length, INFRA_LOD_ACTIVE_MIN); + + // A second walk with no intervening moveEnd must not re-select. + clock.advance(500); + env.preRender.raise(); + const secondSet = env.dataSources[0].entities.values + .filter((entity) => entity.show === true) + .map((entity) => entity.id) + .sort(); + assert.deepEqual(secondSet, firstSet, 'the active set only changes on camera moves'); +}); + +test('getLodDiagnostics reports the active/total split and the band budget', async (t) => { + const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); + const clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + }); + + assert.deepEqual(env.layer.getLodDiagnostics(), { + total: 150, active: 0, budgetLimit: 0, computed: false, + }, 'before the first walk: materialized but not yet selected'); + + env.preRender.raise(); + const global = env.layer.getLodDiagnostics(); + assert.equal(global.total, 150); + assert.equal(global.active, INFRA_LOD_ACTIVE_MIN); + assert.equal(global.budgetLimit, INFRA_LOD_ACTIVE_MIN); + assert.equal(global.computed, true); + assert.ok(global.active < global.total, 'active < total means the declutter is engaged'); + + env.setCameraHeight(60_000); + env.moveEnd.raise(); + clock.advance(500); + env.preRender.raise(); + const regional = env.layer.getLodDiagnostics(); + assert.equal(regional.active, 150, 'regional band lifts the cap above the in-view count'); + assert.ok(regional.budgetLimit >= 150, `regional budget widened (${regional.budgetLimit})`); +}); + +test('globe-LOD releases every stem when the layer is disabled', async (t) => { + const env = await createMultiFeatureLodHarness({ featureCount: 40, cameraHeightM: 9_000_000 }); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + }); + + env.preRender.raise(); + assert.equal(env.shownCount(), 40, 'under-budget: all 40 show'); + + env.layer.disable(env.viewer); + // The data source is hidden wholesale on disable; re-enabling starts from a + // fresh (empty) LOD selection rather than inheriting the old active set. + await env.layer.enable(env.viewer); + env.preRender.raise(); + assert.equal(env.shownCount(), 40, 're-enable rebuilds the selection cleanly'); +}); + +test('globe-LOD re-selects during continuous motion, without ever seeing a moveEnd', async (t) => { + // A tracked-entity follow, Cockpit view, route flight, or continuous orbit + // moves the camera indefinitely without emitting moveEnd. Before the motion + // fallback the active set stayed pinned to the region the camera left, and + // the walk hid those records as they passed behind the globe while admitting + // nothing newly visible — the layer bled down to sparse-or-empty until the + // motion stopped. + const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); + const clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + }); + + env.preRender.raise(); + assert.equal(env.layer.getLodDiagnostics().budgetLimit, INFRA_LOD_ACTIVE_MIN); + assert.equal(env.shownCount(), INFRA_LOD_ACTIVE_MIN, 'global band caps the first selection'); + + // The camera travels; NO moveEnd is raised, here or anywhere below. + env.setCameraHeight(60_000); + + // Inside the probe window the selection deliberately stays put: the fallback + // is rate-limited so continuous motion cannot recompute on every walk. + clock.advance(500); // > the 450 ms visibility gate, < the 1 s probe window + env.preRender.raise(); + assert.equal(env.layer.getLodDiagnostics().budgetLimit, INFRA_LOD_ACTIVE_MIN, + 'the probe window rate-limits the recompute'); + + // Past the window, the travelled camera re-selects on its own. + clock.advance(600); + env.preRender.raise(); + const moved = env.layer.getLodDiagnostics(); + assert.equal(moved.budgetLimit, INFRA_LOD_ACTIVE_MAX, 'the new camera height re-banded the budget'); + assert.equal(moved.active, 150, 'every in-view record is admitted again'); + assert.equal(env.shownCount(), 150, 'and actually carries a stem'); + + // A parked camera past the same window costs nothing: travel is measured + // from the last selection, so there is none to report. + const parked = env.layer.getLodDiagnostics(); + clock.advance(2_000); + env.preRender.raise(); + assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); +}); diff --git a/src/data/localGeojsonLod.js b/src/data/localGeojsonLod.js new file mode 100644 index 000000000..5c3124855 --- /dev/null +++ b/src/data/localGeojsonLod.js @@ -0,0 +1,369 @@ +// src/data/localGeojsonLod.js +/** + * @module localGeojsonLod + * + * Pure selection policy for the bundled local-infrastructure layers + * (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer` + * dataset). Cesium-specific projection, occlusion, and stem geometry stay in + * `localGeojson.js`; this module turns the resulting in-view records into a + * bounded "active" set — the only records that layer should spend per-frame + * stem-geometry and ground-sample work on. + * + * The screen-space label overlay is already decluttered + * (LOCAL_OVERLAY_COHORT_LIMIT); this also bounds world-space stems and their + * geometry/ground-sampling work. It does not reduce the number of materialized + * entities. Submarine cables use their own renderer and are not changed here. + * Performance must be measured against the current renderer, not inferred + * from the number of hidden stems. + * + * Sense is INVERTED relative to `cctvLod.js`: a CCTV metro view earns MORE + * cards, but a zoomed-out infrastructure view must show FEWER stems — that is + * exactly the view where every feature piles onto the screen at once. Zoom in + * and the in-view count falls naturally, so the budget opens back up. + * + * The engine keeps three concerns, all pure and individually testable: + * - BUDGET: camera height → how many stems may be active. + * - RANK: importance (the existing label-priority score) blended with a + * bounded proximity penalty, plus an incumbency bonus so the active set + * does not batch-swap on small camera moves. + * - EVICTION GRACE: a hysteresis planner (ported from cctvLod) so a stem at + * the budget edge does not blink in and out while the camera orbits. + */ + +/* ------------------------------------------------------------------ * + * BUDGET + * ------------------------------------------------------------------ */ + +/** Active-stem budget at full-earth / global framing — the hot case. */ +export const INFRA_LOD_ACTIVE_MIN = 80; +/** Active-stem budget at continental framing. */ +export const INFRA_LOD_ACTIVE_MID = 200; +/** Active-stem budget at regional framing and closer (effectively "all in view"). */ +export const INFRA_LOD_ACTIVE_MAX = 420; + +/** At or above this camera height (m) the view is global: clamp hardest. */ +export const INFRA_LOD_GLOBAL_HEIGHT_M = 3_000_000; +/** At or above this camera height (m) the view is continental. */ +export const INFRA_LOD_REGIONAL_HEIGHT_M = 200_000; + +/** + * Bounded active-stem budget for the current camera height. Non-finite input + * resolves to the GLOBAL band (fewest stems) — the safe default for a value we + * could not read is the cheapest one, not the most expensive. + * + * @param {number} cameraHeightM + * @returns {{activeLimit:number}} + */ +export function infraLodBudget(cameraHeightM) { + const height = Number.isFinite(cameraHeightM) + ? Math.max(0, cameraHeightM) + : INFRA_LOD_GLOBAL_HEIGHT_M; + if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MIN }; + if (height >= INFRA_LOD_REGIONAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MID }; + return { activeLimit: INFRA_LOD_ACTIVE_MAX }; +} + +/* ------------------------------------------------------------------ * + * RANK + * ------------------------------------------------------------------ */ + +/** + * Priority points added for a record that currently holds an active stem, so a + * non-incumbent displaces it only when meaningfully better — not on sub-pixel + * camera jitter. Deliberately smaller than the label-priority "has a name" + * gap (≥700 in labelPriorityFromProperties), so incumbency reorders WITHIN a + * tier but never keeps an unnamed stem alive over a fresh named feature. + */ +export const INFRA_LOD_INCUMBENT_BONUS = 250; + +/** + * Distance at or beyond which the proximity penalty is fully applied (m). + * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js. + */ +export const INFRA_LOD_FAR_M = 12_000_000; + +/** + * Maximum priority points subtracted for distance. Capped well below the + * label-priority name gap on purpose: proximity breaks ties among + * similarly-important features (show the near named dam before the far named + * dam) but can never promote an unnamed node over a named one. + */ +export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200; + +/** + * Keep-score for one record. Higher wins. Pure; no clamping of the result + * (callers only compare it), but every input is normalized so the output is + * always a finite number. + * + * @param {number} priority Existing label-priority score (localGeojson.js). + * @param {number} distanceM Camera→feature distance in metres. + * @param {boolean} isIncumbent Record currently holds an active stem. + * @param {object} [options] + * @param {number} [options.incumbentBonus] + * @param {number} [options.farM] + * @param {number} [options.maxDistancePenalty] + * @returns {number} + */ +export function infraRankScore(priority, distanceM, isIncumbent, { + incumbentBonus = INFRA_LOD_INCUMBENT_BONUS, + farM = INFRA_LOD_FAR_M, + maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY, +} = {}) { + const p = Number.isFinite(priority) ? priority : 0; + const far = Number.isFinite(farM) && farM > 0 ? farM : INFRA_LOD_FAR_M; + const d = Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : far; + const penaltyCap = Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0 + ? maxDistancePenalty + : INFRA_LOD_MAX_DISTANCE_PENALTY; + const bonus = isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0; + return p + bonus - penaltyCap * Math.min(1, d / far); +} + +/** Infinity-safe distance for a total sort order. */ +function sortableDistance(distanceM) { + return Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : Number.MAX_VALUE; +} + +/** + * Select the active-stem set from already projected in-view candidates. + * + * A candidate is `{ id, priority, distanceM, inView }`. Only `inView === true` + * candidates are eligible — the caller runs the (cheap) ellipsoidal-occluder + * test for every record and hands the result here; this module then decides + * which of those get the (expensive) stem geometry + ground-sample work. + * + * Ranking: `infraRankScore` descending, then nearer first, then id — a total + * order, so an under-budget cut is deterministic. Duplicate ids collapse to + * their best-scoring representative before the cut. + * + * @param {Array<{id:string,priority?:number,distanceM?:number,inView?:boolean}>} candidates + * @param {object} [options] + * @param {number} [options.cameraHeightM] + * @param {Iterable|Set} [options.incumbentIds] Ids that currently hold an active stem. + * @returns {{activeIds:string[], budget:{activeLimit:number}}} + */ +export function selectInfraLod(candidates, { cameraHeightM, incumbentIds } = {}) { + const budget = infraLodBudget(cameraHeightM); + const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []); + + const byId = new Map(); + for (const candidate of Array.isArray(candidates) ? candidates : []) { + if (!candidate || typeof candidate.id !== 'string' || !candidate.id) continue; + if (candidate.inView !== true) continue; + const distanceM = Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0 + ? candidate.distanceM + : Number.POSITIVE_INFINITY; + const normalized = { + id: candidate.id, + distanceM, + score: infraRankScore(candidate.priority, candidate.distanceM, incumbents.has(candidate.id)), + }; + const current = byId.get(normalized.id); + if (!current + || normalized.score > current.score + || (normalized.score === current.score && normalized.distanceM < current.distanceM)) { + byId.set(normalized.id, normalized); + } + } + + const ranked = [...byId.values()].sort((a, b) => ( + b.score - a.score + || sortableDistance(a.distanceM) - sortableDistance(b.distanceM) + || a.id.localeCompare(b.id) + )); + + return { + activeIds: ranked.slice(0, budget.activeLimit).map((entry) => entry.id), + budget, + }; +} + +/* ------------------------------------------------------------------ * + * EVICTION GRACE + * ------------------------------------------------------------------ */ + +export const INFRA_LOD_GRACE_PASSES = 2; +export const INFRA_LOD_GRACE_MS = 4_000; + +/** + * Eviction-grace hysteresis for the active-stem set. Ported from + * cctvLod.applyEvictionGrace — same algorithm, infra-tuned constants. + * + * Raw per-pass selection has no memory: a stem sitting at the budget edge + * churns in and out on every small camera move. This planner keeps an + * already-built stem alive for a short grace window after it falls out of the + * selection — dropped only once it STAYS unselected for `gracePasses` + * consecutive passes or `graceMs` of wall time, whichever comes first. A newly + * selected record enters immediately; the hard `activeLimit` cap is never + * exceeded (grace-period stems are evicted first under cap pressure, + * oldest-in-grace first). + * + * Pure: `graceState` is never mutated; the returned `graceState` replaces it. + * + * @param {object} [input] + * @param {string[]} [input.selectedIds] This pass's selection (already capped). + * @param {string[]} [input.builtIds] Ids that currently have a live stem. + * @param {Map} [input.graceState] + * @param {number} [input.nowMs] + * @param {number} [input.activeLimit] Hard cap on total kept stems. + * @param {number} [input.gracePasses] + * @param {number} [input.graceMs] + * @returns {{keepIds:string[], evictIds:string[], graceState:Map}} + */ +export function applyInfraEvictionGrace({ + selectedIds = [], + builtIds = [], + graceState = new Map(), + nowMs = 0, + activeLimit = INFRA_LOD_ACTIVE_MAX, + gracePasses = INFRA_LOD_GRACE_PASSES, + graceMs = INFRA_LOD_GRACE_MS, +} = {}) { + const selectedSet = new Set( + (Array.isArray(selectedIds) ? selectedIds : []).filter((id) => typeof id === 'string' && id), + ); + const keepIds = [...selectedSet]; + const evictIds = []; + const nextGrace = new Map(); + + const graced = []; + for (const id of Array.isArray(builtIds) ? builtIds : []) { + if (typeof id !== 'string' || !id || selectedSet.has(id)) continue; + const prior = graceState instanceof Map ? graceState.get(id) : undefined; + const misses = (prior?.misses || 0) + 1; + const since = Number.isFinite(prior?.since) ? prior.since : nowMs; + if (misses > gracePasses || nowMs - since >= graceMs) { + evictIds.push(id); + } else { + graced.push({ id, misses, since }); + } + } + + // Under cap pressure, grace-period stems go first: oldest-in-grace first + // (longest chance to return), then more misses, then id for determinism. + graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id)); + const cap = Number.isFinite(activeLimit) ? Math.max(0, Math.floor(activeLimit)) : INFRA_LOD_ACTIVE_MAX; + const capacity = Math.max(0, cap - keepIds.length); + const overflow = Math.max(0, graced.length - capacity); + for (let i = 0; i < graced.length; i++) { + if (i < overflow) { + evictIds.push(graced[i].id); + continue; + } + keepIds.push(graced[i].id); + nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since }); + } + + return { keepIds, evictIds, graceState: nextGrace }; +} + +/* ------------------------------------------------------------------ * + * MOTION FALLBACK + * ------------------------------------------------------------------ */ + +/** + * Probe window for the motion fallback. `localGeojson.js` marks its selection + * dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice + * `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route + * flight, or a continuous orbit never emits one. Without a fallback the + * active set stays pinned to the region the camera left: the walk hides those + * records one by one as they rotate behind the globe and admits nothing newly + * visible, so the layer bleeds down to sparse-or-empty until motion stops. + * + * The fallback therefore samples camera travel at most this often. Shorter + * than the cable layer's 2 s window because the walk that consumes it already + * runs on a 450 ms cadence and the work it re-triggers is bounded by the + * budget above; longer than that cadence so continuous motion recomputes on a + * fixed clock rather than on every walk. + */ +export const INFRA_LOD_MOTION_PROBE_INTERVAL_MS = 1_000; + +/** + * Camera travel that counts as material motion, as a fraction of camera + * height. Scale-relative rather than absolute because the selection is a + * function of what the globe shows: 500 m of travel re-frames a city view + * completely and is invisible from 3,000 km up. + */ +export const INFRA_LOD_MOTION_EPSILON_RATIO = 0.02; + +/** + * Floor for that epsilon (m), so a camera near the surface still needs real + * travel to spend a recompute. Matches the cable layer's flat + * CABLE_SWEEP_MOTION_EPSILON_M. + */ +export const INFRA_LOD_MOTION_EPSILON_MIN_M = 250; + +/** + * Camera travel that counts as material motion at this camera height. + * + * An unreadable height resolves to the FLOOR — the most eager epsilon. That is + * the opposite polarity to `infraLodBudget`, deliberately: there, a height we + * could not read must not buy the most expensive band; here, a missed + * recompute empties the layer while a surplus one costs a single bounded pass. + * + * @param {number} cameraHeightM + * @param {object} [options] + * @param {number} [options.ratio] + * @param {number} [options.minM] + * @returns {number} + */ +export function infraLodMotionEpsilonM(cameraHeightM, { + ratio = INFRA_LOD_MOTION_EPSILON_RATIO, + minM = INFRA_LOD_MOTION_EPSILON_MIN_M, +} = {}) { + const floor = Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M; + const scale = Number.isFinite(ratio) && ratio >= 0 ? ratio : INFRA_LOD_MOTION_EPSILON_RATIO; + const height = Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0; + return Math.max(floor, height * scale); +} + +/** + * Should the layer re-run its LOD selection on this pass? Pure: the caller + * owns `lastProbeMs` and the camera position the last selection ran from, and + * writes the returned `lastProbeMs` back. + * + * Two conditions, both required: + * - the probe window has elapsed — the rate limit, so continuous motion + * cannot recompute on every walk; + * - the camera has travelled past the height-scaled epsilon SINCE THE LAST + * SELECTION, not since the previous walk. A parked camera therefore costs + * zero recomputes and sub-epsilon jitter never accumulates into one, while + * genuine slow travel eventually does. + * + * The window re-arms whenever it opens, whether or not it recomputes, so a + * creeping camera probes on a fixed cadence instead of on every walk. + * + * Travel arrives SQUARED so the caller can hand over + * `Cesium.Cartesian3.distanceSquared` and spend no sqrt on a per-walk probe, + * the same trade `createCableReferenceSweepGate` makes. + * + * @param {object} [input] + * @param {number} [input.nowMs] + * @param {number} [input.lastProbeMs] When the window last opened, or the last selection ran. + * @param {number} [input.movedSqM] Squared camera travel (m²) since the last selection. + * @param {number} [input.cameraHeightM] Current height — a near camera earns a tighter epsilon. + * @param {number} [input.probeIntervalMs] + * @param {number} [input.motionEpsilonM] Explicit epsilon (m); overrides the height scale. + * @returns {{recompute:boolean, lastProbeMs:number}} + */ +export function shouldRecomputeInfraLod({ + nowMs = 0, + lastProbeMs = Number.NEGATIVE_INFINITY, + movedSqM = 0, + cameraHeightM, + probeIntervalMs = INFRA_LOD_MOTION_PROBE_INTERVAL_MS, + motionEpsilonM, +} = {}) { + const now = Number.isFinite(nowMs) ? nowMs : 0; + const last = Number.isFinite(lastProbeMs) ? lastProbeMs : Number.NEGATIVE_INFINITY; + const interval = Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0 + ? probeIntervalMs + : INFRA_LOD_MOTION_PROBE_INTERVAL_MS; + if (now - last < interval) return { recompute: false, lastProbeMs: last }; + + const movedSq = Number.isFinite(movedSqM) && movedSqM > 0 ? movedSqM : 0; + const epsilon = Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0 + ? motionEpsilonM + : infraLodMotionEpsilonM(cameraHeightM); + return { recompute: movedSq > epsilon * epsilon, lastProbeMs: now }; +} diff --git a/src/data/localGeojsonLod.test.mjs b/src/data/localGeojsonLod.test.mjs new file mode 100644 index 000000000..fcbdc754f --- /dev/null +++ b/src/data/localGeojsonLod.test.mjs @@ -0,0 +1,379 @@ +// src/data/localGeojsonLod.test.mjs +// Pure LOD-engine tests for the bundled local-infrastructure layers: +// camera-height budget bands (inverted sense vs CCTV), importance+proximity +// ranking with an incumbency bonus, in-view filtering, dedupe, and the +// ported eviction-grace planner. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + INFRA_LOD_ACTIVE_MIN, + INFRA_LOD_ACTIVE_MID, + INFRA_LOD_ACTIVE_MAX, + INFRA_LOD_GLOBAL_HEIGHT_M, + INFRA_LOD_REGIONAL_HEIGHT_M, + INFRA_LOD_INCUMBENT_BONUS, + INFRA_LOD_FAR_M, + INFRA_LOD_MAX_DISTANCE_PENALTY, + INFRA_LOD_GRACE_PASSES, + INFRA_LOD_GRACE_MS, + infraLodBudget, + infraRankScore, + selectInfraLod, + applyInfraEvictionGrace, + INFRA_LOD_MOTION_PROBE_INTERVAL_MS, + INFRA_LOD_MOTION_EPSILON_RATIO, + INFRA_LOD_MOTION_EPSILON_MIN_M, + infraLodMotionEpsilonM, + shouldRecomputeInfraLod, +} from './localGeojsonLod.js'; + +/** + * `count` in-view candidates. index 0 is the most important (highest priority) + * and the nearest; importance and distance both worsen with index. + */ +function candidates(count, options = {}) { + return Array.from({ length: count }, (_, index) => ({ + id: `f-${String(index).padStart(3, '0')}`, + priority: 1000 - index, + distanceM: index * 1000, + inView: options.hiddenAt !== index, + })); +} + +/* ----------------------------- budget ----------------------------- */ + +test('infraLodBudget clamps hardest at global height and opens up as you zoom in', () => { + assert.equal(INFRA_LOD_ACTIVE_MIN, 80); + assert.equal(INFRA_LOD_ACTIVE_MID, 200); + assert.equal(INFRA_LOD_ACTIVE_MAX, 420); + assert.deepEqual(infraLodBudget(9_000_000), { activeLimit: INFRA_LOD_ACTIVE_MIN }); + assert.deepEqual(infraLodBudget(1_000_000), { activeLimit: INFRA_LOD_ACTIVE_MID }); + assert.deepEqual(infraLodBudget(50_000), { activeLimit: INFRA_LOD_ACTIVE_MAX }); +}); + +test('infraLodBudget band boundaries are inclusive at the lower edge', () => { + assert.deepEqual(infraLodBudget(INFRA_LOD_GLOBAL_HEIGHT_M), { activeLimit: INFRA_LOD_ACTIVE_MIN }); + assert.deepEqual(infraLodBudget(INFRA_LOD_GLOBAL_HEIGHT_M - 1), { activeLimit: INFRA_LOD_ACTIVE_MID }); + assert.deepEqual(infraLodBudget(INFRA_LOD_REGIONAL_HEIGHT_M), { activeLimit: INFRA_LOD_ACTIVE_MID }); + assert.deepEqual(infraLodBudget(INFRA_LOD_REGIONAL_HEIGHT_M - 1), { activeLimit: INFRA_LOD_ACTIVE_MAX }); +}); + +test('infraLodBudget treats non-finite / negative height as the global (cheapest) band', () => { + assert.deepEqual(infraLodBudget(undefined), { activeLimit: INFRA_LOD_ACTIVE_MIN }); + assert.deepEqual(infraLodBudget(NaN), { activeLimit: INFRA_LOD_ACTIVE_MIN }); + assert.deepEqual(infraLodBudget(Number.POSITIVE_INFINITY), { activeLimit: INFRA_LOD_ACTIVE_MIN }); + assert.deepEqual(infraLodBudget(-1), { activeLimit: INFRA_LOD_ACTIVE_MAX }); // max(0,-1) -> 0 -> closest band +}); + +/* ------------------------------ rank ----------------------------- */ + +test('infraRankScore is priority minus a bounded distance penalty', () => { + assert.equal(infraRankScore(1000, 0, false), 1000); + assert.equal(infraRankScore(1000, INFRA_LOD_FAR_M, false), 1000 - INFRA_LOD_MAX_DISTANCE_PENALTY); + // half-way to FAR -> half the penalty + assert.equal(infraRankScore(1000, INFRA_LOD_FAR_M / 2, false), 1000 - INFRA_LOD_MAX_DISTANCE_PENALTY / 2); + // beyond FAR the penalty is capped, not extrapolated + assert.equal(infraRankScore(1000, INFRA_LOD_FAR_M * 10, false), 1000 - INFRA_LOD_MAX_DISTANCE_PENALTY); +}); + +test('infraRankScore incumbency bonus reorders within a tier but never beats a name gap', () => { + // Two similarly-important features: incumbency flips the order. + const freshNear = infraRankScore(900, 0, false); + const incumbentFar = infraRankScore(900, INFRA_LOD_FAR_M, true); + assert.ok(incumbentFar > freshNear); + + // An unnamed incumbent (priority ~60) must NOT outrank a fresh named feature + // (priority ~1000): the incumbency bonus is smaller than that gap. + const unnamedIncumbent = infraRankScore(60, 0, true); + const namedFresh = infraRankScore(1000, INFRA_LOD_FAR_M, false); + assert.ok(namedFresh > unnamedIncumbent); + assert.ok(INFRA_LOD_INCUMBENT_BONUS < 700); +}); + +test('infraRankScore normalizes garbage inputs to a finite number', () => { + assert.equal(infraRankScore(NaN, NaN, false), 0 - INFRA_LOD_MAX_DISTANCE_PENALTY); // priority 0, distance -> FAR + assert.equal(Number.isFinite(infraRankScore('x', -5, true)), true); + assert.equal(infraRankScore(500, -5, false), 500 - INFRA_LOD_MAX_DISTANCE_PENALTY); // negative dist -> FAR +}); + +/* --------------------------- selectInfraLod ---------------------- */ + +test('selectInfraLod returns everything in view when under budget', () => { + const { activeIds, budget } = selectInfraLod(candidates(10), { cameraHeightM: 9_000_000 }); + assert.equal(budget.activeLimit, INFRA_LOD_ACTIVE_MIN); + assert.equal(activeIds.length, 10); + assert.deepEqual(activeIds.slice(0, 3), ['f-000', 'f-001', 'f-002']); +}); + +test('selectInfraLod caps at the global budget and keeps the most important', () => { + const { activeIds } = selectInfraLod(candidates(500), { cameraHeightM: 9_000_000 }); + assert.equal(activeIds.length, INFRA_LOD_ACTIVE_MIN); + assert.equal(activeIds[0], 'f-000'); + assert.equal(activeIds.at(-1), `f-${String(INFRA_LOD_ACTIVE_MIN - 1).padStart(3, '0')}`); +}); + +test('selectInfraLod cap follows the zoom band', () => { + assert.equal(selectInfraLod(candidates(600), { cameraHeightM: 9_000_000 }).activeIds.length, INFRA_LOD_ACTIVE_MIN); + assert.equal(selectInfraLod(candidates(600), { cameraHeightM: 1_000_000 }).activeIds.length, INFRA_LOD_ACTIVE_MID); + assert.equal(selectInfraLod(candidates(600), { cameraHeightM: 50_000 }).activeIds.length, INFRA_LOD_ACTIVE_MAX); +}); + +test('selectInfraLod excludes out-of-view records', () => { + const input = candidates(20, { hiddenAt: 0 }); + const { activeIds } = selectInfraLod(input, { cameraHeightM: 50_000 }); + assert.equal(activeIds.includes('f-000'), false); + assert.equal(activeIds.length, 19); +}); + +test('selectInfraLod orders by score: a near unnamed node never displaces a far named one', () => { + const input = [ + { id: 'named-far', priority: 1000, distanceM: INFRA_LOD_FAR_M, inView: true }, + { id: 'unnamed-near', priority: 60, distanceM: 0, inView: true }, + ]; + const { activeIds } = selectInfraLod(input, { cameraHeightM: 9_000_000 }); + assert.deepEqual(activeIds, ['named-far', 'unnamed-near']); +}); + +test('selectInfraLod proximity breaks ties among equally important features', () => { + const input = [ + { id: 'far', priority: 500, distanceM: 5_000_000, inView: true }, + { id: 'near', priority: 500, distanceM: 1_000, inView: true }, + ]; + const { activeIds } = selectInfraLod(input, { cameraHeightM: 9_000_000 }); + assert.deepEqual(activeIds, ['near', 'far']); +}); + +test('selectInfraLod incumbency keeps a budget-edge stem that would otherwise be cut', () => { + const base = candidates(INFRA_LOD_ACTIVE_MIN + 5, {}); + // The record that sits just past the cap: + const edgeId = `f-${String(INFRA_LOD_ACTIVE_MIN).padStart(3, '0')}`; + const withoutIncumbency = selectInfraLod(base, { cameraHeightM: 9_000_000 }); + assert.equal(withoutIncumbency.activeIds.includes(edgeId), false); + + const withIncumbency = selectInfraLod(base, { + cameraHeightM: 9_000_000, + incumbentIds: new Set([edgeId]), + }); + assert.equal(withIncumbency.activeIds.includes(edgeId), true); + assert.equal(withIncumbency.activeIds.length, INFRA_LOD_ACTIVE_MIN); +}); + +test('selectInfraLod accepts an array or a Set of incumbent ids', () => { + const base = candidates(INFRA_LOD_ACTIVE_MIN + 3, {}); + const edgeId = `f-${String(INFRA_LOD_ACTIVE_MIN).padStart(3, '0')}`; + const viaArray = selectInfraLod(base, { cameraHeightM: 9_000_000, incumbentIds: [edgeId] }); + assert.equal(viaArray.activeIds.includes(edgeId), true); +}); + +test('selectInfraLod collapses duplicate ids to their best-scoring representative', () => { + const input = [ + { id: 'dup', priority: 100, distanceM: INFRA_LOD_FAR_M, inView: true }, + { id: 'dup', priority: 900, distanceM: 0, inView: true }, + { id: 'other', priority: 500, distanceM: 0, inView: true }, + ]; + const { activeIds } = selectInfraLod(input, { cameraHeightM: 9_000_000 }); + assert.deepEqual(activeIds, ['dup', 'other']); + assert.equal(activeIds.filter((id) => id === 'dup').length, 1); +}); + +test('selectInfraLod is defensive about junk input', () => { + assert.deepEqual(selectInfraLod(null, {}).activeIds, []); + assert.deepEqual(selectInfraLod(undefined).activeIds, []); + assert.deepEqual( + selectInfraLod([null, {}, { id: '' }, { id: 'x', inView: false }], { cameraHeightM: 1000 }).activeIds, + [], + ); +}); + +test('selectInfraLod produces a deterministic order for score+distance ties', () => { + const input = [ + { id: 'b', priority: 500, distanceM: 1000, inView: true }, + { id: 'a', priority: 500, distanceM: 1000, inView: true }, + { id: 'c', priority: 500, distanceM: 1000, inView: true }, + ]; + const { activeIds } = selectInfraLod(input, { cameraHeightM: 50_000 }); + assert.deepEqual(activeIds, ['a', 'b', 'c']); +}); + +/* ---------------------- applyInfraEvictionGrace ------------------ */ + +test('applyInfraEvictionGrace keeps every selected id and starts no grace for them', () => { + const out = applyInfraEvictionGrace({ + selectedIds: ['a', 'b'], + builtIds: ['a', 'b'], + nowMs: 1000, + }); + assert.deepEqual(out.keepIds.sort(), ['a', 'b']); + assert.deepEqual(out.evictIds, []); + assert.equal(out.graceState.size, 0); +}); + +test('applyInfraEvictionGrace holds a dropped stem through the grace window then evicts it', () => { + let grace = new Map(); + // pass 1: 'x' built but not selected -> graced (miss 1) + let out = applyInfraEvictionGrace({ selectedIds: ['a'], builtIds: ['a', 'x'], graceState: grace, nowMs: 0 }); + assert.deepEqual(out.keepIds.sort(), ['a', 'x']); + assert.deepEqual(out.evictIds, []); + grace = out.graceState; + + // pass 2: still missing -> miss 2, still within gracePasses (2) + out = applyInfraEvictionGrace({ selectedIds: ['a'], builtIds: ['a', 'x'], graceState: grace, nowMs: 10 }); + assert.equal(out.keepIds.includes('x'), true); + grace = out.graceState; + + // pass 3: miss 3 > gracePasses -> evicted + out = applyInfraEvictionGrace({ selectedIds: ['a'], builtIds: ['a', 'x'], graceState: grace, nowMs: 20 }); + assert.deepEqual(out.evictIds, ['x']); + assert.equal(out.keepIds.includes('x'), false); + assert.equal(out.graceState.has('x'), false); +}); + +test('applyInfraEvictionGrace evicts on wall-clock even before the pass count', () => { + const grace = new Map([['x', { misses: 1, since: 0 }]]); + const out = applyInfraEvictionGrace({ + selectedIds: [], + builtIds: ['x'], + graceState: grace, + nowMs: INFRA_LOD_GRACE_MS, + gracePasses: 99, + }); + assert.deepEqual(out.evictIds, ['x']); +}); + +test('applyInfraEvictionGrace never exceeds the cap: oldest-in-grace evicted first', () => { + const grace = new Map([ + ['old', { misses: 1, since: 0 }], + ['mid', { misses: 1, since: 5 }], + ['new', { misses: 1, since: 9 }], + ]); + const out = applyInfraEvictionGrace({ + selectedIds: ['s1', 's2'], + builtIds: ['s1', 's2', 'old', 'mid', 'new'], + graceState: grace, + nowMs: 10, + activeLimit: 3, // room for only ONE graced stem on top of s1,s2 + }); + assert.deepEqual(out.keepIds.slice(0, 2).sort(), ['s1', 's2']); + assert.equal(out.keepIds.includes('new'), true); + assert.deepEqual(out.evictIds.sort(), ['mid', 'old']); +}); + +test('applyInfraEvictionGrace does not mutate the graceState it was given', () => { + const grace = new Map([['x', { misses: 1, since: 0 }]]); + const snapshot = JSON.stringify([...grace]); + applyInfraEvictionGrace({ selectedIds: [], builtIds: ['x'], graceState: grace, nowMs: 5 }); + assert.equal(JSON.stringify([...grace]), snapshot); +}); + +test('applyInfraEvictionGrace tolerates missing / malformed input', () => { + const out = applyInfraEvictionGrace(); + assert.deepEqual(out.keepIds, []); + assert.deepEqual(out.evictIds, []); + assert.equal(out.graceState instanceof Map, true); + + const out2 = applyInfraEvictionGrace({ selectedIds: ['a', '', null], builtIds: [null, 'a', 3] }); + assert.deepEqual(out2.keepIds, ['a']); +}); + +test('grace constants are the documented defaults', () => { + assert.equal(INFRA_LOD_GRACE_PASSES, 2); + assert.equal(INFRA_LOD_GRACE_MS, 4_000); +}); + +/* -------------------------- motion fallback ------------------------- */ + +test('infraLodMotionEpsilonM scales with camera height above a fixed floor', () => { + assert.equal(INFRA_LOD_MOTION_EPSILON_RATIO, 0.02); + assert.equal(INFRA_LOD_MOTION_EPSILON_MIN_M, 250); + // Well above the floor: pure ratio. + assert.equal(infraLodMotionEpsilonM(3_000_000), 60_000); + assert.equal(infraLodMotionEpsilonM(1_000_000), 20_000); + // Near the surface the floor wins, so a low camera still needs real travel. + assert.equal(infraLodMotionEpsilonM(5_000), INFRA_LOD_MOTION_EPSILON_MIN_M); + assert.equal(infraLodMotionEpsilonM(0), INFRA_LOD_MOTION_EPSILON_MIN_M); +}); + +test('infraLodMotionEpsilonM falls back to the floor (most eager) on unreadable height', () => { + // Opposite polarity to infraLodBudget on purpose: a missed recompute empties + // the layer, a surplus one costs a single bounded pass. + assert.equal(infraLodMotionEpsilonM(undefined), INFRA_LOD_MOTION_EPSILON_MIN_M); + assert.equal(infraLodMotionEpsilonM(NaN), INFRA_LOD_MOTION_EPSILON_MIN_M); + assert.equal(infraLodMotionEpsilonM(Number.POSITIVE_INFINITY), INFRA_LOD_MOTION_EPSILON_MIN_M); + assert.equal(infraLodMotionEpsilonM(-9_000), INFRA_LOD_MOTION_EPSILON_MIN_M); +}); + +test('shouldRecomputeInfraLod recomputes once the camera has moved far enough', () => { + const out = shouldRecomputeInfraLod({ + nowMs: 10_000, + lastProbeMs: 0, + movedSqM: 500_000 ** 2, // 500 km >> the 60 km epsilon at 3,000 km up + cameraHeightM: 3_000_000, + }); + assert.equal(out.recompute, true); + assert.equal(out.lastProbeMs, 10_000, 'the window re-arms at this pass'); +}); + +test('shouldRecomputeInfraLod holds the window shut inside the probe interval', () => { + const out = shouldRecomputeInfraLod({ + nowMs: 500, + lastProbeMs: 0, + movedSqM: 9_000_000 ** 2, // enormous motion, but the rate limit comes first + cameraHeightM: 3_000_000, + probeIntervalMs: INFRA_LOD_MOTION_PROBE_INTERVAL_MS, + }); + assert.equal(out.recompute, false); + assert.equal(out.lastProbeMs, 0, 'a shut window does not re-arm'); +}); + +test('shouldRecomputeInfraLod spends nothing on a parked camera', () => { + const out = shouldRecomputeInfraLod({ + nowMs: 60_000, + lastProbeMs: 0, + movedSqM: 0, + cameraHeightM: 3_000_000, + }); + assert.equal(out.recompute, false); + assert.equal(out.lastProbeMs, 60_000, 'the window still re-arms, so the probe stays on a fixed cadence'); +}); + +test('shouldRecomputeInfraLod ignores sub-epsilon jitter but accumulates real travel', () => { + const height = 1_000_000; // epsilon 20,000 m + const jitter = shouldRecomputeInfraLod({ + nowMs: 5_000, lastProbeMs: 0, movedSqM: 19_999 ** 2, cameraHeightM: height, + }); + assert.equal(jitter.recompute, false); + // Travel is measured from the last SELECTION, so a creeping camera keeps + // closing on the epsilon across windows instead of resetting every pass. + const crept = shouldRecomputeInfraLod({ + nowMs: 10_000, lastProbeMs: 5_000, movedSqM: 20_001 ** 2, cameraHeightM: height, + }); + assert.equal(crept.recompute, true); +}); + +test('shouldRecomputeInfraLod opens on first use and tolerates missing / malformed input', () => { + // No prior probe: the window is open immediately. + const first = shouldRecomputeInfraLod({ nowMs: 1_000, movedSqM: 5_000 ** 2, cameraHeightM: 50_000 }); + assert.equal(first.recompute, true); + assert.equal(first.lastProbeMs, 1_000); + + const empty = shouldRecomputeInfraLod(); + assert.equal(empty.recompute, false, 'no motion reported means no recompute'); + assert.equal(empty.lastProbeMs, 0); + + const garbage = shouldRecomputeInfraLod({ + nowMs: NaN, lastProbeMs: NaN, movedSqM: NaN, cameraHeightM: NaN, probeIntervalMs: NaN, + }); + assert.equal(garbage.recompute, false); + + // An explicit epsilon overrides the height scale. + const explicit = shouldRecomputeInfraLod({ + nowMs: 2_000, lastProbeMs: 0, movedSqM: 300 ** 2, cameraHeightM: 9_000_000, motionEpsilonM: 100, + }); + assert.equal(explicit.recompute, true); +}); + +test('motion-fallback constants are the documented defaults', () => { + assert.equal(INFRA_LOD_MOTION_PROBE_INTERVAL_MS, 1_000); + assert.equal(INFRA_LOD_MOTION_EPSILON_RATIO, 0.02); + assert.equal(INFRA_LOD_MOTION_EPSILON_MIN_M, 250); +});