diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bbc070758..0d288d8ca 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3638,7 +3638,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > and mapped installations write explicit `labelModel` objects and > `displayPosition` getters, backed by their layer-owned frame/display cache, > into Core's entity records (`src/data/entityRecords.js`), never onto the -> Cesium entity. +> Cesium entity. A layer's private per-entity state goes in its own store from +> `createEntityStore()` (ALPR markers: `src/layers/alpr/markerState.js`). > Selected AIS vessels continue using the vessel source's protected selected > card, so they do not create a duplicate tracked readout. The host registers > the active readout in the protected tracked lane with a zero ambient quota; diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8e7516715..5fbd46610 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1957,6 +1957,7 @@ "src/layers/alpr/presentation.js", "src/layers/alpr/visuals.js", "src/layers/alpr/overlay.js", + "src/layers/alpr/markerState.js", "src/layers/alpr/source.js", "src/data/inputOwnership.js", "src/layers/alpr/records.js", @@ -2442,6 +2443,7 @@ "src/layers/alpr/presentation.js", "src/layers/alpr/visuals.js", "src/layers/alpr/overlay.js", + "src/layers/alpr/markerState.js", "src/layers/alpr/source.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", @@ -3347,6 +3349,7 @@ "src/layers/alpr/presentation.js", "src/layers/alpr/visuals.js", "src/layers/alpr/overlay.js", + "src/layers/alpr/markerState.js", "src/layers/alpr/source.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 977ae8e1f..a09dd6887 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -32,6 +32,7 @@ import { normalizeAlprNode, } from './alprCameras.js'; import { readEntityRecord } from './entityRecords.js'; +import { alprMarkers } from '../layers/alpr/markerState.js'; test('normalizeAlprNode: maps a full OSM node to a plain record', () => { const record = normalizeAlprNode({ @@ -1445,12 +1446,12 @@ test('map-source changes reposition all ALPR entities and saved native appearanc await layer.update(); const entities = [...h.source.entities.values]; const saved = entities[1]; - saved.gevAlprNativeAppearance = { + alprMarkers.write(saved).nativeAppearance = { position: saved.position, heightReference: saved.billboard.heightReference, }; saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190); - saved.gevAlprPickPosition = saved.position; + alprMarkers.write(saved).pickPosition = saved.position; for (const next of [150, 120, null, 180]) { floor = next; // Includes a provider switch with the same globe.show value. @@ -1471,10 +1472,10 @@ test('map-source changes reposition all ALPR entities and saved native appearanc ? Cesium.HeightReference.CLAMP_TO_GROUND : Cesium.HeightReference.NONE, ); - assert.equal(entity.gevAlprCanvasPosition, null); + assert.equal(alprMarkers.read(entity)?.canvasPosition, null); } assert.equal( - saved.gevAlprNativeAppearance, + alprMarkers.read(saved)?.nativeAppearance, null, 'saved appearance restores at the new floor', ); diff --git a/src/data/entityRecords.js b/src/data/entityRecords.js index 6b026fa09..d0fddf1dc 100644 --- a/src/data/entityRecords.js +++ b/src/data/entityRecords.js @@ -12,9 +12,34 @@ * - `visualPosition()`: where a 3D model is actually drawn, for the tracked card. * - `labelModel`: the tracked card's `{ title, details, accent, … }`. * - `selectionOrigin`: what started the selection (`'user'`, …). + * + * A layer's private per-entity state goes in its own store ({@link createEntityStore}). */ -const records = new WeakMap(); +/** + * A store of one record per renderer object, held by Core and dropped with the object. + * @returns {{ write: (entity: Object) => Object, read: (entity: unknown) => Object|null }} + * `write` makes the record empty on first use; `read` gives null when there is none. + */ +export function createEntityStore() { + const records = new WeakMap(); + return { + write(entity) { + let record = records.get(entity); + if (!record) { + record = {}; + records.set(entity, record); + } + return record; + }, + read(entity) { + if (!entity || typeof entity !== 'object') return null; + return records.get(entity) ?? null; + }, + }; +} + +const shared = createEntityStore(); /** * The record for `entity`, made empty on first use. For layers writing their own state. @@ -22,12 +47,7 @@ const records = new WeakMap(); * @returns {Object} */ export function entityRecord(entity) { - let record = records.get(entity); - if (!record) { - record = {}; - records.set(entity, record); - } - return record; + return shared.write(entity); } /** @@ -36,8 +56,7 @@ export function entityRecord(entity) { * @returns {Object|null} */ export function readEntityRecord(entity) { - if (!entity || typeof entity !== 'object') return null; - return records.get(entity) ?? null; + return shared.read(entity); } /** diff --git a/src/data/entityRecords.test.mjs b/src/data/entityRecords.test.mjs index 0f258953e..a47ac940c 100644 --- a/src/data/entityRecords.test.mjs +++ b/src/data/entityRecords.test.mjs @@ -1,6 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { entityRecord, readEntityRecord, withEntityRecord } from './entityRecords.js'; +import { + createEntityStore, + entityRecord, + readEntityRecord, + withEntityRecord, +} from './entityRecords.js'; test('an entity record is made on first write and read back by the same entity', () => { const entity = {}; @@ -29,3 +34,15 @@ test('withEntityRecord writes fields into the record and returns the entity', () assert.equal(readEntityRecord(entity)?.trackedId, 'alpr:42'); assert.equal(Object.hasOwn(entity, 'trackedId'), false); }); + +test('a layer store is separate from the shared records and from other stores', () => { + const entity = {}; + const a = createEntityStore(); + const b = createEntityStore(); + a.write(entity).canvasPosition = 1; + assert.equal(a.read(entity)?.canvasPosition, 1); + assert.equal(b.read(entity), null); + assert.equal(readEntityRecord(entity), null); + assert.equal(a.read(null), null); + assert.deepEqual(Object.keys(entity), []); +}); diff --git a/src/layers/alpr/markerState.js b/src/layers/alpr/markerState.js new file mode 100644 index 000000000..c2931663a --- /dev/null +++ b/src/layers/alpr/markerState.js @@ -0,0 +1,14 @@ +/** + * @module alpr/markerState + * @description The ALPR layer's own per-marker state, held in Core rather than on the + * Cesium entities it draws: + * - `record`: the camera record the marker shows. + * - `clamped`: whether the marker sits on the surface rather than at its height. + * - `canvasPosition`, `displayPosition`: the canvas anchor and the position the card reads. + * - `wedge`: the direction wedge's positions. + * - `nativeAppearance`, `pickPosition`: the native look saved while the canvas draws it. + * - `shown`, `surfaceCheckedFor`: the last visibility applied and the anchor last checked. + */ +import { createEntityStore } from '../../data/entityRecords.js'; + +export const alprMarkers = createEntityStore(); diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js index 202a98ed7..ea62468c9 100644 --- a/src/layers/alpr/overlay.js +++ b/src/layers/alpr/overlay.js @@ -18,6 +18,7 @@ import { paintDirectionWedge, validAlprGroundHeight, } from './visuals.js'; +import { alprMarkers } from './markerState.js'; /** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */ export function createAlprOverlay({ @@ -48,29 +49,30 @@ export function createAlprOverlay({ function canvasOwned(entity, owned) { const visible = !owned; const billboard = entity.billboard; + const marker = alprMarkers.write(entity); // Keep a faint native pick target under the canvas badge so sibling layer // handlers recognize ALPR ownership instead of treating the click as empty. - if (!visible && !entity.gevAlprNativeAppearance) { - entity.gevAlprNativeAppearance = { + if (!visible && !marker.nativeAppearance) { + marker.nativeAppearance = { position: entity.position, heightReference: billboard.heightReference, scaleByDistance: billboard.scaleByDistance, color: billboard.color, }; - entity.position = entity.gevAlprCanvasPosition; - entity.gevAlprPickPosition = entity.position; + entity.position = marker.canvasPosition; + marker.pickPosition = entity.position; billboard.heightReference = Cesium.HeightReference.NONE; billboard.scaleByDistance = undefined; billboard.color = Cesium.Color.WHITE.withAlpha(0.01); - } else if (visible && entity.gevAlprNativeAppearance) { - const saved = entity.gevAlprNativeAppearance; - if (entity.position === entity.gevAlprPickPosition) + } else if (visible && marker.nativeAppearance) { + const saved = marker.nativeAppearance; + if (entity.position === marker.pickPosition) entity.position = saved.position; billboard.heightReference = saved.heightReference; billboard.scaleByDistance = saved.scaleByDistance; billboard.color = saved.color; - entity.gevAlprNativeAppearance = null; - entity.gevAlprPickPosition = null; + marker.nativeAppearance = null; + marker.pickPosition = null; } } @@ -78,8 +80,9 @@ export function createAlprOverlay({ // and raises definitionChanged, which paint used to do for every overlay // camera on every frame. function setShown(entity, visible) { - if (entity.gevAlprShown === visible) return; - entity.gevAlprShown = visible; + const marker = alprMarkers.write(entity); + if (marker.shown === visible) return; + marker.shown = visible; entity.billboard.show = visible; if (entity.polyline) entity.polyline.show = visible; if (entity.polygon) entity.polygon.show = visible; @@ -87,9 +90,10 @@ export function createAlprOverlay({ function resetAnchors() { for (const entity of state.dataSource.entities.values) { - entity.gevAlprCanvasPosition = null; - entity.gevAlprWedge = null; - entity.gevAlprDisplayPosition = null; + const marker = alprMarkers.write(entity); + marker.canvasPosition = null; + marker.wedge = null; + marker.displayPosition = null; nativeVisible(entity, true); } state.lastAnchorSampleAt = 0; @@ -98,7 +102,8 @@ export function createAlprOverlay({ } function anchorFor(record, entity, budget) { - if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition; + const cached = alprMarkers.read(entity)?.canvasPosition; + if (cached) return cached; const scene = state.viewer.scene; const location = Cesium.Cartographic.fromDegrees( record.longitude, @@ -126,7 +131,7 @@ export function createAlprOverlay({ height = scene.globe.getHeight?.(location); if (!validAlprGroundHeight(height)) return null; setAnchor(record, entity, height); - return entity.gevAlprCanvasPosition; + return alprMarkers.read(entity)?.canvasPosition; } function floorHeightAt(latitude, longitude) { @@ -135,16 +140,13 @@ export function createAlprOverlay({ } function setAnchor(record, entity, height) { - entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees( + const marker = alprMarkers.write(entity); + marker.canvasPosition = Cesium.Cartesian3.fromDegrees( record.longitude, record.latitude, height, ); - entity.gevAlprWedge = directionWedgePositions( - record, - height, - floorHeightAt, - ); + marker.wedge = directionWedgePositions(record, height, floorHeightAt); } /** Whether the visible photoreal tiles (or globe) have finished streaming. */ @@ -192,8 +194,9 @@ export function createAlprOverlay({ let pending = false; for (const record of records) { const entity = state.dataSource.entities.getById(record.id); - const anchor = entity?.gevAlprCanvasPosition; - if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue; + const marker = alprMarkers.read(entity); + const anchor = marker?.canvasPosition; + if (!anchor || marker.surfaceCheckedFor === anchor) continue; if (!still || !surfaceSettled(scene) || budget.samples <= 0) { pending = true; continue; @@ -220,9 +223,10 @@ export function createAlprOverlay({ sample <= floor + SURFACE_ABOVE_FLOOR_M)); if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1) setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M); - entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition; + // setAnchor rewrote the same record, so this is the new anchor. + marker.surfaceCheckedFor = marker.canvasPosition; if (entity.id === state.selectedId) - entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition; + marker.displayPosition = marker.canvasPosition; } return pending; } @@ -272,7 +276,7 @@ export function createAlprOverlay({ setShown(entity, false); continue; } - const wedge = entity.gevAlprWedge; + const wedge = alprMarkers.read(entity)?.wedge; if (wedge) { const left = Cesium.SceneTransforms.worldToWindowCoordinates( scene, @@ -309,7 +313,7 @@ export function createAlprOverlay({ Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor), ), }); - if (selected) entity.gevAlprDisplayPosition = anchor; + if (selected) alprMarkers.write(entity).displayPosition = anchor; } // Paint all glyphs after the wedges so one camera's cone cannot wash out another. for (const { record, origin, selected, image, alpha, scale } of painted) { diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index c9aa8e5f5..a84510aac 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -8,6 +8,7 @@ import { alprLabelDetails, markerScale, } from './visuals.js'; +import { alprMarkers } from './markerState.js'; function harness(t, count = 2, { floor = null } = {}) { const previousImage = globalThis.Image; @@ -144,7 +145,9 @@ test('the overlay bounds its cohort and promotes selection while leaving farther h.state.selectedId = 'camera:69'; h.overlay.sync(h.records); h.paint(); - const hidden = h.entities.values.filter((e) => e.gevAlprNativeAppearance); + const hidden = h.entities.values.filter( + (e) => alprMarkers.read(e)?.nativeAppearance, + ); assert.equal(hidden.length, 64); assert.ok(hidden.some((e) => e.id === 'camera:69')); assert.equal( @@ -161,16 +164,16 @@ test('surface changes invalidate cached anchors, and disable/disposal release pa h.overlay.sync(h.records); h.paint(); const entity = h.entities.getById('camera:0'); - const first = entity.gevAlprCanvasPosition; + const first = alprMarkers.read(entity)?.canvasPosition; h.paint(); assert.equal( - entity.gevAlprCanvasPosition, + alprMarkers.read(entity)?.canvasPosition, first, 'stable anchor across frames', ); h.state.viewer.scene.globe.show = false; h.mapChanged(); - assert.equal(entity.gevAlprCanvasPosition, null); + assert.equal(alprMarkers.read(entity)?.canvasPosition, null); h.state.enabled = false; h.overlay.clear(); assert.equal(h.active, false); @@ -260,7 +263,7 @@ test('a still view replaces floor anchors with the rendered surface, within the h.paint(); const height = (id) => Cesium.Cartographic.fromCartesian( - h.entities.getById(id).gevAlprCanvasPosition, + alprMarkers.read(h.entities.getById(id))?.canvasPosition, ).height; assert.equal( samples, diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 8fa6d416b..9df584a7a 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -24,6 +24,7 @@ import { validAlprGroundHeight, } from './visuals.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; +import { alprMarkers } from './markerState.js'; export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; @@ -171,7 +172,7 @@ export function createAlprPresentation({ state, services, source }) { const heightReference = clamped ? Cesium.HeightReference.CLAMP_TO_GROUND : Cesium.HeightReference.NONE; - const saved = entity.gevAlprNativeAppearance; + const saved = alprMarkers.read(entity)?.nativeAppearance; if (saved) { saved.position = position; saved.heightReference = heightReference; @@ -179,7 +180,7 @@ export function createAlprPresentation({ state, services, source }) { entity.position = position; entity.billboard.heightReference = heightReference; } - entity.gevAlprClamped = clamped; + alprMarkers.write(entity).clamped = clamped; } /** Refresh all native placements, including the appearances held by the canvas. */ @@ -187,7 +188,9 @@ export function createAlprPresentation({ state, services, source }) { if (!state.enabled || !state.dataSource) return; clampedMarkers.clear(); for (const entity of state.dataSource.entities.values) { - const { position, clamped } = markerPlacement(entity.gevAlprRecord); + const { position, clamped } = markerPlacement( + alprMarkers.read(entity)?.record, + ); setMarkerPosition(entity, position, clamped); if (clamped) clampedMarkers.set(entity.id, entity); } @@ -203,10 +206,10 @@ export function createAlprPresentation({ state, services, source }) { const batch = [...clampedMarkers.values()]; const request = Promise.resolve( floors.resolve( - batch.map((entity) => ({ - lat: entity.gevAlprRecord.latitude, - lon: entity.gevAlprRecord.longitude, - })), + batch.map((entity) => { + const { record } = alprMarkers.read(entity); + return { lat: record.latitude, lon: record.longitude }; + }), ), ) .catch(() => {}) @@ -218,7 +221,9 @@ export function createAlprPresentation({ state, services, source }) { for (const entity of batch) { if (clampedMarkers.get(entity.id) !== entity) continue; clampedMarkers.delete(entity.id); - const { position, clamped } = markerPlacement(entity.gevAlprRecord); + const { position, clamped } = markerPlacement( + alprMarkers.read(entity)?.record, + ); if (clamped) continue; setMarkerPosition(entity, position, false); moved = true; @@ -370,7 +375,7 @@ export function createAlprPresentation({ state, services, source }) { removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds }); for (const record of visible) { const existing = state.dataSource.entities.getById(record.id); - if (existing?.gevAlprRecord === record) { + if (alprMarkers.read(existing)?.record === record) { updateAppearance(existing, record.id === state.selectedId); continue; } @@ -401,37 +406,39 @@ export function createAlprPresentation({ state, services, source }) { let entity = existing; // Keep the Cesium entity and its ground-clamping subscription while its // geometry is unchanged. Updating metadata must not rebuild the marker. - const previous = entity?.gevAlprRecord; + const previous = alprMarkers.read(entity)?.record; if (!entity) { entity = state.dataSource.entities.add(entityDef); - entity.gevAlprClamped = clamped; changed = true; + } + const marker = alprMarkers.write(entity); + if (!existing) { + marker.clamped = clamped; } else { if ( previous.latitude !== record.latitude || previous.longitude !== record.longitude ) { setMarkerPosition(entity, position, clamped); - entity.gevAlprCanvasPosition = null; - entity.gevAlprDisplayPosition = null; + marker.canvasPosition = null; + marker.displayPosition = null; } if ( previous.latitude !== record.latitude || previous.longitude !== record.longitude || previous.directionDeg !== record.directionDeg ) { - entity.gevAlprCanvasPosition = null; - entity.gevAlprWedge = null; + marker.canvasPosition = null; + marker.wedge = null; } } - entity.gevAlprRecord = record; + marker.record = record; entityRecord(entity).trackedId = record.id; // The mapped camera datum has no elevation; it is not the clamped marker's // visual anchor. Only the selected marker samples the rendered surface, // at most once per second, through Cesium's public height APIs. - entity.gevAlprDisplayPosition ??= null; - entityRecord(entity).displayPosition = () => - entity.gevAlprDisplayPosition; + marker.displayPosition ??= null; + entityRecord(entity).displayPosition = () => marker.displayPosition; entityRecord(entity).labelModel = { title: alprDisplayId(record), details: alprLabelDetails(record, source), @@ -473,7 +480,8 @@ export function createAlprPresentation({ state, services, source }) { if (changed) state.renderRevision += 1; state.onScreen = countOnScreen(); for (const entity of state.dataSource.entities.values) - if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity); + if (alprMarkers.read(entity)?.clamped) + clampedMarkers.set(entity.id, entity); resolveClampedFloors(); const selectedEntity = state.selectedId ? state.dataSource.entities.getById(state.selectedId) @@ -577,8 +585,9 @@ export function createAlprPresentation({ state, services, source }) { } const entity = state.dataSource?.entities.getById(state.selectedId); const record = state.recordById.get(state.selectedId); - if (entity?.gevAlprCanvasPosition) { - entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition; + const marker = alprMarkers.read(entity); + if (marker?.canvasPosition) { + marker.displayPosition = marker.canvasPosition; return; } if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000) @@ -607,11 +616,9 @@ export function createAlprPresentation({ state, services, source }) { record.latitude, height, ); - if ( - !entity.gevAlprDisplayPosition || - Cesium.Cartesian3.distance(next, entity.gevAlprDisplayPosition) > 0.1 - ) { - entity.gevAlprDisplayPosition = next; + const shown = marker?.displayPosition; + if (!shown || Cesium.Cartesian3.distance(next, shown) > 0.1) { + alprMarkers.write(entity).displayPosition = next; governorRequestRender('alpr-anchor'); } }