mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user