mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled worldwide name index; wide views show named points. ALPR reads an hourly OpenStreetMap extract. Cockpit place context and country, state and US county outlines use bundled Natural Earth and US Census data. Overpass is used only when an operator configures their own endpoint. Also: camera arrivals no longer land below ground, keyless terrain retries on 429, bounded relation geometry for configured Overpass, one shared OpenStreetMap credit, and separate tile and API transports for embedders. Co-authored-by: theGaryLarson <[email protected]> Co-authored-by: Milan Khanal <[email protected]>
1368 lines
51 KiB
JavaScript
1368 lines
51 KiB
JavaScript
import * as Cesium from 'cesium';
|
||
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
|
||
import { isPointerFree } from './inputOwnership.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;
|
||
// 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;
|
||
|
||
/**
|
||
* 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 };
|
||
}
|
||
|
||
/**
|
||
* Map one local infrastructure feature to a JSON-safe analyst record
|
||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||
* fields are null, never NaN/undefined. Layer-specific fields that do not
|
||
* apply (river/output on datacenters, capacity on dams) stay null so a
|
||
* shared compact payload can copy them without inventing values.
|
||
* Names are unclamped — overlay cards shorten for paint; queries need the
|
||
* full source string ("Usina Hidrelétrica de Itaipu").
|
||
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
|
||
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
|
||
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
|
||
* operator: string|null, capacity: string|null, river: string|null,
|
||
* output: string|null}}
|
||
*/
|
||
export function mapAnalystRecord(raw, layerId = '') {
|
||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||
const text = (v) => {
|
||
const t = String(v ?? '').trim();
|
||
return t && t !== 'undefined' && t !== 'null' ? t : null;
|
||
};
|
||
const props =
|
||
raw?.properties &&
|
||
typeof raw.properties === 'object' &&
|
||
!Array.isArray(raw.properties)
|
||
? raw.properties
|
||
: {};
|
||
const tags =
|
||
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
|
||
? props.tags
|
||
: {};
|
||
const name =
|
||
text(props.name) ||
|
||
text(tags.name) ||
|
||
text(tags['name:en']) ||
|
||
text(tags.official_name) ||
|
||
null;
|
||
const operator =
|
||
text(tags.operator) ||
|
||
text(props.operator) ||
|
||
text(tags['operator:short']) ||
|
||
null;
|
||
const capacity =
|
||
layerId === 'local-datacenters'
|
||
? text(tags['capacity:it_load']) ||
|
||
text(tags.it_load) ||
|
||
text(tags.capacity) ||
|
||
text(props.capacity)
|
||
: null;
|
||
const river =
|
||
layerId === 'local-dams'
|
||
? text(tags.associated_river) ||
|
||
text(props.associated_river) ||
|
||
text(tags.river) ||
|
||
text(props.river) ||
|
||
text(tags['river:name'])
|
||
: null;
|
||
const output =
|
||
layerId === 'local-dams'
|
||
? text(props.output) || text(tags['plant:output:electricity'])
|
||
: null;
|
||
return {
|
||
id: name || text(raw?.id) || layerTitle(layerId),
|
||
name,
|
||
lat: num(raw?.lat),
|
||
lon: num(raw?.lon),
|
||
operator,
|
||
capacity,
|
||
river,
|
||
output,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 createInfrastructureOverlayEntry({
|
||
id,
|
||
source: layerId,
|
||
position,
|
||
title: copy.title,
|
||
details: copy.details,
|
||
accent,
|
||
priority,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 }) {
|
||
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.
|
||
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
|
||
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
|
||
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
|
||
* One live instance per layer id is allowed in a given viewer/context/overlay host.
|
||
* Destroy the previous instance before replacing it. Importing creates no layers.
|
||
*/
|
||
export function createLocalGeoJsonLayer(
|
||
{
|
||
id,
|
||
url,
|
||
name,
|
||
color,
|
||
icon = '📍',
|
||
source = 'Local JSONL',
|
||
osmDerived = false,
|
||
labels = true,
|
||
labelMax = DEFAULT_LABEL_MAX,
|
||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||
screenSpaceEventHandlerFactory = (canvas) =>
|
||
new Cesium.ScreenSpaceEventHandler(canvas),
|
||
projectToWindow = (scene, position) =>
|
||
Cesium.SceneTransforms.worldToWindowCoordinates(scene, position),
|
||
},
|
||
{
|
||
overlayHost,
|
||
registerEntityContext,
|
||
selectEntityContext,
|
||
clearSelectedEntityContextForLayer,
|
||
removeEntityContextsForLayer,
|
||
governorRequestRender,
|
||
showOsmCredit,
|
||
hideOsmCredit,
|
||
},
|
||
) {
|
||
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;
|
||
let _loadPromise = null;
|
||
let _loadController = null;
|
||
/**
|
||
* The parsed bundled dataset, kept across disable/enable so a re-enable
|
||
* rebuilds entities without refetching. The Cesium entities themselves are
|
||
* NOT kept: a hidden data source still costs every frame (DataSourceDisplay
|
||
* walks every visualizer over every entity regardless of `show`), and the
|
||
* ~11k entities of the three bundled layers hold hundreds of MB, so leaving
|
||
* them parked after a toggle-off made the whole scene render ~2× slower for
|
||
* the rest of the session (owner field test 2026-09-13).
|
||
* @type {Array<object>|null}
|
||
*/
|
||
let _cachedFeatures = null;
|
||
/**
|
||
* 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,
|
||
});
|
||
|
||
/**
|
||
* Take the built entities out of the scene entirely. The parsed features
|
||
* stay cached, so the next enable() rebuilds without a fetch; what must not
|
||
* survive a disable is the per-frame visualizer walk and the entity memory.
|
||
* @param {object} viewer
|
||
*/
|
||
const releaseDataSource = (viewer) => {
|
||
if (!_dataSource) return;
|
||
const source = _dataSource;
|
||
_dataSource = null;
|
||
_stemRecords = [];
|
||
_stemGeometryDirty = true;
|
||
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
||
removeEntityContextsForLayer(id);
|
||
try {
|
||
viewer?.dataSources?.remove(source, true);
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
};
|
||
|
||
const disableLayer = (viewer) => {
|
||
hideOsmCredit?.(viewer, id);
|
||
_enabled = false;
|
||
clearGroundRetryRender();
|
||
_activeLodIds = new Set();
|
||
_lodGraceState = new Map();
|
||
_lastLodBudgetLimit = 0;
|
||
_lodComputed = false;
|
||
_lastLodProbeMs = Number.NEGATIVE_INFINITY;
|
||
releaseDataSource(viewer);
|
||
_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,
|
||
}),
|
||
|
||
/**
|
||
* Snapshot in-memory infrastructure features as plain JSON-safe objects
|
||
* for the analyst query engine. On-demand only (called at most once per
|
||
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
|
||
* [] while the layer is disabled or empty. Disable keeps the loaded
|
||
* stems for reuse; this method still returns [] until the next enable.
|
||
* @param {number} [maxCount=2000] Maximum records to return (truncation).
|
||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||
*/
|
||
getAnalystRecords(maxCount = 2000) {
|
||
if (!_enabled || !_stemRecords.length) return [];
|
||
const limit = Number.isFinite(maxCount)
|
||
? Math.max(1, Math.floor(maxCount))
|
||
: 2000;
|
||
const result = [];
|
||
for (let i = 0; i < _stemRecords.length; i++) {
|
||
if (result.length >= limit) break;
|
||
const record = _stemRecords[i];
|
||
const carto = record.carto;
|
||
result.push(
|
||
mapAnalystRecord(
|
||
{
|
||
id: record.id,
|
||
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
|
||
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
|
||
properties: propertyObject(record.entity),
|
||
},
|
||
id,
|
||
),
|
||
);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
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) {
|
||
if (!_loadPromise)
|
||
_loadPromise = (async () => {
|
||
_loadController = new AbortController();
|
||
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 {
|
||
let features = _cachedFeatures;
|
||
if (!features) {
|
||
const response = await fetch(url, {
|
||
signal: _loadController.signal,
|
||
});
|
||
if (_destroyed) return;
|
||
// 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();
|
||
if (_destroyed) return;
|
||
const lines = text
|
||
.split('\n')
|
||
.filter((l) => l.trim().length > 0);
|
||
|
||
features = lines.map((line) => JSON.parse(line));
|
||
_cachedFeatures = features;
|
||
}
|
||
|
||
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,
|
||
});
|
||
|
||
if (_destroyed) return;
|
||
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;
|
||
if (_destroyed) {
|
||
viewer.dataSources.remove(loaded, true);
|
||
return;
|
||
}
|
||
|
||
// 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.
|
||
if (!_destroyed) _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 */
|
||
}
|
||
}
|
||
if (_destroyed) return;
|
||
removeEntityContextsForLayer(id);
|
||
_count = 0;
|
||
_stemRecords = [];
|
||
console.error(`Failed to load ${id}:`, e);
|
||
}
|
||
|
||
if (_destroyed) return;
|
||
// 2. Install native global click handler
|
||
if (!_clickHandler) {
|
||
_clickHandler = screenSpaceEventHandlerFactory(
|
||
viewer.scene.canvas,
|
||
);
|
||
_clickHandler.setInputAction((click) => {
|
||
// A tool owns the pointer (src/data/inputOwnership.js).
|
||
if (!isPointerFree()) return;
|
||
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);
|
||
}
|
||
})();
|
||
try {
|
||
await _loadPromise;
|
||
} finally {
|
||
_loadPromise = null;
|
||
_loadController = null;
|
||
}
|
||
}
|
||
|
||
if (_destroyed) return;
|
||
if (_enabled && _count > 0 && osmDerived) showOsmCredit?.(viewer, id);
|
||
// 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 release is a no-op —
|
||
// release the finished build here instead of parking it hidden in the
|
||
// scene (the parsed features stay cached for the next enable).
|
||
if (!_enabled) {
|
||
releaseDataSource(viewer);
|
||
return;
|
||
}
|
||
if (_dataSource) _dataSource.show = true;
|
||
viewer.scene.requestRender?.();
|
||
},
|
||
|
||
disable: disableLayer,
|
||
|
||
destroy: (viewer) => {
|
||
if (_destroyed) return;
|
||
_destroyed = true;
|
||
_loadController?.abort();
|
||
// Defensively disable first so listeners and selection state are
|
||
// torn down even if destroy is called while the layer is enabled.
|
||
disableLayer(viewer);
|
||
removeEntityContextsForLayer(id);
|
||
if (_clickHandler) {
|
||
_clickHandler.destroy();
|
||
_clickHandler = null;
|
||
}
|
||
if (_dataSource && viewer) {
|
||
viewer.dataSources.remove(_dataSource, true);
|
||
}
|
||
_overlayPublisher.destroy();
|
||
_dataSource = null;
|
||
_cachedFeatures = 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';
|
||
}
|