Files
gods-eye-view/src/data/trackedReadout.js
T
f4fcf267c6 fix(osm): stop using public Overpass by default (#648) (#742)
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]>
2026-09-28 19:07:57 -05:00

308 lines
9.6 KiB
JavaScript

import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js';
/**
* @module trackedReadout
* @description Presentation-model bridge for the protected tracked-target
* entry in the shared world-overlay host. This module owns no canvas,
* projection, frame listener, layout, fade, or paint path.
*/
export const TRACKED_OVERLAY_SOURCE_ID = 'tracked';
export const TRACKED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 0,
moving: true,
solveIntervalMs: 0,
hideInCockpit: true,
});
const TRACKED_BILLBOARD_SCALE = Object.freeze({
near: 1000,
nearValue: 3,
far: 8_000_000,
farValue: 0.5,
});
const DEFAULT_TRACKED_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
let _viewer = null;
let _trackedEntityChangedRemove = null;
let _selectedContext = null;
let _contextSelectedHandler = null;
let _contextClearedHandler = null;
let _aircraftSelectedHandler = null;
let _activeEntryId = null;
let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
/**
* Convert the former newline/inline label text into the explicit presentation
* model used by the host.
* @param {string} text Raw source-formatted label text.
* @param {string} accent Source-owned accent color.
* @returns {{title:string,details:string[],accent:string}}
*/
export function trackedLabelModelFromText(
text,
accent = WORLD_OVERLAY_STYLE.accent,
) {
const raw = String(text || '').trim();
if (!raw) return { title: '', details: [], accent };
let lines;
if (raw.includes('\n')) {
lines = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
} else {
const parts = raw.split(' · ');
lines =
parts.length > 1
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
: [raw];
}
return { title: lines[0] || '', details: lines.slice(1), accent };
}
/**
* Read only the layer-owned display cache. There is deliberately no fallback
* to `entity.position.getValue()`: doing so in the host's post-render phase
* would recompute dead reckoning after the follow camera settled and jitter.
* @param {Object|null} entity Tracked or selected presentation entity.
* @returns {Object|null}
*/
export function cachedTrackedDisplayPosition(entity) {
if (!entity || typeof entity.gevDisplayPosition !== 'function') return null;
try {
return entity.gevDisplayPosition() || null;
} catch {
return null;
}
}
/**
* Anchor for the tracked CARD: the position the target is visually at.
*
* A layer that renders its tracked contact as a 3D model publishes
* `gevVisualPosition`, which returns the translation that model is actually rendering
* with. For a grounded aircraft that differs from the display position by the full
* ground-snap offset (~100 m at an inland airport), so a card anchored to the display
* position sits below the aircraft and climbs slowly as the coarse floor cell warms.
*
* Falls back to the display position, so layers without a 3D visual (and any layer
* before its model is ready) are unchanged. This deliberately does NOT repurpose
* `gevDisplayPosition`: that accessor carries the follow-camera anti-jitter contract
* and must keep returning the value the camera settled on.
* @param {Object|null} entity Tracked or selected presentation entity.
* @returns {Object|null}
*/
export function cachedTrackedVisualPosition(entity) {
if (entity && typeof entity.gevVisualPosition === 'function') {
try {
const visual = entity.gevVisualPosition();
if (visual) return visual;
} catch {
/* fall through to the display position */
}
}
return cachedTrackedDisplayPosition(entity);
}
function entryIdFor(entity) {
const explicit = String(entity?.gevTrackedId || '').trim();
if (explicit) return explicit;
const fallback = String(entity?.id || '').trim();
return fallback ? `entity:${fallback}` : null;
}
function activeEntity() {
return _viewer?.trackedEntity || _selectedContext?.entity || null;
}
/**
* Build the host entry from a layer-owned tracked presentation model.
* @param {Object|null} entity Tracked or selected presentation entity.
* @returns {Object|null}
*/
export function createTrackedOverlayEntry(entity) {
const model = entity?.gevLabelModel;
const id = entryIdFor(entity);
if (!id || !model || typeof entity.gevDisplayPosition !== 'function') {
return null;
}
const title = String(model.title || '').trim();
if (!title) return null;
return {
id,
position: () => cachedTrackedVisualPosition(entity),
variant: 'tracked',
tracked: true,
protected: true,
paintLane: 'tracked',
collisionGroup: 'ambient-card',
priority: Number.MAX_SAFE_INTEGER,
title,
details: Array.isArray(model.details)
? model.details.map((line) => String(line))
: [],
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
cardStyle: model.cardStyle,
selected: model.selected === true,
leaderStyle: model.leaderStyle,
leaderAnimationMs: model.leaderAnimationMs,
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
leaderDrawRatio: model.leaderDrawRatio,
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
? Math.max(0, Number(model.anchorRadiusPx))
: 10,
anchorRadiusScale: Object.prototype.hasOwnProperty.call(
model,
'anchorRadiusScale',
)
? model.anchorRadiusScale
: TRACKED_BILLBOARD_SCALE,
minAnchorGapPx: 16,
anchorGapPaddingPx: 10,
verticalOnly: true,
viewportMargin: 6,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
interactive: false,
};
}
function publishEntity(entity) {
const entry = createTrackedOverlayEntry(entity);
if (!entry) {
clearTrackedSource();
return;
}
_activeEntryId = entry.id;
_overlayHost.setEntries(
TRACKED_OVERLAY_SOURCE_ID,
[entry],
TRACKED_OVERLAY_SOURCE_OPTIONS,
);
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
}
function clearTrackedSource() {
_activeEntryId = null;
_overlayHost.clearSource(TRACKED_OVERLAY_SOURCE_ID);
}
function syncActiveEntity() {
publishEntity(activeEntity());
}
/**
* Republish a layer's newly assigned `gevLabelModel` when that entity owns the
* active tracked readout. Layers call this only when presentation text changes;
* position remains the live frame-cache getter already registered with host.
* @param {Object} entity Entity whose model changed.
*/
export function refreshTrackedReadout(entity) {
if (entity && entity === activeEntity()) publishEntity(entity);
}
/** Current tracked host entry id, used to resolve its actual painted rectangle. */
export function getActiveTrackedReadoutId() {
return _activeEntryId;
}
/** Static-context layers whose click selection publishes the readout card. */
const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']);
/**
* Initialize the model bridge and selection listeners. No render listener is
* installed; the already-initialized world-overlay host owns the frame lane.
* @param {Object} viewer Active Cesium viewer.
*/
export function initTrackedReadout(viewer) {
if (!viewer || _viewer === viewer) return;
if (_viewer) destroyTrackedReadout();
_viewer = viewer;
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
_trackedEntityChangedRemove =
viewer.trackedEntityChanged?.addEventListener?.(() => {
if (viewer.trackedEntity) _selectedContext = null;
syncActiveEntity();
}) || null;
_contextSelectedHandler = (event) => {
const record = event.detail;
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
_selectedContext = record;
publishEntity(record.entity);
return;
}
if (_selectedContext) {
_selectedContext = null;
syncActiveEntity();
}
};
_contextClearedHandler = (event) => {
if (
!_selectedContext ||
event.detail?.layerId === _selectedContext.layerId
) {
_selectedContext = null;
syncActiveEntity();
}
};
_aircraftSelectedHandler = () => {
_selectedContext = null;
if (!_viewer?.trackedEntity) clearTrackedSource();
};
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
window.addEventListener(
'gev:entity-selection-cleared',
_contextClearedHandler,
);
window.addEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
syncActiveEntity();
// Boot-verification contract: the track regression harness asserts this
// exact line at init (dropped by the host migration; restored).
console.log('[TrackedReadout] Initialized');
}
/** Tear down selection listeners and clear the host source. */
export function destroyTrackedReadout() {
_trackedEntityChangedRemove?.();
_trackedEntityChangedRemove = null;
if (_contextSelectedHandler)
window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
if (_contextClearedHandler)
window.removeEventListener(
'gev:entity-selection-cleared',
_contextClearedHandler,
);
if (_aircraftSelectedHandler)
window.removeEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
_contextSelectedHandler = null;
_contextClearedHandler = null;
_aircraftSelectedHandler = null;
_selectedContext = null;
clearTrackedSource();
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false);
_viewer = null;
}
/** Inject a host recorder for focused lifecycle tests; null restores production. */
export function _setTrackedOverlayHostForTest(host = null) {
_overlayHost = host || DEFAULT_TRACKED_OVERLAY_HOST;
}