mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
687edc4da7
commit
87629ac175
21 files changed
+951
-97
No files matched your search
@@ -30,6 +30,12 @@
|
||||
|
||||
## 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
|
||||
SHOW NEAREST to frame and select a loaded camera when none are on screen,
|
||||
using the available 3D-tile or globe terrain height.
|
||||
|
||||
@@ -89,8 +89,15 @@ registration supplies selection, picking, terrain and
|
||||
render services. Selected cards and Data attribution identify OpenStreetMap.
|
||||
Layer state and the existing voice layer tools include `alpr-cameras`.
|
||||
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
|
||||
selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is
|
||||
screen. Cyan camera badges use Manjunath's camera artwork; selection switches to a
|
||||
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
|
||||
chooses from the current source snapshot and uses rendered surface height from
|
||||
3D tiles or globe terrain when available.
|
||||
|
||||
@@ -476,6 +476,9 @@
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.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.test.mjs",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
|
||||
@@ -925,6 +925,8 @@
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/source.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -1215,6 +1217,8 @@
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
@@ -1665,6 +1669,8 @@
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
|
||||
@@ -2,12 +2,30 @@ import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.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. */
|
||||
export function createApplicationAlpr({ surface, source }) {
|
||||
const { groundFloor } = surface;
|
||||
return createAlprCamerasLayer({
|
||||
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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -462,29 +462,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
||||
assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.gevLabelModel.title),
|
||||
Array(3).fill('ALPR CAMERA'),
|
||||
['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
|
||||
);
|
||||
assert.equal(
|
||||
new Set(
|
||||
entities.map((entity) =>
|
||||
entity.point.color.getValue().toCssColorString(),
|
||||
),
|
||||
).size,
|
||||
new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(entities[0].gevLabelModel.details, [
|
||||
'Manufacturer: Flock Safety',
|
||||
'Operator: City Police',
|
||||
'Type: fixed',
|
||||
'Source: OpenStreetMap',
|
||||
'OSM MAPPED',
|
||||
'FLOCK SAFETY · CITY POLICE · FIXED',
|
||||
'PUBLIC MAP DATA',
|
||||
]);
|
||||
assert.deepEqual(entities[1].gevLabelModel.details, [
|
||||
'Manufacturer: Motorola Solutions',
|
||||
'Source: OpenStreetMap',
|
||||
'OSM MAPPED',
|
||||
'MOTOROLA SOLUTIONS',
|
||||
'PUBLIC MAP DATA',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entities[2].gevLabelModel.details,
|
||||
['Source: OpenStreetMap'],
|
||||
['OSM MAPPED', 'PUBLIC MAP DATA'],
|
||||
'unknown metadata is not guessed, but the source is always named',
|
||||
);
|
||||
for (const entity of entities) {
|
||||
@@ -523,8 +519,8 @@ test('clicking the selected camera or empty map deselects, including after refre
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42').point.pixelSize.getValue(),
|
||||
8,
|
||||
h.source.entities.getById('alpr:42').billboard.width.getValue(),
|
||||
38,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -582,8 +578,8 @@ test('disable removes contexts and highlight; cache reuse after re-enable does n
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42').point.pixelSize.getValue(),
|
||||
8,
|
||||
h.source.entities.getById('alpr:42').billboard.width.getValue(),
|
||||
38,
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
@@ -1100,7 +1096,7 @@ test('nearby count and discovery control frame a real loaded camera without fetc
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby');
|
||||
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(flights.length, 1);
|
||||
assert.equal(getSelectedEntityContext().id, 'alpr:42');
|
||||
|
||||
@@ -142,8 +142,16 @@ export function createTrackedOverlayEntry(entity) {
|
||||
title,
|
||||
details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [],
|
||||
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
|
||||
anchorRadiusPx: 10,
|
||||
anchorRadiusScale: TRACKED_BILLBOARD_SCALE,
|
||||
cardStyle: model.cardStyle,
|
||||
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,
|
||||
anchorGapPaddingPx: 10,
|
||||
verticalOnly: true,
|
||||
|
||||
@@ -121,6 +121,26 @@ test('tracked entry factory pins the production protected-lane policy', () => {
|
||||
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', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
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 |
@@ -69,7 +69,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
lastQueryBox: null,
|
||||
};
|
||||
const {
|
||||
markerColor,
|
||||
initOverlay,
|
||||
destroyOverlay,
|
||||
viewportBox,
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
@@ -250,6 +251,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
viewer.camera.moveEnd.addEventListener(scheduleLoad);
|
||||
state.postRenderRemove =
|
||||
viewer.scene.postRender?.addEventListener(updateSelectedAnchor);
|
||||
initOverlay();
|
||||
installInteraction(viewer);
|
||||
},
|
||||
enable() {
|
||||
@@ -281,6 +283,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
},
|
||||
destroy(viewer = state.viewer) {
|
||||
this.disable();
|
||||
destroyOverlay();
|
||||
state.moveEndRemove?.();
|
||||
state.moveEndRemove = null;
|
||||
state.postRenderRemove?.();
|
||||
@@ -320,11 +323,11 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
],
|
||||
legend: [
|
||||
{
|
||||
label: 'Purple dots',
|
||||
label: 'Camera badges',
|
||||
color: ALPR_COLOR,
|
||||
count,
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -33,8 +33,9 @@ export const QUERY_LIMIT = 1500;
|
||||
* stayed false and `count` still reported them — a lie about coverage. */
|
||||
export const MAX_RENDERED = 1500;
|
||||
|
||||
/** Meters — length of the facing-direction indicator line, when a camera reports one. */
|
||||
export const DIRECTION_CONE_M = 25;
|
||||
/** Meters — illustrative facing wedge depth, when a camera reports a bearing. */
|
||||
export const DIRECTION_CONE_M = 90;
|
||||
export const DIRECTION_CONE_HALF_ANGLE_DEG = 20;
|
||||
|
||||
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. */
|
||||
export const QUERY_REUSE_MS = 10 * 60 * 1000;
|
||||
|
||||
/** One vendor-neutral color, distinct from CCTV's cyan markers. */
|
||||
export const ALPR_COLOR = '#c084fc';
|
||||
/** Vendor-neutral camera badge palette, with coral selection. */
|
||||
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. */
|
||||
export const CREDIT_DISPLAY_MS = 5000;
|
||||
+102
-47
@@ -3,11 +3,22 @@ import {
|
||||
LAYER_ID,
|
||||
MAX_VIEWPORT_DEGREES,
|
||||
MAX_RENDERED,
|
||||
DIRECTION_CONE_M,
|
||||
ALPR_COLOR,
|
||||
ALPR_SELECTED_COLOR,
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
CREDIT_DISPLAY_MS,
|
||||
} 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 }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
@@ -20,6 +31,31 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
} = services.context;
|
||||
|
||||
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() {
|
||||
return Cesium.Color.fromCssColorString(ALPR_COLOR);
|
||||
@@ -81,6 +117,8 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
}
|
||||
|
||||
function clearRendered() {
|
||||
overlay.clear();
|
||||
visibleRecords = [];
|
||||
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
@@ -116,6 +154,7 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
)
|
||||
.slice(0, MAX_RENDERED)
|
||||
: [];
|
||||
visibleRecords = visible;
|
||||
// A refresh may retain its own selection, never reclaim one cleared or
|
||||
// replaced by an aircraft, another layer, or a voice action.
|
||||
if (
|
||||
@@ -135,9 +174,7 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
for (const record of visible) {
|
||||
const existing = state.dataSource.entities.getById(record.id);
|
||||
if (existing?.gevAlprRecord === record) {
|
||||
existing.point.pixelSize = record.id === state.selectedId ? 12 : 8;
|
||||
existing.point.color =
|
||||
record.id === state.selectedId ? Cesium.Color.WHITE : markerColor();
|
||||
updateAppearance(existing, record.id === state.selectedId);
|
||||
continue;
|
||||
}
|
||||
const color = markerColor();
|
||||
@@ -149,31 +186,30 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
const entityDef = {
|
||||
id: record.id,
|
||||
position,
|
||||
point: {
|
||||
pixelSize: selected ? 12 : 8,
|
||||
color: selected ? Cesium.Color.WHITE : color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 1,
|
||||
billboard: {
|
||||
image: selected ? SELECTED_IMAGE : MARKER_IMAGE,
|
||||
width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
|
||||
height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(350, 1.15, 4_000_000, 0.48),
|
||||
},
|
||||
};
|
||||
if (Number.isFinite(record.directionDeg)) {
|
||||
const tip = destinationPointDeg(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
record.directionDeg,
|
||||
DIRECTION_CONE_M,
|
||||
);
|
||||
const wedge = directionWedgePositions(record);
|
||||
if (wedge) {
|
||||
entityDef.polyline = {
|
||||
positions: [
|
||||
position,
|
||||
Cesium.Cartesian3.fromDegrees(tip.longitude, tip.latitude),
|
||||
],
|
||||
width: 2,
|
||||
material: color.withAlpha(0.85),
|
||||
positions: [wedge[1], position, wedge[2]],
|
||||
width: selected ? 3 : 1.75,
|
||||
material: color.withAlpha(0.82),
|
||||
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;
|
||||
// Keep the Cesium entity and its ground-clamping subscription while its
|
||||
@@ -184,16 +220,21 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude
|
||||
)
|
||||
) {
|
||||
entity.position = position;
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
}
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude ||
|
||||
previous.directionDeg !== record.directionDeg
|
||||
)
|
||||
) {
|
||||
entity.polyline = entityDef.polyline;
|
||||
entity.point.pixelSize = selected ? 12 : 8;
|
||||
entity.point.color = selected ? Cesium.Color.WHITE : color;
|
||||
entity.polygon = entityDef.polygon;
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprWedge = null;
|
||||
}
|
||||
}
|
||||
entity.gevAlprRecord = record;
|
||||
entity.gevTrackedId = record.id;
|
||||
@@ -203,15 +244,19 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
entity.gevAlprDisplayPosition ??= null;
|
||||
entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: 'ALPR CAMERA',
|
||||
details: [
|
||||
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),
|
||||
title: alprDisplayId(record),
|
||||
details: alprLabelDetails(record, source),
|
||||
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, {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
@@ -240,6 +285,8 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
: null;
|
||||
if (!selectedEntity) state.selectedId = null;
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visible);
|
||||
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
|
||||
// Start only when mapped data is actually displayed, not while an upstream
|
||||
// request or a zoom-in prompt could consume the five-second introduction.
|
||||
if (visible.length) presentOnMapCredit();
|
||||
@@ -276,14 +323,14 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
/* 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);
|
||||
if (!Number.isFinite(height) || height < -500 || height > 10000)
|
||||
if (!validAlprGroundHeight(height))
|
||||
height = cachedGroundFloor(record.latitude, record.longitude);
|
||||
const center = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
Number.isFinite(height) ? height : 0,
|
||||
validAlprGroundHeight(height) ? height : 0,
|
||||
);
|
||||
if (!selectRecord(nearest.id)) return false;
|
||||
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;
|
||||
clearSelection();
|
||||
state.selectedId = id;
|
||||
entity.point.pixelSize = 12;
|
||||
entity.point.color = Cesium.Color.WHITE;
|
||||
selectionStartedAt = globalThis.performance?.now?.() ?? Date.now();
|
||||
updateAppearance(entity, true);
|
||||
state.lastAnchorSampleAt = 0;
|
||||
selectEntityContext(entity);
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visibleRecords);
|
||||
governorRequestRender('alpr-selection');
|
||||
return true;
|
||||
}
|
||||
@@ -317,11 +365,10 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
? state.dataSource?.entities.getById(state.selectedId)
|
||||
: null;
|
||||
const record = state.recordById.get(state.selectedId);
|
||||
if (entity && record) {
|
||||
entity.point.pixelSize = 8;
|
||||
entity.point.color = markerColor();
|
||||
}
|
||||
if (entity && record) updateAppearance(entity, false);
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
overlay.sync(visibleRecords);
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
governorRequestRender('alpr-selection');
|
||||
}
|
||||
@@ -335,6 +382,10 @@ 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;
|
||||
return;
|
||||
}
|
||||
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
||||
return;
|
||||
state.lastAnchorSampleAt = Date.now();
|
||||
@@ -351,11 +402,11 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
/* tiles may still be streaming */
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(height) && scene.globe.show)
|
||||
if (!validAlprGroundHeight(height) && scene.globe.show)
|
||||
height = scene.globe.getHeight?.(location);
|
||||
if (!Number.isFinite(height))
|
||||
if (!validAlprGroundHeight(height))
|
||||
height = cachedGroundFloor(record.latitude, record.longitude);
|
||||
if (!Number.isFinite(height)) return;
|
||||
if (!validAlprGroundHeight(height)) return;
|
||||
const next = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
@@ -378,7 +429,9 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
state.clickHandler.setInputAction((click) => {
|
||||
if (!state.enabled) return;
|
||||
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 (
|
||||
id &&
|
||||
state.recordById.has(id) &&
|
||||
@@ -389,6 +442,8 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
return {
|
||||
initOverlay: () => overlay.init(),
|
||||
destroyOverlay: () => overlay.destroy(),
|
||||
markerColor,
|
||||
viewportBox,
|
||||
clearRendered,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -466,6 +466,16 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
leaderOffsetPx: Number.isFinite(Number(entry.leaderOffsetPx))
|
||||
? Math.max(0, Number(entry.leaderOffsetPx))
|
||||
: 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))
|
||||
? Math.max(0, Number(entry.anchorRadiusPx))
|
||||
: 0,
|
||||
@@ -1421,6 +1431,19 @@ function activeFadeCount(timestamp) {
|
||||
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()) {
|
||||
if (activeCustomPaintLaneCount() > 0) return true;
|
||||
if (activeEntryCount() > 0) return true;
|
||||
@@ -2177,8 +2200,8 @@ function drawWorldOverlay() {
|
||||
_diagnostics.projectionMs = nowMs() - projectionStarted;
|
||||
solveDomains(timestamp);
|
||||
paintFrame(keyhole);
|
||||
const fadesRemaining = activeFadeCount(timestamp);
|
||||
if (fadesRemaining > 0) _viewer.scene.requestRender?.();
|
||||
const animationsRemaining = activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp);
|
||||
if (animationsRemaining > 0) _viewer.scene.requestRender?.();
|
||||
}
|
||||
|
||||
function createDevFacade() {
|
||||
|
||||
@@ -521,6 +521,74 @@ function tacticalAccentColors(entry) {
|
||||
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. */
|
||||
export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
|
||||
const selected = entry.selected || entry.variant === 'selected';
|
||||
@@ -528,21 +596,15 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
|
||||
const layout = entry._overlayLayout || {};
|
||||
const { x, y, w, h } = placement.rect;
|
||||
const accentColors = tacticalAccentColors(entry);
|
||||
const animationTimestamp = globalThis.performance?.now?.() ?? Date.now();
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
ctx.strokeStyle = accentColors.leader;
|
||||
ctx.lineWidth = 1 / (placement.paintScale || 1);
|
||||
ctx.beginPath();
|
||||
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();
|
||||
drawTacticalLeader(ctx, entry, placement, entry.leaderStyle === 'elbow'
|
||||
? colorWithAlpha(entry.accent, 0.95)
|
||||
: accentColors.leader, animationTimestamp);
|
||||
|
||||
ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp);
|
||||
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, 4);
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
paintThumbnail,
|
||||
paintTrack,
|
||||
paintTracked,
|
||||
leaderRevealProgress,
|
||||
tacticalCardRevealAlpha,
|
||||
placementVariants,
|
||||
roundedRectPath,
|
||||
} 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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const ctx = mockContext();
|
||||
const entry = { variant: 'track', title: 'UAL123', details: ['450 KT'] };
|
||||
|
||||
@@ -1274,10 +1274,17 @@
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
/* Qualified nearby counts must also fit beside the wider loading chip. */
|
||||
.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-right {
|
||||
margin-left: auto;
|
||||
/* 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-right:has(
|
||||
.data-toggle-btn:is(
|
||||
.feed-loading,
|
||||
.feed-degraded,
|
||||
.feed-stale,
|
||||
.feed-fallback,
|
||||
.feed-unavailable
|
||||
)
|
||||
)
|
||||
.data-count {
|
||||
display: none;
|
||||
}
|
||||
Reference in new issue
Block a user