mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import {
|
|
LAYER_ID,
|
|
MAX_VIEWPORT_DEGREES,
|
|
MAX_RENDERED,
|
|
DIRECTION_CONE_M,
|
|
ALPR_COLOR,
|
|
CREDIT_DISPLAY_MS,
|
|
} from './policy.js';
|
|
import { boxContains, destinationPointDeg } from './model.js';
|
|
|
|
export function createAlprPresentation({ state, services, source }) {
|
|
const { governorRequestRender } = services.render;
|
|
const {
|
|
clearSelectedEntityContextForLayer,
|
|
getSelectedEntityContext,
|
|
registerEntityContext,
|
|
removeEntityContextsForLayer,
|
|
selectEntityContext,
|
|
} = services.context;
|
|
|
|
const { cachedGroundFloor } = services.groundFloor;
|
|
|
|
function markerColor() {
|
|
return Cesium.Color.fromCssColorString(ALPR_COLOR);
|
|
}
|
|
|
|
// The horizon rectangle is unstable during low-angle orbits and can exclude
|
|
// the ground point in the center of the screen. Bound nearby coverage around
|
|
// that point instead; the camera-to-ground range keeps zoom-out queries capped.
|
|
function viewportBox(viewer) {
|
|
const camera = viewer?.camera;
|
|
const canvas = viewer?.scene.canvas;
|
|
if (typeof camera?.pickEllipsoid === 'function' && canvas) {
|
|
const width = canvas.clientWidth || canvas.width;
|
|
const height = canvas.clientHeight || canvas.height;
|
|
if (!width || !height) return null;
|
|
const focus = camera.pickEllipsoid(
|
|
new Cesium.Cartesian2(width / 2, height / 2),
|
|
viewer.scene.globe.ellipsoid,
|
|
);
|
|
if (!focus) return null;
|
|
const location = Cesium.Cartographic.fromCartesian(focus);
|
|
const range = Cesium.Cartesian3.distance(camera.positionWC, focus);
|
|
const radius = Math.max(1000, 2 * range);
|
|
const latitude = Cesium.Math.toDegrees(location.latitude);
|
|
const longitude = Cesium.Math.toDegrees(location.longitude);
|
|
const latSpan = radius / 111000;
|
|
const lonSpan = latSpan / Math.cos(location.latitude);
|
|
if (
|
|
!Number.isFinite(latSpan + lonSpan) ||
|
|
2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES ||
|
|
Math.abs(latitude) + latSpan > 90 ||
|
|
Math.abs(longitude) + lonSpan > 180
|
|
)
|
|
return null;
|
|
return {
|
|
south: latitude - latSpan,
|
|
west: longitude - lonSpan,
|
|
north: latitude + latSpan,
|
|
east: longitude + lonSpan,
|
|
};
|
|
}
|
|
// Rectangle-only viewers retain the same bounded-area contract.
|
|
const rectangle = viewer?.camera?.computeViewRectangle(
|
|
viewer.scene.globe.ellipsoid,
|
|
);
|
|
if (!rectangle) return null;
|
|
const south = Cesium.Math.toDegrees(rectangle.south);
|
|
const north = Cesium.Math.toDegrees(rectangle.north);
|
|
const west = Cesium.Math.toDegrees(rectangle.west);
|
|
const east = Cesium.Math.toDegrees(rectangle.east);
|
|
if (
|
|
!Number.isFinite(south + north + west + east) ||
|
|
east <= west ||
|
|
north - south > MAX_VIEWPORT_DEGREES ||
|
|
east - west > MAX_VIEWPORT_DEGREES
|
|
)
|
|
return null;
|
|
return { south, west, north, east };
|
|
}
|
|
|
|
function clearRendered() {
|
|
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
|
|
removeEntityContextsForLayer(LAYER_ID);
|
|
}
|
|
|
|
function hideOnMapCredit() {
|
|
clearTimeout(state.creditTimer);
|
|
state.creditTimer = null;
|
|
if (state.credit)
|
|
state.viewer?.creditDisplay?.removeStaticCredit(state.credit);
|
|
governorRequestRender('alpr-credit');
|
|
}
|
|
|
|
function presentOnMapCredit() {
|
|
if (!state.enabled || state.creditPresented || !state.credit) return;
|
|
state.creditPresented = true;
|
|
state.viewer.creditDisplay?.addStaticCredit(state.credit);
|
|
state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
|
|
governorRequestRender('alpr-credit');
|
|
}
|
|
|
|
function renderRecords() {
|
|
const selectedContext = getSelectedEntityContext();
|
|
const box = viewportBox(state.viewer);
|
|
const visible = box
|
|
? state.records
|
|
.filter((record) =>
|
|
boxContains(box, {
|
|
south: record.latitude,
|
|
north: record.latitude,
|
|
west: record.longitude,
|
|
east: record.longitude,
|
|
}),
|
|
)
|
|
.slice(0, MAX_RENDERED)
|
|
: [];
|
|
// A refresh may retain its own selection, never reclaim one cleared or
|
|
// replaced by an aircraft, another layer, or a voice action.
|
|
if (
|
|
selectedContext?.layerId !== LAYER_ID ||
|
|
selectedContext.id !== state.selectedId ||
|
|
!visible.some((record) => record.id === state.selectedId)
|
|
) {
|
|
state.selectedId = null;
|
|
clearSelectedEntityContextForLayer(LAYER_ID);
|
|
}
|
|
governorRequestRender('alpr-render');
|
|
const visibleIds = new Set(visible.map((record) => record.id));
|
|
for (const entity of [...state.dataSource.entities.values]) {
|
|
if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity);
|
|
}
|
|
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
|
|
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();
|
|
continue;
|
|
}
|
|
const color = markerColor();
|
|
const selected = record.id === state.selectedId;
|
|
const position = Cesium.Cartesian3.fromDegrees(
|
|
record.longitude,
|
|
record.latitude,
|
|
);
|
|
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,
|
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
};
|
|
if (Number.isFinite(record.directionDeg)) {
|
|
const tip = destinationPointDeg(
|
|
record.latitude,
|
|
record.longitude,
|
|
record.directionDeg,
|
|
DIRECTION_CONE_M,
|
|
);
|
|
entityDef.polyline = {
|
|
positions: [
|
|
position,
|
|
Cesium.Cartesian3.fromDegrees(tip.longitude, tip.latitude),
|
|
],
|
|
width: 2,
|
|
material: color.withAlpha(0.85),
|
|
clampToGround: true,
|
|
};
|
|
}
|
|
let entity = existing;
|
|
// Keep the Cesium entity and its ground-clamping subscription while its
|
|
// geometry is unchanged. Updating metadata must not rebuild the marker.
|
|
const previous = entity?.gevAlprRecord;
|
|
if (!entity) entity = state.dataSource.entities.add(entityDef);
|
|
else {
|
|
if (
|
|
previous.latitude !== record.latitude ||
|
|
previous.longitude !== record.longitude
|
|
)
|
|
entity.position = position;
|
|
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.gevAlprRecord = record;
|
|
entity.gevTrackedId = record.id;
|
|
// The mapped camera datum has no elevation; it is not the clamped marker's
|
|
// visual anchor. Only the selected marker samples the rendered surface,
|
|
// at most once per second, through Cesium's public height APIs.
|
|
entity.gevAlprDisplayPosition ??= null;
|
|
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),
|
|
accent: color.toCssColorString(),
|
|
};
|
|
registerEntityContext(entity, {
|
|
id: record.id,
|
|
layerId: LAYER_ID,
|
|
dataSource: state.dataSource,
|
|
layerName: 'ALPR Cameras',
|
|
source:
|
|
source.attribution?.description || source.label || 'Camera source',
|
|
label: 'ALPR camera',
|
|
latitude: record.latitude,
|
|
longitude: record.longitude,
|
|
properties: {
|
|
operator: record.operator,
|
|
manufacturer: record.manufacturer,
|
|
cameraType: record.cameraType,
|
|
zone: record.zone,
|
|
directionDeg: record.directionDeg,
|
|
ref: record.ref,
|
|
lastVerified: record.lastVerified,
|
|
osmId: record.osmId,
|
|
sourceTag: record.source,
|
|
},
|
|
});
|
|
}
|
|
const selectedEntity = state.selectedId
|
|
? state.dataSource.entities.getById(state.selectedId)
|
|
: null;
|
|
if (!selectedEntity) state.selectedId = null;
|
|
updateSelectedAnchor();
|
|
// 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();
|
|
}
|
|
|
|
function selectRecord(id) {
|
|
const entity = state.dataSource?.entities.getById(id);
|
|
if (!entity || !state.recordById.has(id)) return false;
|
|
clearSelection();
|
|
state.selectedId = id;
|
|
entity.point.pixelSize = 12;
|
|
entity.point.color = Cesium.Color.WHITE;
|
|
state.lastAnchorSampleAt = 0;
|
|
selectEntityContext(entity);
|
|
updateSelectedAnchor();
|
|
governorRequestRender('alpr-selection');
|
|
return true;
|
|
}
|
|
|
|
function clearSelection() {
|
|
const entity = state.selectedId
|
|
? 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();
|
|
}
|
|
state.selectedId = null;
|
|
clearSelectedEntityContextForLayer(LAYER_ID);
|
|
governorRequestRender('alpr-selection');
|
|
}
|
|
|
|
function updateSelectedAnchor() {
|
|
if (!state.enabled || !state.selectedId) return;
|
|
const selected = getSelectedEntityContext();
|
|
if (selected?.id !== state.selectedId) {
|
|
clearSelection();
|
|
return;
|
|
}
|
|
const entity = state.dataSource?.entities.getById(state.selectedId);
|
|
const record = state.recordById.get(state.selectedId);
|
|
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
|
return;
|
|
state.lastAnchorSampleAt = Date.now();
|
|
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 {
|
|
/* tiles may still be streaming */
|
|
}
|
|
}
|
|
if (!Number.isFinite(height) && scene.globe.show)
|
|
height = scene.globe.getHeight?.(location);
|
|
if (!Number.isFinite(height))
|
|
height = cachedGroundFloor(record.latitude, record.longitude);
|
|
if (!Number.isFinite(height)) return;
|
|
const next = Cesium.Cartesian3.fromDegrees(
|
|
record.longitude,
|
|
record.latitude,
|
|
height,
|
|
);
|
|
if (
|
|
!entity.gevAlprDisplayPosition ||
|
|
Cesium.Cartesian3.distance(next, entity.gevAlprDisplayPosition) > 0.1
|
|
) {
|
|
entity.gevAlprDisplayPosition = next;
|
|
governorRequestRender('alpr-anchor');
|
|
}
|
|
}
|
|
|
|
function installInteraction(viewer) {
|
|
if (state.clickHandler) return;
|
|
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
|
viewer.scene.canvas,
|
|
);
|
|
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;
|
|
if (
|
|
id &&
|
|
state.recordById.has(id) &&
|
|
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
|
|
)
|
|
selectRecord(id);
|
|
else if (state.selectedId) clearSelection();
|
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
}
|
|
return {
|
|
markerColor,
|
|
viewportBox,
|
|
clearRendered,
|
|
hideOnMapCredit,
|
|
presentOnMapCredit,
|
|
renderRecords,
|
|
selectRecord,
|
|
clearSelection,
|
|
updateSelectedAnchor,
|
|
installInteraction,
|
|
};
|
|
}
|