Answer earthquake, CCTV pick and tracked sprite reads from Core

Earthquake analyst records come from the events the layer drew, gated on
its own enabled flag, instead of reading each entity's position and
property bag back; the discs no longer carry the bag. CCTV coverage rays
and projection planes record their camera id in a layer pick store
instead of a cctvCameraId entity property, with the same ownership checks.

The tracked flight, military and local ADS-B billboards record their size
and distance scaling as the entity record's sprite, which the close-range
camera guard and the screen annotations' tracked footprint now read in
place of the billboard's property getters.
This commit is contained in:
Sameh Khamis committed 2026-11-03 13:32:53 -08:00
1 parent 66869b09ef
commit c5c0803901
16 files changed
+160 -96

No files matched your search

+3
View File
@@ -1867,6 +1867,7 @@
"src/layers/cctv/rendering.js",
"src/data/billboardMirror.js",
"src/layers/cctv/selection.js",
"src/layers/cctv/pickState.js",
"src/layers/cctv/source.js",
"src/layers/cctv/state.js",
"src/layers/cctv/testing.js",
@@ -2518,6 +2519,7 @@
"src/layers/cctv/videoPlayback.js",
"src/layers/cctv/rendering.js",
"src/layers/cctv/selection.js",
"src/layers/cctv/pickState.js",
"src/layers/cctv/source.js",
"src/layers/cctv/state.js",
"src/layers/cctv/testing.js",
@@ -3429,6 +3431,7 @@
"src/layers/cctv/videoPlayback.js",
"src/layers/cctv/rendering.js",
"src/layers/cctv/selection.js",
"src/layers/cctv/pickState.js",
"src/layers/cctv/source.js",
"src/layers/cctv/state.js",
"src/layers/cctv/testing.js",
+7 -7
View File
@@ -424,7 +424,6 @@ export function createScreenAnnotationRenderer(
let bottom = painted ? painted.y + painted.h : undefined;
const ent = viewer.trackedEntity;
const now = Cesium.JulianDate.now();
const displayPosition = readEntityRecord(ent)?.displayPosition;
const world =
typeof displayPosition === 'function' ? displayPosition() : null;
@@ -436,13 +435,14 @@ export function createScreenAnnotationRenderer(
)
: null;
// Billboard box — centered on the anchor, magnified by scaleByDistance.
const bb = ent?.billboard;
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
const baseW = bb.width?.getValue?.(now) ?? 28;
const baseH = bb.height?.getValue?.(now) ?? 28;
// Billboard box — centered on the anchor, magnified by scaleByDistance, from
// the size the tracking layer recorded for its sprite.
const sprite = readEntityRecord(ent)?.sprite;
if (sprite && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
const baseW = sprite.width ?? 28;
const baseH = sprite.height ?? 28;
let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now);
const sbd = sprite.scaleByDistance;
if (sbd)
scale = nearFarValue(
sbd,
+10 -4
View File
@@ -80,6 +80,7 @@ import {
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
import { mirrorBillboard } from './billboardMirror.js';
import { cctvPicks } from '../layers/cctv/pickState.js';
const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180;
@@ -1130,10 +1131,10 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
test('CCTV camera extraction requires layer ownership, not a colliding sibling ID', () => {
const billboard = { id: 'shared-id' };
const ownedCoverageEntity = {
id: 'cctv-shared-id-cap',
properties: { cctvCameraId: 'shared-id' },
};
const ownedCoverageEntity = { id: 'cctv-shared-id-cap' };
cctvPicks.write(ownedCoverageEntity).cameraId = 'shared-id';
const strayEntity = { id: 'cctv-shared-id-stray' };
cctvPicks.write(strayEntity).cameraId = 'shared-id';
const record = {
...makeDeselectRecord('shared-id'),
billboard,
@@ -1176,6 +1177,11 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
null,
'a sibling cannot impersonate CCTV by copying its property shape',
);
assert.equal(
_extractPickedCameraIdForTest({ id: strayEntity }),
null,
'a camera id alone is not ownership: the record must hold the entity',
);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
+2 -2
View File
@@ -118,8 +118,8 @@ export function signedAngleDelta(fromRad, toRad) {
// ---------------------------------------------------------------------------
/** Entity id prefix for every gizmo part — the layer's pick-owner regex and
* click-to-select guard key off this. Gizmo entities NEVER carry a
* `cctvCameraId` property, so the layer's selection path ignores them. */
* click-to-select guard key off this. Gizmo entities NEVER get a camera id in
* the layer's pick store, so the layer's selection path ignores them. */
export const GIZMO_ID_PREFIX = 'cctv-gizmo-';
const RING_SEGMENTS = 96;
+2
View File
@@ -12,6 +12,8 @@
* - `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'`, …).
* - `sprite`: a tracked billboard's pushed `{ width, height, scale, scaleByDistance }`, for
* the close-range camera guard and the screen annotations' tracked footprint.
*
* A layer's private per-entity state goes in its own store ({@link createEntityStore}).
*/
+6 -3
View File
@@ -17,6 +17,7 @@ import {
requiredPlaneLift,
} from '../../data/cctvFootprint.js';
import { setBillboard } from '../../data/billboardMirror.js';
import { cctvPicks } from './pickState.js';
export function createGeometry({ state: layerState, services, parts, source }) {
const { warmGroundFloor, cachedGroundFloor } = services.ground;
@@ -700,16 +701,18 @@ export function createGeometry({ state: layerState, services, parts, source }) {
record.position = positions.mount;
}
const addPolyline = (role, linePositions) =>
layerState._viewer.entities.add({
const addPolyline = (role, linePositions) => {
const entity = layerState._viewer.entities.add({
id: `cctv-${camera.id}-${role}`,
properties: { cctvCameraId: camera.id },
polyline: {
positions: linePositions,
width: 1.2,
material: IDLE_COVERAGE_COLOR,
},
});
cctvPicks.write(entity).cameraId = camera.id;
return entity;
};
const entities = [
addPolyline('ray-tl', [positions.mount, positions.tl]),
+7
View File
@@ -0,0 +1,7 @@
import { createEntityStore } from '../../data/entityRecords.js';
/**
* The camera a picked coverage ray or projection plane belongs to: `cameraId`
* is the upstream camera id. Gizmo entities never get one.
*/
export const cctvPicks = createEntityStore();
+2 -1
View File
@@ -7,6 +7,7 @@ import {
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
} from './policy.js';
import { cctvPicks } from './pickState.js';
export function createProjection({
state: layerState,
@@ -134,7 +135,6 @@ export function createProjection({
});
runtime.planeEntity = layerState._viewer.entities.add({
id: `cctv-${record.camera.id}-plane`,
properties: { cctvCameraId: record.camera.id },
show: false,
position: positions.capCenter,
orientation: parts.model.planeOrientationFor(
@@ -152,6 +152,7 @@ export function createProjection({
outlineColor: PLANE_OUTLINE_COLOR,
},
});
cctvPicks.write(runtime.planeEntity).cameraId = record.camera.id;
return runtime.planeEntity;
}
+6 -9
View File
@@ -3,6 +3,7 @@ import {
isTrackingClickGesture,
} from '../../data/trackingClickGesture.js';
import * as Cesium from 'cesium';
import { cctvPicks } from './pickState.js';
export function createSelection({
state: layerState,
@@ -207,7 +208,7 @@ export function createSelection({
/**
* Extracts a camera ID from a Cesium pick result by checking billboard IDs,
* primitive IDs, and entity cctvCameraId properties.
* primitive IDs, and the camera ids the layer keeps for its coverage entities.
* @param {Object|null} picked - Result from scene.pick().
* @returns {string|null} Camera ID, or null if the pick is not a CCTV entity.
*/
@@ -215,17 +216,13 @@ export function createSelection({
function extractPickedCameraId(picked) {
if (!picked) return null;
const entity = picked.id?.properties
const entity = cctvPicks.read(picked.id)
? picked.id
: picked.primitive?.id?.properties
: cctvPicks.read(picked.primitive?.id)
? picked.primitive.id
: null;
const maybeProp = entity?.properties?.cctvCameraId;
if (maybeProp) {
const value =
typeof maybeProp.getValue === 'function'
? maybeProp.getValue(Cesium.JulianDate.now())
: maybeProp;
const value = cctvPicks.read(entity)?.cameraId;
if (value !== undefined) {
const record =
typeof value === 'string' ? layerState._recordById.get(value) : null;
const ownsCoverageEntity = Boolean(
+21 -38
View File
@@ -23,6 +23,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
let _viewer = null;
let _request = null;
let _dataSource = null;
// What the discs were drawn from, so analyst reads come from Core, not the entities.
let _events = [];
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
@@ -77,6 +79,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
return false;
const nextEntities = [];
const nextEvents = [];
let count = 0;
const overlayEntries = [];
@@ -98,6 +101,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
const outlineAlpha = isSignificant ? 1.0 : 0.8;
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
nextEvents.push({ usgsId, mag, place, time, depthKm, lon, lat });
nextEntities.push(
new Cesium.Entity({
id: `earthquake:${stableId}`,
@@ -115,14 +119,6 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
outlineWidth: isSignificant ? 3 : 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
properties: {
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
usgsId,
mag,
place,
time,
depth: depthKm,
},
}),
);
overlayEntries.push(
@@ -137,6 +133,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
_events = nextEvents;
if (_enabled) {
overlayHost.setEntries(
EARTHQUAKE_OVERLAY_SOURCE_ID,
@@ -176,6 +173,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_events = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
@@ -190,39 +188,24 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
const entities = _dataSource.entities.values;
if (!entities.length) return [];
if (!_dataSource || !_enabled || !_events.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const now = Cesium.JulianDate.now();
const result = [];
for (const entity of entities) {
if (result.length >= limit) break;
const cartesian = entity.position
? entity.position.getValue(now)
: null;
const carto = cartesian
? Cesium.Cartographic.fromCartesian(cartesian)
: null;
const p = entity.properties;
result.push(
mapAnalystRecord(
{
id: p?.usgsId?.getValue(now) ?? null,
mag: p?.mag?.getValue(now),
place: p?.place?.getValue(now),
time: p?.time?.getValue(now),
depth: p?.depth?.getValue(now),
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
},
result.length,
),
);
}
return result;
return _events.slice(0, limit).map((event, index) =>
mapAnalystRecord(
{
id: event.usgsId ?? null,
mag: event.mag,
place: event.place,
time: event.time,
depth: event.depthKm,
lat: event.lat,
lon: event.lon,
},
index,
),
);
},
getStats() {
+35
View File
@@ -88,3 +88,38 @@ test('USGS body completion honors cancellation even with an uncooperative transp
name: 'AbortError',
});
});
test('analyst records come from the events the layer drew, and only while it is on', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: 'event-b', usgsId: 'event-b', mag: 5.5 },
],
});
await h.layer.update(h.viewer);
assert.deepEqual(h.layer.getAnalystRecords(), [
{
id: 'event-a',
magnitude: 4,
depthKm: 3,
lat: 20,
lon: 30,
timeMs: 1000,
place: 'Fixture',
},
{
id: 'event-b',
magnitude: 5.5,
depthKm: 3,
lat: 20,
lon: 30,
timeMs: 1000,
place: 'Fixture',
},
]);
assert.equal(h.layer.getAnalystRecords(1).length, 1);
const [entity] = h.sources[0].entities.values;
assert.equal(entity.properties, undefined, 'the entity carries drawing only');
h.layer.disable(h.viewer);
assert.deepEqual(h.layer.getAnalystRecords(), []);
h.layer.destroy(h.viewer);
});
+6 -7
View File
@@ -37,6 +37,7 @@ import {
} from './policy.js';
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
import { modelState } from '../../data/modelMirror.js';
import { readEntityRecord } from '../../data/entityRecords.js';
export function createMotion({
flightState,
@@ -372,16 +373,14 @@ export function createMotion({
);
}
const billboard = flightState._trackedEntity?.billboard;
const time = flightState._viewer.clock.currentTime;
const width = billboard?.width?.getValue(time) ?? 28;
const height = billboard?.height?.getValue(time) ?? 28;
const sprite = readEntityRecord(flightState._trackedEntity)?.sprite;
const width = sprite?.width ?? 28;
const height = sprite?.height ?? 28;
const scale =
billboard?.scale?.getValue(time) ??
sprite?.scale ??
(CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1);
const scaleByDistance =
billboard?.scaleByDistance?.getValue(time) ??
TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
sprite?.scaleByDistance ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM);
return Math.max(width, height) * scale * distanceScale;
}
+17 -7
View File
@@ -23,6 +23,7 @@ import {
RENDER_DELAY_SEC,
TRAIL_COLOR,
CYAN_TRANSPARENT,
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
@@ -952,6 +953,17 @@ export function createTracking({
// entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel).
// Keep Cesium's generated entity ID: re-init without destroy can temporarily
// overlap collections, and an explicit ICAO ID would throw on that duplicate.
// What the tracked billboard is drawn at, kept in its entity record so the
// camera guard and screen annotations size it from Core.
const sprite = {
width: 28,
height: 28,
scale:
CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1,
scaleByDistance: TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
};
flightState._trackedEntity = flightState._viewer.entities.add({
position: positionProperty,
// Force Cesium's built-in EntityView and our close-range camera guard to
@@ -967,12 +979,9 @@ export function createTracking({
),
TRACKED_ICON_PX,
),
width: 28,
height: 28,
scale:
CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1,
width: sprite.width,
height: sprite.height,
scale: sprite.scale,
// Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(
@@ -983,7 +992,7 @@ export function createTracking({
false,
),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
scaleByDistance: sprite.scaleByDistance,
alignedAxis: Cesium.Cartesian3.ZERO,
// The tracked target must never vanish into tile geometry — tracking a
// taxiing plane at street level would otherwise bury the cyan icon inside
@@ -1016,6 +1025,7 @@ export function createTracking({
});
entityRecord(flightState._trackedEntity).selectionOrigin = origin;
entityRecord(flightState._trackedEntity).trackedId = `flights:${icao24}`;
entityRecord(flightState._trackedEntity).sprite = sprite;
entityRecord(flightState._trackedEntity).labelModel =
trackedLabelModelFromText(_trackedLabelText(icao24), '#39d0ff');
+12 -3
View File
@@ -45,6 +45,12 @@ const FEET_TO_METERS = 0.3048;
const RENDER_HOLD_ID = 'local-adsb';
/** Picks looked through under a click for a local aircraft. */
const CLICK_DRILL_LIMIT = 8;
/** Marker billboard size and distance scaling, as pushed and as recorded. */
const MARKER_SPRITE = Object.freeze({
width: 20,
height: 20,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
});
/** Trail lines pass through their aircraft; a click never stops on one. */
const TRAIL_PICK_PREFIX = 'gev-trail:';
/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */
@@ -265,6 +271,7 @@ export function createLocalAdsbLayer({
if (marker.iconScale !== scale) {
marker.iconScale = scale;
billboard.scale = scale;
entityRecord(marker.entity).sprite.scale = scale;
}
}
@@ -437,12 +444,12 @@ export function createLocalAdsbLayer({
position: new Cesium.CallbackProperty(() => marker.position, false),
billboard: {
image: aircraftIcon('airliner'),
width: 20,
height: 20,
width: MARKER_SPRITE.width,
height: MARKER_SPRITE.height,
scale: 1,
color,
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
scaleByDistance: MARKER_SPRITE.scaleByDistance,
alignedAxis: Cesium.Cartesian3.ZERO,
rotation: new Cesium.CallbackProperty(
() => markerRotation(marker),
@@ -460,6 +467,8 @@ export function createLocalAdsbLayer({
},
});
entityRecord(marker.entity).trackedId = id;
// Sized from Core when Cesium tracks the marker (screen annotations).
entityRecord(marker.entity).sprite = { ...MARKER_SPRITE, scale: 1 };
entityRecord(marker.entity).displayPosition = () => marker.position;
}
refreshClass(marker);
+6 -7
View File
@@ -25,6 +25,7 @@ import {
COURSE_MAX_DPS,
} from './policy.js';
import { modelState } from '../../data/modelMirror.js';
import { readEntityRecord } from '../../data/entityRecords.js';
export function createMotion({
flightState,
@@ -250,17 +251,15 @@ export function createMotion({
);
}
const billboard = flightState._trackedEntity?.billboard;
const time = flightState._viewer.clock.currentTime;
const width = billboard?.width?.getValue(time) ?? 28;
const height = billboard?.height?.getValue(time) ?? 28;
const sprite = readEntityRecord(flightState._trackedEntity)?.sprite;
const width = sprite?.width ?? 28;
const height = sprite?.height ?? 28;
const scale =
billboard?.scale?.getValue(time) ??
sprite?.scale ??
BILLBOARD_SCALE *
(CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1);
const scaleByDistance =
billboard?.scaleByDistance?.getValue(time) ??
TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
sprite?.scaleByDistance ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM);
return Math.max(width, height) * scale * distanceScale;
}
+18 -8
View File
@@ -24,6 +24,7 @@ import {
GROUND_SCALE,
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
@@ -915,6 +916,18 @@ export function createTracking({
// The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D
// model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is
// what makes the follow-camera's bounding sphere always ready (see _trackedModel).
// What the tracked billboard is drawn at, kept in its entity record so the
// camera guard and screen annotations size it from Core.
const sprite = {
width: 28,
height: 28,
scale:
BILLBOARD_SCALE *
(CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1),
scaleByDistance: TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
};
flightState._trackedEntity = flightState._viewer.entities.add({
position: positionProperty,
// Keep Cesium's EntityView in the same ENU frame as the close-range
@@ -928,13 +941,9 @@ export function createTracking({
),
TRACKED_ICON_PX,
),
width: 28,
height: 28,
scale:
BILLBOARD_SCALE *
(CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1),
width: sprite.width,
height: sprite.height,
scale: sprite.scale,
// Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(
@@ -945,7 +954,7 @@ export function createTracking({
false,
),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
scaleByDistance: sprite.scaleByDistance,
alignedAxis: Cesium.Cartesian3.ZERO,
// The tracked target must never vanish into tile geometry — tracking a
// taxiing plane at street level would otherwise bury the amber icon inside
@@ -978,6 +987,7 @@ export function createTracking({
});
entityRecord(flightState._trackedEntity).selectionOrigin = origin;
entityRecord(flightState._trackedEntity).trackedId = `military:${icao24}`;
entityRecord(flightState._trackedEntity).sprite = sprite;
entityRecord(flightState._trackedEntity).labelModel =
trackedLabelModelFromText(_buildTrackedLabel(info, icao24), '#ffd166');