Files
gods-eye-view/src/data/trackedReadout.js
T
Sameh Khamisandmanjunath22466 87629ac175 Port ALPR camera visuals into the shared layer
Add camera badges, gradient direction wedges and animated tactical selection cards while preserving bounded loading and layer lifecycle. Align the ALPR data-layer header and retain native pick ownership for overlay badges.

Co-authored-by: manjunath22466 <[email protected]>
2026-09-14 17:35:24 -07:00

273 lines
9.4 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(['military-installations', 'alpr-cameras']);
/**
* 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;
}