mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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
@@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Datacenter and dam marker stems use bounded, zoom-dependent active sets with
|
||||
stable selection during camera motion. Close-up stems scale to the actual
|
||||
camera distance; source totals and submarine cables remain unchanged.
|
||||
|
||||
- Keyboard focus rings now survive active/selected button styles across the
|
||||
interface. Visual Styles, Location cities and points of interest, search,
|
||||
Context/mission actions, Cockpit utilities, and sliders retain a distinct
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Infrastructure marker visibility
|
||||
|
||||
Datacenters and dams retain their full datasets while limiting active marker
|
||||
stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km,
|
||||
and 420 below 200 km. Selection favors existing label priority and nearby
|
||||
features, retains stable choices across small camera movements, and refreshes
|
||||
during continuous motion. Disable/re-enable resets selection cleanly.
|
||||
Close-up stem sizing uses camera-to-feature distance without a 5 km minimum.
|
||||
The submarine-cable renderer is unchanged. These limits bound active stem work;
|
||||
they do not reduce the materialized entity count or establish an FPS gain.
|
||||
|
||||
## Keyboard interaction and focus
|
||||
|
||||
- Enter on the map-source disclosure opens immediately. A short Space press
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-infra-lod — browser checks for the local-infrastructure LOD declutter.
|
||||
*
|
||||
* Datacenters and dams use a bounded active-stem set. Cables are loaded for
|
||||
* a representative combined view but use their own unchanged renderer.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. GATE — with datacenters + dams enabled:
|
||||
* a. at a full-earth camera, each layer's getLodDiagnostics() reports
|
||||
* computed===true, active<=budgetLimit, budgetLimit===INFRA_LOD_ACTIVE_MIN,
|
||||
* and active<total (the declutter is actually engaged);
|
||||
* b. flying to a city lifts budgetLimit to INFRA_LOD_ACTIVE_MAX and the
|
||||
* active count grows (or pins to the in-view count);
|
||||
* c. the selection does not churn between camera moves.
|
||||
* 2. MEASURE — actual postRender frame intervals over a ~10 s driven orbit at
|
||||
* global zoom; --control disables infra. This is not a before/after test.
|
||||
* Report the renderer; software-GL timings are relative-only evidence.
|
||||
*
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Usage: node scripts/qa-infra-lod.mjs [--url http://localhost:5180] [--control] [--headful]
|
||||
* Requires a running dev server with the bundled local_data present. Headless;
|
||||
* rAF throttling disabled so the frame clock is honest. Exits non-zero on any
|
||||
* FAIL. Commits nothing.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
INFRA_LOD_ACTIVE_MIN,
|
||||
INFRA_LOD_ACTIVE_MAX,
|
||||
} from '../src/data/localGeojsonLod.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SHOTS_DIR = process.env.QA_SHOTS_DIR || path.join(REPO_ROOT, 'qa-shots');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:5180');
|
||||
const CONTROL = argv.includes('--control');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
|
||||
const INFRA_LAYER_IDS = ['local-datacenters', 'local-dams'];
|
||||
const CABLE_LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const results = [];
|
||||
function check(name, pass, detail) {
|
||||
results.push({ name, pass });
|
||||
const tag = pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${tag}] ${name}${detail !== undefined ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||
}
|
||||
function report(name, detail) {
|
||||
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(String(error.message).replace(/https?:\/\/\S+/g, '[URL]')));
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
const testUrl = new URL(APP_URL);
|
||||
testUrl.searchParams.set('welcome', '0');
|
||||
await page.goto(testUrl.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager
|
||||
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90_000 });
|
||||
check('first-run chooser does not cover the test surface', await page.evaluate(() => (
|
||||
!document.querySelector('#first-run-launcher:not([hidden])')
|
||||
)));
|
||||
report('renderer', await page.evaluate(() => {
|
||||
const gl = window.__godsEyeView.viewer.scene.context._gl;
|
||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
||||
}));
|
||||
|
||||
// Park at a full-earth view and disable every layer so infra is measured
|
||||
// in isolation.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) {
|
||||
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 4_000));
|
||||
|
||||
// Enable the infrastructure layers (skipped in --control so the orbit cost
|
||||
// measures the empty scene for attribution).
|
||||
if (!CONTROL) {
|
||||
const loaded = await page.evaluate(async (infraIds, cableId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ids = [...infraIds, cableId];
|
||||
for (const id of ids) {
|
||||
try { await gev.dataManager.setEnabled(id, true, { origin: 'user' }); } catch { /* reported below */ }
|
||||
}
|
||||
const deadline = performance.now() + 60_000;
|
||||
const stat = (id) => gev.dataManager.layers.get(id)?.module?.getStats?.() || {};
|
||||
while (performance.now() < deadline) {
|
||||
// Every id, cables included. The cable layer's enable() only starts
|
||||
// `void load()`, and the manager's immediate update() returns early
|
||||
// because `_loading` is already true — so waiting on datacenters and
|
||||
// dams alone releases the measurement before ~2,600 cable references
|
||||
// exist, and the advertised three-layer frame cost underreports.
|
||||
const done = ids.every((id) => {
|
||||
const s = stat(id);
|
||||
return (s.count || 0) > 0 || s.error;
|
||||
});
|
||||
if (done) break;
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
const out = {};
|
||||
for (const id of ids) out[id] = stat(id);
|
||||
return out;
|
||||
}, INFRA_LAYER_IDS, CABLE_LAYER_ID);
|
||||
report('layer load stats', loaded);
|
||||
for (const id of [...INFRA_LAYER_IDS, CABLE_LAYER_ID]) {
|
||||
check(`${id} loaded its bundled dataset`, (loaded[id]?.count || 0) > 0, loaded[id]);
|
||||
}
|
||||
// Nudge a render pass so the first post-enable LOD walk runs.
|
||||
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
}
|
||||
|
||||
// ── GATE a: full-earth budget engaged ─────────────────────────────────
|
||||
const readLod = (label) => page.evaluate((infraIds) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const out = {};
|
||||
for (const id of infraIds) {
|
||||
out[id] = gev.dataManager.layers.get(id)?.module?.getLodDiagnostics?.() || null;
|
||||
}
|
||||
return out;
|
||||
}, INFRA_LAYER_IDS).then((lod) => { report(label, lod); return lod; });
|
||||
|
||||
if (!CONTROL) {
|
||||
const globalLod = await readLod('getLodDiagnostics @ full-earth');
|
||||
for (const id of INFRA_LAYER_IDS) {
|
||||
const d = globalLod[id];
|
||||
check(`${id}: LOD selection has run`, !!d && d.computed === true, d);
|
||||
check(`${id}: active stems within the band budget`, !!d && d.active <= d.budgetLimit, d);
|
||||
check(`${id}: full-earth band budget is INFRA_LOD_ACTIVE_MIN`,
|
||||
!!d && d.budgetLimit === INFRA_LOD_ACTIVE_MIN, { got: d?.budgetLimit, want: INFRA_LOD_ACTIVE_MIN });
|
||||
// Every bound above is satisfied by an EMPTY active set: `computed` is
|
||||
// true, `0 <= budgetLimit`, and `0 < total`. A regressed candidate build
|
||||
// or occlusion test would therefore pass the gate while both layers
|
||||
// render nothing, so assert the selection is actually populated.
|
||||
check(`${id}: active set is non-empty`, !!d && d.active > 0, d);
|
||||
// "declutter engaged" only asserts when the dataset actually exceeds the
|
||||
// cap — a tiny dataset legitimately shows every feature. This fixture is
|
||||
// a full-earth view of a global dataset an order of magnitude past the
|
||||
// global band, so the only correct outcome is a cap-filled set.
|
||||
if (d && d.total > d.budgetLimit) {
|
||||
check(`${id}: declutter is engaged (active < total)`, d.active < d.total, d);
|
||||
check(`${id}: full-earth active set fills the band budget`,
|
||||
d.active === d.budgetLimit, { active: d.active, budgetLimit: d.budgetLimit });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GATE b: zoom in widens the budget ───────────────────────────────
|
||||
await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 55_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||
});
|
||||
// setView does not fire moveEnd; raise the LOD recompute trigger.
|
||||
v.camera.moveEnd.raiseEvent?.();
|
||||
v.scene.requestRender?.();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3_000));
|
||||
const regionalLod = await readLod('getLodDiagnostics @ city');
|
||||
for (const id of INFRA_LAYER_IDS) {
|
||||
const g = globalLod[id];
|
||||
const r = regionalLod[id];
|
||||
check(`${id}: city band lifts the budget to INFRA_LOD_ACTIVE_MAX`,
|
||||
!!r && r.budgetLimit === INFRA_LOD_ACTIVE_MAX, { got: r?.budgetLimit, want: INFRA_LOD_ACTIVE_MAX });
|
||||
// `>=` alone accepts 0 >= 0, so the non-shrink check needs a floor too.
|
||||
check(`${id}: active set did not shrink when zooming in`,
|
||||
!!r && !!g && r.active > 0 && r.active >= Math.min(g.active, r.total),
|
||||
{ global: g?.active, regional: r?.active });
|
||||
}
|
||||
|
||||
// ── GATE c: no churn between camera moves ───────────────────────────
|
||||
const churn = await page.evaluate(async (infraIds) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const snap = () => infraIds.map((id) => {
|
||||
const visibleIds = [];
|
||||
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
||||
for (const entity of gev.viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id));
|
||||
}
|
||||
}
|
||||
return visibleIds.sort();
|
||||
});
|
||||
const before = snap();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
return { before, after: snap() };
|
||||
}, INFRA_LAYER_IDS);
|
||||
check('the same visible IDs remain stable without a camera move',
|
||||
churn.before.every((ids) => ids.length > 0)
|
||||
&& JSON.stringify(churn.before) === JSON.stringify(churn.after),
|
||||
{ beforeCounts: churn.before.map((ids) => ids.length), afterCounts: churn.after.map((ids) => ids.length) });
|
||||
|
||||
// Back to full-earth for the frame-cost measurement.
|
||||
await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent?.();
|
||||
v.scene.requestRender?.();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
|
||||
// Count actual rendered frames: scene.render() can return without drawing.
|
||||
const frameCost = await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const scene = v.scene;
|
||||
const durations = [];
|
||||
let frames = 0;
|
||||
let previous;
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
const now = performance.now();
|
||||
if (previous !== undefined) durations.push(now - previous);
|
||||
previous = now;
|
||||
frames++;
|
||||
});
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
v.camera.rotateRight(0.0004);
|
||||
scene.requestRender();
|
||||
if (performance.now() - t0 < 10_000) requestAnimationFrame(tick);
|
||||
else {
|
||||
remove();
|
||||
const elapsedMs = performance.now() - t0;
|
||||
durations.sort((a, b) => a - b);
|
||||
const n = durations.length;
|
||||
const sum = durations.reduce((s, d) => s + d, 0);
|
||||
resolve({
|
||||
frames,
|
||||
elapsedMs: +elapsedMs.toFixed(1),
|
||||
meanMs: +(sum / Math.max(1, n)).toFixed(2),
|
||||
p50Ms: +(durations[Math.floor(n * 0.5)] || 0).toFixed(2),
|
||||
p95Ms: +(durations[Math.floor(n * 0.95)] || 0).toFixed(2),
|
||||
maxMs: +(durations[n - 1] || 0).toFixed(2),
|
||||
effectiveFps: +(frames * 1000 / elapsedMs).toFixed(1),
|
||||
});
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
report(CONTROL
|
||||
? 'rendered-frame intervals, control (infra OFF), 10s orbit'
|
||||
: 'rendered-frame intervals, infra ON, 10s orbit', frameCost);
|
||||
|
||||
// ── parked-idle honesty: the LOD walk must not force continuous render ─
|
||||
await new Promise((r) => setTimeout(r, 4_000));
|
||||
const idle = await page.evaluate(() => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let renders = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
|
||||
}));
|
||||
report(CONTROL
|
||||
? 'parked idle, control (postRender fires / 5s)'
|
||||
: 'parked idle with infra ON (postRender fires / 5s)', idle);
|
||||
if (!CONTROL) {
|
||||
check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle);
|
||||
}
|
||||
check('no uncaught application errors', pageErrors.length === 0, pageErrors);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const shot = path.join(SHOTS_DIR, CONTROL ? 'infra-lod-control.png' : 'infra-lod.png');
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(` [SHOT] ${shot}`);
|
||||
} catch (e) {
|
||||
console.log(` [SHOT] skipped — ${e?.message || e}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
console.log(`\nqa-infra-lod: ${passed}/${results.length} passed${CONTROL ? ' (control mode — gates skipped)' : ''}`);
|
||||
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
+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