mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(infrastructure): declutter stems and correct close-up sizing
This commit is contained in:
1 parent
c7ef01827d
commit
6dd00d1e76
7 files changed
+1505
-4
No files matched your search
+141
-4
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user