mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Draw vessels, satellites, Space Missions, ALPR, bikeshare and OSM Places through one display each
Each layer now has one render/display.js on the globe contract, the same factory on both engines; its render/cesium* and render/navara* files go. Cesium's behaviour is the reference, and the logic that was written twice is written once: - AIS vessels: atlas chevrons and engine.lines trails. The display registers the gev-trail pick claim itself and its trailing visibility pass now also runs on Navara. - Satellites: point banks per look, orbit rings as lines turned by their model matrix, and selection and follow through engine.camera.follow. The emulated _viewer is gone, and the tracked readout republishes card text on Navara too. - Space Missions: dots, reticle, trajectories and tactical orbits as movers and lines. The layer keeps its own records instead of Entities and DataSources, and camera work goes through engine.camera. Clicks go through the selection dispatcher, and hover keeps Cesium's throttle. The pad zone is its rim only: the contract has no surface fill yet. - ALPR, bikeshare and OSM Places: one display and, for bikeshare, one style module each; clicks through engine.input, no ScreenSpaceEventHandler or EntityCluster. ALPR keeps Navara's horizon cull, and OSM Places points sample the surface where Cesium clamped them. Coupling: layer renderer lines fall from 4546/11106 to 2638/8735 (Cesium/Navara), `viewer` lines from 2209 to 2014, ScreenSpaceEventHandler files from 20 to 15.
This commit is contained in:
1 parent
3e261d1baf
commit
f06b93fbc9
120 files changed
+6626
-6998
No files matched your search
@@ -0,0 +1,769 @@
|
||||
// The ALPR camera badges on the globe contract (engine.movers, engine.input,
|
||||
// engine.camera, engine.projector, engine.surface, engine.credits), the
|
||||
// same on every engine. Each displayed camera is a plain marker handle: a
|
||||
// 38 px badge in one sprite set, or the 60 px selected badge in a set of
|
||||
// one drawn over it, shrinking with distance, always on top and culled
|
||||
// behind the globe's horizon. The overlay (../overlay.js) paints the
|
||||
// nearest cameras' artwork and bearing wedges on the canvas and keeps a
|
||||
// faint, unscaled badge under each as a pick target. Records, requests and
|
||||
// the view's camera set stay in the layer (../index.js).
|
||||
//
|
||||
// The contract has no ground clamping: a marker sits on its cached ground
|
||||
// floor cell, else on the terrain, and waits unseen until one of them is
|
||||
// known; floors still resolving are asked for once per render.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { distanceScale } from '../../../globe/cpuPicks.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { alprCreditMarkup } from '../model.js';
|
||||
import { createAlprOverlayCore } from '../overlay.js';
|
||||
import {
|
||||
alprEntityContext,
|
||||
alprFocusBox,
|
||||
alprLabelModel,
|
||||
alprViewRecords,
|
||||
} from '../presentation.js';
|
||||
import { createAlprFloorResolver } from '../floors.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
SELECTED_IMAGE,
|
||||
validAlprGroundHeight,
|
||||
} from '../visuals.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
MAX_RENDERED,
|
||||
ALPR_COLOR,
|
||||
ALPR_SELECTED_COLOR,
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
CREDIT_DISPLAY_MS,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from '../policy.js';
|
||||
|
||||
export const ALPR_SELECTED_SET_ID = 'alpr-selected';
|
||||
// Under the moving contacts (src/data/spriteOrder.js), as map data; the
|
||||
// selected badge over the others.
|
||||
const MARKER_ORDER = -1;
|
||||
const SELECTED_ORDER = -0.5;
|
||||
// What a canvas-owned marker keeps of its badge: enough for a pick.
|
||||
const PICK_TARGET_ALPHA = 0.01;
|
||||
// The label accents, in Cesium's CSS form.
|
||||
const ACCENT = rgbaFromCss(ALPR_COLOR).toCssColorString();
|
||||
const SELECTED_ACCENT = rgbaFromCss(ALPR_SELECTED_COLOR).toCssColorString();
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ state: object, services: object, source: object }} options
|
||||
* The layer's state, services and camera source.
|
||||
*/
|
||||
export function createAlprDisplay(engine, { state, services, source }) {
|
||||
if (!engine) throw new TypeError('The ALPR display needs a globe engine');
|
||||
const { governorRequestRender } = services.render;
|
||||
const {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
const markers = new Map();
|
||||
const floors = createAlprFloorResolver(services.groundFloor);
|
||||
let normal = null,
|
||||
selected = null,
|
||||
initialized = false,
|
||||
visible = false;
|
||||
let visibleRecords = [],
|
||||
selectionStartedAt = 0,
|
||||
generation = 0,
|
||||
floorRequest = null;
|
||||
let removeMove = null,
|
||||
removeFrame = null,
|
||||
removeClick = null;
|
||||
let creditMarkup = null,
|
||||
removeCredit = null,
|
||||
creditTimer = null,
|
||||
creditPresented = false;
|
||||
let lastCamera = null,
|
||||
dirty = false;
|
||||
|
||||
const cachedFloor = (record) =>
|
||||
services.groundFloor.cachedGroundFloor(record.latitude, record.longitude);
|
||||
const location = (record) =>
|
||||
geo.cartographic.fromDegrees(record.longitude, record.latitude);
|
||||
const ownSets = () => [normal, selected].filter(Boolean);
|
||||
|
||||
// Each sample is a depth render on Cesium, a raycast on Navara.
|
||||
function sampleHeight(record) {
|
||||
if (!engine.surface.supported) return undefined;
|
||||
try {
|
||||
return engine.surface.sampleHeight(location(record), ownSets());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function terrainHeight(record) {
|
||||
return engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(location(record))
|
||||
: undefined;
|
||||
}
|
||||
/** The rendered surface under a camera, else the terrain, else its floor. */
|
||||
function surfaceHeight(record) {
|
||||
let height = sampleHeight(record);
|
||||
if (!validAlprGroundHeight(height)) height = terrainHeight(record);
|
||||
if (!validAlprGroundHeight(height)) height = cachedFloor(record);
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker placement from the cached ground floor (validated mesh or DEM,
|
||||
* the ellipsoidal datum every layer shares); a cell that has not resolved
|
||||
* yet places it on the terrain until it does (`clamped`).
|
||||
*/
|
||||
function placement(record) {
|
||||
const floor = cachedFloor(record);
|
||||
const clamped = !validAlprGroundHeight(floor);
|
||||
const height = clamped ? terrainHeight(record) : floor;
|
||||
const resolved = validAlprGroundHeight(height);
|
||||
return {
|
||||
clamped,
|
||||
resolved,
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
resolved ? height + MARKER_FLOOR_LIFT_M : 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function viewportBox() {
|
||||
const { width, height } = engine.host.size();
|
||||
if (!width || !height) return null;
|
||||
const focus = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 });
|
||||
if (!focus) return null;
|
||||
return alprFocusBox(
|
||||
geo.cartographic.fromCartesian(focus),
|
||||
geo.cartesian3.distance(engine.camera.livePosition, focus),
|
||||
);
|
||||
}
|
||||
/** Records the current view would display (nearest first when capped). */
|
||||
const viewRecords = (records) => alprViewRecords(records, viewportBox());
|
||||
|
||||
/**
|
||||
* Writes a marker's badge when it changed: its own place, or the canvas
|
||||
* anchor at full size and nearly transparent while the overlay paints it.
|
||||
*/
|
||||
function syncMarker(marker) {
|
||||
if (!initialized) return;
|
||||
const owned = marker.canvasOwned;
|
||||
const position =
|
||||
owned && marker.gevAlprCanvasPosition
|
||||
? marker.gevAlprCanvasPosition
|
||||
: marker.position;
|
||||
const isSelected = marker.id === state.selectedId;
|
||||
// The sets shrink badges with distance; a pick target keeps its size.
|
||||
const scale = owned
|
||||
? 1 /
|
||||
distanceScale(
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
geo.cartesian3.distance(engine.camera.livePosition, position),
|
||||
)
|
||||
: 1;
|
||||
const show =
|
||||
visible &&
|
||||
state.enabled &&
|
||||
marker.shown &&
|
||||
marker.resolved &&
|
||||
engine.projector.horizonVisible(position);
|
||||
const alpha = owned ? PICK_TARGET_ALPHA : 1;
|
||||
const previous = marker.drawn;
|
||||
if (
|
||||
previous &&
|
||||
previous.position === position &&
|
||||
previous.scale === scale &&
|
||||
previous.show === show &&
|
||||
previous.alpha === alpha &&
|
||||
previous.selected === isSelected
|
||||
)
|
||||
return;
|
||||
marker.drawn = { position, scale, show, alpha, selected: isSelected };
|
||||
const fields = {
|
||||
position,
|
||||
scale,
|
||||
show,
|
||||
color: [1, 1, 1, alpha],
|
||||
pickId: marker.id,
|
||||
};
|
||||
normal.set(marker.index, { ...fields, show: show && !isSelected });
|
||||
if (isSelected) {
|
||||
selected.setCount(1);
|
||||
selected.set(0, fields);
|
||||
}
|
||||
// The overlay changes ownership while it paints, after the frame's
|
||||
// render request was taken: wake the next frame, which commits.
|
||||
if (!dirty) queueMicrotask(requestDirtySprites);
|
||||
dirty = true;
|
||||
}
|
||||
function requestDirtySprites() {
|
||||
if (dirty) engine.render.request('alpr-render');
|
||||
}
|
||||
function flushSprites() {
|
||||
if (!dirty || !initialized) return;
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
dirty = false;
|
||||
}
|
||||
function dropSelectedSprite() {
|
||||
if (!initialized) return;
|
||||
selected.setCount(0);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
const overlay = createAlprOverlayCore({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange: refreshPlacements,
|
||||
engine: {
|
||||
marker: (id) => markers.get(id),
|
||||
markers: () => markers.values(),
|
||||
canvasOwned(marker, owned) {
|
||||
if (marker.canvasOwned === owned) return;
|
||||
marker.canvasOwned = owned;
|
||||
syncMarker(marker);
|
||||
},
|
||||
setShown(marker, shown) {
|
||||
if (marker.shown === shown) return;
|
||||
marker.shown = shown;
|
||||
syncMarker(marker);
|
||||
},
|
||||
setDisplayPosition(marker, position) {
|
||||
marker.displayPosition = position;
|
||||
},
|
||||
placeAnchor(marker, record, height) {
|
||||
marker.gevAlprCanvasPosition = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
marker.resolved = true;
|
||||
syncMarker(marker);
|
||||
},
|
||||
surfaceSupported: () => engine.surface.supported,
|
||||
sampleHeight: (record) => sampleHeight(record),
|
||||
terrainHeight,
|
||||
surfaceSettled: () => engine.surface.tilesLoaded(),
|
||||
cameraPose: () => engine.camera.pose(),
|
||||
cameraPosition: () => engine.camera.livePosition,
|
||||
projector: ({ width, height, viewProjectionMatrix: matrix }) => ({
|
||||
horizonVisible: (point) => engine.projector.horizonVisible(point),
|
||||
// The host captured the camera and CSS viewport for this frame;
|
||||
// every wedge vertex reuses it instead of rereading DOM geometry.
|
||||
project({ x, y, z }) {
|
||||
const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||
if (!(w > 0)) return null;
|
||||
return {
|
||||
x:
|
||||
(((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) /
|
||||
w +
|
||||
1) *
|
||||
width) /
|
||||
2,
|
||||
y:
|
||||
((1 -
|
||||
(matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) /
|
||||
w) *
|
||||
height) /
|
||||
2,
|
||||
};
|
||||
},
|
||||
}),
|
||||
cullOffscreenWedges: true,
|
||||
createId: () => globalThis.crypto.randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
/** Places every marker again (a new map stack moved the surface). */
|
||||
function refreshPlacements() {
|
||||
if (!state.enabled || !initialized) return;
|
||||
generation++;
|
||||
floorRequest = null;
|
||||
floors.cancel();
|
||||
for (const marker of markers.values()) {
|
||||
Object.assign(marker, placement(marker.record));
|
||||
marker.drawn = null;
|
||||
syncMarker(marker);
|
||||
}
|
||||
resolveFloors();
|
||||
flushSprites();
|
||||
governorRequestRender('alpr-surface');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the floor cells of the records this view will display, waiting
|
||||
* at most the ground service's bounded deadline. Never throws.
|
||||
*/
|
||||
async function prepareFloors(records) {
|
||||
if (typeof services.groundFloor.resolveGroundFloorCells !== 'function')
|
||||
return;
|
||||
const cold = viewRecords(records)
|
||||
.visible.filter((record) => !validAlprGroundHeight(cachedFloor(record)))
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
try {
|
||||
await floors.prepare(cold);
|
||||
} catch {
|
||||
/* markers wait on the terrain until their floors resolve */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the clamped markers' floors once, then places them in place. */
|
||||
function resolveFloors() {
|
||||
if (
|
||||
floorRequest ||
|
||||
typeof services.groundFloor.resolveGroundFloorCells !== 'function'
|
||||
)
|
||||
return;
|
||||
const batch = [...markers.values()].filter((marker) => marker.clamped);
|
||||
if (!batch.length) return;
|
||||
const epoch = generation;
|
||||
const request = Promise.resolve(
|
||||
floors.resolve(
|
||||
batch.map((marker) => ({
|
||||
lat: marker.record.latitude,
|
||||
lon: marker.record.longitude,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (epoch !== generation || floorRequest !== request) return;
|
||||
floorRequest = null;
|
||||
if (!state.enabled || !initialized) return;
|
||||
let moved = false;
|
||||
for (const marker of batch) {
|
||||
if (markers.get(marker.id) !== marker) continue;
|
||||
const next = placement(marker.record);
|
||||
// Cells that never resolved stay clamped; later renders retry them.
|
||||
if (next.clamped) continue;
|
||||
Object.assign(marker, next);
|
||||
syncMarker(marker);
|
||||
moved = true;
|
||||
}
|
||||
if (!moved) return;
|
||||
flushSprites();
|
||||
governorRequestRender('alpr-floor');
|
||||
});
|
||||
floorRequest = request;
|
||||
}
|
||||
|
||||
function updateAppearance(marker, active) {
|
||||
if (marker.gevLabelModel) {
|
||||
marker.gevLabelModel.accent = active ? SELECTED_ACCENT : ACCENT;
|
||||
marker.gevLabelModel.leaderAnimationStartedAt = active
|
||||
? selectionStartedAt
|
||||
: 0;
|
||||
}
|
||||
syncMarker(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded cameras that project inside the canvas and in front of the globe.
|
||||
* "Nearby" spans twice the camera-to-ground range, so a view can hold
|
||||
* cameras while none is on screen; the row then says so rather than
|
||||
* leaving the user to wait for markers.
|
||||
* @returns {number|null} Count, or null without a canvas to project into.
|
||||
*/
|
||||
function countOnScreen() {
|
||||
const { width, height } = engine.host.size();
|
||||
if (!width || !height) return null;
|
||||
let count = 0;
|
||||
for (const marker of markers.values()) {
|
||||
if (!engine.projector.horizonVisible(marker.position)) continue;
|
||||
const point = engine.projector.worldToWindow(marker.position);
|
||||
if (
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.x <= width &&
|
||||
point.y >= 0 &&
|
||||
point.y <= height
|
||||
)
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function hideOnMapCredit() {
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
clearTimeout(creditTimer);
|
||||
creditTimer = null;
|
||||
removeCredit?.();
|
||||
removeCredit = null;
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
/**
|
||||
* The OSM credit inline while cameras show; another source's credit on
|
||||
* screen for a few seconds after it first displays.
|
||||
*/
|
||||
function presentOnMapCredit() {
|
||||
if (
|
||||
state.enabled &&
|
||||
source.attribution?.name === 'OpenStreetMap' &&
|
||||
services.credits?.showOsmCredit
|
||||
) {
|
||||
services.credits.showOsmCredit(engine, LAYER_ID);
|
||||
return;
|
||||
}
|
||||
if (!state.enabled || creditPresented || !creditMarkup) return;
|
||||
creditPresented = true;
|
||||
removeCredit = engine.credits.addStatic(creditMarkup, { onScreen: true });
|
||||
creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
function renderRecords() {
|
||||
if (!initialized) return;
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
// Over the render cap, keep the cameras nearest the view centre and say
|
||||
// coverage is limited rather than dropping an arbitrary subset.
|
||||
const { visible: shown, capped } = viewRecords(state.records);
|
||||
state.renderSaturated = capped;
|
||||
visibleRecords = shown;
|
||||
// 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 ||
|
||||
!shown.some((record) => record.id === state.selectedId)
|
||||
) {
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
dropSelectedSprite();
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
}
|
||||
governorRequestRender('alpr-render');
|
||||
const ids = new Set(shown.map((record) => record.id));
|
||||
let changed = false;
|
||||
for (const id of [...markers.keys()])
|
||||
if (!ids.has(id)) {
|
||||
markers.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: ids });
|
||||
for (const record of shown) {
|
||||
let marker = markers.get(record.id);
|
||||
const isSelected = record.id === state.selectedId;
|
||||
if (marker?.record === record) continue;
|
||||
if (!marker) {
|
||||
marker = {
|
||||
id: record.id,
|
||||
record,
|
||||
...placement(record),
|
||||
index: -1,
|
||||
shown: true,
|
||||
canvasOwned: false,
|
||||
displayPosition: null,
|
||||
gevAlprCanvasPosition: null,
|
||||
gevAlprWedge: null,
|
||||
drawn: null,
|
||||
};
|
||||
markers.set(record.id, marker);
|
||||
changed = true;
|
||||
} else {
|
||||
// The handle and its badge stay while the geometry is unchanged:
|
||||
// updating metadata must not rebuild the marker.
|
||||
const previous = marker.record;
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude
|
||||
) {
|
||||
Object.assign(marker, placement(record));
|
||||
marker.displayPosition = null;
|
||||
marker.gevAlprCanvasPosition = null;
|
||||
}
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude ||
|
||||
previous.directionDeg !== record.directionDeg
|
||||
) {
|
||||
marker.gevAlprCanvasPosition = null;
|
||||
marker.gevAlprWedge = null;
|
||||
}
|
||||
marker.record = record;
|
||||
}
|
||||
// The mapped camera datum has no elevation; it is not the marker's
|
||||
// visual anchor. Only the selected marker samples the rendered
|
||||
// surface, at most once per second.
|
||||
marker.gevTrackedId = record.id;
|
||||
marker.gevDisplayPosition = () => marker.displayPosition;
|
||||
marker.gevLabelModel = alprLabelModel(record, source, {
|
||||
accent: ACCENT,
|
||||
startedAt: isSelected ? selectionStartedAt : 0,
|
||||
});
|
||||
registerEntityContext(marker, alprEntityContext(record, source));
|
||||
}
|
||||
normal.setCount(markers.size);
|
||||
let index = 0;
|
||||
for (const marker of markers.values()) {
|
||||
marker.index = index++;
|
||||
marker.drawn = null;
|
||||
updateAppearance(marker, marker.id === state.selectedId);
|
||||
}
|
||||
flushSprites();
|
||||
if (changed) state.renderRevision += 1;
|
||||
state.onScreen = countOnScreen();
|
||||
resolveFloors();
|
||||
const chosen = state.selectedId ? markers.get(state.selectedId) : null;
|
||||
if (!chosen) state.selectedId = null;
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(shown);
|
||||
if (chosen) services.overlays?.refreshReadout?.(chosen);
|
||||
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
|
||||
if (shown.length) presentOnMapCredit();
|
||||
else hideOnMapCredit();
|
||||
}
|
||||
|
||||
function clearRendered({ retainContext = false } = {}) {
|
||||
hideOnMapCredit();
|
||||
generation++;
|
||||
floors.cancel();
|
||||
floorRequest = null;
|
||||
overlay.clear();
|
||||
visibleRecords = [];
|
||||
markers.clear();
|
||||
if (initialized) {
|
||||
normal.setCount(0);
|
||||
selected.setCount(0);
|
||||
dirty = true;
|
||||
flushSprites();
|
||||
}
|
||||
if (!retainContext) removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
const marker = markers.get(id);
|
||||
if (!marker || !state.recordById.has(id)) return false;
|
||||
clearSelection();
|
||||
state.selectedId = id;
|
||||
selectionStartedAt = globalThis.performance?.now?.() ?? Date.now();
|
||||
updateAppearance(marker, true);
|
||||
state.lastAnchorSampleAt = 0;
|
||||
selectEntityContext(marker);
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visibleRecords);
|
||||
flushSprites();
|
||||
governorRequestRender('alpr-selection');
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const marker = state.selectedId ? markers.get(state.selectedId) : null;
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
dropSelectedSprite();
|
||||
if (marker) updateAppearance(marker, false);
|
||||
overlay.sync(visibleRecords);
|
||||
flushSprites();
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
governorRequestRender('alpr-selection');
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected marker's readout anchor: its canvas anchor, else the
|
||||
* rendered surface under it, sampled at most once per second.
|
||||
*/
|
||||
function updateSelectedAnchor() {
|
||||
if (!state.enabled || !state.selectedId) return;
|
||||
if (getSelectedEntityContext()?.id !== state.selectedId) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const marker = markers.get(state.selectedId);
|
||||
const record = state.recordById.get(state.selectedId);
|
||||
if (marker?.gevAlprCanvasPosition) {
|
||||
marker.displayPosition = marker.gevAlprCanvasPosition;
|
||||
return;
|
||||
}
|
||||
if (!marker || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
||||
return;
|
||||
state.lastAnchorSampleAt = Date.now();
|
||||
const height = surfaceHeight(record);
|
||||
if (!validAlprGroundHeight(height)) return;
|
||||
const next = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
if (
|
||||
!marker.displayPosition ||
|
||||
geo.cartesian3.distance(next, marker.displayPosition) > 0.1
|
||||
) {
|
||||
marker.displayPosition = next;
|
||||
governorRequestRender('alpr-anchor');
|
||||
}
|
||||
}
|
||||
|
||||
function focusNearest() {
|
||||
if (!state.enabled || !initialized || engine.camera.following())
|
||||
return false;
|
||||
let nearest = null,
|
||||
distance = Infinity;
|
||||
for (const marker of markers.values()) {
|
||||
const candidate = geo.cartesian3.distanceSquared(
|
||||
engine.camera.livePosition,
|
||||
marker.position,
|
||||
);
|
||||
if (candidate < distance) {
|
||||
nearest = marker;
|
||||
distance = candidate;
|
||||
}
|
||||
}
|
||||
if (!nearest) return false;
|
||||
const record = state.recordById.get(nearest.id);
|
||||
const height = record ? surfaceHeight(record) : undefined;
|
||||
if (!selectRecord(nearest.id)) return false;
|
||||
const center = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
validAlprGroundHeight(height) ? height : 0,
|
||||
);
|
||||
engine.camera.flyToSphere(
|
||||
{ center, radius: 30 },
|
||||
{
|
||||
duration: 1.2,
|
||||
heading: engine.camera.pose().heading || 0,
|
||||
pitch: -Math.PI / 4,
|
||||
range: 800,
|
||||
},
|
||||
);
|
||||
governorRequestRender('alpr-focus');
|
||||
return true;
|
||||
}
|
||||
|
||||
function onClick({ position, picked }) {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!state.enabled) return;
|
||||
const id =
|
||||
overlay.pick(position) ||
|
||||
(typeof picked?.id === 'string' ? picked.id : null);
|
||||
if (
|
||||
id &&
|
||||
state.recordById.has(id) &&
|
||||
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
|
||||
)
|
||||
selectRecord(id);
|
||||
else if (state.selectedId) clearSelection();
|
||||
}
|
||||
|
||||
// Badges move on screen with the camera: their horizon test and a pick
|
||||
// target's size follow it.
|
||||
function frame() {
|
||||
const camera = engine.camera.livePosition;
|
||||
if (
|
||||
!lastCamera ||
|
||||
!geo.cartesian3.equalsEpsilon(camera, lastCamera, 0, 0.00001)
|
||||
) {
|
||||
lastCamera = geo.cartesian3.clone(camera);
|
||||
for (const marker of markers.values()) syncMarker(marker);
|
||||
}
|
||||
flushSprites();
|
||||
updateSelectedAnchor();
|
||||
}
|
||||
|
||||
function createSet(id, sizePx, capacity, image, order) {
|
||||
return engine.movers.createSet({
|
||||
id,
|
||||
kind: 'sprite',
|
||||
capacity,
|
||||
order,
|
||||
pickable: true,
|
||||
depthTest: 'always',
|
||||
sharpen: true,
|
||||
sprite: {
|
||||
sizePx,
|
||||
// City-wide views hold hundreds of badges; they shrink.
|
||||
scaleByDistance: MARKER_SCALE_BY_DISTANCE,
|
||||
atlas: { url: image, cells: { camera: [0, 0, 1, 1] } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get ready() {
|
||||
return initialized;
|
||||
},
|
||||
count: () => markers.size,
|
||||
following: () => Boolean(engine.camera.following()),
|
||||
viewportBox,
|
||||
prepareFloors,
|
||||
renderRecords,
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
selectRecord,
|
||||
clearSelection,
|
||||
focusNearest,
|
||||
/** The marker handles displayed now. */
|
||||
markers: () => [...markers.values()],
|
||||
init(_viewer, { onMove }) {
|
||||
if (initialized) throw new Error('ALPR renderer is already initialized');
|
||||
// A source credit that is not a public HTTPS link fails here.
|
||||
creditMarkup = alprCreditMarkup(source.attribution);
|
||||
normal = createSet(
|
||||
LAYER_ID,
|
||||
MARKER_ICON_SIZE,
|
||||
MAX_RENDERED,
|
||||
MARKER_IMAGE,
|
||||
MARKER_ORDER,
|
||||
);
|
||||
selected = createSet(
|
||||
ALPR_SELECTED_SET_ID,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
1,
|
||||
SELECTED_IMAGE,
|
||||
SELECTED_ORDER,
|
||||
);
|
||||
normal.setCount(0);
|
||||
selected.setCount(0);
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
initialized = true;
|
||||
removeMove = engine.camera.moveEnd.addEventListener(onMove);
|
||||
removeFrame = engine.frames.preRender.addEventListener(frame);
|
||||
overlay.init();
|
||||
},
|
||||
/** Shows the badges and takes clicks, or hides both. */
|
||||
setVisible(value) {
|
||||
const next = Boolean(value);
|
||||
if (next) creditPresented = false;
|
||||
removeClick?.();
|
||||
removeClick = next
|
||||
? engine.input.on('click', onClick, { pick: true })
|
||||
: null;
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
for (const marker of markers.values()) syncMarker(marker);
|
||||
flushSprites();
|
||||
},
|
||||
destroy(options) {
|
||||
if (!initialized) return;
|
||||
clearRendered(options);
|
||||
overlay.destroy();
|
||||
removeClick?.();
|
||||
removeClick = null;
|
||||
removeMove?.();
|
||||
removeMove = null;
|
||||
removeFrame?.();
|
||||
removeFrame = null;
|
||||
normal.destroy();
|
||||
selected.destroy();
|
||||
normal = selected = null;
|
||||
initialized = false;
|
||||
visible = false;
|
||||
lastCamera = null;
|
||||
dirty = false;
|
||||
creditPresented = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user