fix(infrastructure): declutter stems and correct close-up sizing

This commit is contained in:
Bilawal Sidhu authored and Sameh Khamis committed 2026-09-11 08:59:32 -07:00
1 parent c7ef01827d
commit 6dd00d1e76
7 files changed
+1505 -4

No files matched your search

+141 -4
View File
@@ -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<string>}
*/
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;
+273
View File
@@ -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');
});
+369
View File
@@ -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<string>|Set<string>} [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<string,{misses:number,since:number}>} [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<string,{misses:number,since:number}>}}
*/
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 };
}
+379
View File
@@ -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);
});