mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Adapt the ALPR camera contribution to reusable layer components, retaining source attribution, bounded queries, selection and voice controls. Co-authored-by: Bilawal Sidhu <[email protected]>
265 lines
9.0 KiB
JavaScript
265 lines
9.0 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,
|
|
anchorRadiusPx: 10,
|
|
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;
|
|
}
|