mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Scope tracked readouts and selection context to each scene
Give each scene attachment its own tracked readout, follow subscription, selected context, active card identity and label-subject cache. Bind layer context publishers to that attachment and preserve the page selection slot and existing selection and awareness event payloads. Mirror a clear to the page only when it clears the page selection. Capture display context services so retired callbacks cannot publish into a replacement attachment. Route aircraft, satellite, Local ADS-B and ALPR label refreshes and annotation card lookup to the attached readout. Remove Local ADS-B contexts before dropping its outgoing renderer, and mirror context pruning to the page. Carry the retained page selection through the layer session when an attachment is released. Adopt it only if the page still selects the same record, seed the incoming readout on initialization, and replace its retained carrier when the new display registers its marker. Keep the original page selection timestamp and avoid a duplicate page selection event. A clear between attachments wins, and page teardown still clears the selection. Seed the incoming readout from that retained record without validating the outgoing display's visibility: FIRMS waits for camera restoration before registering its new display. Keep a matching selected fire in the refresh set before pruning top-N contexts, so weaker selected detections retain the page selection too. Preserve Cesium's label text, selected-card policy, visual anchors and follow-camera anti-jitter. Navara retains the same shared behavior. There is no intended user-visible change on either engine. Tests cover simultaneous follows, identical selection IDs, independent refresh and clear, stale notifications, destruction, parked reattachment and real layer publication on the fake and headless Cesium engines. The production aircraft tests also cover grounded fleet and tracked models, the display floor on both sides of the zoom boundary, follow-label refresh and cached position reads. The fire regression exercises the application scene handoff, delayed viewport resume, top-N pruning, unchanged selection timestamp and deliberate clear on both engines. These fixes preserve the reference Cesium selection behavior. All eleven coupling counts remain unchanged; none fell or grew. The counts remain 0 and 189 renderer lines, 3 external Cesium import files, 891 viewer lines, 1 Navara kind check, 0 engine-sniff calls, 3 extension reads, 1 ground pass, 3 SDK loads, 4 screen-handler files and 32 engine-name lines. Checked in the browser, keyless: the overlay baselines on both engines, the ALPR and FIRMS fire-camera switches, and the layer handoff, Local ADS-B, Contacts, space, infrastructure, CCTV, urban, transit, directions and annotation engine switches pass. The tracking regression passes every check that passes before this change.
This commit is contained in:
1 parent
3814221cbc
commit
c1e857f798
32 files changed
+1751
-414
No files matched your search
+13
-2
@@ -290,8 +290,19 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
models resolve their attached engine, and the shell reconnects its action
|
||||
subscription after switching. Destroyed services and old lane handles stay
|
||||
inert; a parked engine receives a new host. Shared font measurements live
|
||||
until the last host detaches. The tracked readout remains page-owned and
|
||||
publishes into the attached overlay; its event isolation is separate work.
|
||||
until the last host detaches.
|
||||
- **Scene tracked readouts.** `scene.services.trackedReadout` owns the follow
|
||||
subscription, selected card, active entry ID and follow-label cache for one
|
||||
attachment. It publishes only into that scene's overlay. Selection records
|
||||
and notifications belong to `scene.services.context`; publishers resolve
|
||||
their attached engine. The existing page context slot and selection events
|
||||
remain available to page consumers with the same payloads. Destroyed readouts
|
||||
and context services stay inert; parked reattachment creates fresh services.
|
||||
Engine handoff carries the retained page selection into the incoming context;
|
||||
rebuilding its marker updates the readout without another page selection event.
|
||||
Readout initialization leaves the selection intact while its outgoing display
|
||||
is hidden and its incoming layer waits for camera restoration.
|
||||
Label text, live visual anchors and follow-camera anti-jitter are unchanged.
|
||||
- **The session owns the state.** Catalog, layer models, sources, selection,
|
||||
camera mode and URL state outlive a switch. A layer that is a session model
|
||||
gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt,
|
||||
|
||||
@@ -587,8 +587,30 @@ The module helper functions take the engine first. Retained layer models use
|
||||
`init()`. Destruction is terminal, including before initialization; a parked
|
||||
engine needs fresh scene services. An old paint-lane handle cannot mutate its
|
||||
replacement. Font measurements are shared until the last live host releases
|
||||
them. The tracked-readout bridge remains page-owned and publishes into its
|
||||
attached engine's overlay.
|
||||
them.
|
||||
|
||||
`scene.services.trackedReadout` bridges the follow handle's label and selected
|
||||
ALPR or Local ADS-B card into this overlay. `init()` subscribes to that engine's
|
||||
follow changes and that attachment's context events; `refresh(entity)` republishes
|
||||
changed label text, and `activeId()` resolves the card for annotation placement.
|
||||
Positions stay live cached visual anchors. `trackedReadoutFor(engine)` resolves
|
||||
the service; destruction removes its listeners and clears only its own source.
|
||||
|
||||
`scene.services.context` owns selection records and notifications. Layer publishers
|
||||
use `bindContextStore` with their current engine, or a context service captured by
|
||||
their display. It mirrors records to the page context slot and preserves the
|
||||
`gev:entity-selected`, `gev:entity-selection-cleared` and aircraft awareness event
|
||||
payloads for page consumers. A clear reaches the page only when that attachment
|
||||
owns the page's selected record. Readouts listen only to their scene's event target.
|
||||
Identical IDs in different scenes cannot share a card or clear one another's
|
||||
selection. Retired context services cannot publish into a replacement attachment.
|
||||
An engine handoff carries the page-selected record through the layer session.
|
||||
The incoming context adopts it only while the page still selects that record;
|
||||
a clear between attachments wins. Rebuilt markers replace the retained carrier
|
||||
in the incoming readout without announcing another page selection.
|
||||
Readout initialization reads the retained record without testing the outgoing
|
||||
carrier's visibility; layers rebuild their carriers after camera restoration.
|
||||
The page chrome and detection controller still have page lifetimes.
|
||||
|
||||
## Render effects
|
||||
|
||||
|
||||
@@ -1706,5 +1706,8 @@
|
||||
"src/globe/cesium/annotations/worldAnnotationRenderer.js",
|
||||
"src/globe/cesium/annotations/screenAnnotationRenderer.js",
|
||||
"src/globe/navara/windFlow.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js",
|
||||
"src/data/trackedReadoutScenes.test.mjs",
|
||||
"src/data/trackedAircraftScenes.test.mjs"
|
||||
]
|
||||
@@ -1237,7 +1237,9 @@
|
||||
"src/overlays/worldOverlay.js",
|
||||
"src/overlays/worldOverlayDraw.js",
|
||||
"src/overlays/worldOverlayTokens.js",
|
||||
"src/time/clock.js"
|
||||
"src/time/clock.js",
|
||||
"src/data/contextStore.js",
|
||||
"src/data/trackedReadout.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
@@ -1464,7 +1466,8 @@
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/aircraft/qaDrawn.js"
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1532,7 +1535,8 @@
|
||||
"src/time/clock.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/layers/aircraft/qaDrawn.js"
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1617,7 +1621,8 @@
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1700,7 +1705,8 @@
|
||||
"src/geo/",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1743,7 +1749,8 @@
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
@@ -3634,7 +3641,8 @@
|
||||
"src/globe/navara/meshes.js",
|
||||
"src/globe/exclusivePointer.js",
|
||||
"src/globe/cesium/followOffset.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4399,7 +4407,8 @@
|
||||
"src/globe/meshes.js",
|
||||
"src/globe/exclusivePointer.js",
|
||||
"src/globe/cesium/followOffset.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -5170,7 +5179,9 @@
|
||||
"src/globe/meshes.js",
|
||||
"src/globe/exclusivePointer.js",
|
||||
"src/data/selectionDispatcher.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/contextStore.js",
|
||||
"src/data/trackedReadout.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -5741,7 +5752,8 @@
|
||||
"src/layers/wildlifeSightings/display.js",
|
||||
"src/data/spriteOrder.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* @property {Map<string, {entry: object | null, enabled: boolean, durable: boolean | undefined}> | null} handoffLayers Switch coordinator: outgoing layer intentions.
|
||||
* @property {boolean} restoringView Switch coordinator: incoming address restoration.
|
||||
* @property {() => boolean} switching Switch coordinator: voice waits while busy.
|
||||
* @property {object | null} selectionContext Scene factory: selected record carried to the next attachment.
|
||||
* @property {object | null} contextScene Shell: retained Context state.
|
||||
*/
|
||||
|
||||
@@ -40,6 +41,7 @@ export function createLayerSession() {
|
||||
restoringView: false,
|
||||
switching: () => false,
|
||||
contextScene: null,
|
||||
selectionContext: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as context from '../data/contextStore.js';
|
||||
import { worldOverlayFor } from '../overlays/worldOverlay.js';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from '../overlays/worldOverlay.js';
|
||||
/** Existing standalone application operations, shared by its local layers. */
|
||||
export const localGeoJsonServices = Object.freeze({
|
||||
context,
|
||||
overlayHost: Object.freeze({
|
||||
forEngine: worldOverlayFor,
|
||||
clearSource: clearOverlaySource,
|
||||
|
||||
+11
-2
@@ -1,3 +1,4 @@
|
||||
import { retainsLayerSession } from './layerSession.js';
|
||||
import { createSceneServices } from '../data/sceneServices.js';
|
||||
import { releaseCameraVerbs } from '../cameraVerbs.js';
|
||||
import { createApplicationOperations } from './operations.js';
|
||||
@@ -76,8 +77,16 @@ export async function createApplicationScene({
|
||||
const { viewer, engine, tileset, mapStackController } = rendererCache
|
||||
? await rendererCache.acquire(options.engineKind, build, defer)
|
||||
: await build({ defer });
|
||||
const services = createSceneServices(engine);
|
||||
defer(services.destroy);
|
||||
const services = createSceneServices(engine, {
|
||||
retainedSelection: layerSession?.selectionContext,
|
||||
});
|
||||
if (layerSession) layerSession.selectionContext = null;
|
||||
defer(() => {
|
||||
const retained = services.destroy({
|
||||
retainSelection: retainsLayerSession(layerSession),
|
||||
});
|
||||
if (layerSession) layerSession.selectionContext = retained;
|
||||
});
|
||||
if (layerSession) {
|
||||
// DEM cells belong to the session. Mesh samples came from the outgoing
|
||||
// scene, including when the incoming canvas was warm in the cache.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { forEngine as contextForEngine } from './contextStore.js';
|
||||
// src/data/aisLiveVessels.test.mjs
|
||||
// Focused tests for the AIS feed-status derivation helper (Batch 10, finding H3/AIS)
|
||||
// and the vessel vertical-datum seam (2026-07-27 datum pass — see
|
||||
@@ -985,6 +986,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
windowTarget,
|
||||
reinstalledHandlers,
|
||||
interactionHandlerFactory,
|
||||
engine: wireEngine,
|
||||
renderer,
|
||||
cleanup() {
|
||||
stopReadout();
|
||||
@@ -1100,12 +1102,12 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
|
||||
const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 });
|
||||
const cleared = [];
|
||||
try {
|
||||
registerEntityContext(harness.record, {
|
||||
contextForEngine(harness.engine).registerEntityContext(harness.record, {
|
||||
id: `ais-${harness.record.mmsi}`,
|
||||
layerId: 'ais-live-vessels',
|
||||
label: harness.record.name,
|
||||
});
|
||||
selectEntityContext(harness.record);
|
||||
contextForEngine(harness.engine).selectEntityContext(harness.record);
|
||||
harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => {
|
||||
cleared.push(event.detail);
|
||||
});
|
||||
@@ -1127,12 +1129,12 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
|
||||
const harness = installWireHarness(undefined);
|
||||
const cleared = [];
|
||||
try {
|
||||
registerEntityContext(harness.record, {
|
||||
contextForEngine(harness.engine).registerEntityContext(harness.record, {
|
||||
id: `ais-${harness.record.mmsi}`,
|
||||
layerId: 'ais-live-vessels',
|
||||
label: harness.record.name,
|
||||
});
|
||||
selectEntityContext(harness.record);
|
||||
contextForEngine(harness.engine).selectEntityContext(harness.record);
|
||||
harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => {
|
||||
cleared.push(event.detail);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Resolve a retained layer's context calls against its current attachment. */
|
||||
export function bindContextStore(context, getEngine) {
|
||||
if (!context?.forEngine) return context;
|
||||
return Object.fromEntries(
|
||||
Object.entries(context)
|
||||
.filter(([, value]) => typeof value === 'function')
|
||||
.map(([name]) => [
|
||||
name,
|
||||
(...args) => context.forEngine(getEngine())?.[name]?.(...args),
|
||||
]),
|
||||
);
|
||||
}
|
||||
+346
-152
@@ -1,4 +1,7 @@
|
||||
import { sceneServicesFor } from './sceneServices.js';
|
||||
|
||||
const STORE_KEY = '__gevContextStore';
|
||||
const contextOwners = new WeakMap();
|
||||
|
||||
function createStore() {
|
||||
return {
|
||||
@@ -28,166 +31,357 @@ function hasContextHost() {
|
||||
return typeof window !== 'undefined' && Boolean(window);
|
||||
}
|
||||
|
||||
export function registerEntityContext(entity, metadata) {
|
||||
if (!entity || !metadata?.id) return null;
|
||||
const store = getContextStore();
|
||||
const record = {
|
||||
...metadata,
|
||||
entity,
|
||||
updatedAt: Date.now(),
|
||||
/** Selection records and notifications for one attachment, mirrored to page consumers. */
|
||||
export function createSceneContext(retainedSelection = null) {
|
||||
return createContext(true, retainedSelection);
|
||||
}
|
||||
|
||||
function createContext(scoped = false, retainedSelection = null) {
|
||||
const local = scoped ? createStore() : null;
|
||||
const events = new EventTarget();
|
||||
let destroyed = false;
|
||||
const storeForContext = () => local ?? getContextStore();
|
||||
const available = () => !destroyed && (scoped || hasContextHost());
|
||||
|
||||
function dispatch(event, publishPage = true) {
|
||||
if (destroyed) return;
|
||||
if (scoped)
|
||||
events.dispatchEvent(
|
||||
new CustomEvent(event.type, { detail: event.detail }),
|
||||
);
|
||||
if (publishPage && hasContextHost()) window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function ownsPageSelection(store) {
|
||||
if (!scoped) return true;
|
||||
if (!hasContextHost()) return false;
|
||||
const page = getContextStore();
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
return Boolean(
|
||||
record && page.entities.get(page.selectedEntityId) === record,
|
||||
);
|
||||
}
|
||||
|
||||
function clearSelection(store) {
|
||||
if (scoped && hasContextHost()) {
|
||||
const page = getContextStore();
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (record && page.entities.get(page.selectedEntityId) === record) {
|
||||
page.selectedEntityId = null;
|
||||
page.selectedAt = null;
|
||||
}
|
||||
}
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
}
|
||||
|
||||
function deleteRecord(store, id) {
|
||||
if (scoped && hasContextHost()) {
|
||||
const page = getContextStore();
|
||||
if (page.entities.get(id) === store.entities.get(id)) {
|
||||
page.entities.delete(id);
|
||||
if (page.selectedEntityId === id) {
|
||||
page.selectedEntityId = null;
|
||||
page.selectedAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
store.entities.delete(id);
|
||||
}
|
||||
|
||||
function registerEntityContext(entity, metadata) {
|
||||
if (destroyed || !entity || !metadata?.id) return null;
|
||||
const store = storeForContext();
|
||||
const record = {
|
||||
...metadata,
|
||||
entity,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
entity.__gevContextId = metadata.id;
|
||||
if (scoped) contextOwners.set(entity, context);
|
||||
const previous = store.entities.get(metadata.id);
|
||||
store.entities.set(metadata.id, record);
|
||||
if (scoped && hasContextHost()) {
|
||||
const page = getContextStore();
|
||||
if (
|
||||
page.selectedEntityId !== metadata.id ||
|
||||
page.entities.get(metadata.id) === previous
|
||||
)
|
||||
page.entities.set(metadata.id, record);
|
||||
}
|
||||
if (
|
||||
scoped &&
|
||||
store.selectedEntityId === metadata.id &&
|
||||
previous?.entity !== entity
|
||||
)
|
||||
dispatch(
|
||||
new CustomEvent('gev:entity-selected', { detail: record }),
|
||||
false,
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
function selectEntityContext(entity) {
|
||||
if (destroyed) return;
|
||||
const store = storeForContext();
|
||||
const contextId = entity?.__gevContextId;
|
||||
if (!contextId || !store.entities.has(contextId)) return null;
|
||||
store.selectedEntityId = contextId;
|
||||
store.selectedAt = Date.now();
|
||||
if (scoped && hasContextHost()) {
|
||||
const page = getContextStore();
|
||||
page.entities.set(
|
||||
store.selectedEntityId,
|
||||
store.entities.get(store.selectedEntityId),
|
||||
);
|
||||
page.selectedEntityId = store.selectedEntityId;
|
||||
page.selectedAt = store.selectedAt;
|
||||
}
|
||||
const record = store.entities.get(contextId);
|
||||
dispatch(new CustomEvent('gev:entity-selected', { detail: record }));
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the live subject of a TRACKING layer (aircraft) into the shared
|
||||
* selection slot.
|
||||
*
|
||||
* Selecting a contact is a click-selection like any other — voice's
|
||||
* `scope:'selected'` and the Cockpit's selected-target lookup both read this
|
||||
* one slot, so a tracking layer that stays out of it is invisible to them
|
||||
* even while its readout card is on screen.
|
||||
*
|
||||
* Deliberately does NOT dispatch `gev:entity-selected`: tracking layers own a
|
||||
* separate publication lane (`gev:awareness-subject-selected`) that the
|
||||
* readout and Contacts panel already consume, and a second event for the same
|
||||
* click would make those two surfaces fight over one subject.
|
||||
*
|
||||
* A tracking layer has at most one subject, so any earlier record it left
|
||||
* behind is dropped — its feed refreshes continuously and a frozen snapshot
|
||||
* must never reach the visible-entity scan.
|
||||
*
|
||||
* @param {object} metadata Record fields; `id` and `layerId` required.
|
||||
* @returns {object|null} The stored record, or null when identity is missing.
|
||||
*/
|
||||
function selectTrackedSubjectContext(metadata) {
|
||||
if (!available() || !metadata?.id || !metadata?.layerId) return null;
|
||||
const store = storeForContext();
|
||||
const id = String(metadata.id);
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === metadata.layerId && key !== id)
|
||||
deleteRecord(store, key);
|
||||
}
|
||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||
const record = registerEntityContext(carrier, { ...metadata, id });
|
||||
if (!record) return null;
|
||||
store.selectedEntityId = id;
|
||||
store.selectedAt = Date.now();
|
||||
if (scoped && hasContextHost()) {
|
||||
const page = getContextStore();
|
||||
page.entities.set(
|
||||
store.selectedEntityId,
|
||||
store.entities.get(store.selectedEntityId),
|
||||
);
|
||||
page.selectedEntityId = store.selectedEntityId;
|
||||
page.selectedAt = store.selectedAt;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a tracking layer's subject in place WITHOUT claiming the selection.
|
||||
*
|
||||
* The per-poll position/identity refresh must not resurrect a subject the
|
||||
* operator has since replaced by clicking something else.
|
||||
* @param {object} metadata Record fields; `id` and `layerId` required.
|
||||
* @returns {object|null} The stored record, or null when it is not registered.
|
||||
*/
|
||||
function refreshTrackedSubjectContext(metadata) {
|
||||
if (!available() || !metadata?.id || !metadata?.layerId) return null;
|
||||
const store = storeForContext();
|
||||
const id = String(metadata.id);
|
||||
const existing = store.entities.get(id);
|
||||
if (!existing || existing.layerId !== metadata.layerId) return null;
|
||||
return registerEntityContext(existing.entity, { ...metadata, id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a tracking layer's subject when the operator deselects it.
|
||||
*
|
||||
* Pairs with {@link selectTrackedSubjectContext} and stays event-free for the
|
||||
* same reason: `gev:awareness-subject-cleared` is the tracking layers' lane.
|
||||
* @param {string} layerId Owning layer.
|
||||
* @returns {void}
|
||||
*/
|
||||
function clearTrackedSubjectContext(layerId) {
|
||||
if (!available() || !layerId) return;
|
||||
const store = storeForContext();
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === layerId) deleteRecord(store, key);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
clearSelection(store);
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedEntityContext({ dataManager = null } = {}) {
|
||||
const store = storeForContext();
|
||||
if (!store.selectedEntityId) return null;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (!record || !isContextRecordActive(record, dataManager)) {
|
||||
clearSelection(store);
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the selected context record owned by a layer.
|
||||
* @param {string} layerId Owning layer.
|
||||
* @param {object} [options] Clear origin.
|
||||
* @param {boolean} [options.evicted=false] The record aged out of its feed
|
||||
* rather than being deselected. Readouts that stay on screen hold their
|
||||
* last-known values for an eviction and only tear down on a deliberate clear.
|
||||
*/
|
||||
function clearSelectedEntityContextForLayer(
|
||||
layerId,
|
||||
{ evicted = false } = {},
|
||||
) {
|
||||
if (destroyed) return;
|
||||
const store = storeForContext();
|
||||
if (!store.selectedEntityId) return;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (record?.layerId === layerId) {
|
||||
const publishPage = ownsPageSelection(store);
|
||||
clearSelection(store);
|
||||
dispatch(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}),
|
||||
publishPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove obsolete context records when a viewport-scoped layer refreshes.
|
||||
* Optional retainIds keeps surviving records and their current selection intact.
|
||||
*/
|
||||
function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
||||
if (destroyed) return;
|
||||
const store = storeForContext();
|
||||
const publishPage = ownsPageSelection(store);
|
||||
for (const [id, record] of store.entities) {
|
||||
if (record?.layerId === layerId && !retainIds?.has(id))
|
||||
deleteRecord(store, id);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
clearSelection(store);
|
||||
// A viewport refresh dropped the record out from under the selection —
|
||||
// the user did not deselect anything.
|
||||
dispatch(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}),
|
||||
publishPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
getContextStore: storeForContext,
|
||||
events,
|
||||
publishAwareness(type, detail) {
|
||||
dispatch(new CustomEvent(type, { detail }));
|
||||
},
|
||||
registerEntityContext,
|
||||
removeEntityContext(id) {
|
||||
if (!destroyed) deleteRecord(storeForContext(), id);
|
||||
},
|
||||
selectEntityContext,
|
||||
selectTrackedSubjectContext,
|
||||
refreshTrackedSubjectContext,
|
||||
clearTrackedSubjectContext,
|
||||
getSelectedEntityContext,
|
||||
clearSelectedEntityContextForLayer,
|
||||
removeEntityContextsForLayer,
|
||||
destroy({ retainSelection = false } = {}) {
|
||||
if (destroyed) return null;
|
||||
const retained =
|
||||
retainSelection && local && ownsPageSelection(local)
|
||||
? local.entities.get(local.selectedEntityId)
|
||||
: null;
|
||||
destroyed = true;
|
||||
if (local) {
|
||||
for (const id of local.entities.keys()) {
|
||||
const entity = local.entities.get(id).entity;
|
||||
if (contextOwners.get(entity) === context)
|
||||
contextOwners.delete(entity);
|
||||
if (id !== retained?.id) deleteRecord(local, id);
|
||||
}
|
||||
local.entities.clear();
|
||||
clearSelection(local);
|
||||
}
|
||||
return retained;
|
||||
},
|
||||
};
|
||||
entity.__gevContextId = metadata.id;
|
||||
store.entities.set(metadata.id, record);
|
||||
return record;
|
||||
// Only an explicit handoff can adopt a page selection; a background scene
|
||||
// never takes it merely because it registers the same id.
|
||||
if (retainedSelection && hasContextHost()) {
|
||||
const page = getContextStore();
|
||||
if (page.entities.get(page.selectedEntityId) === retainedSelection) {
|
||||
local.entities.set(retainedSelection.id, retainedSelection);
|
||||
local.selectedEntityId = retainedSelection.id;
|
||||
local.selectedAt = page.selectedAt;
|
||||
contextOwners.set(retainedSelection.entity, context);
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function selectEntityContext(entity) {
|
||||
const store = getContextStore();
|
||||
const contextId = entity?.__gevContextId;
|
||||
if (!contextId || !store.entities.has(contextId)) return null;
|
||||
store.selectedEntityId = contextId;
|
||||
store.selectedAt = Date.now();
|
||||
const record = store.entities.get(contextId);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selected', { detail: record }),
|
||||
const pageContext = createContext();
|
||||
export const registerEntityContext = (entity, metadata) =>
|
||||
(contextOwners.get(entity) ?? pageContext).registerEntityContext(
|
||||
entity,
|
||||
metadata,
|
||||
);
|
||||
return record;
|
||||
|
||||
// Page actions use the owner of their target, or of the page's current selection.
|
||||
export function selectEntityContext(entity) {
|
||||
const page = getContextStore();
|
||||
const owner =
|
||||
contextOwners.get(entity) ??
|
||||
contextOwners.get(page.entities.get(page.selectedEntityId)?.entity);
|
||||
if (!owner) return pageContext.selectEntityContext(entity);
|
||||
const record = page.entities.get(entity?.__gevContextId);
|
||||
if (record && !contextOwners.has(entity))
|
||||
owner.registerEntityContext(entity, record);
|
||||
return owner.selectEntityContext(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the live subject of a TRACKING layer (aircraft) into the shared
|
||||
* selection slot.
|
||||
*
|
||||
* Selecting a contact is a click-selection like any other — voice's
|
||||
* `scope:'selected'` and the Cockpit's selected-target lookup both read this
|
||||
* one slot, so a tracking layer that stays out of it is invisible to them
|
||||
* even while its readout card is on screen.
|
||||
*
|
||||
* Deliberately does NOT dispatch `gev:entity-selected`: tracking layers own a
|
||||
* separate publication lane (`gev:awareness-subject-selected`) that the
|
||||
* readout and Contacts panel already consume, and a second event for the same
|
||||
* click would make those two surfaces fight over one subject.
|
||||
*
|
||||
* A tracking layer has at most one subject, so any earlier record it left
|
||||
* behind is dropped — its feed refreshes continuously and a frozen snapshot
|
||||
* must never reach the visible-entity scan.
|
||||
*
|
||||
* @param {object} metadata Record fields; `id` and `layerId` required.
|
||||
* @returns {object|null} The stored record, or null when identity is missing.
|
||||
*/
|
||||
export function selectTrackedSubjectContext(metadata) {
|
||||
if (!hasContextHost() || !metadata?.id || !metadata?.layerId) return null;
|
||||
const store = getContextStore();
|
||||
const id = String(metadata.id);
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === metadata.layerId && key !== id)
|
||||
store.entities.delete(key);
|
||||
}
|
||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||
const record = registerEntityContext(carrier, { ...metadata, id });
|
||||
if (!record) return null;
|
||||
store.selectedEntityId = id;
|
||||
store.selectedAt = Date.now();
|
||||
return record;
|
||||
export const removeEntityContext = (...args) =>
|
||||
pageContext.removeEntityContext(...args);
|
||||
export const selectTrackedSubjectContext = (...args) =>
|
||||
pageContext.selectTrackedSubjectContext(...args);
|
||||
export const refreshTrackedSubjectContext = (...args) =>
|
||||
pageContext.refreshTrackedSubjectContext(...args);
|
||||
export const clearTrackedSubjectContext = (...args) =>
|
||||
pageContext.clearTrackedSubjectContext(...args);
|
||||
export const getSelectedEntityContext = (...args) =>
|
||||
pageContext.getSelectedEntityContext(...args);
|
||||
export function clearSelectedEntityContextForLayer(...args) {
|
||||
const page = getContextStore();
|
||||
const owner = contextOwners.get(
|
||||
page.entities.get(page.selectedEntityId)?.entity,
|
||||
);
|
||||
return (owner ?? pageContext).clearSelectedEntityContextForLayer(...args);
|
||||
}
|
||||
export const removeEntityContextsForLayer = (...args) =>
|
||||
pageContext.removeEntityContextsForLayer(...args);
|
||||
|
||||
/**
|
||||
* Refresh a tracking layer's subject in place WITHOUT claiming the selection.
|
||||
*
|
||||
* The per-poll position/identity refresh must not resurrect a subject the
|
||||
* operator has since replaced by clicking something else.
|
||||
* @param {object} metadata Record fields; `id` and `layerId` required.
|
||||
* @returns {object|null} The stored record, or null when it is not registered.
|
||||
*/
|
||||
export function refreshTrackedSubjectContext(metadata) {
|
||||
if (!hasContextHost() || !metadata?.id || !metadata?.layerId) return null;
|
||||
const store = getContextStore();
|
||||
const id = String(metadata.id);
|
||||
const existing = store.entities.get(id);
|
||||
if (!existing || existing.layerId !== metadata.layerId) return null;
|
||||
return registerEntityContext(existing.entity, { ...metadata, id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a tracking layer's subject when the operator deselects it.
|
||||
*
|
||||
* Pairs with {@link selectTrackedSubjectContext} and stays event-free for the
|
||||
* same reason: `gev:awareness-subject-cleared` is the tracking layers' lane.
|
||||
* @param {string} layerId Owning layer.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function clearTrackedSubjectContext(layerId) {
|
||||
if (!hasContextHost() || !layerId) return;
|
||||
const store = getContextStore();
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === layerId) store.entities.delete(key);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSelectedEntityContext({ dataManager = null } = {}) {
|
||||
const store = getContextStore();
|
||||
if (!store.selectedEntityId) return null;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (!record || !isContextRecordActive(record, dataManager)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the selected context record owned by a layer.
|
||||
* @param {string} layerId Owning layer.
|
||||
* @param {object} [options] Clear origin.
|
||||
* @param {boolean} [options.evicted=false] The record aged out of its feed
|
||||
* rather than being deselected. Readouts that stay on screen hold their
|
||||
* last-known values for an eviction and only tear down on a deliberate clear.
|
||||
*/
|
||||
export function clearSelectedEntityContextForLayer(
|
||||
layerId,
|
||||
{ evicted = false } = {},
|
||||
) {
|
||||
const store = getContextStore();
|
||||
if (!store.selectedEntityId) return;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (record?.layerId === layerId) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove obsolete context records when a viewport-scoped layer refreshes.
|
||||
* Optional retainIds keeps surviving records and their current selection intact.
|
||||
*/
|
||||
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
||||
const store = getContextStore();
|
||||
for (const [id, record] of store.entities) {
|
||||
if (record?.layerId === layerId && !retainIds?.has(id))
|
||||
store.entities.delete(id);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
// A viewport refresh dropped the record out from under the selection —
|
||||
// the user did not deselect anything.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
export function forEngine(engine) {
|
||||
return sceneServicesFor(engine)?.context ?? null;
|
||||
}
|
||||
|
||||
export function isContextRecordActive(record, dataManager = null) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSceneServices } from './sceneServices.js';
|
||||
// src/data/flights.test.mjs
|
||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||
// Pure function — no viewer/DOM needed; imported directly.
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
_clearMeshFloorCellsForTest,
|
||||
} from './groundFloor.js';
|
||||
import {
|
||||
_setTrackedOverlayHostForTest,
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
@@ -478,11 +478,12 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
_setTrackedOverlayHostForTest(host);
|
||||
const engine = trackingEngine(viewer);
|
||||
const readoutServices = createSceneServices(engine, { trackedOverlay: host });
|
||||
try {
|
||||
initTrackedReadout(trackingEngine(viewer));
|
||||
initTrackedReadout(engine);
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: (options) => aircraftViewerRenderer({ ...options, engine }),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
@@ -710,8 +711,8 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
} finally {
|
||||
flightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
destroyTrackedReadout();
|
||||
_setTrackedOverlayHostForTest();
|
||||
destroyTrackedReadout(engine);
|
||||
readoutServices.destroy();
|
||||
globalThis.fetch = realFetch;
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSceneServices } from './sceneServices.js';
|
||||
// src/data/militaryFlights.test.mjs
|
||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||
// Pure function — no viewer/DOM needed; imported directly.
|
||||
@@ -13,7 +14,6 @@ import militaryFlightsLayer, {
|
||||
mapAnalystRecord,
|
||||
} from './militaryFlights.js';
|
||||
import {
|
||||
_setTrackedOverlayHostForTest,
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
@@ -406,11 +406,12 @@ test('real military track path creates no native label and publishes every cache
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
_setTrackedOverlayHostForTest(host);
|
||||
const engine = trackingEngine(viewer);
|
||||
const readoutServices = createSceneServices(engine, { trackedOverlay: host });
|
||||
try {
|
||||
initTrackedReadout(trackingEngine(viewer));
|
||||
initTrackedReadout(engine);
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: (options) => aircraftViewerRenderer({ ...options, engine }),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
@@ -649,8 +650,8 @@ test('real military track path creates no native label and publishes every cache
|
||||
} finally {
|
||||
militaryFlightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
destroyTrackedReadout();
|
||||
_setTrackedOverlayHostForTest();
|
||||
destroyTrackedReadout(engine);
|
||||
readoutServices.destroy();
|
||||
globalThis.fetch = realFetch;
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSceneContext } from './contextStore.js';
|
||||
import { createTrackedReadout } from './trackedReadout.js';
|
||||
import { createWorldOverlay } from '../overlays/worldOverlay.js';
|
||||
import { createRenderGovernor } from '../renderGovernor.js';
|
||||
import { createPickRegistry } from './pickRegistry.js';
|
||||
@@ -11,6 +13,17 @@ const scenes = new WeakMap();
|
||||
export function createSceneServices(engine, options = {}) {
|
||||
scenes.get(engine)?.destroy();
|
||||
const overlay = createWorldOverlay(engine);
|
||||
const context = createSceneContext(options.retainedSelection);
|
||||
const trackedReadout = createTrackedReadout(engine, {
|
||||
overlay: options.trackedOverlay ?? overlay,
|
||||
events: context.events,
|
||||
selectedContext: () => {
|
||||
// Retained layers replace hidden outgoing carriers after camera restore.
|
||||
// Initializing a readout must not clear their selection in the meantime.
|
||||
const store = context.getContextStore();
|
||||
return store.entities.get(store.selectedEntityId) ?? null;
|
||||
},
|
||||
});
|
||||
const render = createRenderGovernor(engine);
|
||||
const picking = createPickRegistry();
|
||||
const selection = createSelectionDispatcher(engine, { ...options, picking });
|
||||
@@ -18,15 +31,20 @@ export function createSceneServices(engine, options = {}) {
|
||||
const services = {
|
||||
picking,
|
||||
selection,
|
||||
context,
|
||||
trackedReadout,
|
||||
render,
|
||||
overlay,
|
||||
destroy() {
|
||||
destroy(options) {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
selection.destroy();
|
||||
picking.destroy();
|
||||
trackedReadout.destroy();
|
||||
const retained = context.destroy(options);
|
||||
overlay.destroy();
|
||||
render.destroy();
|
||||
return retained;
|
||||
},
|
||||
};
|
||||
scenes.set(engine, services);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
|
||||
import { createApplicationFlights } from '../app/layers/flights.js';
|
||||
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
|
||||
import {
|
||||
createAircraftDisplay,
|
||||
aircraftAtlasLayout,
|
||||
} from '../layers/aircraft/render/display.js';
|
||||
import { createSceneServices } from './sceneServices.js';
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import * as militaryRegistry from './militaryRegistry.js';
|
||||
import { getSelectedEntityContext } from './contextStore.js';
|
||||
|
||||
const layout = aircraftAtlasLayout();
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const height = (position) => geo.cartographic.fromCartesian(position).height;
|
||||
|
||||
function fakeHarness() {
|
||||
const engine = createFakeEngine();
|
||||
return { engine, frame: () => engine.fake.frame() };
|
||||
}
|
||||
|
||||
for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
|
||||
for (const [layerId, createLayer] of [
|
||||
['flights', createApplicationFlights],
|
||||
['military', createApplicationMilitary],
|
||||
]) {
|
||||
test(`${makeHarness.name}: ${layerId} grounded models, follow readout and display floor share their frame`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
||||
const previous = {
|
||||
window: globalThis.window,
|
||||
document: globalThis.document,
|
||||
fetch: globalThis.fetch,
|
||||
};
|
||||
globalThis.window = new EventTarget();
|
||||
globalThis.document = {
|
||||
body: { classList: { contains: () => false } },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
const h = makeHarness();
|
||||
const { engine } = h;
|
||||
engine.surface.sampleHeightDetailed = () => ({
|
||||
height: 204.5,
|
||||
source: 'tiles',
|
||||
});
|
||||
engine.surface.tilesLoaded = () => true;
|
||||
const publications = [];
|
||||
const services = createSceneServices(engine, {
|
||||
trackedOverlay: {
|
||||
setEntries(source, entries) {
|
||||
publications.push(entries[0]);
|
||||
},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
},
|
||||
});
|
||||
services.trackedReadout.init();
|
||||
let display;
|
||||
let callsign = 'GROUND1';
|
||||
const id = layerId === 'flights' ? 'aaa097' : 'bbb197';
|
||||
let floor;
|
||||
const surface = {
|
||||
...defaultSurface,
|
||||
groundFloor: {
|
||||
...defaultSurface.groundFloor,
|
||||
cachedGroundFloor: () => floor,
|
||||
resolveGroundFloorCellsBounded: async () => {},
|
||||
},
|
||||
meshFloor: { ...defaultSurface.meshFloor, warmMeshFloorCells() {} },
|
||||
};
|
||||
const layer = createLayer({
|
||||
surface,
|
||||
militaryRegistry,
|
||||
source: {
|
||||
label: 'Fixture aircraft',
|
||||
getSnapshot: async () => ({
|
||||
source: 'Fixture aircraft',
|
||||
complete: true,
|
||||
observedAtMs: Date.now(),
|
||||
records: [
|
||||
{
|
||||
id,
|
||||
callsign,
|
||||
latitude: 30.3,
|
||||
longitude: -97.6,
|
||||
onGround: true,
|
||||
baroAltitudeM: 0,
|
||||
courseDeg: 90,
|
||||
speedMps: 0,
|
||||
positionTimeMs: Date.now(),
|
||||
contactTimeMs: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
renderer: {
|
||||
create: ({ engine }) =>
|
||||
(display = createAircraftDisplay(engine, {
|
||||
layerId,
|
||||
drawAtlas: async () => ({
|
||||
canvas: { width: layout.width, height: layout.height },
|
||||
cells: layout.cells,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
layer.disable();
|
||||
layer.destroy();
|
||||
services.destroy();
|
||||
engine.destroy();
|
||||
Object.assign(globalThis, previous);
|
||||
});
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.6, 30.3, 8000),
|
||||
pitch: -Math.PI / 2,
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
layer.enable();
|
||||
layer.setParams({ models3d: true });
|
||||
await display.whenReady();
|
||||
await layer.update();
|
||||
floor = 204.5;
|
||||
async function frames(n = 6) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (h.viewer)
|
||||
h.viewer.scene.frameState.frameNumber =
|
||||
(h.viewer.scene.frameState.frameNumber || 0) + 1;
|
||||
for (const model of h.loads ?? []) model.ready = true;
|
||||
t.mock.timers.tick(250);
|
||||
h.frame();
|
||||
await settle();
|
||||
}
|
||||
}
|
||||
await frames();
|
||||
assert.equal(layer.getAllPositions().length, 1);
|
||||
const fleet = layer.qaDrawn().find((row) => row.id === id);
|
||||
assert.ok(
|
||||
fleet.model?.ready && fleet.model.show,
|
||||
'a grounded fleet contact gets a ready, shown model',
|
||||
);
|
||||
assert.ok(
|
||||
height(fleet.model.position) >= 204.5 &&
|
||||
height(fleet.model.position) < 217,
|
||||
'the fleet model stands on the sampled ground',
|
||||
);
|
||||
assert.equal(layer.trackById(id, { origin: 'user' }), true);
|
||||
await frames();
|
||||
const follow = engine.camera.following();
|
||||
const native = follow.native;
|
||||
assert.equal(services.trackedReadout.activeId(), `${layerId}:${id}`);
|
||||
assert.equal(getSelectedEntityContext().id, id);
|
||||
const trackedModel = layer
|
||||
.qaDrawn()
|
||||
.find((row) => row.trackedModel)?.model;
|
||||
assert.ok(
|
||||
trackedModel?.ready && trackedModel.show,
|
||||
'the tracked model owns the visual',
|
||||
);
|
||||
const cameraPosition = native.gevDisplayPosition();
|
||||
assert.ok(cameraPosition);
|
||||
if (layerId === 'flights')
|
||||
assert.ok(
|
||||
height(native.gevDisplayPosition()) < 100,
|
||||
'a ready tracked model releases the billboard floor',
|
||||
);
|
||||
assert.deepEqual(
|
||||
publications.at(-1).position(),
|
||||
follow.label.position(),
|
||||
'the card uses the follow label cache',
|
||||
);
|
||||
const cached = { ...native.gevDisplayPosition() };
|
||||
publications.at(-1).position();
|
||||
assert.deepEqual(
|
||||
{ ...native.gevDisplayPosition() },
|
||||
cached,
|
||||
'painting never advances the follow sample',
|
||||
);
|
||||
follow.retarget({ viewFrom: { x: 0, y: 0, z: 178000 } });
|
||||
await frames();
|
||||
const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model;
|
||||
assert.ok(!zoomedOut?.show, 'zooming out releases the tracked model');
|
||||
if (layerId === 'flights')
|
||||
assert.ok(
|
||||
height(native.gevDisplayPosition()) >= 205.5,
|
||||
'the billboard regains its floor',
|
||||
);
|
||||
assert.equal(services.trackedReadout.activeId(), `${layerId}:${id}`);
|
||||
assert.equal(engine.camera.following(), follow);
|
||||
callsign = 'GROUND2';
|
||||
await layer.update();
|
||||
await frames();
|
||||
assert.equal(publications.at(-1).title, callsign);
|
||||
assert.equal(engine.camera.following(), follow);
|
||||
assert.ok(
|
||||
Math.abs(layer.getTrackedInfo().altitudeM) < 50,
|
||||
'sensor truth is independent of the display floor',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+195
-170
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { sceneServicesFor } from './sceneServices.js';
|
||||
import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js';
|
||||
|
||||
/**
|
||||
@@ -27,22 +23,6 @@ const TRACKED_BILLBOARD_SCALE = Object.freeze({
|
||||
farValue: 0.5,
|
||||
});
|
||||
|
||||
const DEFAULT_TRACKED_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: (...args) => setOverlayEntries(_engine, ...args),
|
||||
setVisible: (...args) => setOverlaySourceVisible(_engine, ...args),
|
||||
clearSource: (...args) => clearOverlaySource(_engine, ...args),
|
||||
});
|
||||
|
||||
// The globe engine: the followed target is engine.camera.following().
|
||||
let _engine = null;
|
||||
let _trackedEntityChangedRemove = null;
|
||||
let _selectedContext = null;
|
||||
let _contextSelectedHandler = null;
|
||||
let _contextClearedHandler = null;
|
||||
let _aircraftSelectedHandler = null;
|
||||
let _activeEntryId = null;
|
||||
let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
|
||||
|
||||
/**
|
||||
* Convert the former newline/inline label text into the explicit presentation
|
||||
* model used by the host.
|
||||
@@ -123,36 +103,6 @@ function entryIdFor(entity) {
|
||||
return fallback ? `entity:${fallback}` : null;
|
||||
}
|
||||
|
||||
// A follow's plain-data label as the subject the readout reads (the shape a
|
||||
// Cesium layer's tracked Entity has): one subject per follow handle.
|
||||
const _labelSubjects = new WeakMap();
|
||||
function followSubject(handle) {
|
||||
if (!handle) return null;
|
||||
const { label } = handle;
|
||||
if (!label) return handle.native ?? null;
|
||||
let subject = _labelSubjects.get(handle);
|
||||
if (!subject) {
|
||||
subject = {
|
||||
gevTrackedId: label.id,
|
||||
get gevLabelModel() {
|
||||
const { model } = handle.label ?? label;
|
||||
return typeof model === 'function' ? model() : model;
|
||||
},
|
||||
gevDisplayPosition: () => (handle.label ?? label).position(),
|
||||
};
|
||||
_labelSubjects.set(handle, subject);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
function trackedSubject() {
|
||||
return _engine ? followSubject(_engine.camera.following()) : null;
|
||||
}
|
||||
|
||||
function activeEntity() {
|
||||
return trackedSubject() || _selectedContext?.entity || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the host entry from a layer-owned tracked presentation model.
|
||||
* @param {Object|null} entity Tracked or selected presentation entity.
|
||||
@@ -206,138 +156,213 @@ export function createTrackedOverlayEntry(entity) {
|
||||
};
|
||||
}
|
||||
|
||||
function publishEntity(entity) {
|
||||
const entry = createTrackedOverlayEntry(entity);
|
||||
if (!entry) {
|
||||
clearTrackedSource();
|
||||
return;
|
||||
/**
|
||||
* The tracked card bridge for one scene attachment.
|
||||
* @param {import('../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ overlay: object, events: EventTarget }} services
|
||||
*/
|
||||
export function createTrackedReadout(
|
||||
engine,
|
||||
{ overlay, events, selectedContext },
|
||||
) {
|
||||
const _engine = engine;
|
||||
const _overlayHost = overlay;
|
||||
let destroyed = false;
|
||||
let initialized = false;
|
||||
let _trackedEntityChangedRemove = null;
|
||||
let _selectedContext = null;
|
||||
let _contextSelectedHandler = null;
|
||||
let _contextClearedHandler = null;
|
||||
let _aircraftSelectedHandler = null;
|
||||
let _activeEntryId = null;
|
||||
// A follow's plain-data label as the subject the readout reads (the shape a
|
||||
// Cesium layer's tracked Entity has): one subject per follow handle.
|
||||
const _labelSubjects = new WeakMap();
|
||||
function followSubject(handle) {
|
||||
if (!handle) return null;
|
||||
const { label } = handle;
|
||||
if (!label) return handle.native ?? null;
|
||||
let subject = _labelSubjects.get(handle);
|
||||
if (!subject) {
|
||||
subject = {
|
||||
gevTrackedId: label.id,
|
||||
get gevLabelModel() {
|
||||
const { model } = handle.label ?? label;
|
||||
return typeof model === 'function' ? model() : model;
|
||||
},
|
||||
gevDisplayPosition: () => (handle.label ?? label).position(),
|
||||
};
|
||||
_labelSubjects.set(handle, subject);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
_activeEntryId = entry.id;
|
||||
_overlayHost.setEntries(
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
[entry],
|
||||
TRACKED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||
}
|
||||
|
||||
function clearTrackedSource() {
|
||||
_activeEntryId = null;
|
||||
_overlayHost.clearSource(TRACKED_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
function trackedSubject() {
|
||||
return _engine ? followSubject(_engine.camera.following()) : null;
|
||||
}
|
||||
|
||||
function syncActiveEntity() {
|
||||
publishEntity(activeEntity());
|
||||
}
|
||||
function activeEntity() {
|
||||
return trackedSubject() || _selectedContext?.entity || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Republish a layer's newly assigned `gevLabelModel` when that entity owns the
|
||||
* active tracked readout. Layers call this only when presentation text changes;
|
||||
* position remains the live frame-cache getter already registered with host.
|
||||
* @param {Object} entity Entity whose model changed.
|
||||
*/
|
||||
export function refreshTrackedReadout(entity) {
|
||||
// A follow handle stands for its label subject; so does a layer's tracked
|
||||
// visual that names its follow handle (gevFollowHandle, an engine without
|
||||
// tracked entities: the Navara aircraft renderer).
|
||||
const subject =
|
||||
_labelSubjects.get(entity) ??
|
||||
_labelSubjects.get(entity?.gevFollowHandle) ??
|
||||
entity;
|
||||
if (subject && subject === activeEntity()) publishEntity(subject);
|
||||
}
|
||||
|
||||
/** Current tracked host entry id, used to resolve its actual painted rectangle. */
|
||||
export function getActiveTrackedReadoutId() {
|
||||
return _activeEntryId;
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
* installed; the already-initialized world-overlay host owns the frame lane.
|
||||
* @param {import('../globe/contract.js').GlobeEngine} engine The readout
|
||||
* follows engine.camera.following(), its label (plain data) or its Cesium
|
||||
* entity.
|
||||
*/
|
||||
export function initTrackedReadout(engine) {
|
||||
if (!engine || _engine === engine) return;
|
||||
if (_engine) destroyTrackedReadout();
|
||||
_engine = engine;
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||
_trackedEntityChangedRemove =
|
||||
engine.camera.followChanged?.addEventListener?.(() => {
|
||||
if (trackedSubject()) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_contextSelectedHandler = (event) => {
|
||||
const record = event.detail;
|
||||
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
|
||||
_selectedContext = record;
|
||||
publishEntity(record.entity);
|
||||
function publishEntity(entity) {
|
||||
if (destroyed || !initialized) return;
|
||||
const entry = createTrackedOverlayEntry(entity);
|
||||
if (!entry) {
|
||||
clearTrackedSource();
|
||||
return;
|
||||
}
|
||||
if (_selectedContext) {
|
||||
_selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}
|
||||
};
|
||||
_contextClearedHandler = (event) => {
|
||||
if (
|
||||
!_selectedContext ||
|
||||
event.detail?.layerId === _selectedContext.layerId
|
||||
) {
|
||||
_selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}
|
||||
};
|
||||
_aircraftSelectedHandler = () => {
|
||||
_selectedContext = null;
|
||||
if (!trackedSubject()) clearTrackedSource();
|
||||
};
|
||||
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
window.addEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
syncActiveEntity();
|
||||
// Boot-verification contract: the track regression harness asserts this
|
||||
// exact line at init (dropped by the host migration; restored).
|
||||
console.log('[TrackedReadout] Initialized');
|
||||
}
|
||||
_activeEntryId = entry.id;
|
||||
_overlayHost.setEntries(
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
[entry],
|
||||
TRACKED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||
}
|
||||
|
||||
/** Tear down selection listeners and clear the host source. */
|
||||
export function destroyTrackedReadout() {
|
||||
_trackedEntityChangedRemove?.();
|
||||
_trackedEntityChangedRemove = null;
|
||||
if (_contextSelectedHandler)
|
||||
window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
if (_contextClearedHandler)
|
||||
window.removeEventListener(
|
||||
function clearTrackedSource() {
|
||||
_activeEntryId = null;
|
||||
_overlayHost.clearSource(TRACKED_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
|
||||
function syncActiveEntity() {
|
||||
if (destroyed) return;
|
||||
publishEntity(activeEntity());
|
||||
}
|
||||
|
||||
/**
|
||||
* Republish a layer's newly assigned `gevLabelModel` when that entity owns the
|
||||
* active tracked readout. Layers call this only when presentation text changes;
|
||||
* position remains the live frame-cache getter already registered with host.
|
||||
* @param {Object} entity Entity whose model changed.
|
||||
*/
|
||||
function refresh(entity) {
|
||||
if (destroyed) return;
|
||||
// A follow handle stands for its label subject; so does a layer's tracked
|
||||
// visual that names its follow handle (gevFollowHandle, an engine without
|
||||
// tracked entities: the Navara aircraft renderer).
|
||||
const subject =
|
||||
_labelSubjects.get(entity) ??
|
||||
_labelSubjects.get(entity?.gevFollowHandle) ??
|
||||
entity;
|
||||
if (subject && subject === activeEntity()) publishEntity(subject);
|
||||
}
|
||||
|
||||
/** Current tracked host entry id, used to resolve its actual painted rectangle. */
|
||||
function activeId() {
|
||||
return _activeEntryId;
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
* installed; the already-initialized world-overlay host owns the frame lane.
|
||||
*/
|
||||
function init() {
|
||||
if (destroyed || initialized) return;
|
||||
initialized = true;
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||
_trackedEntityChangedRemove =
|
||||
engine.camera.followChanged?.addEventListener?.(() => {
|
||||
if (trackedSubject()) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_contextSelectedHandler = (event) => {
|
||||
if (destroyed) return;
|
||||
const record = event.detail;
|
||||
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
|
||||
_selectedContext = record;
|
||||
publishEntity(record.entity);
|
||||
return;
|
||||
}
|
||||
if (_selectedContext) {
|
||||
_selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}
|
||||
};
|
||||
_contextClearedHandler = (event) => {
|
||||
if (destroyed) return;
|
||||
if (
|
||||
!_selectedContext ||
|
||||
event.detail?.layerId === _selectedContext.layerId
|
||||
) {
|
||||
_selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}
|
||||
};
|
||||
_aircraftSelectedHandler = () => {
|
||||
if (destroyed) return;
|
||||
_selectedContext = null;
|
||||
if (!trackedSubject()) clearTrackedSource();
|
||||
};
|
||||
events.addEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
events.addEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
if (_aircraftSelectedHandler)
|
||||
window.removeEventListener(
|
||||
events.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
_contextSelectedHandler = null;
|
||||
_contextClearedHandler = null;
|
||||
_aircraftSelectedHandler = null;
|
||||
_selectedContext = null;
|
||||
clearTrackedSource();
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false);
|
||||
_engine = null;
|
||||
const selected = selectedContext?.();
|
||||
if (READOUT_CONTEXT_LAYERS.has(selected?.layerId))
|
||||
_selectedContext = selected;
|
||||
syncActiveEntity();
|
||||
// The track regression harness checks this initialization marker.
|
||||
console.log('[TrackedReadout] Initialized');
|
||||
}
|
||||
|
||||
/** Tear down selection listeners and clear the host source. */
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
_trackedEntityChangedRemove?.();
|
||||
_trackedEntityChangedRemove = null;
|
||||
if (_contextSelectedHandler)
|
||||
events.removeEventListener(
|
||||
'gev:entity-selected',
|
||||
_contextSelectedHandler,
|
||||
);
|
||||
if (_contextClearedHandler)
|
||||
events.removeEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
if (_aircraftSelectedHandler)
|
||||
events.removeEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
_contextSelectedHandler = null;
|
||||
_contextClearedHandler = null;
|
||||
_aircraftSelectedHandler = null;
|
||||
_selectedContext = null;
|
||||
clearTrackedSource();
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false);
|
||||
}
|
||||
|
||||
return { init, refresh, activeId, destroy };
|
||||
}
|
||||
|
||||
/** Inject a host recorder for focused lifecycle tests; null restores production. */
|
||||
export function _setTrackedOverlayHostForTest(host = null) {
|
||||
_overlayHost = host || DEFAULT_TRACKED_OVERLAY_HOST;
|
||||
export function trackedReadoutFor(engine) {
|
||||
return sceneServicesFor(engine)?.trackedReadout ?? null;
|
||||
}
|
||||
|
||||
export function initTrackedReadout(engine) {
|
||||
trackedReadoutFor(engine)?.init();
|
||||
}
|
||||
|
||||
export function refreshTrackedReadout(entity, engine) {
|
||||
trackedReadoutFor(engine)?.refresh(entity);
|
||||
}
|
||||
|
||||
export function getActiveTrackedReadoutId(engine) {
|
||||
return trackedReadoutFor(engine)?.activeId() ?? null;
|
||||
}
|
||||
|
||||
export function destroyTrackedReadout(engine) {
|
||||
trackedReadoutFor(engine)?.destroy();
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createSceneServices } from './sceneServices.js';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import {
|
||||
_setTrackedOverlayHostForTest,
|
||||
cachedTrackedDisplayPosition,
|
||||
cachedTrackedVisualPosition,
|
||||
createTrackedOverlayEntry,
|
||||
@@ -156,9 +156,10 @@ test('tracked entity publishes a protected host entry backed by the frame cache'
|
||||
const viewer = { trackedEntity: entity, trackedEntityChanged: changed };
|
||||
const recorder = makeHostRecorder();
|
||||
globalThis.window = fakeWindow;
|
||||
_setTrackedOverlayHostForTest(recorder.host);
|
||||
const engine = trackingEngine(viewer);
|
||||
const services = createSceneServices(engine, { trackedOverlay: recorder.host });
|
||||
try {
|
||||
initTrackedReadout(trackingEngine(viewer));
|
||||
initTrackedReadout(engine);
|
||||
const publication = recorder.calls.find(({ op }) => op === 'set');
|
||||
assert.ok(publication);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
@@ -173,21 +174,21 @@ test('tracked entity publishes a protected host entry backed by the frame cache'
|
||||
assert.equal(entry.position(), display);
|
||||
assert.equal(publication.options.collisionCapacity, 0);
|
||||
assert.equal(publication.options.hideInCockpit, true);
|
||||
assert.equal(getActiveTrackedReadoutId(), 'flights:abc123');
|
||||
assert.equal(getActiveTrackedReadoutId(engine), 'flights:abc123');
|
||||
|
||||
entity.gevLabelModel = { ...entity.gevLabelModel, details: ['FL360 · 455 kts'] };
|
||||
refreshTrackedReadout(entity);
|
||||
refreshTrackedReadout(entity, engine);
|
||||
assert.deepEqual(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details, [
|
||||
'FL360 · 455 kts',
|
||||
]);
|
||||
|
||||
viewer.trackedEntity = null;
|
||||
changed.raise();
|
||||
assert.equal(getActiveTrackedReadoutId(), null);
|
||||
assert.equal(getActiveTrackedReadoutId(engine), null);
|
||||
assert.equal(recorder.calls.at(-1).op, 'clear');
|
||||
} finally {
|
||||
destroyTrackedReadout();
|
||||
_setTrackedOverlayHostForTest();
|
||||
destroyTrackedReadout(engine);
|
||||
services.destroy();
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
@@ -204,19 +205,20 @@ test('selection lifecycle leaves installations to their own entry and retains AL
|
||||
gevLabelModel: { title: 'FORT TEST', details: ['AIRFIELD'], accent: '#5aa9ff' },
|
||||
};
|
||||
globalThis.window = fakeWindow;
|
||||
_setTrackedOverlayHostForTest(recorder.host);
|
||||
const engine = trackingEngine(viewer);
|
||||
const services = createSceneServices(engine, { trackedOverlay: recorder.host });
|
||||
try {
|
||||
initTrackedReadout(trackingEngine(viewer));
|
||||
initTrackedReadout(engine);
|
||||
const setsBefore = recorder.calls.filter(({ op }) => op === 'set').length;
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'ais-live-vessels', entity: installation },
|
||||
}));
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').length, setsBefore);
|
||||
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'military-installations', entity: installation },
|
||||
}));
|
||||
assert.equal(getActiveTrackedReadoutId(), null);
|
||||
assert.equal(getActiveTrackedReadoutId(engine), null);
|
||||
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.
|
||||
@@ -225,35 +227,35 @@ test('selection lifecycle leaves installations to their own entry and retains AL
|
||||
gevDisplayPosition: () => ({ x: 4, y: 5, z: 6 }),
|
||||
gevLabelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' },
|
||||
};
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'alpr-cameras', entity: camera },
|
||||
}));
|
||||
assert.equal(getActiveTrackedReadoutId(), 'alpr:42');
|
||||
assert.equal(getActiveTrackedReadoutId(engine), 'alpr:42');
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FLOCK SAFETY ALPR');
|
||||
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'ais-live-vessels', entity: installation },
|
||||
}));
|
||||
assert.equal(getActiveTrackedReadoutId(), null, 'sibling selection clears an installation card');
|
||||
assert.equal(getActiveTrackedReadoutId(engine), null, 'sibling selection clears an installation card');
|
||||
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
services.context.events.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'military-installations', entity: installation },
|
||||
}));
|
||||
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
services.context.events.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId: 'military-installations' },
|
||||
}));
|
||||
assert.equal(getActiveTrackedReadoutId(), null);
|
||||
assert.equal(getActiveTrackedReadoutId(engine), null);
|
||||
assert.ok(recorder.calls.some(({ op }) => op === 'clear'));
|
||||
|
||||
const clearsBeforeDestroy = recorder.calls.filter(({ op }) => op === 'clear').length;
|
||||
destroyTrackedReadout();
|
||||
destroyTrackedReadout(engine);
|
||||
assert.ok(recorder.calls.filter(({ op }) => op === 'clear').length > clearsBeforeDestroy);
|
||||
assert.equal(changed.size, 0, 'trackedEntityChanged listener was removed');
|
||||
assert.ok(recorder.calls.some(({ op, visible }) => op === 'visible' && visible === false));
|
||||
} finally {
|
||||
destroyTrackedReadout();
|
||||
_setTrackedOverlayHostForTest();
|
||||
destroyTrackedReadout(engine);
|
||||
services.destroy();
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
@@ -316,41 +318,41 @@ test("on a globe engine the readout follows engine.camera.follow's plain-data la
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
const recorder = makeHostRecorder();
|
||||
_setTrackedOverlayHostForTest(recorder.host);
|
||||
const engine = createFakeEngine();
|
||||
const services = createSceneServices(engine, { trackedOverlay: recorder.host });
|
||||
const anchor = { x: 6_378_137, y: 0, z: 1000 };
|
||||
let model = { title: 'ISS (ZARYA)', details: ['408 km'], accent: '#ffd166' };
|
||||
try {
|
||||
initTrackedReadout(engine);
|
||||
assert.equal(getActiveTrackedReadoutId(), null);
|
||||
assert.equal(getActiveTrackedReadoutId(engine), null);
|
||||
const handle = engine.camera.follow({
|
||||
position: () => anchor,
|
||||
viewFrom: { x: 0, y: -5000, z: 2000 },
|
||||
label: { id: 'satellites:25544', model: () => model, position: () => anchor },
|
||||
});
|
||||
assert.equal(getActiveTrackedReadoutId(), 'satellites:25544');
|
||||
assert.equal(getActiveTrackedReadoutId(engine), 'satellites:25544');
|
||||
const entry = recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0];
|
||||
assert.equal(entry.title, 'ISS (ZARYA)');
|
||||
assert.equal(entry.position(), anchor);
|
||||
// A layer republishes its model through the follow handle.
|
||||
model = { ...model, details: ['409 km'] };
|
||||
refreshTrackedReadout(handle);
|
||||
refreshTrackedReadout(handle, engine);
|
||||
assert.deepEqual(
|
||||
recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details,
|
||||
['409 km'],
|
||||
);
|
||||
// So does a layer's tracked visual that names its follow handle.
|
||||
model = { ...model, details: ['410 km'] };
|
||||
refreshTrackedReadout({ gevFollowHandle: handle });
|
||||
refreshTrackedReadout({ gevFollowHandle: handle }, engine);
|
||||
assert.deepEqual(
|
||||
recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details,
|
||||
['410 km'],
|
||||
);
|
||||
handle.stop();
|
||||
assert.equal(getActiveTrackedReadoutId(), null);
|
||||
assert.equal(getActiveTrackedReadoutId(engine), null);
|
||||
} finally {
|
||||
destroyTrackedReadout();
|
||||
_setTrackedOverlayHostForTest();
|
||||
destroyTrackedReadout(engine);
|
||||
services.destroy();
|
||||
engine.destroy();
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createApplicationScene } from '../app/scene.js';
|
||||
import { createLayerSession } from '../app/layerSession.js';
|
||||
import * as context from './contextStore.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
import * as selection from './selectionDispatcher.js';
|
||||
import { createLocalAdsbLayer } from '../layers/localAdsb/index.js';
|
||||
import { createLocalAdsbDisplay } from '../layers/localAdsb/render/display.js';
|
||||
import { aircraftAtlasLayout } from '../layers/aircraft/render/display.js';
|
||||
import { createAlprDisplay } from '../layers/alpr/render/display.js';
|
||||
import { createFirmsHeatmapLayer } from '../layers/firms/index.js';
|
||||
import { createFirmsDisplay } from '../layers/firms/render/display.js';
|
||||
import { CONTEXT_TOP_N } from '../layers/firms/policy.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
|
||||
import { createSceneServices, sceneServicesFor } from './sceneServices.js';
|
||||
import { getSelectedEntityContext } from './contextStore.js';
|
||||
import { trackedReadoutFor, refreshTrackedReadout } from './trackedReadout.js';
|
||||
|
||||
function recorder() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
setEntries(source, entries) {
|
||||
calls.push({ source, entry: entries[0] });
|
||||
},
|
||||
setVisible() {},
|
||||
clearSource(source) {
|
||||
calls.push({ source, entry: null });
|
||||
},
|
||||
last: () => calls.at(-1)?.entry,
|
||||
};
|
||||
}
|
||||
|
||||
function subject(title = 'Camera') {
|
||||
return {
|
||||
gevTrackedId: 'shared-id',
|
||||
gevLabelModel: { title, details: ['Original'] },
|
||||
gevDisplayPosition: () => ({ x: 6378137, y: 0, z: 100 }),
|
||||
};
|
||||
}
|
||||
|
||||
function select(context, entity, layerId = 'alpr-cameras') {
|
||||
const record = context.registerEntityContext(entity, {
|
||||
id: 'shared-id',
|
||||
layerId,
|
||||
});
|
||||
context.selectEntityContext(entity);
|
||||
return record;
|
||||
}
|
||||
|
||||
for (const makeHarness of GROUND_HARNESSES) {
|
||||
function setup(t) {
|
||||
const original = globalThis.window;
|
||||
const page = new EventTarget();
|
||||
globalThis.window = page;
|
||||
const harnesses = [makeHarness(), makeHarness()];
|
||||
const hosts = [recorder(), recorder()];
|
||||
const services = harnesses.map(({ engine }, i) =>
|
||||
createSceneServices(engine, { trackedOverlay: hosts[i] }),
|
||||
);
|
||||
services.forEach((s) => s.trackedReadout.init());
|
||||
t.after(() => {
|
||||
for (const { engine } of harnesses) sceneServicesFor(engine).destroy();
|
||||
harnesses.forEach((h) => h.destroy());
|
||||
globalThis.window = original;
|
||||
});
|
||||
return { harnesses, hosts, services, page };
|
||||
}
|
||||
|
||||
test(`${makeHarness.name}: simultaneous follows refresh only their own card and cached anchor`, (t) => {
|
||||
const { harnesses, services, hosts } = setup(t);
|
||||
const models = [
|
||||
{ title: 'First', details: ['100 km'] },
|
||||
{ title: 'Second', details: ['200 km'] },
|
||||
];
|
||||
const anchors = [
|
||||
{ x: 6378137, y: 0, z: 100 },
|
||||
{ x: 6378137, y: 100, z: 200 },
|
||||
];
|
||||
const handles = harnesses.map(({ engine }, i) =>
|
||||
engine.camera.follow({
|
||||
position: () => anchors[i],
|
||||
viewFrom: { x: 0, y: -5000, z: 2000 },
|
||||
label: {
|
||||
id: 'same-follow-id',
|
||||
model: () => models[i],
|
||||
position: () => anchors[i],
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
trackedReadoutFor(harnesses[0].engine),
|
||||
services[0].trackedReadout,
|
||||
);
|
||||
assert.deepEqual(
|
||||
hosts.map((h) => h.last().title),
|
||||
['First', 'Second'],
|
||||
);
|
||||
assert.equal(hosts[0].last().position(), anchors[0]);
|
||||
assert.equal(hosts[1].last().position(), anchors[1]);
|
||||
models[0] = { title: 'First refreshed', details: ['101 km'] };
|
||||
const before = hosts[1].calls.length;
|
||||
refreshTrackedReadout(handles[0], harnesses[0].engine);
|
||||
assert.equal(hosts[0].last().title, 'First refreshed');
|
||||
assert.equal(hosts[1].calls.length, before);
|
||||
models[1] = { title: 'Second refreshed', details: ['201 km'] };
|
||||
refreshTrackedReadout({ gevFollowHandle: handles[1] }, harnesses[1].engine);
|
||||
assert.equal(hosts[1].last().title, 'Second refreshed');
|
||||
refreshTrackedReadout(handles[1], harnesses[0].engine);
|
||||
assert.equal(hosts[0].last().title, 'First refreshed');
|
||||
handles[0].stop();
|
||||
assert.equal(hosts[0].last(), null);
|
||||
assert.equal(services[1].trackedReadout.activeId(), 'same-follow-id');
|
||||
services[1].destroy();
|
||||
const callsAfterDestroy = hosts[1].calls.length;
|
||||
handles[1].stop();
|
||||
refreshTrackedReadout(handles[1], harnesses[1].engine);
|
||||
assert.equal(hosts[1].last(), null);
|
||||
assert.equal(
|
||||
hosts[1].calls.length,
|
||||
callsAfterDestroy,
|
||||
'follow changes after teardown cannot republish',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: scoped selections, clears and aircraft events preserve page notifications`, (t) => {
|
||||
const {
|
||||
services: [a, b],
|
||||
hosts,
|
||||
page,
|
||||
} = setup(t);
|
||||
const notifications = [];
|
||||
const clears = [];
|
||||
for (const scene of [a, b])
|
||||
scene.context.events.addEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
({ detail }) => clears.push(detail),
|
||||
);
|
||||
for (const type of [
|
||||
'gev:entity-selected',
|
||||
'gev:entity-selection-cleared',
|
||||
'gev:awareness-subject-selected',
|
||||
])
|
||||
page.addEventListener(type, ({ detail }) =>
|
||||
notifications.push({ type, detail }),
|
||||
);
|
||||
const first = subject('First camera');
|
||||
const second = subject('Second camera');
|
||||
const firstRecord = select(a.context, first);
|
||||
assert.equal(notifications[0].detail, firstRecord);
|
||||
assert.equal(getSelectedEntityContext(), firstRecord);
|
||||
// Registering an identical id in another scene does not replace the page selection.
|
||||
b.context.registerEntityContext(second, {
|
||||
id: 'shared-id',
|
||||
layerId: 'alpr-cameras',
|
||||
});
|
||||
assert.equal(getSelectedEntityContext(), firstRecord);
|
||||
const secondRecord = b.context.selectEntityContext(second);
|
||||
assert.equal(getSelectedEntityContext(), secondRecord);
|
||||
assert.deepEqual(
|
||||
hosts.map((h) => h.last().title),
|
||||
['First camera', 'Second camera'],
|
||||
);
|
||||
first.gevLabelModel = { title: 'First refreshed' };
|
||||
a.trackedReadout.refresh(first);
|
||||
assert.equal(hosts[0].last().title, 'First refreshed');
|
||||
assert.equal(hosts[1].last().title, 'Second camera');
|
||||
a.context.clearSelectedEntityContextForLayer('local-adsb');
|
||||
assert.equal(hosts[0].last().title, 'First refreshed');
|
||||
const beforeBackgroundClear = notifications.length;
|
||||
a.context.clearSelectedEntityContextForLayer('alpr-cameras');
|
||||
assert.equal(notifications.length, beforeBackgroundClear);
|
||||
assert.equal(hosts[0].last(), null);
|
||||
assert.equal(hosts[1].last().title, 'Second camera');
|
||||
assert.equal(getSelectedEntityContext(), secondRecord);
|
||||
assert.deepEqual(clears.at(-1), {
|
||||
layerId: 'alpr-cameras',
|
||||
reason: 'deliberate',
|
||||
});
|
||||
select(a.context, first, 'local-adsb');
|
||||
const beforeBackgroundEviction = notifications.length;
|
||||
b.context.removeEntityContextsForLayer('alpr-cameras');
|
||||
assert.equal(notifications.length, beforeBackgroundEviction);
|
||||
assert.equal(hosts[1].last(), null);
|
||||
assert.deepEqual(clears.at(-1), {
|
||||
layerId: 'alpr-cameras',
|
||||
reason: 'evicted',
|
||||
});
|
||||
context.clearSelectedEntityContextForLayer('local-adsb');
|
||||
assert.deepEqual(notifications.at(-1).detail, {
|
||||
layerId: 'local-adsb',
|
||||
reason: 'deliberate',
|
||||
});
|
||||
select(b.context, second);
|
||||
a.context.publishAwareness('gev:awareness-subject-selected', {
|
||||
layerId: 'flights',
|
||||
id: 'plane',
|
||||
});
|
||||
assert.equal(hosts[0].last(), null);
|
||||
assert.equal(hosts[1].last().title, 'Second camera');
|
||||
assert.deepEqual(notifications.at(-1).detail, {
|
||||
layerId: 'flights',
|
||||
id: 'plane',
|
||||
});
|
||||
select(a.context, first, 'local-adsb');
|
||||
select(a.context, subject('Other layer'), 'local-datacenters');
|
||||
assert.equal(
|
||||
hosts[0].last(),
|
||||
null,
|
||||
'another layer selection clears only its scene readout',
|
||||
);
|
||||
assert.equal(hosts[1].last().title, 'Second camera');
|
||||
page.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId: 'alpr-cameras' },
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
hosts[1].last().title,
|
||||
'Second camera',
|
||||
'unscoped page notifications cannot mutate scene readouts',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: a handoff carries only the page selection and respects a clear between scenes`, (t) => {
|
||||
const {
|
||||
harnesses,
|
||||
services: [a, b],
|
||||
page,
|
||||
} = setup(t);
|
||||
const notifications = [];
|
||||
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
|
||||
page.addEventListener(type, (event) => notifications.push(event.detail));
|
||||
const entity = subject('Retained camera');
|
||||
const record = select(a.context, entity);
|
||||
const selectedAt = page.__gevContextStore.selectedAt;
|
||||
const retained = a.destroy({ retainSelection: true });
|
||||
assert.equal(retained, record);
|
||||
assert.equal(getSelectedEntityContext(), record);
|
||||
assert.equal(page.__gevContextStore.selectedAt, selectedAt);
|
||||
const host = recorder();
|
||||
const next = createSceneServices(harnesses[0].engine, {
|
||||
trackedOverlay: host,
|
||||
retainedSelection: retained,
|
||||
});
|
||||
next.trackedReadout.init();
|
||||
assert.equal(next.context.getSelectedEntityContext(), record);
|
||||
assert.equal(host.last().title, 'Retained camera');
|
||||
assert.equal(b.context.getSelectedEntityContext(), null);
|
||||
assert.equal(notifications.length, 1, 'handoff is not another selection');
|
||||
const cleared = next.destroy({ retainSelection: true });
|
||||
context.clearSelectedEntityContextForLayer('alpr-cameras');
|
||||
const replacement = createSceneServices(harnesses[0].engine, {
|
||||
retainedSelection: cleared,
|
||||
});
|
||||
assert.equal(replacement.context.getSelectedEntityContext(), null);
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(notifications.at(-1).reason, 'deliberate');
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: application scene release and creation carry the session selection`, async (t) => {
|
||||
const original = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
const first = makeHarness();
|
||||
const second = makeHarness();
|
||||
const session = createLayerSession();
|
||||
session.chrome.sceneChrome = true;
|
||||
session.operations = {
|
||||
surface: {
|
||||
groundFloor: {
|
||||
clearMeshFloorCells() {},
|
||||
setMeshFloorPreferred() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
session.handoffLayers = new Map();
|
||||
const cleanups = [];
|
||||
const open = (engine) =>
|
||||
createApplicationScene({
|
||||
layerSession: session,
|
||||
defer: (cleanup) => cleanups.push(cleanup),
|
||||
rendererCache: {
|
||||
acquire: async () => ({
|
||||
engine,
|
||||
mapStackController: { getActiveId: () => 'osm' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
while (cleanups.length) cleanups.pop()();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
globalThis.window = original;
|
||||
});
|
||||
const outgoing = await open(first.engine);
|
||||
const record = select(outgoing.services.context, subject());
|
||||
cleanups.pop()();
|
||||
assert.equal(session.selectionContext, record);
|
||||
assert.equal(getSelectedEntityContext(), record);
|
||||
const incoming = await open(second.engine);
|
||||
assert.equal(session.selectionContext, null);
|
||||
assert.equal(incoming.services.context.getSelectedEntityContext(), record);
|
||||
assert.equal(getSelectedEntityContext(), record);
|
||||
session.lifetime.abort();
|
||||
cleanups.pop()();
|
||||
assert.equal(
|
||||
getSelectedEntityContext(),
|
||||
null,
|
||||
'page teardown does not retain selection',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: terminal teardown rejects stale notifications and parked reattachment gets fresh state`, (t) => {
|
||||
const {
|
||||
harnesses,
|
||||
services: [a, b],
|
||||
hosts,
|
||||
page,
|
||||
} = setup(t);
|
||||
const first = subject('Old camera');
|
||||
const second = subject('Sibling camera');
|
||||
select(a.context, first);
|
||||
const siblingRecord = select(b.context, second);
|
||||
let pageEvents = 0;
|
||||
page.addEventListener('gev:entity-selected', () => pageEvents++);
|
||||
a.destroy();
|
||||
a.destroy();
|
||||
const calls = hosts[0].calls.length;
|
||||
a.trackedReadout.init();
|
||||
a.trackedReadout.refresh(first);
|
||||
select(a.context, first);
|
||||
a.context.events.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selected', {
|
||||
detail: { entity: first, layerId: 'alpr-cameras' },
|
||||
}),
|
||||
);
|
||||
a.context.publishAwareness('gev:awareness-subject-selected', {
|
||||
layerId: 'flights',
|
||||
});
|
||||
assert.equal(hosts[0].calls.length, calls);
|
||||
assert.equal(pageEvents, 0);
|
||||
assert.equal(getSelectedEntityContext(), siblingRecord);
|
||||
assert.equal(b.trackedReadout.activeId(), 'shared-id');
|
||||
const nextHost = recorder();
|
||||
const next = createSceneServices(harnesses[0].engine, {
|
||||
trackedOverlay: nextHost,
|
||||
});
|
||||
next.trackedReadout.init();
|
||||
assert.notEqual(next.trackedReadout, a.trackedReadout);
|
||||
assert.equal(next.trackedReadout.activeId(), null);
|
||||
select(next.context, subject('New camera'));
|
||||
a.context.clearSelectedEntityContextForLayer('alpr-cameras');
|
||||
a.context.removeEntityContextsForLayer('alpr-cameras');
|
||||
a.destroy();
|
||||
assert.equal(nextHost.last().title, 'New camera');
|
||||
assert.equal(hosts[1].last().title, 'Sibling camera');
|
||||
b.destroy();
|
||||
assert.equal(nextHost.last().title, 'New camera');
|
||||
});
|
||||
}
|
||||
|
||||
for (const makeHarness of GROUND_HARNESSES) {
|
||||
test(`${makeHarness.name}: Local ADS-B publishes and refreshes through its attached readout`, async (t) => {
|
||||
const original = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
const first = makeHarness();
|
||||
const second = makeHarness();
|
||||
const a = createSceneServices(first.engine);
|
||||
const b = createSceneServices(second.engine);
|
||||
a.trackedReadout.init();
|
||||
b.trackedReadout.init();
|
||||
const hosts = [recorder(), recorder()];
|
||||
const scenes = [a, b];
|
||||
// Observe publications while keeping the production overlay as the destination.
|
||||
scenes.forEach((scene, i) => {
|
||||
const setEntries = scene.overlay.setEntries;
|
||||
scene.overlay.setEntries = (...args) => {
|
||||
hosts[i].setEntries(...args);
|
||||
return setEntries(...args);
|
||||
};
|
||||
});
|
||||
const layout = aircraftAtlasLayout();
|
||||
let changed;
|
||||
const aircraft = {
|
||||
icao: 'abc123',
|
||||
callsign: 'LOCAL1',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
altitudeFt: 10000,
|
||||
groundSpeedKt: 180,
|
||||
trackDeg: 0,
|
||||
verticalRateFpm: 0,
|
||||
lastPositionAt: 100000,
|
||||
lastMessageAt: 100000,
|
||||
messageCount: 12,
|
||||
band: '1090',
|
||||
source: 'webusb',
|
||||
};
|
||||
const snapshot = () => ({
|
||||
connected: true,
|
||||
mode: 'adsb',
|
||||
status: 'ready',
|
||||
aircraft: [aircraft],
|
||||
});
|
||||
const layer = createLocalAdsbLayer({
|
||||
receiver: {
|
||||
getState: snapshot,
|
||||
subscribe(fn) {
|
||||
changed = fn;
|
||||
return () => {};
|
||||
},
|
||||
async setMode() {},
|
||||
},
|
||||
now: () => 100000,
|
||||
services: {
|
||||
context,
|
||||
picking,
|
||||
selection,
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: { refreshReadout: refreshTrackedReadout },
|
||||
renderer: {
|
||||
create: ({ engine }) =>
|
||||
createLocalAdsbDisplay(engine, {
|
||||
drawAtlas: async () => ({
|
||||
canvas: { width: layout.width, height: layout.height },
|
||||
cells: layout.cells,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
globalThis.window = original;
|
||||
});
|
||||
layer.init(null, { engine: first.engine });
|
||||
await layer.enable();
|
||||
assert.equal(layer.selectById('abc123'), true);
|
||||
assert.equal(a.trackedReadout.activeId(), 'local-adsb:abc123');
|
||||
assert.equal(b.trackedReadout.activeId(), null);
|
||||
assert.equal(a.overlay.getDiagnostics().entryCount, 1);
|
||||
aircraft.callsign = 'LOCAL2';
|
||||
changed(snapshot());
|
||||
await layer.update();
|
||||
assert.equal(hosts[0].last().title, 'LOCAL2');
|
||||
layer.detachRenderer();
|
||||
assert.equal(a.trackedReadout.activeId(), null);
|
||||
await layer.attachRenderer(null, { engine: second.engine });
|
||||
assert.equal(layer.selectById('abc123'), true);
|
||||
assert.equal(b.trackedReadout.activeId(), 'local-adsb:abc123');
|
||||
assert.equal(a.trackedReadout.activeId(), null);
|
||||
aircraft.callsign = 'LOCAL3';
|
||||
changed(snapshot());
|
||||
await layer.update();
|
||||
assert.equal(hosts[1].last().title, 'LOCAL3');
|
||||
a.destroy();
|
||||
assert.equal(b.overlay.getDiagnostics().entryCount, 1);
|
||||
layer.disable();
|
||||
assert.equal(b.trackedReadout.activeId(), null);
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: a selected fire survives scene handoff before viewport restoration`, async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.window = new EventTarget();
|
||||
globalThis.document = {
|
||||
createElement: () => ({
|
||||
getContext: () => ({
|
||||
createRadialGradient: () => ({ addColorStop() {} }),
|
||||
fillRect() {},
|
||||
}),
|
||||
toDataURL: () => 'fire-glow',
|
||||
}),
|
||||
};
|
||||
const first = makeHarness();
|
||||
const second = makeHarness();
|
||||
const session = createLayerSession();
|
||||
session.chrome.sceneChrome = true;
|
||||
session.operations = {
|
||||
surface: {
|
||||
groundFloor: {
|
||||
clearMeshFloorCells() {},
|
||||
setMeshFloorPreferred() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
session.handoffLayers = new Map();
|
||||
const cleanups = [];
|
||||
const open = (engine) =>
|
||||
createApplicationScene({
|
||||
layerSession: session,
|
||||
defer: (cleanup) => cleanups.push(cleanup),
|
||||
rendererCache: {
|
||||
acquire: async () => ({
|
||||
engine,
|
||||
mapStackController: { getActiveId: () => 'osm' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
const fire = {
|
||||
index: 0,
|
||||
lat: 37.77,
|
||||
lon: -122.42,
|
||||
acqMs: 1792137600000,
|
||||
satellite: 'N20',
|
||||
sensor: 'VIIRS',
|
||||
frp: 999,
|
||||
confidence: 0.9,
|
||||
};
|
||||
const id = 'firms:37.7700:-122.4200:1792137600000:N20';
|
||||
let snapshot = null;
|
||||
const notifications = [];
|
||||
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
|
||||
window.addEventListener(type, (event) => notifications.push(event.type));
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
id: 'local-firms',
|
||||
name: 'Fires',
|
||||
feed: {
|
||||
getSnapshot: async () => {
|
||||
assert.ok(snapshot, 'a retained fire needs no refetch');
|
||||
return snapshot;
|
||||
},
|
||||
},
|
||||
services: {
|
||||
context,
|
||||
picking,
|
||||
render: { governorRequestRender() {} },
|
||||
focus: { requestWorldFocus() {} },
|
||||
anchors: {
|
||||
fireAnchorHeight: () => 0,
|
||||
warmFireAnchorFloors: async () => false,
|
||||
},
|
||||
},
|
||||
overlayHost: recorder(),
|
||||
renderer: {
|
||||
create: ({ engine, id }) =>
|
||||
createFirmsDisplay(engine, {
|
||||
id,
|
||||
loadImage: async () => ({ width: 16, height: 16 }),
|
||||
createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
session.lifetime.abort();
|
||||
while (cleanups.length) cleanups.pop()();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
const outgoing = await open(first.engine);
|
||||
outgoing.services.trackedReadout.init();
|
||||
layer._bindInteractionForTest(first.engine, [fire]);
|
||||
first.click({ id: 'firms-0' });
|
||||
assert.equal(window.__gevContextStore.selectedEntityId, id);
|
||||
const selectedAt = window.__gevContextStore.selectedAt;
|
||||
layer.detachRenderer();
|
||||
cleanups.pop()();
|
||||
assert.equal(session.selectionContext.id, id);
|
||||
const incoming = await open(second.engine);
|
||||
layer.attachRenderer(null, {
|
||||
engine: second.engine,
|
||||
waitForCameraRestore: true,
|
||||
});
|
||||
// The shell initializes readouts before restoring the camera and resuming layers.
|
||||
incoming.services.trackedReadout.init();
|
||||
assert.equal(window.__gevContextStore.selectedEntityId, id);
|
||||
assert.equal(window.__gevContextStore.selectedAt, selectedAt);
|
||||
second.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(fire.lon, fire.lat, 100_000),
|
||||
});
|
||||
layer.resumeViewport();
|
||||
assert.equal(layer.getSelectedInfo().id, id);
|
||||
assert.equal(incoming.services.context.getSelectedEntityContext().id, id);
|
||||
assert.equal(getSelectedEntityContext().id, id);
|
||||
assert.equal(window.__gevContextStore.selectedAt, selectedAt);
|
||||
// Refreshing top-N context records must also keep a selected weaker fire.
|
||||
const row = {
|
||||
...fire,
|
||||
acqDate: '2026-10-16',
|
||||
acqTime: '0800',
|
||||
instrument: 'VIIRS',
|
||||
confidence: 'h',
|
||||
};
|
||||
snapshot = {
|
||||
fires: [
|
||||
row,
|
||||
...Array.from({ length: CONTEXT_TOP_N }, (_, index) => ({
|
||||
...row,
|
||||
lat: fire.lat + (index + 1) * 0.001,
|
||||
frp: fire.frp + index + 1,
|
||||
})),
|
||||
],
|
||||
};
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(window.__gevContextStore.selectedEntityId, id);
|
||||
assert.equal(getSelectedEntityContext().id, id);
|
||||
assert.equal(window.__gevContextStore.selectedAt, selectedAt);
|
||||
assert.deepEqual(notifications, ['gev:entity-selected']);
|
||||
layer.disable();
|
||||
assert.equal(window.__gevContextStore.selectedEntityId, null);
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: a selected ALPR camera survives a switch and refreshes the incoming card`, (t) => {
|
||||
const original = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
const first = makeHarness();
|
||||
const second = makeHarness();
|
||||
const hosts = [recorder(), recorder()];
|
||||
const a = createSceneServices(first.engine, { trackedOverlay: hosts[0] });
|
||||
a.trackedReadout.init();
|
||||
const record = {
|
||||
id: 'alpr:9000079',
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
osmId: 9000079,
|
||||
operator: 'Retained operator',
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
lastAnchorSampleAt: 0,
|
||||
renderRevision: 0,
|
||||
records: [record],
|
||||
recordById: new Map([[record.id, record]]),
|
||||
};
|
||||
function displayFor(engine) {
|
||||
engine.surface.terrainHeight = () => 150;
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97, 30, 2000),
|
||||
});
|
||||
const display = createAlprDisplay(engine, {
|
||||
state,
|
||||
source: { label: 'Fixture' },
|
||||
services: {
|
||||
context,
|
||||
render: { governorRequestRender() {} },
|
||||
groundFloor: { cachedGroundFloor: () => 150 },
|
||||
overlays: { refreshReadout: refreshTrackedReadout },
|
||||
},
|
||||
});
|
||||
display.init(null, { onMove() {} });
|
||||
display.setVisible(true);
|
||||
display.renderRecords();
|
||||
return display;
|
||||
}
|
||||
const outgoing = displayFor(first.engine);
|
||||
let incoming, b;
|
||||
t.after(() => {
|
||||
outgoing.destroy();
|
||||
incoming?.destroy();
|
||||
a.destroy();
|
||||
b?.destroy();
|
||||
first.destroy();
|
||||
second.destroy();
|
||||
globalThis.window = original;
|
||||
});
|
||||
outgoing.selectRecord(record.id);
|
||||
const oldEntity = getSelectedEntityContext().entity;
|
||||
outgoing.destroy({ retainContext: true });
|
||||
const retainedSelection = a.destroy({ retainSelection: true });
|
||||
assert.equal(window.__gevContextStore.selectedEntityId, record.id);
|
||||
b = createSceneServices(second.engine, {
|
||||
retainedSelection,
|
||||
trackedOverlay: hosts[1],
|
||||
});
|
||||
incoming = displayFor(second.engine);
|
||||
// The shell initializes its readout after the layer's scene attachment.
|
||||
b.trackedReadout.init();
|
||||
assert.equal(state.selectedId, record.id);
|
||||
assert.equal(window.__gevContextStore.selectedEntityId, record.id);
|
||||
const selected = b.context.getSelectedEntityContext();
|
||||
assert.equal(selected.id, record.id);
|
||||
assert.equal(getSelectedEntityContext(), selected);
|
||||
assert.notEqual(selected.entity, oldEntity);
|
||||
assert.equal(hosts[1].last().id, record.id);
|
||||
assert.equal(a.trackedReadout.activeId(), null);
|
||||
const moved = { x: 6378000, y: 0, z: 100 };
|
||||
selected.entity.displayPosition = moved;
|
||||
assert.equal(hosts[1].last().position(), moved);
|
||||
context.clearSelectedEntityContextForLayer('alpr-cameras');
|
||||
assert.equal(b.context.getSelectedEntityContext(), null);
|
||||
assert.equal(hosts[1].last(), null);
|
||||
assert.equal(window.__gevContextStore.selectedEntityId, null);
|
||||
});
|
||||
|
||||
test(`${makeHarness.name}: ALPR selection and refresh keep their attachment after replacement`, (t) => {
|
||||
const original = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
const h = makeHarness();
|
||||
h.engine.surface.terrainHeight = () => 150;
|
||||
h.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97, 30, 2000),
|
||||
});
|
||||
const host = recorder();
|
||||
const a = createSceneServices(h.engine, { trackedOverlay: host });
|
||||
a.trackedReadout.init();
|
||||
const records = [
|
||||
{
|
||||
id: 'alpr:0',
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
osmId: 0,
|
||||
operator: 'First operator',
|
||||
},
|
||||
];
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
lastAnchorSampleAt: 0,
|
||||
renderRevision: 0,
|
||||
records,
|
||||
recordById: new Map(records.map((r) => [r.id, r])),
|
||||
};
|
||||
const display = createAlprDisplay(h.engine, {
|
||||
state,
|
||||
source: { label: 'Fixture' },
|
||||
services: {
|
||||
context,
|
||||
render: { governorRequestRender() {} },
|
||||
groundFloor: { cachedGroundFloor: () => 150 },
|
||||
overlays: { refreshReadout: refreshTrackedReadout },
|
||||
},
|
||||
});
|
||||
let next;
|
||||
t.after(() => {
|
||||
display.destroy();
|
||||
a.destroy();
|
||||
next?.destroy();
|
||||
h.destroy();
|
||||
globalThis.window = original;
|
||||
});
|
||||
display.init(null, { onMove() {} });
|
||||
display.setVisible(true);
|
||||
display.renderRecords();
|
||||
display.selectRecord('alpr:0');
|
||||
assert.equal(a.trackedReadout.activeId(), 'alpr:0');
|
||||
const before = host.calls.length;
|
||||
records[0] = { ...records[0], operator: 'Changed operator' };
|
||||
state.recordById.set(records[0].id, records[0]);
|
||||
display.renderRecords();
|
||||
assert.ok(
|
||||
host.calls.length > before,
|
||||
'the active card receives its refreshed model',
|
||||
);
|
||||
assert.ok(
|
||||
host.last().details.some((line) => line.includes('CHANGED OPERATOR')),
|
||||
);
|
||||
next = createSceneServices(h.engine);
|
||||
next.trackedReadout.init();
|
||||
select(next.context, subject('Replacement'));
|
||||
display.clearSelection();
|
||||
display.selectRecord('alpr:0');
|
||||
assert.equal(
|
||||
next.trackedReadout.activeId(),
|
||||
'shared-id',
|
||||
'the old display cannot publish into the replacement context',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function createScreenAnnotationRenderer(
|
||||
{
|
||||
engine,
|
||||
overlayPaintRect = (...args) => getOverlayPaintRect(engine, ...args),
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
activeTrackedReadoutId = () => getActiveTrackedReadoutId(engine),
|
||||
store = useScreenAnnotationsStore(applicationPinia()),
|
||||
} = {},
|
||||
) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { nearFarScale } from '../shared/color.js';
|
||||
import { appClock } from '../../time/clock.js';
|
||||
@@ -81,7 +82,7 @@ export function createAircraftTracking({
|
||||
selectTrackedSubjectContext,
|
||||
clearTrackedSubjectContext,
|
||||
refreshTrackedSubjectContext,
|
||||
} = services.context;
|
||||
} = bindContextStore(services.context, () => flightState._renderer?.engine);
|
||||
const { isTr3b, tr3bConvertedIds } = services.aircraftPresentation;
|
||||
const { ensureGeoidReady, geoidHeight } = services.geoid;
|
||||
const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } =
|
||||
@@ -91,6 +92,12 @@ export function createAircraftTracking({
|
||||
const { selectionDispatcherFor } = services.selection;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
if (services.context.forEngine && flightState._renderer?.engine) {
|
||||
services.context
|
||||
.forEngine(flightState._renderer?.engine)
|
||||
?.publishAwareness(type, detail);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!window.dispatchEvent ||
|
||||
@@ -251,7 +258,10 @@ export function createAircraftTracking({
|
||||
trackedLabelText(icao24),
|
||||
trackedStyle.labelColor,
|
||||
);
|
||||
refreshTrackedReadout(flightState._trackedEntity);
|
||||
refreshTrackedReadout(
|
||||
flightState._trackedEntity,
|
||||
flightState._renderer?.engine,
|
||||
);
|
||||
// The readout and the context slot describe the same contact — refresh them
|
||||
// together so voice never narrates a fix the card has already replaced.
|
||||
refreshTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
||||
@@ -998,7 +1008,10 @@ export function createAircraftTracking({
|
||||
// that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead.
|
||||
flightState._trackedEntity.gevVisualPosition =
|
||||
parts.motion._trackedVisualCached;
|
||||
refreshTrackedReadout(flightState._trackedEntity);
|
||||
refreshTrackedReadout(
|
||||
flightState._trackedEntity,
|
||||
flightState._renderer?.engine,
|
||||
);
|
||||
// Camera follows the tracked entity (the renderer cancels any flight
|
||||
// first and applies the one exact tracked frame).
|
||||
// A resumed binding or a share restore re-follows the session's own
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
} = services.context.forEngine?.(engine) ?? services.context;
|
||||
const markers = new Map();
|
||||
const floors = createAlprFloorResolver(services.groundFloor);
|
||||
let normal = null,
|
||||
@@ -525,7 +525,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
|
||||
if (!chosen) state.selectedId = null;
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(shown);
|
||||
if (chosen) services.overlays?.refreshReadout?.(chosen);
|
||||
if (chosen) services.overlays?.refreshReadout?.(chosen, engine);
|
||||
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
|
||||
if (shown.length) presentOnMapCredit();
|
||||
else hideOnMapCredit();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
@@ -75,6 +76,7 @@ export function createCyclonesLayer({
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Cyclones require a snapshot source');
|
||||
let engine = null;
|
||||
context = bindContextStore(context, () => engine);
|
||||
hitTestOverlay ??= (...args) => hitTestWorldOverlay(engine, ...args);
|
||||
let rendering = null,
|
||||
snapshot = null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { filterFires } from '../../data/firmsIntensity.js';
|
||||
@@ -10,7 +11,10 @@ export function createIngestion({
|
||||
config,
|
||||
feed,
|
||||
}) {
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
const { clearSelectedEntityContextForLayer } = bindContextStore(
|
||||
services.context,
|
||||
() => layerState._renderer?.engine,
|
||||
);
|
||||
const { id } = config;
|
||||
|
||||
/** Replace a validated snapshot while preserving source freshness and selection identity. */
|
||||
@@ -65,6 +69,7 @@ export function createIngestion({
|
||||
// last-known values for this instead of tearing down.
|
||||
clearSelectedEntityContextForLayer(id, { evicted: true });
|
||||
}
|
||||
layerState._selectedFire = reselected;
|
||||
components.rendering.renderCurrentLod(true);
|
||||
if (reselected) components.selection.selectFire(reselected, false);
|
||||
layerState._rowControlsListener?.();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -8,7 +9,10 @@ export function createLifecycle({
|
||||
feed,
|
||||
}) {
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
const { clearSelectedEntityContextForLayer } = bindContextStore(
|
||||
services.context,
|
||||
() => layerState._renderer?.engine,
|
||||
);
|
||||
const { id, overlayHost } = config;
|
||||
|
||||
// The heatmap is draped on the renderer's globe; it goes with the renderer
|
||||
@@ -135,12 +139,12 @@ export function createLifecycle({
|
||||
components.selection.removeClickHandler();
|
||||
unregisterPickOwner(id, layerState._renderer?.engine);
|
||||
components.rendering.removeDetectionCollections();
|
||||
components.selection.clearContextRegistrations();
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
layerState._renderer?.destroy();
|
||||
layerState._renderer = null;
|
||||
layerState._viewer = null;
|
||||
layerState._dataSource = null;
|
||||
components.selection.clearContextRegistrations();
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
layerState._allFires = [];
|
||||
layerState._allFiresByFrp = [];
|
||||
layerState._fires = [];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import {
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
fireDetectionKey,
|
||||
@@ -19,8 +20,9 @@ export function createSelection({
|
||||
selectEntityContext,
|
||||
clearSelectedEntityContextForLayer,
|
||||
getContextStore,
|
||||
removeEntityContext = (id) => getContextStore()?.entities.delete(id),
|
||||
registerEntityContext,
|
||||
} = services.context;
|
||||
} = bindContextStore(services.context, () => layerState._renderer?.engine);
|
||||
const { id, name, overlayHost } = config;
|
||||
|
||||
/**
|
||||
@@ -143,9 +145,8 @@ export function createSelection({
|
||||
*/
|
||||
|
||||
function refreshContextRegistrations(fires) {
|
||||
let store = null;
|
||||
try {
|
||||
store = getContextStore();
|
||||
if (!getContextStore()) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -161,7 +162,7 @@ export function createSelection({
|
||||
}
|
||||
|
||||
for (const staleId of layerState._contextIds) {
|
||||
if (!nextIds.has(staleId)) store.entities.delete(staleId);
|
||||
if (!nextIds.has(staleId)) removeEntityContext(staleId);
|
||||
}
|
||||
layerState._contextIds = nextIds;
|
||||
}
|
||||
@@ -211,9 +212,8 @@ export function createSelection({
|
||||
function clearContextRegistrations() {
|
||||
if (!layerState._contextIds.size) return;
|
||||
try {
|
||||
const store = getContextStore();
|
||||
for (const recordId of layerState._contextIds)
|
||||
store.entities.delete(recordId);
|
||||
removeEntityContext(recordId);
|
||||
} catch {
|
||||
// store unavailable — nothing to clean
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@ export function createInfrastructureDisplay(
|
||||
engine,
|
||||
{ config, services = {} } = {},
|
||||
) {
|
||||
services = {
|
||||
...services,
|
||||
...(services.context?.forEngine?.(engine) ?? services.context),
|
||||
};
|
||||
if (!engine?.movers || !engine?.lines || !engine?.ground)
|
||||
throw new TypeError('The infrastructure display needs a globe engine');
|
||||
const {
|
||||
|
||||
@@ -160,7 +160,7 @@ export function createInstallationsDisplay(
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
} = services.context.forEngine?.(engine) ?? services.context;
|
||||
const namedMarkers = createNamedMarkers(engine, {
|
||||
state,
|
||||
services,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
@@ -108,7 +109,7 @@ export function createLocalAdsbLayer({
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
} = services.context;
|
||||
} = bindContextStore(services.context, () => renderer?.engine);
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
/** The engine's Local ADS-B display for this lifecycle (render/display.js). */
|
||||
@@ -434,7 +435,7 @@ export function createLocalAdsbLayer({
|
||||
const marker = selectedId ? markers.get(selectedId) : null;
|
||||
if (!marker?.entity) return;
|
||||
marker.entity.gevLabelModel = cardModel(marker, now());
|
||||
services.overlays?.refreshReadout?.(marker.entity);
|
||||
services.overlays?.refreshReadout?.(marker.entity, renderer?.engine);
|
||||
}
|
||||
|
||||
function updateRenderHold() {
|
||||
@@ -484,7 +485,8 @@ export function createLocalAdsbLayer({
|
||||
if (getSelectedEntityContext()?.id !== selectedId) {
|
||||
selectedId = null;
|
||||
releaseTrail();
|
||||
} else services.overlays?.refreshReadout?.(selected.entity);
|
||||
} else
|
||||
services.overlays?.refreshReadout?.(selected.entity, renderer?.engine);
|
||||
}
|
||||
requestEnrichment();
|
||||
updateRenderHold();
|
||||
@@ -672,13 +674,13 @@ export function createLocalAdsbLayer({
|
||||
renderHeld = false;
|
||||
renderer.detach();
|
||||
renderer.destroy();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
renderer = null;
|
||||
for (const marker of markers.values()) {
|
||||
marker.entity = null;
|
||||
marker.iconKind = marker.iconScale = null;
|
||||
marker.modelOwnsVisual = false;
|
||||
}
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
updateRenderHold();
|
||||
},
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { constantRgba } from '../shared/color.js';
|
||||
import { appClock } from '../../time/clock.js';
|
||||
@@ -28,7 +29,7 @@ export function createTracking({
|
||||
getContextStore,
|
||||
refreshTrackedSubjectContext,
|
||||
selectTrackedSubjectContext,
|
||||
} = services.context;
|
||||
} = bindContextStore(services.context, () => getRenderer()?.engine);
|
||||
const { refreshTrackedReadout } = services.readout;
|
||||
|
||||
function _normalizeTrackedNorad(candidate) {
|
||||
@@ -39,6 +40,12 @@ export function createTracking({
|
||||
}
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
if (services.context.forEngine && getRenderer()?.engine) {
|
||||
services.context
|
||||
.forEngine(getRenderer()?.engine)
|
||||
?.publishAwareness(type, detail);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!window.dispatchEvent ||
|
||||
@@ -407,7 +414,7 @@ export function createTracking({
|
||||
details,
|
||||
accent: '#ffd84d',
|
||||
};
|
||||
refreshTrackedReadout(layerState._trackedEntity);
|
||||
refreshTrackedReadout(layerState._trackedEntity, getRenderer()?.engine);
|
||||
}
|
||||
|
||||
function _trackSatellite(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
|
||||
@@ -15,7 +16,10 @@ export function createSelection({
|
||||
selectEntityContext,
|
||||
registerEntityContext,
|
||||
clearSelectedEntityContextForLayer,
|
||||
} = services.context;
|
||||
} = bindContextStore(
|
||||
services.context,
|
||||
() => vesselState.state.renderer?.engine,
|
||||
);
|
||||
const aisLiveVesselsLayer = layer;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindContextStore } from '../../data/bindContextStore.js';
|
||||
import { bindOverlayHost } from '../../overlays/bindOverlayHost.js';
|
||||
import { createPhotoSlots } from './photos.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
@@ -74,6 +75,7 @@ export function createWildlifeSightingsLayer({
|
||||
retryAt: 0,
|
||||
failures: 0,
|
||||
};
|
||||
context = bindContextStore(context, () => state.engine);
|
||||
overlayHost = bindOverlayHost(overlayHost, () => state.engine);
|
||||
const parts = {};
|
||||
const services = {
|
||||
|
||||
@@ -372,8 +372,11 @@ export function createSelection({ state, services, parts }) {
|
||||
const store = services.context.getContextStore?.();
|
||||
for (const id of state.contextIds) {
|
||||
if (!next.has(id)) {
|
||||
if (store?.entities?.get(id)?.layerId === 'wildlife-sightings')
|
||||
store.entities.delete(id);
|
||||
if (store?.entities?.get(id)?.layerId === 'wildlife-sightings') {
|
||||
if (services.context.removeEntityContext)
|
||||
services.context.removeEntityContext(id);
|
||||
else store.entities.delete(id);
|
||||
}
|
||||
state.carriers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ export class ShellScene extends ShellFacade {
|
||||
this._visualSettings.releaseIrBoost();
|
||||
this._cockpitCoordinator.destroy();
|
||||
this._layerBindings.disconnect();
|
||||
destroyTrackedReadout();
|
||||
destroyTrackedReadout(this._scene.engine);
|
||||
destroyDetection();
|
||||
destroyWorldOverlay(this._scene.engine);
|
||||
this._overlayActionsUnsubscribe?.();
|
||||
|
||||
Reference in new issue
Block a user