diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e95e44578..bbc070758 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3513,7 +3513,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > ambient-label host source. Its getter reads the already-propagated point > cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS > propagation. Satellite tracking continues to publish exactly one protected -> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the +> tracked-lane card through its entity record's `labelModel` and `_trackedDisplayCached`; the > tracked entity is point-only. Tracking ISS suppresses the ambient entry and > untracking restores it, preventing duplicate ISS text. Disable, orbit-text > preference changes, catalog rebuild, and destroy clear/hide the source. @@ -3528,7 +3528,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity > is a billboard-only camera target with no native label. The flight source > retains callsign/registration fallback, flight-level/altitude, speed, stale -> state, airline/type, and plausible-route formatting in `gevLabelModel`; the +> state, airline/type, and plausible-route formatting in the entity record's `labelModel`; the > one protected tracked-lane host card renders that complete model. Its > position getter reads only `_trackedDisplayCached`, the same authoritative > Cartesian already consumed by the tracked visual and camera, and never @@ -3635,8 +3635,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model > bridge only; it owns no canvas, projection, post-render listener, layout, > keyhole fade, or paint path. Civilian flights, military flights, satellites, -> and mapped installations write explicit `gevLabelModel` objects and expose -> `gevDisplayPosition` getters backed by their layer-owned frame/display cache. +> and mapped installations write explicit `labelModel` objects and +> `displayPosition` getters, backed by their layer-owned frame/display cache, +> into Core's entity records (`src/data/entityRecords.js`), never onto the +> Cesium entity. > Selected AIS vessels continue using the vessel source's protected selected > card, so they do not create a duplicate tracked readout. The host registers > the active readout in the protected tracked lane with a zero ambient quota; diff --git a/package.json b/package.json index 7dda05144..802fe88fd 100644 --- a/package.json +++ b/package.json @@ -227,6 +227,7 @@ "./standalone/catalog": "./src/standalone/catalog.js", "./application/layers": "./src/app/constructCatalog.js", "./application/operations": "./src/app/operations.js", + "./data/entity-records": "./src/data/entityRecords.js", "./data/lifecycle": "./src/data/lifecycle.js", "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8ed8b859a..8e7516715 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -871,7 +871,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/data/entityRecords.js" ], "external": ["cesium"] }, @@ -1124,7 +1125,8 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/data/entityRecords.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1335,7 +1337,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/data/entityRecords.js" ], "external": ["cesium"] }, @@ -1391,7 +1394,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/entityRecords.js" ], "external": ["cesium"] }, @@ -1576,7 +1580,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/data/entityRecords.js" ], "external": ["cesium", "satellite.js"] }, @@ -1667,7 +1672,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/layers/installations/classLabel.js" + "src/layers/installations/classLabel.js", + "src/data/entityRecords.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1713,7 +1719,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/data/entityRecords.js" ], "external": ["cesium"] }, @@ -1973,7 +1980,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/entityRecords.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -3184,7 +3192,8 @@ "src/data/placeBoundaries.js", "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", - "src/data/local_data/wof_neighborhoods/files.js" + "src/data/local_data/wof_neighborhoods/files.js", + "src/data/entityRecords.js" ], "external": [ "hls.js", @@ -3792,7 +3801,8 @@ "src/data/placeBoundaries.js", "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", - "src/data/local_data/wof_neighborhoods/files.js" + "src/data/local_data/wof_neighborhoods/files.js", + "src/data/entityRecords.js" ], "external": [ "hls.js", @@ -3890,6 +3900,11 @@ "mgrs" ] }, + "entity-records": { + "exports": ["./data/entity-records"], + "modules": ["src/data/entityRecords.js"], + "external": [] + }, "layer-lifecycle": { "exports": ["./data/lifecycle"], "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], @@ -3944,7 +3959,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/data/entityRecords.js" ], "external": [] }, diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index c5a94018f..47065d658 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -763,7 +763,7 @@ try { } : null, navigation: snapshot?.navigation || null, afterId: trackedInfo?.icao24 || null, - viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null, + viewerTrackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null, contextVisible: !document.getElementById('military-awareness-panel')?.hidden, entryAvailableAfterSettlement: Boolean(entry && !entry.hidden), blockerStillRegistered: dataManager.layers.has(blockerId), @@ -1362,7 +1362,7 @@ try { contextMode: styleManager._contextControls._contextMode, contextChanging: styleManager._contextControls._contextModeChanging, subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null, - tracked: tracker?.gevTrackedId || null, + tracked: window.__godsEyeView.entityRecords.read(tracker)?.trackedId || null, density: styleManager.getDetectionState().densityPct, detectionMode: styleManager.getDetectionState().detectionMode, installations: dataManager.getLayerLifecycleState('military-installations'), diff --git a/scripts/qa-context-arrivals.mjs b/scripts/qa-context-arrivals.mjs index 72a6ffab8..09780c3ff 100644 --- a/scripts/qa-context-arrivals.mjs +++ b/scripts/qa-context-arrivals.mjs @@ -360,7 +360,9 @@ async function contacts() { entities: ds?.entities.values.length ?? 0, created, tracked: - window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null, + window.__godsEyeView.entityRecords.read( + window.__godsEyeView.viewer.trackedEntity, + )?.trackedId ?? null, cockpit: document.body.classList.contains('cockpit-mode'), }); if (samples.length > 5000) samples.splice(0, 1000); @@ -375,8 +377,9 @@ async function contacts() { await page .waitForFunction( () => - window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === - 'flights:aaa051', + window.__godsEyeView.entityRecords.read( + window.__godsEyeView.viewer.trackedEntity, + )?.trackedId === 'flights:aaa051', { timeout: 60_000 }, ) .catch(() => {}); diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index cbba1b88e..0f515fe5c 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -169,8 +169,8 @@ async function observeTrackedHostPaint(page, timeoutMs = 5000) { const tracked = viewer?.trackedEntity; const current = { elapsedMs: Math.round(performance.now() - startedAt), - entryId: tracked?.gevTrackedId || null, - hasPresentationModel: Boolean(tracked?.gevLabelModel?.title), + entryId: window.__godsEyeView?.entityRecords?.read(tracked)?.trackedId || null, + hasPresentationModel: Boolean(window.__godsEyeView?.entityRecords?.read(tracked)?.labelModel?.title), candidateCount: diagnostics.candidateCount || 0, projectedCount: diagnostics.projectedCount || 0, selectedCount: diagnostics.selectedCount || 0, @@ -610,7 +610,7 @@ async function main() { if (!pool.length) return null; const ent = v.trackedEntity; let tracked = null; - if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition(); + if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition(); if (!tracked) return pool[0]; let best = null, bestD = Infinity; for (const m of pool) { diff --git a/scripts/qa-installation-polish.mjs b/scripts/qa-installation-polish.mjs index 6971d85ba..bda5f0dfe 100644 --- a/scripts/qa-installation-polish.mjs +++ b/scripts/qa-installation-polish.mjs @@ -333,7 +333,7 @@ export async function checkInstallationSelection( for (let y = 250; y < 650; y += 75) for (let x = 400; x < 850; x += 75) { const pick = g.viewer.scene.pick({ x, y }); - if (!pick?.id?.installationId && !pick?.id?.gevTrackedId) + if (!pick?.id?.installationId && !window.__godsEyeView.entityRecords.read(pick?.id)?.trackedId) return { x, y }; } throw new Error('No empty map point'); diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 83b359605..957144a50 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -3817,7 +3817,7 @@ async function main() { id: `qa:${ownerPrefix}:radio-camera-owner`, position: viewer.camera.positionWC.clone(), }); - owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`; + window.__godsEyeView.entityRecords.write(owner).trackedId = `${ownerPrefix}:qa-radio-camera-owner`; viewer.trackedEntity = owner; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const beforeStationId = radio.getUIState().selected?.id || null; @@ -3831,7 +3831,7 @@ async function main() { afterStationId: radio.getUIState().selected?.id || null, flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, trackedIdentityPreserved: viewer.trackedEntity === owner, - trackedId: viewer.trackedEntity?.gevTrackedId || null, + trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null, }; viewer.trackedEntity = undefined; viewer.entities.remove(owner); @@ -3867,11 +3867,11 @@ async function main() { id: 'qa:flights:delayed-radio-fallback-owner', position: viewer.camera.positionWC.clone(), }); - owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner'; + window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-delayed-radio-fallback-owner'; viewer.trackedEntity = owner; window.__qaDelayedRadioTrackingOwner = owner; window.__qaRejectRadioPlay(); - return owner.gevTrackedId; + return window.__godsEyeView.entityRecords.read(owner)?.trackedId; }); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => { @@ -3880,7 +3880,7 @@ async function main() { const result = { flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, trackedIdentityPreserved: viewer.trackedEntity === owner, - trackedId: viewer.trackedEntity?.gevTrackedId || null, + trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null, selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null, }; viewer.trackedEntity = undefined; @@ -4222,7 +4222,7 @@ async function main() { id: 'qa:flights:radio-recenter-stage-owner', position: viewer.camera.positionWC.clone(), }); - owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner'; + window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-radio-recenter-stage-owner'; viewer.trackedEntity = owner; completeFlight(0); const trackingBetweenStagesCalls = calls.length; diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index e00dadfc3..4792d57a3 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -701,7 +701,7 @@ async function main() { const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.(); return manager?.isEnabled?.('flights') && tracked?.icao24 === 'aaa001' - && window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001'; + && window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001'; }, { timeout: 30000, polling: 100 }, ); @@ -712,7 +712,7 @@ async function main() { flightsEnabled: manager.isEnabled('flights'), trackedId: flights.getTrackedInfo()?.icao24 || null, durableId: flights.getParams()?.selectedFlightsTrackingId || null, - viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null, + viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null, }; }); record( @@ -742,7 +742,7 @@ async function main() { const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.(); return manager?.isEnabled?.('flights') && tracked?.icao24 === 'aaa001' - && window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001'; + && window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001'; }, { timeout: 30000, polling: 100 }, ); @@ -753,7 +753,7 @@ async function main() { flightsEnabled: manager.isEnabled('flights'), trackedId: flights.getTrackedInfo()?.icao24 || null, durableId: flights.getParams()?.selectedFlightsTrackingId || null, - viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null, + viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null, }; }); record( @@ -815,7 +815,7 @@ async function main() { const latchedShare = await evalPage(() => ({ tracked: window.__godsEyeView.dataManager.layers .get('flights').module.getTrackedInfo()?.icao24 || null, - viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null, + viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null, })); record( 'share-v2: the pending subject latches on when it arrives on a later poll', @@ -1380,7 +1380,7 @@ async function main() { if (exactPickModel) return exactPickModel; const ent = window.__godsEyeView.viewer.trackedEntity; let tracked = null; - if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition(); + if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition(); if (!tracked) return pool[0]; let best = null, bestD = Infinity; for (const m of pool) { @@ -1427,7 +1427,7 @@ async function main() { const dm = window.__godsEyeView.dataManager; const fl = dm.layers.get('flights').module; const ent = window.__godsEyeView.viewer.trackedEntity; - const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null; + const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null; const dets = fl.getDetectableObjects({ maxCount: 1000 }); const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign)); if (!disp || !me || !me.position) return null; @@ -1656,7 +1656,7 @@ async function main() { const ti = fl.getTrackedInfo(); if (!ti) return null; const ent = v.trackedEntity; - const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null; + const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null; const pos = disp || ti.position; if (!pos) return null; const center = v.scene.cartesianToCanvasCoordinates(pos); @@ -1908,7 +1908,7 @@ async function main() { // presentation model (entities are asserted native-label-free); // the native read stays as a fallback so this probe still fails // loudly if BOTH surfaces are ever absent. - const model = ent.gevLabelModel; + const model = window.__godsEyeView.entityRecords.read(ent)?.labelModel; if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n'); if (!ent.label || !ent.label.text) return null; const t = ent.label.text; @@ -2553,7 +2553,7 @@ async function main() { } // WELD (tracked): the tracked CARD anchors to the model you can see. - // `gevVisualPosition` is a SEPARATE accessor from `gevDisplayPosition` on + // the record's `visualPosition` is a SEPARATE accessor from its `displayPosition` on // purpose — the latter carries the follow-camera anti-jitter contract and // must keep returning the cached dead-reckoned value the camera settled // on. So this asserts both halves: the visual accessor is welded to the @@ -2580,10 +2580,10 @@ async function main() { const at = (p) => (p ? Math.hypot( p.x - anchor.x, p.y - anchor.y, p.z - anchor.z, ) : null); - const visual = typeof ent?.gevVisualPosition === 'function' ? ent.gevVisualPosition() : null; - const display = typeof ent?.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null; + const visual = typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.visualPosition() : null; + const display = typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null; return { - hasVisualAccessor: typeof ent?.gevVisualPosition === 'function', + hasVisualAccessor: typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function', visualDelta: at(visual), displayDelta: at(display), }; @@ -2599,7 +2599,7 @@ async function main() { // anti-jitter accessor has been repurposed and the follow camera is at // risk. Skipped (not failed) when the DR cache is momentarily invalid. const displayDelta = trackedWeld.displayDelta; - record('weld: gevDisplayPosition still reports the dead-reckoned position (anti-jitter intact)', + record('weld: the record displayPosition still reports the dead-reckoned position (anti-jitter intact)', !Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M, Number.isFinite(displayDelta) ? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)` diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index 0509a47a5..54ca3205f 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -8,6 +8,7 @@ import { t } from '../i18n/index.js'; import { nextTick } from 'vue'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { applicationPinia } from '../ui/vueRuntime.js'; +import { readEntityRecord } from '../data/entityRecords.js'; /** A label whose detailed outline could not be resolved. */ function outlineUnavailableLabel(label) { @@ -424,10 +425,9 @@ export function createScreenAnnotationRenderer( const ent = viewer.trackedEntity; const now = Cesium.JulianDate.now(); + const displayPosition = readEntityRecord(ent)?.displayPosition; const world = - typeof ent?.gevDisplayPosition === 'function' - ? ent.gevDisplayPosition() - : null; + typeof displayPosition === 'function' ? displayPosition() : null; const win = world ? Cesium.SceneTransforms.worldToWindowCoordinates( scene, diff --git a/src/app/tools.js b/src/app/tools.js index 9d91f70a9..0556ccc55 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -17,6 +17,7 @@ import { holdContinuousRender, releaseContinuousRender, } from '../renderGovernor.js'; +import { entityRecord, readEntityRecord } from '../data/entityRecords.js'; /** Attach scene tools, rendering listeners and the application debug handle. */ export function createApplicationTools({ @@ -194,6 +195,7 @@ export function createApplicationTools({ getRenderGovernorDiagnostics, surfaceServices: operations.surface, requestRender: governorRequestRender, + entityRecords: { read: readEntityRecord, write: entityRecord }, }; const debug = window.__godsEyeView; defer(() => { diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 64d03914b..580920d81 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -227,7 +227,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc assert.doesNotMatch(exit, /applyTrackedCameraFrame/); assert.match( ui, - /restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/, + /restoreTrackingFrame: \(entity\) => \{[\s\S]*?readEntityRecord\(entity\)\?\.trackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/, 'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner', ); }); @@ -299,7 +299,7 @@ test('the cockpit reads its aircraft from the layer that owns Cesium tracking', const read = readAircraftInfo.toString(); assert.ok(read, 'readAircraftInfo is missing'); assert.match(read, /resolveTrackedAircraftInfo\(\{/); - assert.match(read, /gevTrackedId/); + assert.match(read, /readEntityRecord\([^)]*\)\?\.trackedId/); // In cockpit mode the controller moves the entity off viewer.trackedEntity, // so its own handle is the tracked identity there. assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 3186b1f3c..dc6078500 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -306,7 +306,7 @@ export function relativeBearing(bearing, heading) { * Resolve which flight layer owns the cockpit's tracked aircraft. * * Both layers can briefly describe a tracked aircraft during a cross-layer - * handoff, so the viewer's normalized tracked identity (`gevTrackedId`) is the + * handoff, so the viewer's normalized tracked identity (the entity record's `trackedId`) is the * tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject` * uses. Layer precedence remains the fallback when no normalized identity is * available, so tracking paths that never stamp one keep working. diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index 5a7f029c0..41c99f6cd 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -365,7 +365,7 @@ test('tracked-identity resolution falls back to layer precedence', () => { const civilian = { icao24: 'aaa077' }; const military = { icao24: 'ae01ce' }; // No stamped identity (or one that matches neither layer) keeps the historic - // civilian-first order so tracking paths without gevTrackedId still work. + // civilian-first order so tracking paths without an entity record trackedId still work. assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights'); assert.equal( resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId, diff --git a/src/cockpitTracking.js b/src/cockpitTracking.js index 3671e3a36..07c32fcf2 100644 --- a/src/cockpitTracking.js +++ b/src/cockpitTracking.js @@ -1,7 +1,7 @@ /* * Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo` * in `cockpitMath.js` — the landed owner of that rule. It keeps layer - * precedence as the fallback when no normalized `gevTrackedId` is available, + * precedence as the fallback when the entity record has no normalized `trackedId`, * so tracking paths that never stamp one keep working; `readAircraftInfo` is * pinned to it by `cockpitMarkup.test.mjs`. This module owns only the * multi-step ENTRY TRANSACTION (adopt → enter → roll back). diff --git a/src/cockpitTracking.test.mjs b/src/cockpitTracking.test.mjs index fd6a171cc..b3a711167 100644 --- a/src/cockpitTracking.test.mjs +++ b/src/cockpitTracking.test.mjs @@ -5,6 +5,7 @@ import { enterCockpitWithTracking, restoreAircraftTrackingOwner, } from './cockpitTracking.js'; +import { readEntityRecord, withEntityRecord } from './data/entityRecords.js'; /** * Model the two production stamping seams that sit under this transaction: @@ -22,7 +23,7 @@ function stampingWorld({ entryAllowed = true } = {}) { layerId, trackById(id) { if (!trackable) return false; - world.trackedEntity = { gevTrackedId: `${layerId}:${id}` }; + world.trackedEntity = withEntityRecord({}, { trackedId: `${layerId}:${id}` }); stamp('trackedEntityChanged'); // the ui.js listener return true; }, @@ -80,7 +81,7 @@ test('a refused Cockpit entry still stamps for the tracker it already moved', () // Adoption stamped, and the rollback re-track stamped again. assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']); assert.equal(world.generation, 2); - assert.equal(world.trackedEntity.gevTrackedId, 'military:ae01ce'); + assert.equal(readEntityRecord(world.trackedEntity)?.trackedId, 'military:ae01ce'); }); test('a Cockpit entry that mutates nothing takes no camera authority', () => { diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 549adde96..977ae8e1f 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -31,6 +31,7 @@ import { isAlprSurveillanceType, normalizeAlprNode, } from './alprCameras.js'; +import { readEntityRecord } from './entityRecords.js'; test('normalizeAlprNode: maps a full OSM node to a plain record', () => { const record = normalizeAlprNode({ @@ -443,25 +444,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a const entities = h.source.entities.values; assert.equal(alprCamerasLayer.name, 'ALPR Cameras'); assert.deepEqual( - entities.map((entity) => entity.gevLabelModel.title), + entities.map((entity) => readEntityRecord(entity)?.labelModel.title), ['ALPR-0042', 'ALPR-0043', 'ALPR-0044'], ); assert.equal( new Set(entities.map((entity) => entity.billboard.image.getValue())).size, 1, ); - assert.deepEqual(entities[0].gevLabelModel.details, [ + assert.deepEqual(readEntityRecord(entities[0])?.labelModel.details, [ 'OSM MAPPED', 'FLOCK SAFETY · CITY POLICE · FIXED', 'PUBLIC MAP DATA', ]); - assert.deepEqual(entities[1].gevLabelModel.details, [ + assert.deepEqual(readEntityRecord(entities[1])?.labelModel.details, [ 'OSM MAPPED', 'MOTOROLA SOLUTIONS', 'PUBLIC MAP DATA', ]); assert.deepEqual( - entities[2].gevLabelModel.details, + readEntityRecord(entities[2])?.labelModel.details, ['OSM MAPPED', 'PUBLIC MAP DATA'], 'unknown metadata is not guessed, but the source is always named', ); @@ -813,7 +814,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di h.click('alpr:42'); const entity = h.source.entities.getById('alpr:42'); const anchor = Cesium.Cartographic.fromCartesian( - entity.gevDisplayPosition(), + readEntityRecord(entity)?.displayPosition(), ); assert.ok(Math.abs(anchor.height - 187) < 0.001); assert.ok( @@ -821,7 +822,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di ); for (let i = 0; i < 60; i++) { h.viewer.scene.postRender.raiseEvent(); - entity.gevDisplayPosition(); + readEntityRecord(entity)?.displayPosition(); } assert.equal(samples, 1, 'no per-frame raycasts'); h.advance(1000); @@ -921,9 +922,9 @@ test('two factories keep their requests, records and destruction independent', a assert.equal(b.source.entities.values[0].id, 'directory:99'); assert.equal(second.source, 'Test camera directory'); assert.ok( - b.source.entities.values[0].gevLabelModel.details.includes( - 'Source: Test directory', - ), + readEntityRecord( + b.source.entities.values[0], + )?.labelModel.details.includes('Source: Test directory'), ); assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0); assert.equal(a.viewer.scene.postRender.numberOfListeners, 0); diff --git a/src/data/entityRecords.js b/src/data/entityRecords.js new file mode 100644 index 000000000..6b026fa09 --- /dev/null +++ b/src/data/entityRecords.js @@ -0,0 +1,52 @@ +/** + * @module entityRecords + * @description Core-owned records for the entities layers draw, so layer state lives in Core + * rather than on the renderer's objects. A layer writes the record for an entity it draws; + * the tracked readout, follow camera, cockpit, share links and awareness read it back + * through the entity the renderer hands them (a pick or the tracked entity). + * + * Fields a record may hold: + * - `trackedId`: the normalized `layer:id` identity, e.g. `flights:a1b2c3`. + * - `displayPosition()`: the layer's cached display position (the follow camera's + * anti-jitter contract: the value the camera settled on, never recomputed late). + * - `visualPosition()`: where a 3D model is actually drawn, for the tracked card. + * - `labelModel`: the tracked card's `{ title, details, accent, … }`. + * - `selectionOrigin`: what started the selection (`'user'`, …). + */ + +const records = new WeakMap(); + +/** + * The record for `entity`, made empty on first use. For layers writing their own state. + * @param {Object} entity An entity the layer draws. + * @returns {Object} + */ +export function entityRecord(entity) { + let record = records.get(entity); + if (!record) { + record = {}; + records.set(entity, record); + } + return record; +} + +/** + * The record for `entity`, or null when it has none (or there is no entity). For readers. + * @param {Object|null|undefined} entity + * @returns {Object|null} + */ +export function readEntityRecord(entity) { + if (!entity || typeof entity !== 'object') return null; + return records.get(entity) ?? null; +} + +/** + * Write `fields` into `entity`'s record and return the entity. + * @param {Object} entity + * @param {Object} fields Record fields to set. + * @returns {Object} `entity` + */ +export function withEntityRecord(entity, fields) { + Object.assign(entityRecord(entity), fields); + return entity; +} diff --git a/src/data/entityRecords.test.mjs b/src/data/entityRecords.test.mjs new file mode 100644 index 000000000..0f258953e --- /dev/null +++ b/src/data/entityRecords.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { entityRecord, readEntityRecord, withEntityRecord } from './entityRecords.js'; + +test('an entity record is made on first write and read back by the same entity', () => { + const entity = {}; + assert.equal(readEntityRecord(entity), null); + entityRecord(entity).trackedId = 'flights:a1b2c3'; + assert.equal(entityRecord(entity), entityRecord(entity), 'one record per entity'); + assert.equal(readEntityRecord(entity)?.trackedId, 'flights:a1b2c3'); + assert.equal(readEntityRecord({}), null, 'another entity has its own record'); +}); + +test('the record lives in Core, not on the entity', () => { + const entity = {}; + entityRecord(entity).labelModel = { title: 'DAL123', details: [] }; + assert.deepEqual(Object.keys(entity), []); +}); + +test('reading without an entity gives null', () => { + assert.equal(readEntityRecord(null), null); + assert.equal(readEntityRecord(undefined), null); + assert.equal(readEntityRecord('flights:a1b2c3'), null); +}); + +test('withEntityRecord writes fields into the record and returns the entity', () => { + const entity = withEntityRecord({ id: 'alpr:42' }, { trackedId: 'alpr:42' }); + assert.equal(entity.id, 'alpr:42'); + assert.equal(readEntityRecord(entity)?.trackedId, 'alpr:42'); + assert.equal(Object.hasOwn(entity, 'trackedId'), false); +}); diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 7fe197c30..d6a06e5b9 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -28,6 +28,7 @@ import { destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; +import { readEntityRecord, withEntityRecord } from './entityRecords.js'; test('share-Follow absence requires an accepted OpenSky snapshot', async () => { _setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' }); @@ -184,7 +185,7 @@ test('flights first update forwards caller cancellation into the feed request', test('nonempty OpenSky payload with zero usable rows cannot prove share target absence', async () => { _setTrackedFlightRefreshStateForTest({ icao24: 'abc123', - entity: { gevLabelModel: { title: 'WARM', details: [] } }, + entity: withEntityRecord({}, { labelModel: { title: 'WARM', details: [] } }), billboard: { show: false }, billboardCollection: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, @@ -317,7 +318,7 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STALE', async () => { const icao24 = 'a1b2c3'; - const entity = { gevLabelModel: { title: 'OLD', details: [] } }; + const entity = withEntityRecord({}, { labelModel: { title: 'OLD', details: [] } }); const billboard = { position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000), color: Cesium.Color.WHITE, @@ -388,13 +389,13 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA try { await flightsLayer.update(viewer); - assert.equal(entity.gevLabelModel.title, 'DAL123'); - assert.match(entity.gevLabelModel.details.join(' · '), /FL350/); - assert.match(entity.gevLabelModel.details.join(' · '), /486 kts/); + assert.equal(readEntityRecord(entity)?.labelModel.title, 'DAL123'); + assert.match(readEntityRecord(entity)?.labelModel.details.join(' · '), /FL350/); + assert.match(readEntityRecord(entity)?.labelModel.details.join(' · '), /486 kts/); await flightsLayer.update(viewer); assert.match( - [entity.gevLabelModel.title, ...entity.gevLabelModel.details].join(' · '), + [readEntityRecord(entity)?.labelModel.title, ...readEntityRecord(entity)?.labelModel.details].join(' · '), /STALE/, ); } finally { @@ -527,7 +528,7 @@ test('real civil track path creates no native label and publishes every cached h assert.ok( entities.values.every((candidate) => candidate.label === undefined), ); - assert.deepEqual(entity.gevLabelModel, { + assert.deepEqual(readEntityRecord(entity)?.labelModel, { title: 'N12345 · FL350 · 486 kts', details: ['TEST AIR · A320', 'AUS → LAX'], accent: '#39d0ff', @@ -546,7 +547,7 @@ test('real civil track path creates no native label and publishes every cached h 'user', 'same-target selection upgrades durable authority', ); - assert.equal(entity.gevSelectionOrigin, 'user'); + assert.equal(readEntityRecord(entity)?.selectionOrigin, 'user'); assert.equal(flightsLayer.refocusTrackedById('different-flight'), false); assert.equal( flightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), @@ -579,8 +580,8 @@ test('real civil track path creates no native label and publishes every cached h const entry = publication.entries[0]; assert.equal(entry.protected, true); assert.equal(entry.paintLane, 'tracked'); - assert.equal(entry.title, entity.gevLabelModel.title); - assert.deepEqual(entry.details, entity.gevLabelModel.details); + assert.equal(entry.title, readEntityRecord(entity)?.labelModel.title); + assert.deepEqual(entry.details, readEntityRecord(entity)?.labelModel.details); const display = entity.position.getValue(now); assert.ok(display, 'tracked position callback must seed its frame cache'); @@ -941,7 +942,7 @@ test('civil label chain: the awareness selection EVENT uses the canonical chain' }); test('civil tracked readout: a callsign-less enriched contact reads as its registration', async () => { - const entity = { gevLabelModel: { title: 'OLD', details: [] } }; + const entity = withEntityRecord({}, { labelModel: { title: 'OLD', details: [] } }); seedLabelContact({ callsign: ' ', registration: ' N123AB ', tracked: true }); _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, @@ -981,9 +982,9 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis camera: { positionCartographic: null }, scene: {}, }); - assert.match(entity.gevLabelModel.title, /^N123AB\b/); + assert.match(readEntityRecord(entity)?.labelModel.title, /^N123AB\b/); assert.doesNotMatch( - [entity.gevLabelModel.title, ...entity.gevLabelModel.details].join(' · '), + [readEntityRecord(entity)?.labelModel.title, ...readEntityRecord(entity)?.labelModel.details].join(' · '), /ae1fa4/, ); } finally { diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 05fd97dc0..5a17be256 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -46,6 +46,7 @@ import { presentAwarenessDistance, } from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; +import { withEntityRecord } from './entityRecords.js'; const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), @@ -823,9 +824,7 @@ for (const fixture of [ let retrackCalls = 0; try { - const trackedEntity = { - gevTrackedId: `${subject.layerId}:${subject.id}`, - }; + const trackedEntity = withEntityRecord({}, { trackedId: `${subject.layerId}:${subject.id}` }); runtime.viewer.trackedEntity = trackedEntity; replaceMethod( fixture.layer, @@ -927,7 +926,7 @@ test('Contacts activation adopts the production military tracked-subject descrip ), 'mutating one accessor result must not alter the tracked source position', ); - runtime.viewer.trackedEntity = { gevTrackedId: `military:${id}` }; + runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `military:${id}` }); replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); replaceMethod( flightsLayer, @@ -1032,7 +1031,7 @@ test('production military tracked-subject label falls back to registration befor 'getAllPositions id stays the identity hex', ); - runtime.viewer.trackedEntity = { gevTrackedId: `military:${id}` }; + runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `military:${id}` }); replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); replaceMethod(flightsLayer, 'trackById', () => true, restores); replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores); @@ -1104,7 +1103,7 @@ test('a cached Context subject re-reads its label when enrichment lands after se meta, }); - runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` }; + runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` }); replaceMethod( militaryFlightsLayer, 'getTrackedSubject', @@ -1247,7 +1246,7 @@ test('Contacts activation adopts the production civilian tracked-subject descrip ), 'mutating one accessor result must not alter the tracked source position', ); - runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` }; + runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` }); replaceMethod( militaryFlightsLayer, 'getTrackedSubject', @@ -1334,7 +1333,7 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after const restores = []; try { - runtime.viewer.trackedEntity = { gevTrackedId: 'flights:first' }; + runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:first' }); replaceMethod( flightsLayer, 'getTrackedSubject', @@ -1357,7 +1356,7 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after civilianTracked = null; militaryTracked = awarenessSubject('military', 'latest', positions.latest); - runtime.viewer.trackedEntity = { gevTrackedId: 'military:latest' }; + runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'military:latest' }); releaseDependencies(); await nextTurn(); await nextTurn(); @@ -1401,7 +1400,7 @@ test('tracked flight cleared during Contacts activation suppresses fallback auto const focused = []; try { - runtime.viewer.trackedEntity = { gevTrackedId: 'flights:selected' }; + runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:selected' }); replaceMethod(flightsLayer, 'getTrackedSubject', () => tracked, restores); replaceMethod( militaryFlightsLayer, diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index d04061793..3ea1c5f69 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -17,6 +17,7 @@ import { destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; +import { readEntityRecord, withEntityRecord } from './entityRecords.js'; test('share-Follow absence requires an accepted adsb.lol snapshot', async () => { _setMilitaryTrackingRefreshOutcomeForTest({ status: 'source-unavailable' }); @@ -147,7 +148,7 @@ test('military first update forwards caller cancellation into the feed request', test('nonempty adsb.lol payload with zero usable rows cannot prove share target absence', async () => { _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae1234', - entity: { gevLabelModel: { title: 'WARM', details: [] } }, + entity: withEntityRecord({}, { labelModel: { title: 'WARM', details: [] } }), billboard: { show: false }, billboardCollection: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, @@ -183,7 +184,7 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target test('military poll refreshes tracked callsign/altitude/kts and marks a missed poll STALE', async () => { const icao24 = 'ae01ce'; - const entity = { gevLabelModel: { title: 'OLD', details: [] } }; + const entity = withEntityRecord({}, { labelModel: { title: 'OLD', details: [] } }); const billboard = { position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000), color: Cesium.Color.WHITE, @@ -246,12 +247,12 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p try { await militaryFlightsLayer.update(viewer); - assert.equal(entity.gevLabelModel.title, 'RCH451'); - assert.match(entity.gevLabelModel.details.join(' · '), /28000 ft/); - assert.match(entity.gevLabelModel.details.join(' · '), /400 kt/); + assert.equal(readEntityRecord(entity)?.labelModel.title, 'RCH451'); + assert.match(readEntityRecord(entity)?.labelModel.details.join(' · '), /28000 ft/); + assert.match(readEntityRecord(entity)?.labelModel.details.join(' · '), /400 kt/); await militaryFlightsLayer.update(viewer); - assert.match(entity.gevLabelModel.title, /STALE/); + assert.match(readEntityRecord(entity)?.labelModel.title, /STALE/); } finally { globalThis.fetch = realFetch; } @@ -264,7 +265,7 @@ test('cached military snapshots retain their observation time and expose stale f const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae01ce', - entity: { gevLabelModel: { title: 'RCH451', details: [] } }, + entity: withEntityRecord({}, { labelModel: { title: 'RCH451', details: [] } }), billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false, @@ -442,7 +443,7 @@ test('real military track path creates no native label and publishes every cache assert.ok( entities.values.every((candidate) => candidate.label === undefined), ); - assert.deepEqual(entity.gevLabelModel, { + assert.deepEqual(readEntityRecord(entity)?.labelModel, { title: 'RCH451', details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'], accent: '#ffd166', @@ -464,7 +465,7 @@ test('real military track path creates no native label and publishes every cache 'user', 'same-target selection upgrades durable authority', ); - assert.equal(entity.gevSelectionOrigin, 'user'); + assert.equal(readEntityRecord(entity)?.selectionOrigin, 'user'); assert.equal( militaryFlightsLayer.refocusTrackedById('different-flight'), false, @@ -500,8 +501,8 @@ test('real military track path creates no native label and publishes every cache const entry = publication.entries[0]; assert.equal(entry.protected, true); assert.equal(entry.paintLane, 'tracked'); - assert.equal(entry.title, entity.gevLabelModel.title); - assert.deepEqual(entry.details, entity.gevLabelModel.details); + assert.equal(entry.title, readEntityRecord(entity)?.labelModel.title); + assert.deepEqual(entry.details, readEntityRecord(entity)?.labelModel.details); const display = entity.position.getValue(now); assert.ok(display, 'tracked position callback must seed its frame cache'); @@ -845,7 +846,7 @@ test('the military tracked descriptor publishes the reported vertical rate', () const seedRate = (verticalRateMps) => _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae7f01', - entity: { gevLabelModel: { title: 'TEST', details: [] } }, + entity: withEntityRecord({}, { labelModel: { title: 'TEST', details: [] } }), billboard: { position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000), color: Cesium.Color.WHITE, diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index c3f9cdb89..ff1d2dc1b 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -25,6 +25,7 @@ import { } from '../renderGovernor.js'; import * as Cesium from 'cesium'; import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js'; +import { readEntityRecord } from './entityRecords.js'; test('clicking a selected installation again or empty map clears it through refresh', async () => { const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42, @@ -435,7 +436,7 @@ test('a footprint-less relation just outside the viewport still renders', async }); try { assert.deepEqual( - harness.entities().map((entity) => entity.gevLabelModel?.title), + harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title), ['Straddling Range'], ); } finally { @@ -475,7 +476,7 @@ test('a legacy-shaped payload at the cap fires the exact-viewport retry end to e assert.equal(harness.requests.length, 2, 'a legacy entry must not skip the retry'); assert.equal(harness.requests[1].includes('exact=1'), true); assert.deepEqual( - harness.entities().map((entity) => entity.gevLabelModel?.title), + harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title), ['Rescued From Legacy'], ); } finally { @@ -506,7 +507,7 @@ test('off-viewport records from the snapped superset never render or enter conte ], }); try { - const titles = harness.entities().map((entity) => entity.gevLabelModel?.title); + const titles = harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title); assert.deepEqual(titles, ['In View'], 'only the in-viewport site renders'); assert.equal(harness.contextLabels().includes('Off View'), false, 'and none enters context'); assert.equal(harness.stats().count, 1); @@ -534,7 +535,7 @@ test('a saturated snapped tile refetches the exact viewport before rendering', a assert.equal(harness.requests[0].includes('exact=1'), false, 'first ask uses the shared snapped tile'); assert.equal(harness.requests[1].includes('exact=1'), true, 'retry opts out of the snap'); assert.deepEqual( - harness.entities().map((entity) => entity.gevLabelModel?.title), + harness.entities().map((entity) => readEntityRecord(entity)?.labelModel?.title), ['Rescued'], 'the in-viewport site is no longer starved by off-view ones', ); diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index de5717a11..e9a07d6d2 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -52,6 +52,7 @@ import { stationMatchesRadioCategory, } from './radio.js'; import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs'; +import { withEntityRecord } from './entityRecords.js'; const stations = [ { id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] }, @@ -1652,10 +1653,10 @@ test('Radio recovery preserves closer altitude and caps extreme full-globe zoom' test('Radio camera navigation yields to every live tracked-entity owner', () => { const viewer = { camera: {} }; assert.equal(radioCameraNavigationAllowed(viewer), true); - for (const gevTrackedId of ['flights:abc123', 'military:def456']) { - const trackedEntity = { gevTrackedId }; + for (const trackedId of ['flights:abc123', 'military:def456']) { + const trackedEntity = withEntityRecord({}, { trackedId }); viewer.trackedEntity = trackedEntity; - assert.equal(radioCameraNavigationAllowed(viewer), false, gevTrackedId); + assert.equal(radioCameraNavigationAllowed(viewer), false, trackedId); assert.equal(viewer.trackedEntity, trackedEntity); } viewer.trackedEntity = undefined; diff --git a/src/data/satellitesTrackedRefresh.test.mjs b/src/data/satellitesTrackedRefresh.test.mjs index cf70b373c..9a6e3b26e 100644 --- a/src/data/satellitesTrackedRefresh.test.mjs +++ b/src/data/satellitesTrackedRefresh.test.mjs @@ -18,6 +18,7 @@ import { } from './satellites.js'; import satellitesLayer from './satellites.js'; import { createTrackedOverlayEntry } from './trackedReadout.js'; +import { readEntityRecord, withEntityRecord } from './entityRecords.js'; const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; @@ -117,7 +118,7 @@ test('selected satellite params survive delayed arrival and yield to newer expli satellitesLayer.setParams({ selectedSatTrackingId: 25544 }), ); assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544); - assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544'); + assert.equal(readEntityRecord(viewer.trackedEntity)?.trackedId, 'satellites:25544'); satellitesLayer.stopTracking(); _removeSatelliteTrackingCandidateForTest(25544); @@ -194,7 +195,7 @@ test('a tracked docked cluster consolidates its companions onto one card', () => // so their ambient labels stack underneath the tracked card. Owner ruling: // consolidate them as secondary info on that card, and suppress only those // members — never unrelated satellites that merely happen to be nearby. - const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } }; + const entity = withEntityRecord({}, { labelModel: { title: 'OLD', details: ['? km'] } }); const point = { position: new Cesium.Cartesian3() }; const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } }; let nowMs = Date.UTC(2008, 8, 20, 12, 30); @@ -229,7 +230,7 @@ test('a tracked docked cluster consolidates its companions onto one card', () => viewer.scene.frameState.frameNumber = 2; _runSatellitePreRenderForTest(); - const details = entity.gevLabelModel.details; + const details = readEntityRecord(entity)?.labelModel.details; // Class leads the detail block; the altitude line follows it, and the // consolidated companions stay last. assert.equal( @@ -252,7 +253,7 @@ test('a tracked docked cluster consolidates its companions onto one card', () => assert.doesNotMatch(details[2], /\+2/); }); test('satellite pre-render refreshes the tracked altitude on each propagated frame', () => { - const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } }; + const entity = withEntityRecord({}, { labelModel: { title: 'OLD', details: ['? km'] } }); const point = { position: new Cesium.Cartesian3() }; const viewer = { camera: null, @@ -271,17 +272,17 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra }); _runSatellitePreRenderForTest(); - assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)'); - assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS'); + assert.equal(readEntityRecord(entity)?.labelModel.title, 'ISS (ZARYA)'); + assert.equal(readEntityRecord(entity)?.labelModel.details[0], 'STATION · ISS'); // formatQuantity keeps the number and its unit together with a no-break space. - assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544'); + assert.equal(readEntityRecord(entity)?.labelModel.details[1], '353\u00a0km · NORAD 25544'); epochIndex = 1; viewer.scene.frameState.frameNumber = 2; _runSatellitePreRenderForTest(); - assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544'); + assert.equal(readEntityRecord(entity)?.labelModel.details[1], '366\u00a0km · NORAD 25544'); assert.equal( - entity.gevLabelModel.details[0], + readEntityRecord(entity)?.labelModel.details[0], 'STATION · ISS', 'the class line survives an altitude-only republish', ); diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index 98fd534c4..9ba8c25c2 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -33,6 +33,7 @@ import militaryFlightsLayer, { import { findCompatibleHistoryIndex } from './militaryAwareness.js'; import { createGevActionRunner } from '../voice/gevActions.js'; import { ANALYST_LAYERS, createAnalystEngine } from './analystEngine.js'; +import { readEntityRecord, withEntityRecord } from './entityRecords.js'; /** Strip block and line comments so source pins scan CODE, not prose. */ function stripComments(source) { @@ -149,7 +150,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke const icao24 = 'a1b2c3'; setTr3b(icao24, true); - const entity = { gevLabelModel: { title: 'OLD', details: [] } }; + const entity = withEntityRecord({}, { labelModel: { title: 'OLD', details: [] } }); const billboard = { position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000), color: Cesium.Color.WHITE, @@ -212,11 +213,11 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke assert.equal(billboard.image, aircraftIcon('tr3b'), 'the poll reconciler re-images through the TR-3B resolver, not the raw class'); // Live telemetry keeps flowing; only the class label is the operator's fiction. - assert.match(entity.gevLabelModel.title, /^DAL123 · FL350 · 486 kts$/); - assert.deepEqual(entity.gevLabelModel.details.slice(0, 1), ['TR-3B'], + assert.match(readEntityRecord(entity)?.labelModel.title, /^DAL123 · FL350 · 486 kts$/); + assert.deepEqual(readEntityRecord(entity)?.labelModel.details.slice(0, 1), ['TR-3B'], 'the tracked card class line reports TR-3B, replacing operator/type'); assert.equal( - [entity.gevLabelModel.title, ...entity.gevLabelModel.details].join(' · ').includes('Southwest'), + [readEntityRecord(entity)?.labelModel.title, ...readEntityRecord(entity)?.labelModel.details].join(' · ').includes('Southwest'), false, 'the real operator is not shown alongside the TR-3B classification', ); diff --git a/src/data/trackedCamera.js b/src/data/trackedCamera.js index b5991835d..37b04de89 100644 --- a/src/data/trackedCamera.js +++ b/src/data/trackedCamera.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { readEntityRecord } from './entityRecords.js'; const MAX_FRAME_ATTEMPTS = 120; const MIN_TRACKED_RANGE_M = 150; @@ -57,7 +58,7 @@ export function trackedModelScaleForPixelCap({ * @returns {Cesium.Cartesian3|undefined} Camera-frame position. */ export function trackedDisplayPositionForCamera(entity, time, result) { - const displayedPosition = entity?.gevDisplayPosition?.(); + const displayedPosition = readEntityRecord(entity)?.displayPosition?.(); return displayedPosition ? Cesium.Cartesian3.clone(displayedPosition, result) : entity?.position?.getValue(time, result); diff --git a/src/data/trackedCamera.test.mjs b/src/data/trackedCamera.test.mjs index 50e93b367..fc436507b 100644 --- a/src/data/trackedCamera.test.mjs +++ b/src/data/trackedCamera.test.mjs @@ -6,6 +6,7 @@ import { trackedDisplayPositionForCamera, trackedModelScaleForPixelCap, } from './trackedCamera.js'; +import { withEntityRecord } from './entityRecords.js'; test('tracked camera origin crossing returns to the prior side at the minimum range', () => { const camera = { @@ -74,15 +75,12 @@ test('tracked model shrinks smoothly when close enough to exceed the pixel cap', test('tracked camera prefers the already-rendered display cache', () => { let callbackReads = 0; const cached = new Cesium.Cartesian3(1, 2, 3); - const entity = { - gevDisplayPosition: () => cached, - position: { + const entity = withEntityRecord({ position: { getValue: () => { callbackReads += 1; return new Cesium.Cartesian3(4, 5, 6); }, - }, - }; + } }, { displayPosition: () => cached }); const result = trackedDisplayPositionForCamera( entity, Cesium.JulianDate.now(), diff --git a/src/data/trackedCardLocale.test.mjs b/src/data/trackedCardLocale.test.mjs index 6877ea41f..ac26ed61d 100644 --- a/src/data/trackedCardLocale.test.mjs +++ b/src/data/trackedCardLocale.test.mjs @@ -8,6 +8,7 @@ import { _trackedFrameCartesianForTest, } from './satellites.js'; import { setLocale } from '../i18n/index.js'; +import { readEntityRecord, withEntityRecord } from './entityRecords.js'; const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; @@ -16,7 +17,7 @@ const L2 = function run(locale) { setLocale(locale); - const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } }; + const entity = withEntityRecord({}, { labelModel: { title: 'OLD', details: ['? km'] } }); const point = { position: new Cesium.Cartesian3() }; const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } }; let nowMs = Date.UTC(2008, 8, 20, 12, 30); @@ -43,7 +44,7 @@ function run(locale) { nowMs += 2000; viewer.scene.frameState.frameNumber = 2; _runSatellitePreRenderForTest(); - const details = entity.gevLabelModel.details; + const details = readEntityRecord(entity)?.labelModel.details; setLocale('en'); return details; } diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 486967bf3..921449866 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -4,6 +4,7 @@ import { setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js'; +import { readEntityRecord } from './entityRecords.js'; /** * @module trackedReadout @@ -79,9 +80,10 @@ export function trackedLabelModelFromText( * @returns {Object|null} */ export function cachedTrackedDisplayPosition(entity) { - if (!entity || typeof entity.gevDisplayPosition !== 'function') return null; + const displayPosition = readEntityRecord(entity)?.displayPosition; + if (typeof displayPosition !== 'function') return null; try { - return entity.gevDisplayPosition() || null; + return displayPosition() || null; } catch { return null; } @@ -91,22 +93,23 @@ export function cachedTrackedDisplayPosition(entity) { * 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 + * the entity record's `visualPosition`, 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 + * the entity record's `displayPosition`: 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') { + const visualPosition = readEntityRecord(entity)?.visualPosition; + if (typeof visualPosition === 'function') { try { - const visual = entity.gevVisualPosition(); + const visual = visualPosition(); if (visual) return visual; } catch { /* fall through to the display position */ @@ -116,7 +119,7 @@ export function cachedTrackedVisualPosition(entity) { } function entryIdFor(entity) { - const explicit = String(entity?.gevTrackedId || '').trim(); + const explicit = String(readEntityRecord(entity)?.trackedId || '').trim(); if (explicit) return explicit; const fallback = String(entity?.id || '').trim(); return fallback ? `entity:${fallback}` : null; @@ -132,9 +135,10 @@ function activeEntity() { * @returns {Object|null} */ export function createTrackedOverlayEntry(entity) { - const model = entity?.gevLabelModel; + const record = readEntityRecord(entity); + const model = record?.labelModel; const id = entryIdFor(entity); - if (!id || !model || typeof entity.gevDisplayPosition !== 'function') { + if (!id || !model || typeof record.displayPosition !== 'function') { return null; } const title = String(model.title || '').trim(); @@ -204,7 +208,7 @@ function syncActiveEntity() { } /** - * Republish a layer's newly assigned `gevLabelModel` when that entity owns the + * Republish an entity record's newly assigned `labelModel` 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. diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 425a69ceb..2bd160b9a 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -13,6 +13,7 @@ import { refreshTrackedReadout, trackedLabelModelFromText, } from './trackedReadout.js'; +import { entityRecord, readEntityRecord, withEntityRecord } from './entityRecords.js'; function makeCesiumEvent() { const listeners = new Set(); @@ -53,15 +54,12 @@ test('tracked label text becomes an explicit title/details/accent model', () => ); }); -test('tracked position source reads only gevDisplayPosition and never entity.position', () => { +test('tracked position source reads only the record\'s displayPosition and never entity.position', () => { const cached = { x: 1, y: 2, z: 3 }; let propertyReads = 0; - const entity = { - gevDisplayPosition: () => cached, - position: { + const entity = withEntityRecord({ position: { getValue() { propertyReads += 1; throw new Error('fresh position read is forbidden'); }, - }, - }; + } }, { displayPosition: () => cached }); assert.equal(cachedTrackedDisplayPosition(entity), cached); assert.equal(propertyReads, 0); assert.equal(cachedTrackedDisplayPosition({ position: entity.position }), null); @@ -71,17 +69,12 @@ test('tracked position source reads only gevDisplayPosition and never entity.pos test('the tracked card anchors to the visual position without repurposing the display accessor', () => { // A grounded aircraft's 3D model rides a ground snap while its billboard stays // at the reported (buried) altitude — ~100 m apart. The card must follow the - // model you can see, while `gevDisplayPosition` keeps returning the cached + // model you can see, while the record's `displayPosition` keeps returning the cached // dead-reckoned value the follow camera settled on (anti-jitter contract). const display = { x: 1, y: 2, z: 3 }; const visual = { x: 1, y: 2, z: 103 }; let displayReads = 0; - const entity = { - gevTrackedId: 'flights:aaa077', - gevDisplayPosition: () => { displayReads += 1; return display; }, - gevVisualPosition: () => visual, - gevLabelModel: { title: 'SWA143', details: ['ON GROUND'], accent: '#6be8ff' }, - }; + const entity = withEntityRecord({}, { trackedId: 'flights:aaa077', displayPosition: () => { displayReads += 1; return display; }, visualPosition: () => visual, labelModel: { title: 'SWA143', details: ['ON GROUND'], accent: '#6be8ff' } }); assert.equal(cachedTrackedVisualPosition(entity), visual); assert.equal(displayReads, 0, 'the visual accessor short-circuits the display read'); assert.equal(cachedTrackedDisplayPosition(entity), display, @@ -91,15 +84,12 @@ test('the tracked card anchors to the visual position without repurposing the di // Layers with no 3D visual, and any layer before its model is ready, are // unchanged: the accessor falls back to the display position. - const spriteOnly = { gevDisplayPosition: () => display }; + const spriteOnly = withEntityRecord({}, { displayPosition: () => display }); assert.equal(cachedTrackedVisualPosition(spriteOnly), display); - assert.equal(cachedTrackedVisualPosition({ ...spriteOnly, gevVisualPosition: () => null }), display, + assert.equal(cachedTrackedVisualPosition(withEntityRecord({}, { displayPosition: () => display, visualPosition: () => null })), display, 'a null visual position falls back rather than blanking the card'); assert.equal( - cachedTrackedVisualPosition({ - gevDisplayPosition: () => display, - gevVisualPosition: () => { throw new Error('model gone'); }, - }), + cachedTrackedVisualPosition(withEntityRecord({}, { displayPosition: () => display, visualPosition: () => { throw new Error('model gone'); } })), display, 'a throwing visual accessor falls back instead of dropping the card', ); @@ -108,11 +98,7 @@ test('the tracked card anchors to the visual position without repurposing the di test('tracked entry factory pins the production protected-lane policy', () => { const display = { x: 4, y: 5, z: 6 }; - const entry = createTrackedOverlayEntry({ - gevTrackedId: 'satellites:25544', - gevDisplayPosition: () => display, - gevLabelModel: { title: 'ISS', details: ['420 km · NORAD 25544'], accent: '#ffd84d' }, - }); + const entry = createTrackedOverlayEntry(withEntityRecord({}, { trackedId: 'satellites:25544', displayPosition: () => display, labelModel: { title: 'ISS', details: ['420 km · NORAD 25544'], accent: '#ffd84d' } })); assert.equal(entry.id, 'satellites:25544'); assert.equal(entry.position(), display); assert.equal(entry.protected, true); @@ -122,15 +108,12 @@ test('tracked entry factory pins the production protected-lane policy', () => { }); test('selected camera readout carries tactical animation and fixed badge clearance', () => { - const entry = createTrackedOverlayEntry({ - id: 'alpr:42', gevDisplayPosition: () => ({ x: 1, y: 2, z: 3 }), - gevLabelModel: { + const entry = createTrackedOverlayEntry(withEntityRecord({ id: 'alpr:42' }, { displayPosition: () => ({ x: 1, y: 2, z: 3 }), labelModel: { title: 'ALPR-0042', details: ['OSM MAPPED'], accent: '#ff6474', cardStyle: 'tactical', selected: true, leaderStyle: 'elbow', leaderAnimationMs: 440, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.68, anchorRadiusPx: 30, anchorRadiusScale: null, - }, - }); + } })); assert.equal(entry.cardStyle, 'tactical'); assert.equal(entry.selected, true); assert.equal(entry.leaderStyle, 'elbow'); @@ -146,12 +129,7 @@ test('tracked entity publishes a protected host entry backed by the frame cache' const fakeWindow = new EventTarget(); const changed = makeCesiumEvent(); const display = { x: 10, y: 20, z: 30 }; - const entity = { - id: 'generated', - gevTrackedId: 'flights:abc123', - gevDisplayPosition: () => display, - gevLabelModel: { title: 'UAL123', details: ['FL350 · 451 kts'], accent: '#39d0ff' }, - }; + const entity = withEntityRecord({ id: 'generated' }, { trackedId: 'flights:abc123', displayPosition: () => display, labelModel: { title: 'UAL123', details: ['FL350 · 451 kts'], accent: '#39d0ff' } }); const viewer = { trackedEntity: entity, trackedEntityChanged: changed }; const recorder = makeHostRecorder(); globalThis.window = fakeWindow; @@ -174,7 +152,7 @@ test('tracked entity publishes a protected host entry backed by the frame cache' assert.equal(publication.options.hideInCockpit, true); assert.equal(getActiveTrackedReadoutId(), 'flights:abc123'); - entity.gevLabelModel = { ...entity.gevLabelModel, details: ['FL360 · 455 kts'] }; + entityRecord(entity).labelModel = { ...readEntityRecord(entity)?.labelModel, details: ['FL360 · 455 kts'] }; refreshTrackedReadout(entity); assert.deepEqual(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details, [ 'FL360 · 455 kts', @@ -197,11 +175,7 @@ test('selection lifecycle leaves installations to their own entry and retains AL const changed = makeCesiumEvent(); const viewer = { trackedEntity: null, trackedEntityChanged: changed }; const recorder = makeHostRecorder(); - const installation = { - gevTrackedId: 'installations:fort-test', - gevDisplayPosition: () => ({ x: 1, y: 2, z: 3 }), - gevLabelModel: { title: 'FORT TEST', details: ['AIRFIELD'], accent: '#5aa9ff' }, - }; + const installation = withEntityRecord({}, { trackedId: 'installations:fort-test', displayPosition: () => ({ x: 1, y: 2, z: 3 }), labelModel: { title: 'FORT TEST', details: ['AIRFIELD'], accent: '#5aa9ff' } }); globalThis.window = fakeWindow; _setTrackedOverlayHostForTest(recorder.host); try { @@ -219,11 +193,7 @@ test('selection lifecycle leaves installations to their own entry and retains AL assert.equal(recorder.calls.filter(({ op }) => op === 'set').length, setsBefore, 'installation selection cannot create a second overlay'); // ALPR cameras are static context too: a click publishes the same card. - const camera = { - gevTrackedId: 'alpr:42', - gevDisplayPosition: () => ({ x: 4, y: 5, z: 6 }), - gevLabelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' }, - }; + const camera = withEntityRecord({}, { trackedId: 'alpr:42', displayPosition: () => ({ x: 4, y: 5, z: 6 }), labelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' } }); fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'alpr-cameras', entity: camera }, })); @@ -271,7 +241,7 @@ test('trackedReadout cannot resurrect a dedicated canvas or render listener', as } }); -test('tracking layers write gevLabelModel and expose only their cached display positions', async () => { +test('tracking layers write the record\'s labelModel and expose only their cached display positions', async () => { const files = await Promise.all([ 'flights.js', 'militaryFlights.js', @@ -280,12 +250,16 @@ test('tracking layers write gevLabelModel and expose only their cached display p ].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))])); const sources = Object.fromEntries(files); for (const [name, source] of files) { - assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`); - assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`); + assert.match(source, /entityRecord\([^)]*\)\.labelModel\s*=/, `${name} writes the explicit model into the entity record`); + assert.match(source, /entityRecord\([^)]*\)\.displayPosition\s*=/, `${name} exposes a display-position cache in the entity record`); + assert.doesNotMatch(source, /\.gev(LabelModel|DisplayPosition|VisualPosition|TrackedId|SelectionOrigin)\b/, `${name} hangs no tracked state on the entity`); } - assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); - assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); - assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached')); + assert.match(sources['flights.js'], /entityRecord\([^)]*\)\.displayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); + assert.match(sources['militaryFlights.js'], /entityRecord\([^)]*\)\.displayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); + assert.match( + sources['satellites.js'], + /entityRecord\([^)]*\)\.displayPosition\s*=\s*_trackedDisplayCached/, + ); assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false); assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false); assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false); diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index c7343d573..8fa6d416b 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -23,6 +23,7 @@ import { alprLabelDetails, validAlprGroundHeight, } from './visuals.js'; +import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; @@ -236,11 +237,10 @@ export function createAlprPresentation({ state, services, source }) { entity.billboard.width = entity.billboard.height = selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE; - if (entity.gevLabelModel) { - entity.gevLabelModel.accent = color.toCssColorString(); - entity.gevLabelModel.leaderAnimationStartedAt = selected - ? selectionStartedAt - : 0; + const labelModel = readEntityRecord(entity)?.labelModel; + if (labelModel) { + labelModel.accent = color.toCssColorString(); + labelModel.leaderAnimationStartedAt = selected ? selectionStartedAt : 0; } } @@ -425,13 +425,14 @@ export function createAlprPresentation({ state, services, source }) { } } entity.gevAlprRecord = record; - entity.gevTrackedId = record.id; + entityRecord(entity).trackedId = record.id; // The mapped camera datum has no elevation; it is not the clamped marker's // visual anchor. Only the selected marker samples the rendered surface, // at most once per second, through Cesium's public height APIs. entity.gevAlprDisplayPosition ??= null; - entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition; - entity.gevLabelModel = { + entityRecord(entity).displayPosition = () => + entity.gevAlprDisplayPosition; + entityRecord(entity).labelModel = { title: alprDisplayId(record), details: alprLabelDetails(record, source), accent: color.toCssColorString(), diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1baf54ec4..f116b6b92 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -10,6 +10,7 @@ import { SUBJECT_PRESENCE, AWARENESS_PAGE_SIZE, } from './policy.js'; +import { readEntityRecord } from '../../data/entityRecords.js'; // coverageLabel is machine-composed English from installations/controls.js // (pinned by src/data/militaryInstallations.test.mjs); translate the known @@ -192,7 +193,7 @@ export function createSubject({ state: layerState, services, parts, source }) { function currentTrackedFlightSubject() { const trackedKey = normalizeContextId( - layerState.viewer?.trackedEntity?.gevTrackedId, + readEntityRecord(layerState.viewer?.trackedEntity)?.trackedId, ); if (!trackedKey) return null; const subjects = [ @@ -260,16 +261,19 @@ export function createSubject({ state: layerState, services, parts, source }) { ) { if (!subject?.position) return null; if (subject.layerId === 'flights' || subject.layerId === 'military') { - const trackedPosition = - layerState.viewer?.trackedEntity?.gevDisplayPosition?.(); + const trackedPosition = readEntityRecord( + layerState.viewer?.trackedEntity, + )?.displayPosition?.(); if (trackedPosition) { return { position: Cesium.Cartesian3.clone(trackedPosition), // Only the follow camera's own contact proves presence this way; a // different tracked entity says nothing about this subject. presence: - String(layerState.viewer?.trackedEntity?.gevTrackedId || '') === - subjectKey(subject) + String( + readEntityRecord(layerState.viewer?.trackedEntity)?.trackedId || + '', + ) === subjectKey(subject) ? SUBJECT_PRESENCE.LIVE : SUBJECT_PRESENCE.UNCHECKED, }; diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index b66775f1e..87a0a402b 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -515,7 +515,7 @@ export function createMotion({ * * It reads `modelMatrix`, which `_updateTrackedModel` already wrote this frame — no * sampling, no `_modelDisplayPosition` call from postRender, and no new dead reckoning, - * so the follow-camera anti-jitter contract on `gevDisplayPosition` is untouched. + * so the follow-camera anti-jitter contract on the entity record's `displayPosition` is untouched. */ function _trackedVisualCached() { diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 59270892e..064fce011 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -25,6 +25,7 @@ import { CYAN_TRANSPARENT, } from './policy.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; +import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; export function createTracking({ flightState, @@ -66,7 +67,7 @@ export function createTracking({ const info = flightState.records.data.get(icao24); if (!bb?.position || !info) return false; if (flightState._trackedEntity) - flightState._trackedEntity.gevSelectionOrigin = origin; + entityRecord(flightState._trackedEntity).selectionOrigin = origin; _emitAwarenessEvent('gev:awareness-subject-selected', { layerId: 'flights', id: icao24, @@ -788,10 +789,8 @@ export function createTracking({ function _updateTrackedLabelModel(icao24) { if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) return; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _trackedLabelText(icao24), - '#39d0ff', - ); + entityRecord(flightState._trackedEntity).labelModel = + trackedLabelModelFromText(_trackedLabelText(icao24), '#39d0ff'); refreshTrackedReadout(flightState._trackedEntity); // The readout and the context slot describe the same contact — refresh them // together so voice never narrates a fix the card has already replaced. @@ -1007,12 +1006,10 @@ export function createTracking({ }, false), }, }); - flightState._trackedEntity.gevSelectionOrigin = origin; - flightState._trackedEntity.gevTrackedId = `flights:${icao24}`; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _trackedLabelText(icao24), - '#39d0ff', - ); + entityRecord(flightState._trackedEntity).selectionOrigin = origin; + entityRecord(flightState._trackedEntity).trackedId = `flights:${icao24}`; + entityRecord(flightState._trackedEntity).labelModel = + trackedLabelModelFromText(_trackedLabelText(icao24), '#39d0ff'); // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is // far too tight (the user had to scroll out to read the plane). Give the entity a @@ -1035,12 +1032,12 @@ export function createTracking({ // Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target // readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in // postRender (which would jitter the label against the now-stable plane). - flightState._trackedEntity.gevDisplayPosition = + entityRecord(flightState._trackedEntity).displayPosition = parts.motion._trackedDisplayCached; - // Separate accessor on purpose: `gevDisplayPosition` carries the follow-camera + // Separate accessor on purpose: the entity record's `displayPosition` carries the follow-camera // anti-jitter contract and must keep returning the cached DR position. Presentation - // that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead. - flightState._trackedEntity.gevVisualPosition = + // that should weld to the AIRCRAFT YOU SEE reads the entity record's `visualPosition` instead. + entityRecord(flightState._trackedEntity).visualPosition = parts.motion._trackedVisualCached; refreshTrackedReadout(flightState._trackedEntity); flightState._viewer.camera.cancelFlight(); @@ -1155,8 +1152,8 @@ export function createTracking({ ) { _clearTracking(true, { origin: - flightState._viewer.trackedEntity?.gevSelectionOrigin || - 'programmatic', + readEntityRecord(flightState._viewer.trackedEntity) + ?.selectionOrigin || 'programmatic', }); } }); diff --git a/src/layers/installations/rendering.js b/src/layers/installations/rendering.js index 43489e3f2..94c679b48 100644 --- a/src/layers/installations/rendering.js +++ b/src/layers/installations/rendering.js @@ -2,6 +2,7 @@ import { installationClassLabel, installationTitle } from './classLabel.js'; import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js'; +import { entityRecord } from '../../data/entityRecords.js'; export function createRendering({ state: layerState, @@ -221,7 +222,7 @@ export function createRendering({ if (retained.gevInstallationHeightM !== surfaceHeightM) { retained.gevInstallationHeightM = surfaceHeightM; retained.position = displayPosition; - retained.gevDisplayPosition = () => displayPosition; + entityRecord(retained).displayPosition = () => displayPosition; } if (retained.gevInstallationSelected !== selectedRecord) { retained.gevInstallationSelected = selectedRecord; @@ -269,9 +270,9 @@ export function createRendering({ } entity.gevInstallationHeightM = surfaceHeightM; entity.gevInstallationSelected = selectedRecord; - entity.gevTrackedId = `installations:${record.id}`; - entity.gevDisplayPosition = () => displayPosition; - entity.gevLabelModel = { + entityRecord(entity).trackedId = `installations:${record.id}`; + entityRecord(entity).displayPosition = () => displayPosition; + entityRecord(entity).labelModel = { title: installationTitle(record.name) || t('MAPPED INSTALLATION'), details: [ installationClassLabel(record.class), diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index de4215387..2eb13b80a 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -35,6 +35,7 @@ import { LOCAL_ADSB_UAT_RING_COLOR, } from './policy.js'; import { localAdsbStatus } from './status.js'; +import { entityRecord } from '../../data/entityRecords.js'; export { HEARD_BY_RECEIVER } from './policy.js'; export { localAdsbCardModel, localAdsbReceiverLine } from './card.js'; @@ -458,14 +459,14 @@ export function createLocalAdsbLayer({ ), }, }); - marker.entity.gevTrackedId = id; - marker.entity.gevDisplayPosition = () => marker.position; + entityRecord(marker.entity).trackedId = id; + entityRecord(marker.entity).displayPosition = () => marker.position; } refreshClass(marker); const uat = localAdsbIsUat(record); if (uat !== Boolean(marker.entity.point)) marker.entity.point = uat ? uatRing(marker) : undefined; - marker.entity.gevLabelModel = cardModel(marker, at); + entityRecord(marker.entity).labelModel = cardModel(marker, at); registerEntityContext(marker.entity, contextMetadata(id, marker)); if (newFix && id === selectedId) refreshTrail(); return id; @@ -481,7 +482,7 @@ export function createLocalAdsbLayer({ function refreshSelectedCard() { const marker = selectedId ? markers.get(selectedId) : null; if (!marker) return; - marker.entity.gevLabelModel = cardModel(marker, now()); + entityRecord(marker.entity).labelModel = cardModel(marker, now()); services.overlays?.refreshReadout?.(marker.entity); } @@ -567,7 +568,7 @@ export function createLocalAdsbLayer({ const reselect = id === selectedId && selectedTrail?.id === id; selectedId = id; enrichment.request(marker.record, { selected: true }); - marker.entity.gevLabelModel = cardModel(marker, now()); + entityRecord(marker.entity).labelModel = cardModel(marker, now()); selectEntityContext(marker.entity); if (!reselect) startTrail(marker); governorRequestRender('local-adsb-selection'); diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 263c21210..11596d603 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -12,6 +12,7 @@ import { localAdsbReceiverLine, localAdsbStatus, } from './index.js'; +import { readEntityRecord } from '../../data/entityRecords.js'; function fakeReceiver(initial = {}) { const state = { @@ -252,8 +253,8 @@ test('local aircraft use their class silhouette and scale in magenta', async (t) ), ); assert.equal(entity.billboard.rotation.getValue(), 0); - assert.equal(entity.gevLabelModel.title, 'LOCAL1'); - assert.equal(typeof entity.gevDisplayPosition, 'function'); + assert.equal(readEntityRecord(entity)?.labelModel.title, 'LOCAL1'); + assert.equal(typeof readEntityRecord(entity)?.displayPosition, 'function'); const heli = sources[0].entities.getById('local-adsb:a0b702'); assert.equal(heli.billboard.image.getValue(), aircraftIcon('helicopter')); @@ -265,7 +266,10 @@ test('local aircraft use their class silhouette and scale in magenta', async (t) ), 'the helicopter keeps the local magenta', ); - assert.equal(heli.gevLabelModel.details[1], 'Helicopter · A7'); + assert.equal( + readEntityRecord(heli)?.labelModel.details[1], + 'Helicopter · A7', + ); // On the ground the silhouette shrinks like a grounded public flight. receiver.set({ @@ -479,14 +483,14 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o await new Promise((resolve) => setTimeout(resolve, 400)); assert.deepEqual([...queries].sort(), ['route:EVC145', 'type:a0b702']); const entity = sources[0].entities.getById('local-adsb:a0b702'); - assert.deepEqual(entity.gevLabelModel.details.slice(0, 4), [ + assert.deepEqual(readEntityRecord(entity)?.labelModel.details.slice(0, 4), [ 'ICAO A0B702 · EVC145', 'Helicopter · A7', 'Air Evac · Bell 407', 'AUS → SAT', ]); assert.equal( - entity.gevLabelModel.details.at(-1), + readEntityRecord(entity)?.labelModel.details.at(-1), 'Heard by your receiver · 1090 MHz · browser SDR', ); layer.selectAircraft('a0b702'); @@ -760,7 +764,7 @@ test('feed records merge with browser SDR records by ICAO and UAT-only aircraft ); assert.equal(shared.point, undefined, 'heard on 1090 too: no UAT ring'); assert.equal( - shared.gevLabelModel.details.at(-1), + readEntityRecord(shared)?.labelModel.details.at(-1), 'Heard by your receiver · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed', ); assert.ok(uat.point, 'a UAT-only aircraft carries the ring'); @@ -776,7 +780,7 @@ test('feed records merge with browser SDR records by ICAO and UAT-only aircraft 'same magenta marker family', ); assert.equal( - uat.gevLabelModel.details.at(-1), + readEntityRecord(uat)?.labelModel.details.at(-1), 'Heard by your receiver · 978 MHz UAT · decoder feed', ); const stats = layer.getStats(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 27489400b..06dce1544 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -370,7 +370,7 @@ export function createMotion({ * model is actually rendering with when the model owns the visual, otherwise the cached * dead-reckoned position. Mirror of flights.js; see that copy for the full rationale. * Reads the modelMatrix the tracked-model update already wrote this frame: no sampling, - * no `_modelDisplayPosition` from postRender, and `gevDisplayPosition` keeps its + * no `_modelDisplayPosition` from postRender, and the entity record's `displayPosition` keeps its * follow-camera anti-jitter contract untouched. */ diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 5ad60adf9..16252caca 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -26,6 +26,7 @@ import { TRACKED_ICON_COLOR, } from './policy.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; +import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; export function createTracking({ flightState, @@ -65,7 +66,7 @@ export function createTracking({ const info = flightState.records.data.get(icao24); if (!bb?.position || !info) return false; if (flightState._trackedEntity) - flightState._trackedEntity.gevSelectionOrigin = origin; + entityRecord(flightState._trackedEntity).selectionOrigin = origin; _emitAwarenessEvent('gev:awareness-subject-selected', { layerId: 'military', id: icao24, @@ -252,10 +253,11 @@ export function createTracking({ function _updateTrackedLabelModel(icao24) { if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) return; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(flightState.records.data.get(icao24), icao24), - '#ffd166', - ); + entityRecord(flightState._trackedEntity).labelModel = + trackedLabelModelFromText( + _buildTrackedLabel(flightState.records.data.get(icao24), icao24), + '#ffd166', + ); refreshTrackedReadout(flightState._trackedEntity); // The readout and the context slot describe the same contact — refresh them // together so voice never narrates a fix the card has already replaced. @@ -966,12 +968,10 @@ export function createTracking({ }, false), }, }); - flightState._trackedEntity.gevSelectionOrigin = origin; - flightState._trackedEntity.gevTrackedId = `military:${icao24}`; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(info, icao24), - '#ffd166', - ); + entityRecord(flightState._trackedEntity).selectionOrigin = origin; + entityRecord(flightState._trackedEntity).trackedId = `military:${icao24}`; + entityRecord(flightState._trackedEntity).labelModel = + trackedLabelModelFromText(_buildTrackedLabel(info, icao24), '#ffd166'); // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is // far too tight (the user had to scroll out to read the plane). Give the entity a @@ -990,12 +990,12 @@ export function createTracking({ // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). - flightState._trackedEntity.gevDisplayPosition = + entityRecord(flightState._trackedEntity).displayPosition = parts.motion._trackedDisplayCached; - // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the + // Separate accessor on purpose (mirror of flights.js): the entity record's `displayPosition` keeps the // follow-camera anti-jitter contract; presentation that must weld to the aircraft you - // can see reads `gevVisualPosition`. - flightState._trackedEntity.gevVisualPosition = + // can see reads the entity record's `visualPosition`. + entityRecord(flightState._trackedEntity).visualPosition = parts.motion._trackedVisualCached; refreshTrackedReadout(flightState._trackedEntity); flightState._viewer.camera.cancelFlight(); @@ -1060,8 +1060,8 @@ export function createTracking({ ) { _clearTracking(true, { origin: - flightState._viewer.trackedEntity?.gevSelectionOrigin || - 'programmatic', + readEntityRecord(flightState._viewer.trackedEntity) + ?.selectionOrigin || 'programmatic', }); } }); diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 7ef8816c6..9715d9166 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +import { readEntityRecord } from '../../data/entityRecords.js'; export function createInteraction({ state: layerState, @@ -36,8 +37,8 @@ export function createInteraction({ ) { parts.tracking._clearTracking(true, { origin: - layerState._viewer.trackedEntity?.gevSelectionOrigin || - 'programmatic', + readEntityRecord(layerState._viewer.trackedEntity) + ?.selectionOrigin || 'programmatic', }); } }); diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 2b2624ff7..c39ed0aac 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -9,6 +9,7 @@ import { TRACK_VIEW_FROM_HIGH_SCALE, TRACK_VIEW_FROM_LEO, } from './policy.js'; +import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; export function createTracking({ state: layerState, services, parts, source }) { const { clearFocusTarget, publishFocusTargetFromCachedPosition } = @@ -384,7 +385,7 @@ export function createTracking({ state: layerState, services, parts, source }) { : t('DOCKED · {name}', { name: companions[0] }), ); } - const current = layerState._trackedEntity.gevLabelModel; + const current = readEntityRecord(layerState._trackedEntity)?.labelModel; // Compare the WHOLE detail array: comparing only `details[0]` swallowed any // change confined to the companions line, so the card would never republish. const unchanged = @@ -392,7 +393,7 @@ export function createTracking({ state: layerState, services, parts, source }) { current?.details?.length === details.length && details.every((line, index) => current.details[index] === line); if (unchanged) return; - layerState._trackedEntity.gevLabelModel = { + entityRecord(layerState._trackedEntity).labelModel = { title, details, accent: '#ffd84d', @@ -456,9 +457,10 @@ export function createTracking({ state: layerState, services, parts, source }) { disableDepthTestDistance: Number.POSITIVE_INFINITY, }, }); - layerState._trackedEntity.gevSelectionOrigin = origin; - layerState._trackedEntity.gevTrackedId = `satellites:${noradId}`; - layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached; + entityRecord(layerState._trackedEntity).selectionOrigin = origin; + entityRecord(layerState._trackedEntity).trackedId = `satellites:${noradId}`; + entityRecord(layerState._trackedEntity).displayPosition = + _trackedDisplayCached; _updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null); _emitAwarenessEvent('gev:awareness-subject-selected', { diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index e2a4c6240..30ee4d7f1 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -84,6 +84,7 @@ import { setDetectionTuning, setMode as setDetectionMode, } from '../data/detection.js'; +import { withEntityRecord } from '../data/entityRecords.js'; /** * @module worldOverlayAllocation.worker @@ -650,16 +651,19 @@ function buildPhase3TrackedWorkload(count) { ({ selected }) => selected, ); const selected = vesselRegistration.entries.splice(selectedIndex, 1)[0]; - const trackedEntity = { - gevTrackedId: 'flights:allocation-probe', - gevDisplayPosition: selected.position, - gevLabelModel: { - // i18n-ignore: benchmark fixture - title: 'ALLOC01', - details: ['FL350 · 451 kts', 'TEST AIR · A320'], - accent: '#39d0ff', + const trackedEntity = withEntityRecord( + {}, + { + trackedId: 'flights:allocation-probe', + displayPosition: selected.position, + labelModel: { + // i18n-ignore: benchmark fixture + title: 'ALLOC01', + details: ['FL350 · 451 kts', 'TEST AIR · A320'], + accent: '#39d0ff', + }, }, - }; + ); const tracked = createTrackedOverlayEntry(trackedEntity); tracked.horizonCull = false; vesselRegistration.options = { diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 8b7857784..21a40b867 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -1,13 +1,14 @@ import * as Cesium from 'cesium'; import { isPickedWorldPosition } from '../data/scenePick.js'; import { t } from '../i18n/index.js'; +import { readEntityRecord } from '../data/entityRecords.js'; function trackedTarget(viewer) { const entity = viewer.trackedEntity; // Read the visual's sample first; sampling a flight CallbackProperty twice // can advance dead reckoning ahead of the displayed aircraft. return ( - entity?.gevDisplayPosition?.() || + readEntityRecord(entity)?.displayPosition?.() || entity?.position?.getValue(viewer.clock?.currentTime) ); } diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index 68ba5ac49..6a5820a8f 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -12,6 +12,7 @@ import { toggleCameraTilt, } from './cameraOrientationControls.js'; import { NavigationController } from './navigationController.js'; +import { entityRecord } from '../data/entityRecords.js'; function createViewer({ cameraPosition, target, pickPosition = null } = {}) { const calls = []; @@ -523,7 +524,7 @@ for (const [label, altitude, range] of [ position: new Cesium.CallbackPositionProperty(() => target, false), trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, }); - entity.gevDisplayPosition = () => target; + entityRecord(entity).displayPosition = () => target; viewer.trackedEntity = entity; const follow = new Cesium.EntityView(entity, viewer.scene); follow.update(viewer.clock.currentTime); diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index 216200596..6a4c0d13a 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { CockpitViewController } from './cockpitController.js'; +import { withEntityRecord } from '../data/entityRecords.js'; function environment() { const original = { document: globalThis.document, window: globalThis.window }; @@ -95,9 +96,15 @@ test('aircraft identity is read from the supplied layer instances', () => { militaryFlightsLayer: { getTrackedInfo: () => military }, }, }); - env.viewer.trackedEntity = { gevTrackedId: 'military:def456' }; + env.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: 'military:def456' }, + ); assert.deepEqual(owner.readAircraftInfo(), military); - env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' }; + env.viewer.trackedEntity = withEntityRecord( + {}, + { trackedId: 'flights:abc123' }, + ); assert.deepEqual(owner.readAircraftInfo(), civilian); owner.dispose(); } finally { diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index b71889b4c..3c6168800 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -4,6 +4,7 @@ import { cockpitEntryAllowed } from '../contextModePolicy.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; import { enterCockpitWithTracking } from '../cockpitTracking.js'; import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; +import { readEntityRecord } from '../data/entityRecords.js'; /** Own Cockpit entry/rollback, readouts and the single Display portal. */ export class CockpitCoordinator { @@ -90,9 +91,9 @@ export class CockpitCoordinator { onEntered: () => this.enterPanels(), onExited: () => this.exitPanels(), restoreTrackingFrame: (entity) => { - const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split( - ':', - ); + const [layerId, ...idParts] = String( + readEntityRecord(entity)?.trackedId || '', + ).split(':'); const trackedId = idParts.join(':'); if (!trackedId) return false; if (layerId === 'flights') diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index f308b0d2f..f7f4a7e6e 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -5,6 +5,7 @@ import { normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; +import { readEntityRecord } from '../data/entityRecords.js'; export function readAircraftInfo() { // In cockpit mode the controller takes the entity off `viewer.trackedEntity` @@ -13,7 +14,7 @@ export function readAircraftInfo() { return resolveTrackedAircraftInfo({ civilian: this.services.flightsLayer.getTrackedInfo?.() || null, military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null, - trackedId: trackedEntity?.gevTrackedId || '', + trackedId: readEntityRecord(trackedEntity)?.trackedId || '', }); } diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index a391a6ba7..910993f12 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -10,6 +10,7 @@ import { nextSheet, sheetForPanelId, } from './mobileShell.js'; +import { withEntityRecord } from '../data/entityRecords.js'; function element(tag, document) { const node = new EventTarget(); @@ -824,7 +825,7 @@ test('the tab-open callback reports only a successful manual opening', () => { test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => { const calls = []; const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' }; - const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } }; + const viewer = { trackedEntity: withEntityRecord({}, { trackedId: 'flights:abc123' }) }; const entity = viewer.trackedEntity; const enabled = new Set(['flights', 'weather']); const controls = new ContextControls({ diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js index ba5851da6..e9380c17c 100644 --- a/src/ui/shareFollow.js +++ b/src/ui/shareFollow.js @@ -5,6 +5,7 @@ import { followOffsetFromEnu, followOffsetToEnu, } from '../share/follow.js'; +import { readEntityRecord } from '../data/entityRecords.js'; /** * The bridge between the camera following an entity and a share link's thing @@ -39,7 +40,7 @@ export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { const cameraPosition = viewer?.camera?.positionWC; if (!cameraPosition) return null; const tracked = viewer.trackedEntity; - const trackedId = String(tracked?.gevTrackedId || ''); + const trackedId = String(readEntityRecord(tracked)?.trackedId || ''); const separator = trackedId.indexOf(':'); if (separator <= 0) return null; const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator)); diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs index d7b747d66..7347ce1cf 100644 --- a/src/ui/shareLinks.test.mjs +++ b/src/ui/shareLinks.test.mjs @@ -18,6 +18,7 @@ import { followCameraFor, followOffsetToEnu, } from '../share/viewCodec.js'; +import { entityRecord, withEntityRecord } from '../data/entityRecords.js'; const NOW_MS = Date.now(); const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; @@ -278,10 +279,7 @@ test('a thing view turns its layer on, follows by id and frames the shared offse this.position = Cesium.Cartesian3.clone(offset); }, }); - entity = { - gevTrackedId: 'flights:a1b2c3', - gevDisplayPosition: () => target, - }; + entity = withEntityRecord({}, { trackedId: 'flights:a1b2c3', displayPosition: () => target }); const parsed = page.manager._parseShareCode(aircraftCode()); assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']); @@ -452,10 +450,7 @@ test('the followed entity is read in the share format, and its offset round-trip const viewer = { camera: { positionWC: cameraPosition }, clock: { currentTime: Cesium.JulianDate.now() }, - trackedEntity: { - gevTrackedId: 'military:ae1234', - gevDisplayPosition: () => target, - }, + trackedEntity: withEntityRecord({}, { trackedId: 'military:ae1234', displayPosition: () => target }), }; const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS }); assert.equal(subject.kind, 'military_aircraft'); @@ -464,7 +459,7 @@ test('the followed entity is read in the share format, and its offset round-trip assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9); assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3); // A followed entity the share format has no kind for is not a thing view. - viewer.trackedEntity.gevTrackedId = 'local-adsb:abc'; + entityRecord(viewer.trackedEntity).trackedId = 'local-adsb:abc'; assert.equal(readShareFollowSubject({ viewer }), null); // Nor is a camera that follows nothing (a selected vessel is not followed). viewer.trackedEntity = undefined; @@ -475,7 +470,7 @@ test('a shared offset on the far side swings round, never jumping through the en const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); const listeners = []; const looks = []; - const entity = { gevDisplayPosition: () => target }; + const entity = withEntityRecord({}, { displayPosition: () => target }); const viewer = { clock: { currentTime: Cesium.JulianDate.now() }, scene: { diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 12f09c18b..966318f84 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -4,6 +4,7 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { t, msg, formatDate } from '../i18n/index.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +import { readEntityRecord } from '../data/entityRecords.js'; /** How long a shared vessel may take to appear in the vessel feed. */ export const SHARED_VESSEL_WAIT_MS = 120_000; @@ -424,7 +425,7 @@ export class ShareRestoration { // a long link's follow keeps the app's own framing. if ( !view?.lastKnown || - String(entity?.gevTrackedId || '') !== + String(readEntityRecord(entity)?.trackedId || '') !== `${result.layerId}:${result.targetId}` ) return; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f11abc5ae..96a24edc6 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -23,6 +23,7 @@ import { MAP_STACKS } from '../mapStackController.js'; import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; import { presentResult } from './resultDisplay.js'; import { attachVoiceResult } from './speech.js'; +import { withEntityRecord } from '../data/entityRecords.js'; test('every live basemap is reachable by its own id — no enum value without a voice alias', () => { // B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum) @@ -474,7 +475,7 @@ test('voice Stop Tracking reports exact layers whose active or durable clear fai getAll: () => [], }; const viewer = { - trackedEntity: { gevTrackedId: 'flights:active' }, + trackedEntity: withEntityRecord({}, { trackedId: 'flights:active' }), scene: { canvas: { addEventListener() {}, removeEventListener() {} }, preRender: { addEventListener() {} },