Files
gods-eye-view/src/data/localGeojson.js
T

1078 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
import {
clearSelectedEntityContextForLayer,
registerEntityContext,
selectEntityContext,
} from './contextStore.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import {
selectInfraLod,
applyInfraEvictionGrace,
shouldRecomputeInfraLod,
} from './localGeojsonLod.js';
const DEFAULT_LABEL_MAX = 900;
const DEFAULT_LABEL_GRID_PX = 132;
const VISIBILITY_UPDATE_MS = 450;
// Each source keeps its own bounded cohort; the host sums their ambient-card
// paint budgets only up to its 192-card shared-lane ceiling.
export const LOCAL_OVERLAY_COHORT_LIMIT = 160;
const LOCAL_OVERLAY_COLLISION_CAPACITY = 96;
const LOCAL_OVERLAY_CELL_SURPLUS = 2;
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
const LOCAL_OVERLAY_FADE_START_M = 250000;
const LOCAL_OVERLAY_FADE_START_RATIO = LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
// Stems are anchored at ellipsoid height 0, but high-elevation features
// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid,
// burying the short close-in stem inside the photoreal mesh. Once the
// camera is near enough for tiles to be loaded, sample the real surface
// height once per feature and lift the stem onto it.
const GROUND_SAMPLE_MAX_DISTANCE_M = 75000;
const GROUND_SAMPLE_RETRY_MS = 2000;
const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
/**
* Bounded give-up for the self-armed retry. Sampling can be SUPPORTED and still
* never succeed (no sampleable surface under the feature), in which case each
* requested frame would arm the next 2 s timer forever — an idle-governor leak
* dressed up as a retry. After this many consecutive armed retries with no
* record newly grounded, stop arming; camera motion (a frame we get for free)
* still retries through the normal preRender walk and re-opens the budget.
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
*/
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
const DEFAULT_OVERLAY_HOST = Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
});
/**
* Build the owner-approved local-infrastructure card copy.
* @param {object} properties Unwrapped GeoJSON feature properties.
* @param {string} layerId Local layer id.
* @returns {{title:string,details:string[]}}
*/
export function localInfrastructureOverlayCopy(properties, layerId) {
const props = unwrapProperties(properties) || {};
const tags = props.tags || {};
const title = featureLabelFromProperties(props, layerId);
const details = [];
if (layerId === 'local-datacenters') {
const operator = firstClean([
tags.operator,
props.operator,
tags['operator:short'],
]);
const capacity = firstClean([
tags['capacity:it_load'],
tags.it_load,
tags.capacity,
props.capacity,
]);
const line = [operator, capacity]
.filter((value, index, values) => value && values.indexOf(value) === index)
.filter((value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase())
.join(' · ');
if (line) details.push(clampCardLine(line));
} else if (layerId === 'local-dams') {
const river = firstClean([
tags.associated_river,
props.associated_river,
tags.river,
props.river,
tags['river:name'],
]);
if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) {
details.push(clampCardLine(river));
}
}
return { title, details };
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
* @param {object} options
* @param {string} options.id Stable id within the source.
* @param {string} options.layerId Local layer id.
* @param {Cesium.Cartesian3} options.position Current stem-tip position.
* @param {object} options.properties Unwrapped feature properties.
* @param {number} options.priority Source-owned importance score.
* @param {string} options.accent Source accent color.
* @returns {object}
*/
export function createLocalInfrastructureOverlayEntry({
id,
layerId,
position,
properties,
priority,
accent,
}) {
const copy = localInfrastructureOverlayCopy(properties, layerId);
return {
id: String(id),
source: layerId,
position,
variant: 'card',
title: copy.title,
details: copy.details,
accent,
priority,
collisionGroup: 'ambient-card',
zIndex: 30,
interactive: false,
minDistance: 0,
maxDistance: LOCAL_OVERLAY_MAX_DISTANCE_M,
distanceFadeStartRatio: LOCAL_OVERLAY_FADE_START_RATIO,
distanceScale: {
near: 250000,
nearValue: 1,
far: 9000000,
farValue: 0.62,
},
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 15,
placement: 'above',
};
}
/**
* Retain a bounded screen-grid surplus for the host's final rectangle arbiter.
* Two deterministic contenders per legacy grid cell preserve the old density
* while giving the shared solver an alternative when the first card collides.
* @param {object[]} records Local stem/entry records.
* @param {object} options
* @param {number} options.maxEntries Legacy source cap.
* @param {number} options.gridPx Legacy screen grid size.
* @param {number} options.width Viewport width in CSS pixels.
* @param {number} options.height Viewport height in CSS pixels.
* @param {function(object):({x:number,y:number}|null)} options.project Projection callback.
* @param {number} [options.cohortLimit=Infinity] Host-safe materialization cap.
* @returns {object[]} Bounded overlay entries for shared-host arbitration.
*/
export function selectLocalInfrastructureOverlayCohort(records, {
maxEntries,
gridPx,
width,
height,
project,
cohortLimit = Number.POSITIVE_INFINITY,
}) {
const sourceCap = Math.max(0, Math.floor(Number(maxEntries) || 0));
const materializationCap = Number.isFinite(Number(cohortLimit))
? Math.max(0, Math.floor(Number(cohortLimit)))
: Number.POSITIVE_INFINITY;
const cap = Math.min(sourceCap, materializationCap);
const cellSize = Math.max(1, Number(gridPx) || 1);
if (!Array.isArray(records) || records.length === 0 || cap === 0 || typeof project !== 'function') {
return [];
}
const cells = new Map();
const padding = cellSize;
for (const record of records) {
const screen = project(record);
if (!Number.isFinite(screen?.x) || !Number.isFinite(screen?.y)) continue;
if (screen.x < -padding || screen.x > width + padding
|| screen.y < -padding || screen.y > height + padding) continue;
const key = `${Math.floor(screen.x / cellSize)}:${Math.floor(screen.y / cellSize)}`;
let contenders = cells.get(key);
if (!contenders) {
contenders = [];
cells.set(key, contenders);
}
insertLocalCellContender(contenders, record);
}
const primary = [];
const surplus = [];
for (const contenders of cells.values()) {
if (contenders[0]) primary.push(contenders[0]);
if (contenders[1]) surplus.push(contenders[1]);
}
primary.sort(compareLocalOverlayRecords);
surplus.sort(compareLocalOverlayRecords);
if (primary.length >= cap) return primary.slice(0, cap).map((record) => record.entry);
const candidates = primary.concat(surplus.slice(0, cap - primary.length));
return candidates.map((record) => record.entry);
}
/**
* Bind a local layer's visibility and entry lifecycle to the shared host.
* @param {object} options
* @param {string} options.sourceId Local layer id.
* @param {object} [options.host] Test seam for the three host lifecycle calls.
* @returns {{show:function():void,publish:function(object[]):void,hide:function():void,destroy:function():void}}
*/
export function createLocalInfrastructureOverlayPublisher({
sourceId,
host = DEFAULT_OVERLAY_HOST,
}) {
let visible = false;
let published = false;
let destroyed = false;
// Local entries have immutable metadata and a mutable Cartesian position.
// Snapshot coordinates: retaining only the entry reference would miss a
// stem tip moving in place. Republishing an unchanged cohort invalidates
// the host and can sustain a render loop when frames exceed the 450 ms walk.
let lastPublication = null;
const sourceOptions = {
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
collisionCapacity: LOCAL_OVERLAY_COLLISION_CAPACITY,
moving: false,
};
return {
show() {
if (destroyed || visible) return;
visible = true;
host.setVisible(sourceId, true);
},
publish(entries) {
if (destroyed || !visible) return;
if (lastPublication && entries.length === lastPublication.length
&& entries.every((entry, index) => {
const previous = lastPublication[index];
return entry === previous.entry
&& entry.position?.x === previous.x
&& entry.position?.y === previous.y
&& entry.position?.z === previous.z;
})) return;
host.setEntries(sourceId, entries, sourceOptions);
lastPublication = entries.map(entry => ({
entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z,
}));
published = entries.length > 0;
},
hide() {
if (destroyed) return;
if (published) host.clearSource(sourceId);
if (visible) host.setVisible(sourceId, false);
visible = false;
published = false;
lastPublication = null;
},
destroy() {
if (destroyed) return;
if (published) host.clearSource(sourceId);
if (visible) host.setVisible(sourceId, false);
visible = false;
published = false;
lastPublication = null;
destroyed = true;
},
};
}
/**
* Reduce a bundled-dataset load failure to one short, honest stats string.
*
* These layers ship their data with the build, so a failure means the asset
* is missing (404 / bad path) or corrupt — never "the network is slow". Both
* must reach the user's chip; the raw parser message is console-only because
* a truncated JSON blob is not a status line.
*
* @param {Error|{name?:string, message?:string}|null|undefined} error - The thrown load failure.
* @returns {string} Short reason for getStats().error.
*/
export function localDatasetError(error) {
if (error?.name === 'SyntaxError') return 'dataset is malformed';
const message = String(error?.message || '').trim();
return message ? `dataset unavailable (${message})` : 'dataset unavailable';
}
/**
* A minimal, rock-solid native implementation for loading local GeoJSON Data.
* Draws 3D stems (polylines) attached to Point entities and ensures
* standard scene.pick natively clicks them.
*/
export function createLocalGeoJsonLayer({
id,
url,
name,
color,
icon = '📍',
source = 'Local JSONL',
labels = true,
labelMax = DEFAULT_LABEL_MAX,
labelGridPx = DEFAULT_LABEL_GRID_PX,
overlayHost = DEFAULT_OVERLAY_HOST,
screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
projectToWindow = (scene, position) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position),
}) {
let _dataSource = null;
let _enabled = false;
let _clickHandler = null;
let _count = 0;
/** @type {number|null} Timestamp of the last successful dataset load. */
let _lastUpdate = null;
/** @type {string|null} Short reason the bundled dataset failed to load. */
let _error = null;
let _preRenderRemover = null;
let _cameraMoveEndRemover = null;
let _stemRecords = [];
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;
/** Last observed scene.sampleHeightSupported; null until the first walk. */
let _lastGroundSampleCapability = null;
/**
* Coalesced one-shot: ask the governor for a frame once the retry window
* has elapsed, so the preRender ground-sample retry actually runs while the
* camera is parked. One timer for the whole layer (not per record) — the
* retry pass walks every record anyway. (perf rebase 2026-08-17)
*
* Two gates keep this from becoming an idle leak (review round 2):
* - CAPABILITY: without `scene.sampleHeightSupported` the sample can never
* succeed, so a timer here would re-arm on every requested frame,
* forever. Records simply stay at ellipsoid height — exactly the
* pre-perf keyless behavior.
* - BUDGET: sampling can be supported and still keep failing (no sampleable
* surface yet/ever). Give up after GROUND_SAMPLE_MAX_ARMED_RETRIES
* consecutive arms; free camera-motion frames still retry.
* @param {Cesium.Viewer} viewer
* @returns {void}
*/
function scheduleGroundRetryRender(viewer) {
if (_groundRetryTimer || !_enabled) return;
if (!viewer?.scene?.sampleHeightSupported) return;
if (_groundRetryArms >= GROUND_SAMPLE_MAX_ARMED_RETRIES) return;
_groundRetryArms += 1;
_groundRetryTimer = setTimeout(() => {
_groundRetryTimer = null;
if (!_enabled || _destroyed) return;
governorRequestRender(`local-ground-retry:${id}`);
}, GROUND_SAMPLE_RETRY_MS);
}
function clearGroundRetryRender() {
_groundRetryArms = 0;
_lastGroundSampleCapability = null;
if (!_groundRetryTimer) return;
clearTimeout(_groundRetryTimer);
_groundRetryTimer = null;
}
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
sourceId: id,
host: overlayHost,
});
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);
if (viewer?.selectedEntity?.__localLayerId === id) {
viewer.selectedEntity = undefined;
}
if (_preRenderRemover) {
_preRenderRemover();
_preRenderRemover = null;
}
if (_cameraMoveEndRemover) {
_cameraMoveEndRemover();
_cameraMoveEndRemover = null;
}
};
return {
id,
name,
icon,
source,
updateInterval: 0,
statsRefreshInterval: 1000,
init: async (viewer) => {
// DataLayerManager calls this once
},
update: async (viewer) => {
// DataLayerManager calls this when enabled
},
/**
* @returns {{count:number, lastUpdate:number|null, error:string|null}}
* A dead layer must be distinguishable from an empty one: a failed load
* surfaces `error` (manager chip → UNAVAILABLE) instead of reporting a
* silent zero count as nominal.
*/
getStats: () => {
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;
_stemGeometryDirty = true;
_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
if (!_dataSource) {
const baseColor = Cesium.Color.fromCssColorString(color);
// Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection.
// The source is built into a local and committed to `_dataSource`
// only once setup finishes: a half-built source published early would
// make every later enable() skip this block, so the layer could never
// clear its error or retry.
_error = null;
let loaded = null;
// Whether the scene has actually accepted `loaded` — the two rollback
// windows (before vs after the add settles) need different cleanup.
let addedToScene = false;
try {
const response = await fetch(url);
// A 404 returns an HTML body that would otherwise die in JSON.parse
// one line later, reported as a parse error for a missing file.
if (!response.ok) {
throw new Error(`HTTP ${response.status ?? '?'}`);
}
const text = await response.text();
const lines = text.split('\n').filter(l => l.trim().length > 0);
const features = lines.map(line => JSON.parse(line));
const geojson = {
type: 'FeatureCollection',
features
};
// Natively parse into entities and use it as our _dataSource
loaded = await Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true,
stroke: baseColor,
fill: baseColor.withAlpha(0.3),
strokeWidth: 2,
markerSize: 8,
markerColor: baseColor,
});
loaded.name = name;
loaded.show = false;
// Cesium's DataSourceCollection.add() returns a promise and only
// inserts on a later microtask. Without this await, a throw during
// post-processing would roll back a source the scene had not
// accepted yet — and Cesium would then insert the "removed" source
// anyway, leaving an orphan the retry would double up on. Awaiting
// also routes an add() rejection into the error path below instead
// of leaving it uncaught with healthy-looking stats.
await viewer.dataSources.add(loaded);
addedToScene = true;
// Convert parsed points into 3D stems or style polygons
const entities = loaded.entities.values;
_count = entities.length;
_stemRecords = [];
_stemGeometryDirty = true;
for (let i = 0; i < entities.length; i++) {
const feature = entities[i];
feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer
let pos = feature.position?.getValue(Cesium.JulianDate.now());
if (!pos) {
// It's a polygon or line
if (feature.polygon) {
feature.polygon.outline = true;
feature.polygon.outlineColor = baseColor;
// Calculate center point for the stem
const hierarchy = feature.polygon.hierarchy?.getValue(Cesium.JulianDate.now());
if (hierarchy && hierarchy.positions && hierarchy.positions.length > 0) {
pos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
}
}
}
if (!pos) continue;
const carto = Cesium.Cartographic.fromCartesian(pos);
const groundHeight = 0; // Ellipsoid surface until a scene sample lands
const tipHeight = 2000; // Initial Stem height
const base = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, groundHeight);
const tip = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, tipHeight);
const properties = propertyObject(feature);
const recordId = String(feature.id ?? i);
// Store references for bounded stem scaling and native picking.
feature.__localBaseCarto = carto;
feature.__localBaseCartesian = base;
registerEntityContext(feature, {
id: `${id}:${recordId}`,
layerId: id,
layerName: name,
source,
dataSource: loaded,
label: featureLabelFromProperties(properties, id),
properties,
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)),
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)),
});
// Constant properties are refreshed on the existing 450 ms source
// cadence. Cesium no longer evaluates 2-3 callbacks per entity on
// every frame, while the point/stem pick surface stays native.
feature.position = tip;
const stemPositionBuffers = [[base, tip], [base, tip]];
feature.polyline = new Cesium.PolylineGraphics({
positions: stemPositionBuffers[0],
width: 3.5,
material: new Cesium.ColorMaterialProperty(baseColor),
});
feature.point = new Cesium.PointGraphics({
pixelSize: 10,
color: baseColor,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
// Never depth-cull the anchor against the photoreal mesh —
// globe-horizon culling is handled by the pre-render occluder.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const priority = labelPriorityFromProperties(properties, id);
_stemRecords.push({
id: recordId,
entity: feature,
carto,
base,
tip,
nextTip: Cesium.Cartesian3.clone(tip),
stemPositionBuffers,
stemPositionBufferIndex: 0,
groundHeight,
groundSampled: false,
lastGroundSampleMs: 0,
priority,
entry: labels ? createLocalInfrastructureOverlayEntry({
id: recordId,
layerId: id,
position: tip,
properties,
priority,
accent: color,
}) : null,
});
}
// Setup finished — publish it.
_dataSource = loaded;
_lastUpdate = Date.now();
} catch (e) {
// The dataset ships with the build, so this is a broken install,
// not a blip — it has to reach the chip, not just the console.
_error = localDatasetError(e);
// Roll the partial build back so a later enable() retries from
// scratch instead of inheriting a half-populated source. Only the
// post-add window has something in the scene to remove: a failure
// before (or inside) add() never reached the collection, and
// removing then would race Cesium's pending insert.
if (addedToScene) {
try { viewer?.dataSources?.remove(loaded, true); } catch { /* already gone */ }
}
_count = 0;
_stemRecords = [];
console.error(`Failed to load ${id}:`, e);
}
// 2. Install native global click handler
if (!_clickHandler) {
_clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
_clickHandler.setInputAction((click) => {
if (!_enabled) return;
const picked = viewer.scene.pick(click.position);
if (picked && picked.id && picked.id.__localLayerId === id) {
const entity = picked.id;
viewer.selectedEntity = entity;
selectEntityContext(entity);
// We zoom to the surface base of the stem or the center of the polygon
let targetPos = null;
if (entity.polyline) {
// If it's a stem, fly to the base
const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now());
if (positions && positions.length > 0) {
targetPos = positions[0];
}
} else if (entity.polygon && entity.polygon.hierarchy) {
// If it's a polygon, just fly to its center
const hierarchy = entity.polygon.hierarchy.getValue(Cesium.JulianDate.now());
if (hierarchy && hierarchy.positions.length > 0) {
targetPos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
}
}
if (targetPos) {
const carto = Cesium.Cartographic.fromCartesian(targetPos);
// Disable interactions so Cesium doesn't magically cancel the flight
viewer.scene.screenSpaceCameraController.enableInputs = false;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, 5000),
duration: 1.5,
complete: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
cancel: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
});
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
}
// 3. Add an incredibly fast pre-render occluder to hide points behind the globe
if (_enabled && !_preRenderRemover) {
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (!_enabled || !_dataSource) return;
const now = performance.now();
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return;
_lastVisibilityUpdate = now;
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;
// A scene that cannot sample heights can never ground a record, so it
// must never arm a retry (the arm would re-arm on every requested
// frame, forever) and must not spend ANY per-record work trying.
// Read once per walk, not per record.
const canSampleGround = viewer.scene.sampleHeightSupported === true;
// Capability can arrive late (WebGL context restore, a tileset that
// finally supports sampling). A parked camera has no moveEnd to
// re-open a spent budget, so the false→true edge does it.
if (canSampleGround && _lastGroundSampleCapability === false) _groundRetryArms = 0;
_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;
const terrainFloorChanged = refreshLocalTerrainFloor(viewer, record);
if (refreshStemGeometry || terrainFloorChanged) {
updateLocalStemGeometry(viewer, record, now);
} else if (canSampleGround && !record.groundSampled
&& now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS) {
// Capability first: without it the distance below is pure waste,
// once per ungrounded record per walk, forever.
const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M
&& sampleLocalGroundHeight(viewer, record, now)) {
updateLocalStemGeometry(viewer, record, now, distance);
}
}
if (!wasGroundSampled && record.groundSampled) groundSampleProgress = true;
// Still unsampled AND close enough for a retry to succeed: this
// layer has no hold and no periodic update, so under the idle
// governor the retry's preRender never arrives on a parked camera
// and the stem stays at ellipsoid height (buried/floating) until
// the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range records only, so a far
// camera (or a keyless scene) stays fully idle; the distance is
// only computed for still-unsampled stems.
if (canSampleGround && !record.groundSampled && !groundRetryPending
&& Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base)
< GROUND_SAMPLE_MAX_DISTANCE_M) {
groundRetryPending = true;
}
if (isVisible && record.entry) visibleOverlayRecords.push(record);
}
_stemGeometryDirty = false;
// Tiles ARE streaming in: real progress re-opens the give-up budget
// so the records still waiting get their own bounded run of retries.
if (groundSampleProgress) _groundRetryArms = 0;
if (groundRetryPending) scheduleGroundRetryRender(viewer);
const canvas = viewer.scene.canvas;
const cohort = selectLocalInfrastructureOverlayCohort(visibleOverlayRecords, {
maxEntries: labelMax,
gridPx: labelGridPx,
width: canvas.clientWidth || canvas.width || 0,
height: canvas.clientHeight || canvas.height || 0,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
project: (record) => projectToWindow(viewer.scene, record.tip),
});
_overlayPublisher.publish(cohort);
});
}
if (_enabled && !_cameraMoveEndRemover) {
_cameraMoveEndRemover = viewer.camera.moveEnd.addEventListener(() => {
if (!_enabled) return;
_stemGeometryDirty = true;
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
// Real camera motion is a fresh situation (new tiles, new distances)
// and its frames are free, so it re-opens the retry budget that a
// parked camera may have spent.
_groundRetryArms = 0;
viewer.scene.requestRender?.();
});
}
// Honor a disable() that landed while we were awaiting the fetch/parse:
// disable() runs before _dataSource exists, so its show=false is a no-op —
// reading _enabled here (rather than forcing true) respects the toggle-off.
if (_dataSource) _dataSource.show = _enabled;
viewer.scene.requestRender?.();
},
disable: disableLayer,
destroy: (viewer) => {
if (_destroyed) return;
_destroyed = true;
// Defensively disable first so listeners and selection state are
// torn down even if destroy is called while the layer is enabled.
disableLayer(viewer);
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler = null;
}
if (_dataSource && viewer) {
viewer.dataSources.remove(_dataSource, true);
}
_overlayPublisher.destroy();
_dataSource = null;
_stemRecords = [];
_count = 0;
_lastUpdate = null;
_error = null;
}
};
}
function compareLocalOverlayRecords(a, b) {
return b.priority - a.priority || String(a.id).localeCompare(String(b.id));
}
function insertLocalCellContender(contenders, record) {
let index = 0;
while (index < contenders.length && compareLocalOverlayRecords(contenders[index], record) <= 0) {
index++;
}
contenders.splice(index, 0, record);
if (contenders.length > LOCAL_OVERLAY_CELL_SURPLUS) contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
}
function refreshLocalTerrainFloor(viewer, record) {
const globe = viewer.scene.globe;
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) return false;
if (Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base)
>= GROUND_SAMPLE_MAX_DISTANCE_M) return false;
// Camera motion can refine terrain after the first successful depth sample.
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
// another GPU readback just to maintain this floor. Keep roof elevations.
const height = globe.getHeight?.(record.carto);
if (!Number.isFinite(height) || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M
|| height <= record.groundHeight) return false;
setLocalGroundHeight(record, height);
return true;
}
function sampleLocalGroundHeight(viewer, record, now) {
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
record.lastGroundSampleMs = now;
const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false;
let sampled;
try {
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
} catch {
return false; // tiles not ready; retry on a later bounded update
}
if (!Number.isFinite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) return false;
// A coarse scene-depth sample can lie below the terrain already loaded by
// the visible globe. Keep that terrain as a floor while allowing roofs and
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
// inactive terrain must not constrain their geometry.
const terrainHeight = globe?.show ? globe.getHeight?.(record.carto) : undefined;
if (Number.isFinite(terrainHeight) && Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M) {
sampled = Math.max(sampled, terrainHeight);
}
record.groundSampled = true;
setLocalGroundHeight(record, sampled);
return true;
}
function setLocalGroundHeight(record, height) {
record.groundHeight = height;
Cesium.Cartesian3.fromRadians(
record.carto.longitude,
record.carto.latitude,
record.groundHeight,
Cesium.Ellipsoid.WGS84,
record.base,
);
record.entity.__localBaseCartesian = record.base;
}
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
const distance = Number.isFinite(knownDistance)
? knownDistance
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleLocalGroundHeight(viewer, record, now);
// 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;
const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight);
const tipHeight = record.groundHeight + effectiveDistance * fovFactor;
Cesium.Cartesian3.fromRadians(
record.carto.longitude,
record.carto.latitude,
tipHeight,
Cesium.Ellipsoid.WGS84,
record.nextTip,
);
if (Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <= LOCAL_STEM_TIP_EPSILON_SQ) {
return false;
}
Cesium.Cartesian3.clone(record.nextTip, record.tip);
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
const stemPositions = record.stemPositionBuffers[record.stemPositionBufferIndex];
stemPositions[0] = record.base;
stemPositions[1] = record.tip;
record.entity.position.setValue(record.tip);
record.entity.polyline.positions.setValue(stemPositions);
return true;
}
function featureLabelFromProperties(props, layerId) {
const tags = props.tags || {};
const candidates = [
props.name,
tags.name,
tags['name:en'],
tags.official_name,
tags.operator,
tags['operator:short'],
props.operator,
props.output ? `${layerTitle(layerId)} ${props.output}` : '',
props.osm_id ? `${layerTitle(layerId)} ${props.osm_id}` : '',
];
const text = candidates.map(cleanLabel).find(Boolean);
return clampLabel(text || layerTitle(layerId));
}
function labelPriorityFromProperties(props, layerId) {
const tags = props.tags || {};
let score = 0;
if (cleanLabel(props.name) || cleanLabel(tags.name)) score += 1000;
if (cleanLabel(tags['name:en'])) score += 700;
if (cleanLabel(tags.operator) || cleanLabel(props.operator)) score += 180;
if (props.output || tags['plant:output:electricity']) score += 120;
if (layerId === 'local-dams') score += 80;
if (layerId === 'local-datacenters') score += 60;
return score;
}
function propertyObject(entity) {
const source = entity?.properties;
const raw = typeof source?.getValue === 'function'
? source.getValue(Cesium.JulianDate.now())
: source || {};
return unwrapProperties(raw);
}
function unwrapProperties(value) {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(unwrapProperties);
const out = {};
for (const [key, entry] of Object.entries(value)) {
out[key] = entry && typeof entry.getValue === 'function'
? unwrapProperties(entry.getValue(Cesium.JulianDate.now()))
: unwrapProperties(entry);
}
return out;
}
function cleanLabel(value) {
const text = String(value || '').trim();
if (!text || text === 'undefined' || text === 'null') return '';
return text;
}
function firstClean(values) {
return values.map(cleanLabel).find(Boolean) || '';
}
function clampLabel(value) {
const text = cleanLabel(value);
return text.length > 34 ? `${text.slice(0, 31)}...` : text;
}
function clampCardLine(value) {
const text = cleanLabel(value);
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
}
function layerTitle(layerId) {
if (layerId === 'local-datacenters') return 'Datacenter';
if (layerId === 'local-dams') return 'Dam';
return 'Feature';
}