Port ALPR camera visuals into the shared layer

Add camera badges, gradient direction wedges and animated tactical selection cards while preserving bounded loading and layer lifecycle. Align the ALPR data-layer header and retain native pick ownership for overlay badges.

Co-authored-by: manjunath22466 <[email protected]>
This commit is contained in:
Sameh Khamisandmanjunath22466 committed 2026-09-14 17:35:24 -07:00
1 parent 687edc4da7
commit 87629ac175
21 files changed
+951 -97

No files matched your search

+6
View File
@@ -30,6 +30,12 @@
## ALPR camera locations ## ALPR camera locations
- Port Manjunath's (@manjunath22466) cyan camera badges, coral selection brackets,
gradient direction wedges and animated tactical labels into the reusable ALPR
layer. Keep bounded source loading, stable entities, selection and SHOW NEAREST.
- Align the ALPR layer-row header with other layers, keeping the toggle beside
the name instead of wrapping it onto its own line.
- Label the loaded camera count as nearby, show a purple-dot legend, and add - Label the loaded camera count as nearby, show a purple-dot legend, and add
SHOW NEAREST to frame and select a loaded camera when none are on screen, SHOW NEAREST to frame and select a loaded camera when none are on screen,
using the available 3D-tile or globe terrain height. using the available 3D-tile or globe terrain height.
+9 -2
View File
@@ -89,8 +89,15 @@ registration supplies selection, picking, terrain and
render services. Selected cards and Data attribution identify OpenStreetMap. render services. Selected cards and Data attribution identify OpenStreetMap.
Layer state and the existing voice layer tools include `alpr-cameras`. Layer state and the existing voice layer tools include `alpr-cameras`.
The row count explicitly says **nearby**: loaded records can be outside the The row count explicitly says **nearby**: loaded records can be outside the
screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when screen. Cyan camera badges use Manjunath's camera artwork; selection switches to a
selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is larger coral badge with corner brackets and an animated tactical card. Numeric
mapped bearings draw illustrative 90 m direction wedges (not measured coverage).
The shared overlay paints gradients for at most 64 nearby cameras, promoting the
selected camera; farther cameras retain native Cesium badges and ground-clamped
wedges. Unknown bearings never acquire a wedge. Asset URLs resolve relative to
the reusable layer, and the caller supplies overlay and map-stack services.
The ALPR row uses the same header layout as other data layers. **SHOW NEAREST**
frames and selects the nearest loaded camera; it is
disabled with no loaded cameras or while following a tracked entity. This action disabled with no loaded cameras or while following a tracked entity. This action
chooses from the current source snapshot and uses rendered surface height from chooses from the current source snapshot and uses rendered surface height from
3D tiles or globe terrain when available. 3D tiles or globe terrain when available.
+3
View File
@@ -476,6 +476,9 @@
"src/layers/alpr/model.js", "src/layers/alpr/model.js",
"src/layers/alpr/policy.js", "src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js", "src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/overlay.test.mjs",
"src/layers/alpr/source.js", "src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs", "src/layers/alpr/source.test.mjs",
"src/layers/radio/catalogModel.js", "src/layers/radio/catalogModel.js",
+6
View File
@@ -925,6 +925,8 @@
"src/layers/alpr/model.js", "src/layers/alpr/model.js",
"src/layers/alpr/policy.js", "src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js", "src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/source.js" "src/layers/alpr/source.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1215,6 +1217,8 @@
"src/layers/alpr/model.js", "src/layers/alpr/model.js",
"src/layers/alpr/policy.js", "src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js", "src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/source.js", "src/layers/alpr/source.js",
"src/layers/awareness/controls.js", "src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js", "src/layers/awareness/dependencies.js",
@@ -1665,6 +1669,8 @@
"src/layers/alpr/model.js", "src/layers/alpr/model.js",
"src/layers/alpr/policy.js", "src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js", "src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/source.js", "src/layers/alpr/source.js",
"src/layers/awareness/controls.js", "src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js", "src/layers/awareness/dependencies.js",
+19 -1
View File
@@ -2,12 +2,30 @@ import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js'; import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import { registerWorldOverlayPaintLane } from '../../overlays/worldOverlay.js';
import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/** Construct one layer using the application scene owners and a supplied source. */ /** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationAlpr({ surface, source }) { export function createApplicationAlpr({ surface, source }) {
const { groundFloor } = surface; const { groundFloor } = surface;
return createAlprCamerasLayer({ return createAlprCamerasLayer({
source, source,
services: { render, context, picking, groundFloor }, services: {
render,
context,
picking,
groundFloor,
overlays: {
registerPaintLane: registerWorldOverlayPaintLane,
keyholeAlpha: keyholeLabelAlphaFromGeometry,
refreshReadout: refreshTrackedReadout,
subscribeMapStack(callback) {
window.addEventListener('gev:map-stack-changed', callback);
return () =>
window.removeEventListener('gev:map-stack-changed', callback);
},
},
},
}); });
} }
+14 -18
View File
@@ -462,29 +462,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
assert.equal(alprCamerasLayer.name, 'ALPR Cameras'); assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
assert.deepEqual( assert.deepEqual(
entities.map((entity) => entity.gevLabelModel.title), entities.map((entity) => entity.gevLabelModel.title),
Array(3).fill('ALPR CAMERA'), ['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
); );
assert.equal( assert.equal(
new Set( new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
entities.map((entity) =>
entity.point.color.getValue().toCssColorString(),
),
).size,
1, 1,
); );
assert.deepEqual(entities[0].gevLabelModel.details, [ assert.deepEqual(entities[0].gevLabelModel.details, [
'Manufacturer: Flock Safety', 'OSM MAPPED',
'Operator: City Police', 'FLOCK SAFETY · CITY POLICE · FIXED',
'Type: fixed', 'PUBLIC MAP DATA',
'Source: OpenStreetMap',
]); ]);
assert.deepEqual(entities[1].gevLabelModel.details, [ assert.deepEqual(entities[1].gevLabelModel.details, [
'Manufacturer: Motorola Solutions', 'OSM MAPPED',
'Source: OpenStreetMap', 'MOTOROLA SOLUTIONS',
'PUBLIC MAP DATA',
]); ]);
assert.deepEqual( assert.deepEqual(
entities[2].gevLabelModel.details, entities[2].gevLabelModel.details,
['Source: OpenStreetMap'], ['OSM MAPPED', 'PUBLIC MAP DATA'],
'unknown metadata is not guessed, but the source is always named', 'unknown metadata is not guessed, but the source is always named',
); );
for (const entity of entities) { for (const entity of entities) {
@@ -523,8 +519,8 @@ test('clicking the selected camera or empty map deselects, including after refre
await alprCamerasLayer.update(); await alprCamerasLayer.update();
assert.equal(getSelectedEntityContext(), null); assert.equal(getSelectedEntityContext(), null);
assert.equal( assert.equal(
h.source.entities.getById('alpr:42').point.pixelSize.getValue(), h.source.entities.getById('alpr:42').billboard.width.getValue(),
8, 38,
); );
} }
} finally { } finally {
@@ -582,8 +578,8 @@ test('disable removes contexts and highlight; cache reuse after re-enable does n
await alprCamerasLayer.update(); await alprCamerasLayer.update();
assert.equal(getSelectedEntityContext(), null); assert.equal(getSelectedEntityContext(), null);
assert.equal( assert.equal(
h.source.entities.getById('alpr:42').point.pixelSize.getValue(), h.source.entities.getById('alpr:42').billboard.width.getValue(),
8, 38,
); );
} finally { } finally {
h.restore(); h.restore();
@@ -1100,7 +1096,7 @@ test('nearby count and discovery control frame a real loaded camera without fetc
await alprCamerasLayer.update(); await alprCamerasLayer.update();
assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby'); assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby');
const controls = alprCamerasLayer.getRowControls(); const controls = alprCamerasLayer.getRowControls();
assert.equal(controls.legend[0].label, 'Purple dots'); assert.equal(controls.legend[0].label, 'Camera badges');
assert.equal(controls.chips[0].onClick(), true); assert.equal(controls.chips[0].onClick(), true);
assert.equal(flights.length, 1); assert.equal(flights.length, 1);
assert.equal(getSelectedEntityContext().id, 'alpr:42'); assert.equal(getSelectedEntityContext().id, 'alpr:42');
+10 -2
View File
@@ -142,8 +142,16 @@ export function createTrackedOverlayEntry(entity) {
title, title,
details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [], details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [],
accent: model.accent || WORLD_OVERLAY_STYLE.accent, accent: model.accent || WORLD_OVERLAY_STYLE.accent,
anchorRadiusPx: 10, cardStyle: model.cardStyle,
anchorRadiusScale: TRACKED_BILLBOARD_SCALE, selected: model.selected === true,
leaderStyle: model.leaderStyle,
leaderAnimationMs: model.leaderAnimationMs,
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
leaderDrawRatio: model.leaderDrawRatio,
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
? Math.max(0, Number(model.anchorRadiusPx)) : 10,
anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale')
? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE,
minAnchorGapPx: 16, minAnchorGapPx: 16,
anchorGapPaddingPx: 10, anchorGapPaddingPx: 10,
verticalOnly: true, verticalOnly: true,
+20
View File
@@ -121,6 +121,26 @@ test('tracked entry factory pins the production protected-lane policy', () => {
assert.equal(entry.edgeFade, 'keyhole'); assert.equal(entry.edgeFade, 'keyhole');
}); });
test('selected camera readout carries tactical animation and fixed badge clearance', () => {
const entry = createTrackedOverlayEntry({
id: 'alpr:42', gevDisplayPosition: () => ({ x: 1, y: 2, z: 3 }),
gevLabelModel: {
title: 'ALPR-0042', details: ['OSM MAPPED'], accent: '#ff6474',
cardStyle: 'tactical', selected: true, leaderStyle: 'elbow',
leaderAnimationMs: 440, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.68,
anchorRadiusPx: 30, anchorRadiusScale: null,
},
});
assert.equal(entry.cardStyle, 'tactical');
assert.equal(entry.selected, true);
assert.equal(entry.leaderStyle, 'elbow');
assert.equal(entry.leaderAnimationMs, 440);
assert.equal(entry.leaderAnimationStartedAt, 100);
assert.equal(entry.leaderDrawRatio, 0.68);
assert.equal(entry.anchorRadiusPx, 30);
assert.equal(entry.anchorRadiusScale, null);
});
test('tracked entity publishes a protected host entry backed by the frame cache', () => { test('tracked entity publishes a protected host entry backed by the frame cache', () => {
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
const fakeWindow = new EventTarget(); const fakeWindow = new EventTarget();
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+6 -3
View File
@@ -69,7 +69,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
lastQueryBox: null, lastQueryBox: null,
}; };
const { const {
markerColor, initOverlay,
destroyOverlay,
viewportBox, viewportBox,
clearRendered, clearRendered,
hideOnMapCredit, hideOnMapCredit,
@@ -250,6 +251,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
viewer.camera.moveEnd.addEventListener(scheduleLoad); viewer.camera.moveEnd.addEventListener(scheduleLoad);
state.postRenderRemove = state.postRenderRemove =
viewer.scene.postRender?.addEventListener(updateSelectedAnchor); viewer.scene.postRender?.addEventListener(updateSelectedAnchor);
initOverlay();
installInteraction(viewer); installInteraction(viewer);
}, },
enable() { enable() {
@@ -281,6 +283,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
}, },
destroy(viewer = state.viewer) { destroy(viewer = state.viewer) {
this.disable(); this.disable();
destroyOverlay();
state.moveEndRemove?.(); state.moveEndRemove?.();
state.moveEndRemove = null; state.moveEndRemove = null;
state.postRenderRemove?.(); state.postRenderRemove?.();
@@ -320,11 +323,11 @@ export function createAlprCamerasLayer({ source, services } = {}) {
], ],
legend: [ legend: [
{ {
label: 'Purple dots', label: 'Camera badges',
color: ALPR_COLOR, color: ALPR_COLOR,
count, count,
blurb: blurb:
'Nearby mapped cameras may be outside the screen. Dots stay 8 pixels wide as you zoom; click one for details.', 'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
}, },
], ],
}; };
+298
View File
@@ -0,0 +1,298 @@
import * as Cesium from 'cesium';
import {
MAX_CANVAS_FRUSTUMS,
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
} from './policy.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
BRACKETS_IMAGE,
directionWedgePositions,
paintDirectionWedge,
validAlprGroundHeight,
} from './visuals.js';
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({ state, services }) {
let lane,
removeMapListener,
retryTimer,
retryUntil = 0;
let records = [],
hits = [],
images = [],
destroyed = false;
let surfaceRegime;
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
function nativeVisible(entity, visible) {
if (!entity) return;
const billboard = entity.billboard;
// 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 = {
position: entity.position,
heightReference: billboard.heightReference,
scaleByDistance: billboard.scaleByDistance,
color: billboard.color,
};
entity.position = entity.gevAlprCanvasPosition;
entity.gevAlprPickPosition = 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)
entity.position = saved.position;
billboard.heightReference = saved.heightReference;
billboard.scaleByDistance = saved.scaleByDistance;
billboard.color = saved.color;
entity.gevAlprNativeAppearance = null;
entity.gevAlprPickPosition = null;
}
billboard.show = visible;
if (entity.polyline) entity.polyline.show = visible;
if (entity.polygon) entity.polygon.show = visible;
}
function resetAnchors() {
for (const entity of state.dataSource.entities.values) {
entity.gevAlprCanvasPosition = null;
entity.gevAlprWedge = null;
entity.gevAlprDisplayPosition = null;
nativeVisible(entity, true);
}
state.lastAnchorSampleAt = 0;
retryUntil = Date.now() + 15000;
requestPaint();
}
function anchorFor(record, entity) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
let height;
if (scene.sampleHeightSupported) {
try {
height = scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
}
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height))
height = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (!validAlprGroundHeight(height)) return null;
entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
height,
);
entity.gevAlprWedge = directionWedgePositions(record, height);
return entity.gevAlprCanvasPosition;
}
function paint({ ctx, width, height, keyhole, occluder }) {
hits = [];
if (!state.enabled || destroyed) return;
let unresolved = false;
const painted = [];
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
const selected = record.id === state.selectedId;
const image = images[selected ? 1 : 0];
if (
!image?.complete ||
!image.naturalWidth ||
(selected && !images[2]?.naturalWidth)
) {
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity);
if (!anchor) {
nativeVisible(entity, true);
unresolved = true;
continue;
}
nativeVisible(entity, false);
if (occluder && !occluder.isPointVisible(anchor)) continue;
const scene = state.viewer.scene;
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
anchor,
);
if (!origin) continue;
const alpha =
services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1;
if (!(alpha > 0)) continue;
const wedge = entity.gevAlprWedge;
if (wedge) {
const left = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
wedge[1],
);
const right = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
wedge[2],
);
if (left && right) {
ctx.save();
ctx.globalAlpha *= alpha;
paintDirectionWedge(ctx, origin, left, right, selected);
ctx.restore();
}
}
if (
origin.x < -60 ||
origin.x > width + 60 ||
origin.y < -60 ||
origin.y > height + 60
)
continue;
entity.billboard.show = true;
painted.push({ record, origin, selected, image, alpha });
if (selected) entity.gevAlprDisplayPosition = anchor;
}
// Paint all glyphs after the wedges so one camera's cone cannot wash out another.
for (const { record, origin, selected, image, alpha } of painted) {
const size = selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE;
ctx.save();
ctx.globalAlpha *= alpha;
ctx.drawImage(
image,
origin.x - size / 2,
origin.y - size / 2,
size,
size,
);
if (selected)
ctx.drawImage(
images[2],
origin.x - size / 2,
origin.y - size / 2,
size,
size,
);
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
if (unresolved && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
}, 250);
}
}
return {
init() {
destroyed = false;
if (!services.overlays?.registerPaintLane || typeof Image === 'undefined')
return;
lane = services.overlays.registerPaintLane('selected', paint, {
id: `alpr-${Cesium.createGuid()}`,
active: false,
});
images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => {
const image = new Image();
image.onload = requestPaint;
image.onerror = requestPaint;
image.src = src;
return image;
});
surfaceRegime = state.viewer.scene.globe.show;
removeMapListener = services.overlays.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready') return;
const next = state.viewer.scene.globe.show;
if (next === surfaceRegime) return;
surfaceRegime = next;
resetAnchors();
});
},
sync(visible) {
if (!lane) return;
for (const record of records)
nativeVisible(state.dataSource.entities.getById(record.id), true);
const position = state.viewer.camera.positionWC;
records = visible
.filter(
(record) =>
Number.isFinite(record.directionDeg) &&
record.id !== state.selectedId,
)
.map((record) => ({
record,
distance: position
? Cesium.Cartesian3.distanceSquared(
position,
Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
),
)
: 0,
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, MAX_CANVAS_FRUSTUMS)
.map((item) => item.record);
const selected = visible.find((record) => record.id === state.selectedId);
if (selected) {
if (records.length >= MAX_CANVAS_FRUSTUMS) records.pop();
records.push(selected);
}
hits = [];
retryUntil = Date.now() + 15000;
lane.setActive(state.enabled && records.length > 0);
requestPaint();
},
pick(position) {
if (!state.enabled || !position) return null;
let closest,
distance = Infinity;
for (const hit of hits) {
const next = (hit.x - position.x) ** 2 + (hit.y - position.y) ** 2;
if (next <= hit.radius ** 2 && next <= distance) {
closest = hit.id;
distance = next;
}
}
return closest || null;
},
clear() {
clearTimeout(retryTimer);
retryTimer = null;
for (const record of records)
nativeVisible(state.dataSource?.entities.getById(record.id), true);
records = [];
hits = [];
lane?.setActive(false);
},
destroy() {
this.clear();
destroyed = true;
removeMapListener?.();
removeMapListener = null;
lane?.unregister();
lane = null;
for (const image of images) {
image.onload = null;
image.onerror = null;
}
images = [];
},
};
}
+201
View File
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { directionWedgePositions, alprLabelDetails } from './visuals.js';
function harness(t, count = 2) {
const previousImage = globalThis.Image;
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.Image = class {
complete = true;
naturalWidth = 128;
};
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 100, y: 100 });
const records = Array.from({ length: count }, (_, i) => ({
id: `camera:${i}`,
latitude: 30 + i / 10000,
longitude: -97,
directionDeg: 90,
}));
const entities = new Cesium.EntityCollection();
for (const record of records)
entities.add({
id: record.id,
billboard: { image: 'fallback', show: true },
polyline: { show: true },
polygon: { show: true },
});
const state = {
enabled: true,
selectedId: null,
dataSource: { entities },
viewer: {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(-97, 30, 500) },
scene: { globe: { show: true, getHeight: () => 150 } },
},
};
let paint,
mapListener,
active,
removed = 0,
paints = 0;
const overlay = createAlprOverlay({
state,
services: {
groundFloor: { cachedGroundFloor: () => null },
overlays: {
registerPaintLane(_lane, painter) {
paint = painter;
return {
setActive(value) {
active = value;
},
requestPaint() {
paints++;
},
unregister() {
removed++;
},
};
},
keyholeAlpha: () => 1,
subscribeMapStack(fn) {
mapListener = fn;
return () => {
mapListener = null;
};
},
},
},
});
overlay.init();
t.after(() => {
overlay.destroy();
if (previousImage === undefined) delete globalThis.Image;
else globalThis.Image = previousImage;
Cesium.SceneTransforms.worldToWindowCoordinates = project;
});
const draw = [];
const ctx = {
globalAlpha: 1,
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
closePath() {},
stroke() {},
fill() {
draw.push('wedge');
},
createLinearGradient: () => ({ addColorStop() {} }),
drawImage(image) {
draw.push(image.src);
},
};
return {
state,
records,
overlay,
entities,
draw,
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
mapChanged() {
mapListener({ detail: { status: 'ready' } });
},
get active() {
return active;
},
get removed() {
return removed;
},
get paints() {
return paints;
},
};
}
test('a selected camera without a bearing gets its badge and brackets, never a fabricated wedge', (t) => {
const h = harness(t, 1);
h.records[0].directionDeg = null;
h.state.selectedId = h.records[0].id;
h.overlay.sync(h.records);
h.paint();
assert.equal(h.draw.length, 2);
assert.ok(h.draw[0].endsWith('alpr-marker-selected.png'));
assert.ok(h.draw[1].endsWith('alpr-marker-selected-brackets.png'));
assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:0');
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
assert.equal(
h.entities.getById('camera:0').billboard.color.getValue().alpha,
0.01,
);
assert.equal(directionWedgePositions(h.records[0]), null);
});
test('the overlay bounds its cohort and promotes selection while leaving farther cameras native', (t) => {
const h = harness(t, 70);
h.state.selectedId = 'camera:69';
h.overlay.sync(h.records);
h.paint();
const hidden = h.entities.values.filter((e) => e.gevAlprNativeAppearance);
assert.equal(hidden.length, 64);
assert.ok(hidden.some((e) => e.id === 'camera:69'));
assert.equal(
h.overlay.pick({ x: 100, y: 100 }),
'camera:69',
'selected glyph paints and picks on top',
);
const firstGlyph = h.draw.findIndex((value) => value !== 'wedge');
assert.equal(firstGlyph, 64, 'all wedges paint before any glyph');
});
test('surface changes invalidate cached anchors, and disable/disposal release painting and hits', (t) => {
const h = harness(t, 1);
h.overlay.sync(h.records);
h.paint();
const entity = h.entities.getById('camera:0');
const first = entity.gevAlprCanvasPosition;
h.paint();
assert.equal(
entity.gevAlprCanvasPosition,
first,
'stable anchor across frames',
);
h.state.viewer.scene.globe.show = false;
h.mapChanged();
assert.equal(entity.gevAlprCanvasPosition, null);
h.state.enabled = false;
h.overlay.clear();
assert.equal(h.active, false);
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
assert.equal(entity.billboard.show.getValue(), true);
assert.equal(entity.billboard.color, undefined);
assert.equal(entity.position, undefined);
h.overlay.destroy();
assert.equal(h.removed, 1);
});
test('an unresolved surface preserves the native marker; the horizon removes overlay hits', (t) => {
const h = harness(t, 1);
h.state.viewer.scene.globe.getHeight = () => -15000;
h.overlay.sync(h.records);
h.paint();
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
assert.equal(h.draw.length, 0);
h.state.viewer.scene.globe.getHeight = () => 150;
h.paint({ isPointVisible: () => false });
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
assert.equal(h.draw.length, 0);
});
test('adapter labels never claim a custom source is public OSM data', () => {
assert.deepEqual(
alprLabelDetails(
{ manufacturer: 'Vendor', operator: 'Vendor' },
{ label: 'Custom directory' },
),
['Source: Custom directory', 'VENDOR'],
);
});
+9 -4
View File
@@ -33,8 +33,9 @@ export const QUERY_LIMIT = 1500;
* stayed false and `count` still reported them — a lie about coverage. */ * stayed false and `count` still reported them — a lie about coverage. */
export const MAX_RENDERED = 1500; export const MAX_RENDERED = 1500;
/** Meters — length of the facing-direction indicator line, when a camera reports one. */ /** Meters — illustrative facing wedge depth, when a camera reports a bearing. */
export const DIRECTION_CONE_M = 25; export const DIRECTION_CONE_M = 90;
export const DIRECTION_CONE_HALF_ANGLE_DEG = 20;
export const EARTH_MEAN_RADIUS_M = 6371008.8; export const EARTH_MEAN_RADIUS_M = 6371008.8;
@@ -47,8 +48,12 @@ export const QUERY_SNAP_DEGREES = 0.05;
* for this long before asking Overpass again. */ * for this long before asking Overpass again. */
export const QUERY_REUSE_MS = 10 * 60 * 1000; export const QUERY_REUSE_MS = 10 * 60 * 1000;
/** One vendor-neutral color, distinct from CCTV's cyan markers. */ /** Vendor-neutral camera badge palette, with coral selection. */
export const ALPR_COLOR = '#c084fc'; export const ALPR_COLOR = '#52d4ff';
export const ALPR_SELECTED_COLOR = '#ff6474';
export const MARKER_ICON_SIZE = 38;
export const SELECTED_MARKER_ICON_SIZE = 60;
export const MAX_CANVAS_FRUSTUMS = 64;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */ /** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
export const CREDIT_DISPLAY_MS = 5000; export const CREDIT_DISPLAY_MS = 5000;
+102 -47
View File
@@ -3,11 +3,22 @@ import {
LAYER_ID, LAYER_ID,
MAX_VIEWPORT_DEGREES, MAX_VIEWPORT_DEGREES,
MAX_RENDERED, MAX_RENDERED,
DIRECTION_CONE_M,
ALPR_COLOR, ALPR_COLOR,
ALPR_SELECTED_COLOR,
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
CREDIT_DISPLAY_MS, CREDIT_DISPLAY_MS,
} from './policy.js'; } from './policy.js';
import { boxContains, destinationPointDeg } from './model.js'; import { boxContains } from './model.js';
import { createAlprOverlay } from './overlay.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
alprDisplayId,
alprLabelDetails,
directionWedgePositions,
validAlprGroundHeight,
} from './visuals.js';
export function createAlprPresentation({ state, services, source }) { export function createAlprPresentation({ state, services, source }) {
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
@@ -20,6 +31,31 @@ export function createAlprPresentation({ state, services, source }) {
} = services.context; } = services.context;
const { cachedGroundFloor } = services.groundFloor; const { cachedGroundFloor } = services.groundFloor;
const overlay = createAlprOverlay({ state, services });
let visibleRecords = [];
let selectionStartedAt = 0;
function updateAppearance(entity, selected) {
const color = Cesium.Color.fromCssColorString(
selected ? ALPR_SELECTED_COLOR : ALPR_COLOR,
);
entity.billboard.image = selected ? SELECTED_IMAGE : MARKER_IMAGE;
entity.billboard.width = entity.billboard.height = selected
? SELECTED_MARKER_ICON_SIZE
: MARKER_ICON_SIZE;
if (entity.polyline) {
entity.polyline.width = selected ? 3 : 1.75;
entity.polyline.material = color.withAlpha(selected ? 0.98 : 0.82);
}
if (entity.polygon)
entity.polygon.material = color.withAlpha(selected ? 0.8 : 0.2);
if (entity.gevLabelModel) {
entity.gevLabelModel.accent = color.toCssColorString();
entity.gevLabelModel.leaderAnimationStartedAt = selected
? selectionStartedAt
: 0;
}
}
function markerColor() { function markerColor() {
return Cesium.Color.fromCssColorString(ALPR_COLOR); return Cesium.Color.fromCssColorString(ALPR_COLOR);
@@ -81,6 +117,8 @@ export function createAlprPresentation({ state, services, source }) {
} }
function clearRendered() { function clearRendered() {
overlay.clear();
visibleRecords = [];
if (state.dataSource?.entities) state.dataSource.entities.removeAll(); if (state.dataSource?.entities) state.dataSource.entities.removeAll();
removeEntityContextsForLayer(LAYER_ID); removeEntityContextsForLayer(LAYER_ID);
} }
@@ -116,6 +154,7 @@ export function createAlprPresentation({ state, services, source }) {
) )
.slice(0, MAX_RENDERED) .slice(0, MAX_RENDERED)
: []; : [];
visibleRecords = visible;
// A refresh may retain its own selection, never reclaim one cleared or // A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action. // replaced by an aircraft, another layer, or a voice action.
if ( if (
@@ -135,9 +174,7 @@ export function createAlprPresentation({ state, services, source }) {
for (const record of visible) { for (const record of visible) {
const existing = state.dataSource.entities.getById(record.id); const existing = state.dataSource.entities.getById(record.id);
if (existing?.gevAlprRecord === record) { if (existing?.gevAlprRecord === record) {
existing.point.pixelSize = record.id === state.selectedId ? 12 : 8; updateAppearance(existing, record.id === state.selectedId);
existing.point.color =
record.id === state.selectedId ? Cesium.Color.WHITE : markerColor();
continue; continue;
} }
const color = markerColor(); const color = markerColor();
@@ -149,31 +186,30 @@ export function createAlprPresentation({ state, services, source }) {
const entityDef = { const entityDef = {
id: record.id, id: record.id,
position, position,
point: { billboard: {
pixelSize: selected ? 12 : 8, image: selected ? SELECTED_IMAGE : MARKER_IMAGE,
color: selected ? Cesium.Color.WHITE : color, width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
outlineColor: Cesium.Color.BLACK.withAlpha(0.8), height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
outlineWidth: 1,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY, disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(350, 1.15, 4_000_000, 0.48),
}, },
}; };
if (Number.isFinite(record.directionDeg)) { const wedge = directionWedgePositions(record);
const tip = destinationPointDeg( if (wedge) {
record.latitude,
record.longitude,
record.directionDeg,
DIRECTION_CONE_M,
);
entityDef.polyline = { entityDef.polyline = {
positions: [ positions: [wedge[1], position, wedge[2]],
position, width: selected ? 3 : 1.75,
Cesium.Cartesian3.fromDegrees(tip.longitude, tip.latitude), material: color.withAlpha(0.82),
],
width: 2,
material: color.withAlpha(0.85),
clampToGround: true, clampToGround: true,
}; };
entityDef.polygon = {
hierarchy: wedge,
material: color.withAlpha(0.2),
height: 0,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
classificationType: Cesium.ClassificationType.BOTH,
};
} }
let entity = existing; let entity = existing;
// Keep the Cesium entity and its ground-clamping subscription while its // Keep the Cesium entity and its ground-clamping subscription while its
@@ -184,16 +220,21 @@ export function createAlprPresentation({ state, services, source }) {
if ( if (
previous.latitude !== record.latitude || previous.latitude !== record.latitude ||
previous.longitude !== record.longitude previous.longitude !== record.longitude
) ) {
entity.position = position; entity.position = position;
entity.gevAlprCanvasPosition = null;
entity.gevAlprDisplayPosition = null;
}
if ( if (
previous.latitude !== record.latitude || previous.latitude !== record.latitude ||
previous.longitude !== record.longitude || previous.longitude !== record.longitude ||
previous.directionDeg !== record.directionDeg previous.directionDeg !== record.directionDeg
) ) {
entity.polyline = entityDef.polyline; entity.polyline = entityDef.polyline;
entity.point.pixelSize = selected ? 12 : 8; entity.polygon = entityDef.polygon;
entity.point.color = selected ? Cesium.Color.WHITE : color; entity.gevAlprCanvasPosition = null;
entity.gevAlprWedge = null;
}
} }
entity.gevAlprRecord = record; entity.gevAlprRecord = record;
entity.gevTrackedId = record.id; entity.gevTrackedId = record.id;
@@ -203,15 +244,19 @@ export function createAlprPresentation({ state, services, source }) {
entity.gevAlprDisplayPosition ??= null; entity.gevAlprDisplayPosition ??= null;
entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition; entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition;
entity.gevLabelModel = { entity.gevLabelModel = {
title: 'ALPR CAMERA', title: alprDisplayId(record),
details: [ details: alprLabelDetails(record, source),
record.manufacturer ? `Manufacturer: ${record.manufacturer}` : null,
record.operator ? `Operator: ${record.operator}` : null,
record.cameraType ? `Type: ${record.cameraType}` : null,
`Source: ${source.attribution?.name || source.label || 'Camera source'}`,
].filter(Boolean),
accent: color.toCssColorString(), accent: color.toCssColorString(),
cardStyle: 'tactical',
selected: true,
leaderStyle: 'elbow',
leaderAnimationMs: 440,
leaderAnimationStartedAt: selected ? selectionStartedAt : 0,
leaderDrawRatio: 0.68,
anchorRadiusPx: SELECTED_MARKER_ICON_SIZE / 2,
anchorRadiusScale: null,
}; };
updateAppearance(entity, selected);
registerEntityContext(entity, { registerEntityContext(entity, {
id: record.id, id: record.id,
layerId: LAYER_ID, layerId: LAYER_ID,
@@ -240,6 +285,8 @@ export function createAlprPresentation({ state, services, source }) {
: null; : null;
if (!selectedEntity) state.selectedId = null; if (!selectedEntity) state.selectedId = null;
updateSelectedAnchor(); updateSelectedAnchor();
overlay.sync(visible);
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
// Start only when mapped data is actually displayed, not while an upstream // Start only when mapped data is actually displayed, not while an upstream
// request or a zoom-in prompt could consume the five-second introduction. // request or a zoom-in prompt could consume the five-second introduction.
if (visible.length) presentOnMapCredit(); if (visible.length) presentOnMapCredit();
@@ -276,14 +323,14 @@ export function createAlprPresentation({ state, services, source }) {
/* tiles may still be streaming */ /* tiles may still be streaming */
} }
} }
if (!Number.isFinite(height) && state.viewer.scene.globe.show) if (!validAlprGroundHeight(height) && state.viewer.scene.globe.show)
height = state.viewer.scene.globe.getHeight?.(location); height = state.viewer.scene.globe.getHeight?.(location);
if (!Number.isFinite(height) || height < -500 || height > 10000) if (!validAlprGroundHeight(height))
height = cachedGroundFloor(record.latitude, record.longitude); height = cachedGroundFloor(record.latitude, record.longitude);
const center = Cesium.Cartesian3.fromDegrees( const center = Cesium.Cartesian3.fromDegrees(
record.longitude, record.longitude,
record.latitude, record.latitude,
Number.isFinite(height) ? height : 0, validAlprGroundHeight(height) ? height : 0,
); );
if (!selectRecord(nearest.id)) return false; if (!selectRecord(nearest.id)) return false;
camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), { camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), {
@@ -303,11 +350,12 @@ export function createAlprPresentation({ state, services, source }) {
if (!entity || !state.recordById.has(id)) return false; if (!entity || !state.recordById.has(id)) return false;
clearSelection(); clearSelection();
state.selectedId = id; state.selectedId = id;
entity.point.pixelSize = 12; selectionStartedAt = globalThis.performance?.now?.() ?? Date.now();
entity.point.color = Cesium.Color.WHITE; updateAppearance(entity, true);
state.lastAnchorSampleAt = 0; state.lastAnchorSampleAt = 0;
selectEntityContext(entity); selectEntityContext(entity);
updateSelectedAnchor(); updateSelectedAnchor();
overlay.sync(visibleRecords);
governorRequestRender('alpr-selection'); governorRequestRender('alpr-selection');
return true; return true;
} }
@@ -317,11 +365,10 @@ export function createAlprPresentation({ state, services, source }) {
? state.dataSource?.entities.getById(state.selectedId) ? state.dataSource?.entities.getById(state.selectedId)
: null; : null;
const record = state.recordById.get(state.selectedId); const record = state.recordById.get(state.selectedId);
if (entity && record) { if (entity && record) updateAppearance(entity, false);
entity.point.pixelSize = 8;
entity.point.color = markerColor();
}
state.selectedId = null; state.selectedId = null;
selectionStartedAt = 0;
overlay.sync(visibleRecords);
clearSelectedEntityContextForLayer(LAYER_ID); clearSelectedEntityContextForLayer(LAYER_ID);
governorRequestRender('alpr-selection'); governorRequestRender('alpr-selection');
} }
@@ -335,6 +382,10 @@ export function createAlprPresentation({ state, services, source }) {
} }
const entity = state.dataSource?.entities.getById(state.selectedId); const entity = state.dataSource?.entities.getById(state.selectedId);
const record = state.recordById.get(state.selectedId); const record = state.recordById.get(state.selectedId);
if (entity?.gevAlprCanvasPosition) {
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
return;
}
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000) if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
return; return;
state.lastAnchorSampleAt = Date.now(); state.lastAnchorSampleAt = Date.now();
@@ -351,11 +402,11 @@ export function createAlprPresentation({ state, services, source }) {
/* tiles may still be streaming */ /* tiles may still be streaming */
} }
} }
if (!Number.isFinite(height) && scene.globe.show) if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location); height = scene.globe.getHeight?.(location);
if (!Number.isFinite(height)) if (!validAlprGroundHeight(height))
height = cachedGroundFloor(record.latitude, record.longitude); height = cachedGroundFloor(record.latitude, record.longitude);
if (!Number.isFinite(height)) return; if (!validAlprGroundHeight(height)) return;
const next = Cesium.Cartesian3.fromDegrees( const next = Cesium.Cartesian3.fromDegrees(
record.longitude, record.longitude,
record.latitude, record.latitude,
@@ -378,7 +429,9 @@ export function createAlprPresentation({ state, services, source }) {
state.clickHandler.setInputAction((click) => { state.clickHandler.setInputAction((click) => {
if (!state.enabled) return; if (!state.enabled) return;
const picked = viewer.scene.pick(click.position); const picked = viewer.scene.pick(click.position);
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; const id =
overlay.pick(click.position) ||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
if ( if (
id && id &&
state.recordById.has(id) && state.recordById.has(id) &&
@@ -389,6 +442,8 @@ export function createAlprPresentation({ state, services, source }) {
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
} }
return { return {
initOverlay: () => overlay.init(),
destroyOverlay: () => overlay.destroy(),
markerColor, markerColor,
viewportBox, viewportBox,
clearRendered, clearRendered,
+106
View File
@@ -0,0 +1,106 @@
import * as Cesium from 'cesium';
import { destinationPointDeg } from './model.js';
import { DIRECTION_CONE_M, DIRECTION_CONE_HALF_ANGLE_DEG } from './policy.js';
// Camera artwork and visual treatment contributed by Manjunath (@manjunath22466).
// Module-relative assets also resolve when the layer is consumed by another app.
export const MARKER_IMAGE = new URL(
'./assets/alpr-marker-normal.png',
import.meta.url,
).href;
export const SELECTED_IMAGE = new URL(
'./assets/alpr-marker-selected.png',
import.meta.url,
).href;
export const BRACKETS_IMAGE = new URL(
'./assets/alpr-marker-selected-brackets.png',
import.meta.url,
).href;
/** Compact display label; selection identity always uses the full record id. */
export function alprDisplayId(record) {
if (!Number.isSafeInteger(record.osmId)) return 'ALPR CAMERA';
return `ALPR-${String(record.osmId).slice(-4).padStart(4, '0')}`;
}
/** Show only supplied metadata, with the actual source named for custom adapters. */
export function alprLabelDetails(record, source) {
const sourceName =
source.attribution?.name || source.label || 'Camera source';
const details = [
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
];
if (Number.isFinite(record.directionDeg))
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
const equipment = [
...new Set(
[record.manufacturer, record.operator, record.cameraType]
.map((value) =>
String(value || '')
.trim()
.toUpperCase(),
)
.filter(Boolean),
),
];
if (equipment.length) details.push(equipment.join(' · '));
if (sourceName === 'OpenStreetMap') details.push('PUBLIC MAP DATA');
return details;
}
/** Illustrative bearing wedge, not measured field of view or operating range. */
export function directionWedgePositions(record, heightM = 0) {
if (!Number.isFinite(record.directionDeg)) return null;
const left = destinationPointDeg(
record.latitude,
record.longitude,
record.directionDeg - DIRECTION_CONE_HALF_ANGLE_DEG,
DIRECTION_CONE_M,
);
const right = destinationPointDeg(
record.latitude,
record.longitude,
record.directionDeg + DIRECTION_CONE_HALF_ANGLE_DEG,
DIRECTION_CONE_M,
);
return [
Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, heightM),
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, heightM),
Cesium.Cartesian3.fromDegrees(right.longitude, right.latitude, heightM),
];
}
/** Paint the original cyan/coral gradient with a crisp V-shaped boundary. */
export function paintDirectionWedge(ctx, origin, left, right, selected) {
const rgb = selected ? '255, 100, 116' : '82, 212, 255';
const gradient = ctx.createLinearGradient(
origin.x,
origin.y,
(left.x + right.x) / 2,
(left.y + right.y) / 2,
);
const alpha = selected ? [0.8, 0.48, 0.22, 0.06] : [0.34, 0.2, 0.09, 0.025];
[0, 0.36, 0.72, 1].forEach((stop, i) =>
gradient.addColorStop(stop, `rgba(${rgb}, ${alpha[i]})`),
);
ctx.beginPath();
ctx.moveTo(origin.x, origin.y);
ctx.lineTo(left.x, left.y);
ctx.lineTo(right.x, right.y);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
ctx.beginPath();
ctx.moveTo(left.x, left.y);
ctx.lineTo(origin.x, origin.y);
ctx.lineTo(right.x, right.y);
ctx.strokeStyle = `rgba(${rgb}, ${selected ? 0.98 : 0.76})`;
ctx.lineWidth = selected ? 2.5 : 1.5;
ctx.lineJoin = 'round';
ctx.stroke();
}
/** Reject transient tile/terrain samples outside plausible mapped ground heights. */
export function validAlprGroundHeight(height) {
return Number.isFinite(height) && height >= -500 && height <= 10000;
}
+25 -2
View File
@@ -466,6 +466,16 @@ export function normalizeOverlayEntry(sourceId, entry) {
leaderOffsetPx: Number.isFinite(Number(entry.leaderOffsetPx)) leaderOffsetPx: Number.isFinite(Number(entry.leaderOffsetPx))
? Math.max(0, Number(entry.leaderOffsetPx)) ? Math.max(0, Number(entry.leaderOffsetPx))
: 0, : 0,
leaderStyle: entry.leaderStyle === 'elbow' ? 'elbow' : 'straight',
leaderAnimationMs: Number.isFinite(Number(entry.leaderAnimationMs))
? Math.max(0, Number(entry.leaderAnimationMs))
: 0,
leaderAnimationStartedAt: Number.isFinite(Number(entry.leaderAnimationStartedAt))
? Number(entry.leaderAnimationStartedAt)
: 0,
leaderDrawRatio: Number.isFinite(Number(entry.leaderDrawRatio))
? Math.max(0.1, Math.min(0.9, Number(entry.leaderDrawRatio)))
: 0.68,
anchorRadiusPx: Number.isFinite(Number(entry.anchorRadiusPx)) anchorRadiusPx: Number.isFinite(Number(entry.anchorRadiusPx))
? Math.max(0, Number(entry.anchorRadiusPx)) ? Math.max(0, Number(entry.anchorRadiusPx))
: 0, : 0,
@@ -1421,6 +1431,19 @@ function activeFadeCount(timestamp) {
return count; return count;
} }
function activeLeaderAnimationCount(timestamp) {
let count = 0;
for (let i = 0; i < _sourceList.length; i++) {
const source = _sourceList[i];
if (!sourceActive(source)) continue;
for (const entry of source.entries.values()) {
if (entry.leaderAnimationMs > 0
&& timestamp - entry.leaderAnimationStartedAt < entry.leaderAnimationMs) count++;
}
}
return count;
}
function overlayHasPaintWork(timestamp = nowMs()) { function overlayHasPaintWork(timestamp = nowMs()) {
if (activeCustomPaintLaneCount() > 0) return true; if (activeCustomPaintLaneCount() > 0) return true;
if (activeEntryCount() > 0) return true; if (activeEntryCount() > 0) return true;
@@ -2177,8 +2200,8 @@ function drawWorldOverlay() {
_diagnostics.projectionMs = nowMs() - projectionStarted; _diagnostics.projectionMs = nowMs() - projectionStarted;
solveDomains(timestamp); solveDomains(timestamp);
paintFrame(keyhole); paintFrame(keyhole);
const fadesRemaining = activeFadeCount(timestamp); const animationsRemaining = activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp);
if (fadesRemaining > 0) _viewer.scene.requestRender?.(); if (animationsRemaining > 0) _viewer.scene.requestRender?.();
} }
function createDevFacade() { function createDevFacade() {
+74 -12
View File
@@ -521,6 +521,74 @@ function tacticalAccentColors(entry) {
return colors; return colors;
} }
function animationLinearProgress(entry, timestamp) {
const duration = Math.max(0, Number(entry?.leaderAnimationMs) || 0);
if (!duration) return 1;
const startedAt = Number(entry?.leaderAnimationStartedAt);
if (!Number.isFinite(startedAt)) return 1;
return Math.max(0, Math.min(1, (timestamp - startedAt) / duration));
}
function leaderDrawRatio(entry) {
const ratio = Number(entry?.leaderDrawRatio);
return Number.isFinite(ratio) ? Math.max(0.1, Math.min(0.9, ratio)) : 0.68;
}
export function leaderRevealProgress(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
const linear = Math.min(1, animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry));
return 1 - Math.pow(1 - linear, 3);
}
export function tacticalCardRevealAlpha(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
if (!(Math.max(0, Number(entry?.leaderAnimationMs) || 0) > 0)) return 1;
const start = leaderDrawRatio(entry);
const linear = Math.max(0, Math.min(1,
(animationLinearProgress(entry, timestamp) - start) / Math.max(0.1, 1 - start)));
return linear * linear * (3 - 2 * linear);
}
function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
ctx.strokeStyle = accent;
ctx.lineWidth = (entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
if (entry.leaderStyle !== 'elbow'
|| (placement.corner !== 'above' && placement.corner !== 'below')) {
if (placement.leaderOffset === 0) {
ctx.moveTo(placement.leadFromX, placement.leadFromY);
} else if (placement.corner === 'above' || placement.corner === 'below') {
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
} else {
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
}
ctx.lineTo(placement.leadToX, placement.leadToY);
ctx.stroke();
return;
}
const { x, y, w, h } = placement.rect;
const cardX = x + Math.max(18, Math.min(w - 18, w * 0.22));
const cardY = placement.corner === 'above' ? y + h : y;
const anchorRadius = Math.abs(placement.leaderOffset);
const anchorX = placement.leadFromX - anchorRadius;
const anchorY = placement.leadFromY;
const horizontalLength = Math.abs(anchorX - cardX);
const verticalLength = Math.abs(anchorY - cardY);
let remaining = leaderRevealProgress(entry, timestamp) * (horizontalLength + verticalLength);
ctx.moveTo(anchorX, anchorY);
if (remaining <= horizontalLength) {
const direction = Math.sign(cardX - anchorX) || -1;
ctx.lineTo(anchorX + direction * remaining, anchorY);
} else {
ctx.lineTo(cardX, anchorY);
remaining -= horizontalLength;
const direction = Math.sign(cardY - anchorY) || -1;
ctx.lineTo(cardX, anchorY + direction * Math.min(verticalLength, remaining));
}
ctx.stroke();
}
/** Paint the legacy FIRMS/vessel tactical card inside the shared host. */ /** Paint the legacy FIRMS/vessel tactical card inside the shared host. */
export function paintTacticalCard(ctx, entry, placement, alpha = 1) { export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
const selected = entry.selected || entry.variant === 'selected'; const selected = entry.selected || entry.variant === 'selected';
@@ -528,21 +596,15 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
const layout = entry._overlayLayout || {}; const layout = entry._overlayLayout || {};
const { x, y, w, h } = placement.rect; const { x, y, w, h } = placement.rect;
const accentColors = tacticalAccentColors(entry); const accentColors = tacticalAccentColors(entry);
const animationTimestamp = globalThis.performance?.now?.() ?? Date.now();
ctx.save(); ctx.save();
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.strokeStyle = accentColors.leader; drawTacticalLeader(ctx, entry, placement, entry.leaderStyle === 'elbow'
ctx.lineWidth = 1 / (placement.paintScale || 1); ? colorWithAlpha(entry.accent, 0.95)
ctx.beginPath(); : accentColors.leader, animationTimestamp);
if (placement.leaderOffset === 0) {
ctx.moveTo(placement.leadFromX, placement.leadFromY); ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp);
} else if (placement.corner === 'above' || placement.corner === 'below') {
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
} else {
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
}
ctx.lineTo(placement.leadToX, placement.leadToY);
ctx.stroke();
ctx.beginPath(); ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, 4); roundedRectPath(ctx, x, y, w, h, 4);
+30
View File
@@ -20,6 +20,8 @@ import {
paintThumbnail, paintThumbnail,
paintTrack, paintTrack,
paintTracked, paintTracked,
leaderRevealProgress,
tacticalCardRevealAlpha,
placementVariants, placementVariants,
roundedRectPath, roundedRectPath,
} from './worldOverlayDraw.js'; } from './worldOverlayDraw.js';
@@ -486,6 +488,34 @@ test('shared tactical painter preserves FIRMS card metrics and top-rule treatmen
assert.equal(ctx.calls.at(-1)[0], 'restore'); assert.equal(ctx.calls.at(-1)[0], 'restore');
}); });
test('selected elbow leader reveals from the glyph before its card fades in', () => {
const animation = {
leaderAnimationMs: 1000, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.7,
};
assert.equal(leaderRevealProgress(animation, 100), 0);
assert.ok(leaderRevealProgress(animation, 450) > 0.8);
assert.equal(leaderRevealProgress(animation, 800), 1);
assert.equal(tacticalCardRevealAlpha(animation, 799), 0);
assert.ok(tacticalCardRevealAlpha(animation, 950) > 0.4);
assert.equal(tacticalCardRevealAlpha(animation, 1100), 1);
const ctx = mockContext();
const entry = {
variant: 'selected', selected: true, cardStyle: 'tactical', title: 'ALPR-2516',
details: ['OSM MAPPED'], accent: '#ff6474', leaderStyle: 'elbow',
leaderAnimationMs: 1, leaderAnimationStartedAt: 0,
};
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
const placement = placementVariants({
anchorX: 240, anchorY: 180, width: entry._overlayLayout.w, height: entry._overlayLayout.h,
viewportWidth: 500, viewportHeight: 300, gap: 20, leaderOffset: 30, verticalOnly: true,
})[0];
paintTacticalCard(ctx, entry, placement, 1);
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 210, 180]);
const lines = ctx.calls.filter(([name]) => name === 'lineTo');
assert.ok(lines.length >= 2, 'leader draws horizontally from the glyph, then vertically to the card');
assert.ok(ctx.calls.some(([name, value]) => name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)'));
});
test('track display text is cached across measure and paint and invalidates on content change', () => { test('track display text is cached across measure and paint and invalidates on content change', () => {
const ctx = mockContext(); const ctx = mockContext();
const entry = { variant: 'track', title: 'UAL123', details: ['450 KT'] }; const entry = { variant: 'track', title: 'UAL123', details: ['450 KT'] };
+13 -6
View File
@@ -1274,10 +1274,17 @@
padding: 2px 4px; padding: 2px 4px;
} }
/* Qualified nearby counts must also fit beside the wider loading chip. */ /* ALPR keeps its nearby count in the legend while a wider status chip is shown. */
.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-top { .data-toggle-row[data-layer-id='alpr-cameras']
flex-wrap: wrap; .data-toggle-right:has(
} .data-toggle-btn:is(
.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-right { .feed-loading,
margin-left: auto; .feed-degraded,
.feed-stale,
.feed-fallback,
.feed-unavailable
)
)
.data-count {
display: none;
} }