// src/data/localGeojsonLod.js /** * @module localGeojsonLod * * Pure selection policy for the bundled local-infrastructure layers * (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer` * dataset). Cesium-specific projection, occlusion, and stem geometry stay in * `localGeojson.js`; this module turns the resulting in-view records into a * bounded "active" set — the only records that layer should spend per-frame * stem-geometry and ground-sample work on. * * The screen-space label overlay is already decluttered * (LOCAL_OVERLAY_COHORT_LIMIT); this also bounds world-space stems and their * geometry/ground-sampling work. It does not reduce the number of materialized * entities. Submarine cables use their own renderer and are not changed here. * Performance must be measured against the current renderer, not inferred * from the number of hidden stems. * * Sense is INVERTED relative to `cctvLod.js`: a CCTV metro view earns MORE * cards, but a zoomed-out infrastructure view must show FEWER stems — that is * exactly the view where every feature piles onto the screen at once. Zoom in * and the in-view count falls naturally, so the budget opens back up. * * The engine keeps three concerns, all pure and individually testable: * - BUDGET: camera height → how many stems may be active. * - RANK: importance (the existing label-priority score) blended with a * bounded proximity penalty, plus an incumbency bonus so the active set * does not batch-swap on small camera moves. * - EVICTION GRACE: a hysteresis planner (ported from cctvLod) so a stem at * the budget edge does not blink in and out while the camera orbits. */ /* ------------------------------------------------------------------ * * BUDGET * ------------------------------------------------------------------ */ /** Active-stem budget at full-earth / global framing — the hot case. */ export const INFRA_LOD_ACTIVE_MIN = 80; /** Active-stem budget at continental framing. */ export const INFRA_LOD_ACTIVE_MID = 200; /** Active-stem budget at regional framing and closer (effectively "all in view"). */ export const INFRA_LOD_ACTIVE_MAX = 420; /** At or above this camera height (m) the view is global: clamp hardest. */ export const INFRA_LOD_GLOBAL_HEIGHT_M = 3_000_000; /** At or above this camera height (m) the view is continental. */ export const INFRA_LOD_REGIONAL_HEIGHT_M = 200_000; /** * Bounded active-stem budget for the current camera height. Non-finite input * resolves to the GLOBAL band (fewest stems) — the safe default for a value we * could not read is the cheapest one, not the most expensive. * * @param {number} cameraHeightM * @returns {{activeLimit:number}} */ export function infraLodBudget(cameraHeightM) { const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : INFRA_LOD_GLOBAL_HEIGHT_M; if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MIN }; if (height >= INFRA_LOD_REGIONAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MID }; return { activeLimit: INFRA_LOD_ACTIVE_MAX }; } /* ------------------------------------------------------------------ * * RANK * ------------------------------------------------------------------ */ /** * Priority points added for a record that currently holds an active stem, so a * non-incumbent displaces it only when meaningfully better — not on sub-pixel * camera jitter. Deliberately smaller than the label-priority "has a name" * gap (≥700 in labelPriorityFromProperties), so incumbency reorders WITHIN a * tier but never keeps an unnamed stem alive over a fresh named feature. */ export const INFRA_LOD_INCUMBENT_BONUS = 250; /** * Distance at or beyond which the proximity penalty is fully applied (m). * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js. */ export const INFRA_LOD_FAR_M = 12_000_000; /** * Maximum priority points subtracted for distance. Capped well below the * label-priority name gap on purpose: proximity breaks ties among * similarly-important features (show the near named dam before the far named * dam) but can never promote an unnamed node over a named one. */ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200; /** * Keep-score for one record. Higher wins. Pure; no clamping of the result * (callers only compare it), but every input is normalized so the output is * always a finite number. * * @param {number} priority Existing label-priority score (localGeojson.js). * @param {number} distanceM Camera→feature distance in metres. * @param {boolean} isIncumbent Record currently holds an active stem. * @param {object} [options] * @param {number} [options.incumbentBonus] * @param {number} [options.farM] * @param {number} [options.maxDistancePenalty] * @returns {number} */ export function infraRankScore( priority, distanceM, isIncumbent, { incumbentBonus = INFRA_LOD_INCUMBENT_BONUS, farM = INFRA_LOD_FAR_M, maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY, } = {}, ) { const p = Number.isFinite(priority) ? priority : 0; const far = Number.isFinite(farM) && farM > 0 ? farM : INFRA_LOD_FAR_M; const d = Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : far; const penaltyCap = Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0 ? maxDistancePenalty : INFRA_LOD_MAX_DISTANCE_PENALTY; const bonus = isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0; return p + bonus - penaltyCap * Math.min(1, d / far); } /** Infinity-safe distance for a total sort order. */ function sortableDistance(distanceM) { return Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : Number.MAX_VALUE; } /** * Select the active-stem set from already projected in-view candidates. * * A candidate is `{ id, priority, distanceM, inView }`. Only `inView === true` * candidates are eligible — the caller runs the (cheap) ellipsoidal-occluder * test for every record and hands the result here; this module then decides * which of those get the (expensive) stem geometry + ground-sample work. * * Ranking: `infraRankScore` descending, then nearer first, then id — a total * order, so an under-budget cut is deterministic. Duplicate ids collapse to * their best-scoring representative before the cut. * * @param {Array<{id:string,priority?:number,distanceM?:number,inView?:boolean}>} candidates * @param {object} [options] * @param {number} [options.cameraHeightM] * @param {Iterable|Set} [options.incumbentIds] Ids that currently hold an active stem. * @returns {{activeIds:string[], budget:{activeLimit:number}}} */ export function selectInfraLod( candidates, { cameraHeightM, incumbentIds } = {}, ) { const budget = infraLodBudget(cameraHeightM); const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []); const byId = new Map(); for (const candidate of Array.isArray(candidates) ? candidates : []) { if (!candidate || typeof candidate.id !== 'string' || !candidate.id) continue; if (candidate.inView !== true) continue; const distanceM = Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0 ? candidate.distanceM : Number.POSITIVE_INFINITY; const normalized = { id: candidate.id, distanceM, score: infraRankScore( candidate.priority, candidate.distanceM, incumbents.has(candidate.id), ), }; const current = byId.get(normalized.id); if ( !current || normalized.score > current.score || (normalized.score === current.score && normalized.distanceM < current.distanceM) ) { byId.set(normalized.id, normalized); } } const ranked = [...byId.values()].sort( (a, b) => b.score - a.score || sortableDistance(a.distanceM) - sortableDistance(b.distanceM) || a.id.localeCompare(b.id), ); return { activeIds: ranked.slice(0, budget.activeLimit).map((entry) => entry.id), budget, }; } /* ------------------------------------------------------------------ * * EVICTION GRACE * ------------------------------------------------------------------ */ export const INFRA_LOD_GRACE_PASSES = 2; export const INFRA_LOD_GRACE_MS = 4_000; /** * Eviction-grace hysteresis for the active-stem set. Ported from * cctvLod.applyEvictionGrace — same algorithm, infra-tuned constants. * * Raw per-pass selection has no memory: a stem sitting at the budget edge * churns in and out on every small camera move. This planner keeps an * already-built stem alive for a short grace window after it falls out of the * selection — dropped only once it STAYS unselected for `gracePasses` * consecutive passes or `graceMs` of wall time, whichever comes first. A newly * selected record enters immediately; the hard `activeLimit` cap is never * exceeded (grace-period stems are evicted first under cap pressure, * oldest-in-grace first). * * Pure: `graceState` is never mutated; the returned `graceState` replaces it. * * @param {object} [input] * @param {string[]} [input.selectedIds] This pass's selection (already capped). * @param {string[]} [input.builtIds] Ids that currently have a live stem. * @param {Map} [input.graceState] * @param {number} [input.nowMs] * @param {number} [input.activeLimit] Hard cap on total kept stems. * @param {number} [input.gracePasses] * @param {number} [input.graceMs] * @returns {{keepIds:string[], evictIds:string[], graceState:Map}} */ export function applyInfraEvictionGrace({ selectedIds = [], builtIds = [], graceState = new Map(), nowMs = 0, activeLimit = INFRA_LOD_ACTIVE_MAX, gracePasses = INFRA_LOD_GRACE_PASSES, graceMs = INFRA_LOD_GRACE_MS, } = {}) { const selectedSet = new Set( (Array.isArray(selectedIds) ? selectedIds : []).filter( (id) => typeof id === 'string' && id, ), ); const keepIds = [...selectedSet]; const evictIds = []; const nextGrace = new Map(); const graced = []; for (const id of Array.isArray(builtIds) ? builtIds : []) { if (typeof id !== 'string' || !id || selectedSet.has(id)) continue; const prior = graceState instanceof Map ? graceState.get(id) : undefined; const misses = (prior?.misses || 0) + 1; const since = Number.isFinite(prior?.since) ? prior.since : nowMs; if (misses > gracePasses || nowMs - since >= graceMs) { evictIds.push(id); } else { graced.push({ id, misses, since }); } } // Under cap pressure, grace-period stems go first: oldest-in-grace first // (longest chance to return), then more misses, then id for determinism. graced.sort( (a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id), ); const cap = Number.isFinite(activeLimit) ? Math.max(0, Math.floor(activeLimit)) : INFRA_LOD_ACTIVE_MAX; const capacity = Math.max(0, cap - keepIds.length); const overflow = Math.max(0, graced.length - capacity); for (let i = 0; i < graced.length; i++) { if (i < overflow) { evictIds.push(graced[i].id); continue; } keepIds.push(graced[i].id); nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since, }); } return { keepIds, evictIds, graceState: nextGrace }; } /* ------------------------------------------------------------------ * * MOTION FALLBACK * ------------------------------------------------------------------ */ /** * Probe window for the motion fallback. `localGeojson.js` marks its selection * dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice * `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route * flight, or a continuous orbit never emits one. Without a fallback the * active set stays pinned to the region the camera left: the walk hides those * records one by one as they rotate behind the globe and admits nothing newly * visible, so the layer bleeds down to sparse-or-empty until motion stops. * * The fallback therefore samples camera travel at most this often. Shorter * than the cable layer's 2 s window because the walk that consumes it already * runs on a 450 ms cadence and the work it re-triggers is bounded by the * budget above; longer than that cadence so continuous motion recomputes on a * fixed clock rather than on every walk. */ export const INFRA_LOD_MOTION_PROBE_INTERVAL_MS = 1_000; /** * Camera travel that counts as material motion, as a fraction of camera * height. Scale-relative rather than absolute because the selection is a * function of what the globe shows: 500 m of travel re-frames a city view * completely and is invisible from 3,000 km up. */ export const INFRA_LOD_MOTION_EPSILON_RATIO = 0.02; /** * Floor for that epsilon (m), so a camera near the surface still needs real * travel to spend a recompute. Matches the cable layer's flat * CABLE_SWEEP_MOTION_EPSILON_M. */ export const INFRA_LOD_MOTION_EPSILON_MIN_M = 250; /** * Camera travel that counts as material motion at this camera height. * * An unreadable height resolves to the FLOOR — the most eager epsilon. That is * the opposite polarity to `infraLodBudget`, deliberately: there, a height we * could not read must not buy the most expensive band; here, a missed * recompute empties the layer while a surplus one costs a single bounded pass. * * @param {number} cameraHeightM * @param {object} [options] * @param {number} [options.ratio] * @param {number} [options.minM] * @returns {number} */ export function infraLodMotionEpsilonM( cameraHeightM, { ratio = INFRA_LOD_MOTION_EPSILON_RATIO, minM = INFRA_LOD_MOTION_EPSILON_MIN_M, } = {}, ) { const floor = Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M; const scale = Number.isFinite(ratio) && ratio >= 0 ? ratio : INFRA_LOD_MOTION_EPSILON_RATIO; const height = Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0; return Math.max(floor, height * scale); } /** * Should the layer re-run its LOD selection on this pass? Pure: the caller * owns `lastProbeMs` and the camera position the last selection ran from, and * writes the returned `lastProbeMs` back. * * Two conditions, both required: * - the probe window has elapsed — the rate limit, so continuous motion * cannot recompute on every walk; * - the camera has travelled past the height-scaled epsilon SINCE THE LAST * SELECTION, not since the previous walk. A parked camera therefore costs * zero recomputes and sub-epsilon jitter never accumulates into one, while * genuine slow travel eventually does. * * The window re-arms whenever it opens, whether or not it recomputes, so a * creeping camera probes on a fixed cadence instead of on every walk. * * Travel arrives SQUARED so the caller can hand over * `Cesium.Cartesian3.distanceSquared` and spend no sqrt on a per-walk probe, * the same trade `createCableReferenceSweepGate` makes. * * @param {object} [input] * @param {number} [input.nowMs] * @param {number} [input.lastProbeMs] When the window last opened, or the last selection ran. * @param {number} [input.movedSqM] Squared camera travel (m²) since the last selection. * @param {number} [input.cameraHeightM] Current height — a near camera earns a tighter epsilon. * @param {number} [input.probeIntervalMs] * @param {number} [input.motionEpsilonM] Explicit epsilon (m); overrides the height scale. * @returns {{recompute:boolean, lastProbeMs:number}} */ export function shouldRecomputeInfraLod({ nowMs = 0, lastProbeMs = Number.NEGATIVE_INFINITY, movedSqM = 0, cameraHeightM, probeIntervalMs = INFRA_LOD_MOTION_PROBE_INTERVAL_MS, motionEpsilonM, } = {}) { const now = Number.isFinite(nowMs) ? nowMs : 0; const last = Number.isFinite(lastProbeMs) ? lastProbeMs : Number.NEGATIVE_INFINITY; const interval = Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0 ? probeIntervalMs : INFRA_LOD_MOTION_PROBE_INTERVAL_MS; if (now - last < interval) return { recompute: false, lastProbeMs: last }; const movedSq = Number.isFinite(movedSqM) && movedSqM > 0 ? movedSqM : 0; const epsilon = Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0 ? motionEpsilonM : infraLodMotionEpsilonM(cameraHeightM); return { recompute: movedSq > epsilon * epsilon, lastProbeMs: now }; }