Keep ALPR marker state in a layer store instead of on Cesium entities

The ALPR layer's private per-entity fields (record, clamp, canvas and display
anchors, wedge, native appearance, pick position, shown, surface check) move
from gevAlpr* expandos into alprMarkers, a store made by the new
createEntityStore(). The shared entity records are built on the same factory.
This commit is contained in:
Sameh Khamis committed 2026-11-03 08:24:33 -08:00
1 parent 95c0070412
commit 07c8d7a17c
9 files changed
+144 -75

No files matched your search

+2 -1
View File
@@ -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;
+3
View File
@@ -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",
+5 -4
View File
@@ -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',
);
+28 -9
View File
@@ -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);
}
/**
+18 -1
View File
@@ -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), []);
});
+14
View File
@@ -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();
+32 -28
View File
@@ -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) {
+8 -5
View File
@@ -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,
+34 -27
View File
@@ -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');
}
}