Merge remote-tracking branch 'origin/main' into claude/hal-676-gev-fire-intensity-742bd1

This commit is contained in:
German Khokhlov committed 2026-10-30 20:54:33 +04:00
commit b07429df12
139 files changed
+12892 -1770

No files matched your search

+10 -3
View File
@@ -99,7 +99,8 @@ export function createCards({ state: layerState, services, parts, source }) {
const candidates = [];
const screenById = new Map();
for (const record of layerState._records) {
// Cards go only on the icons the view pass shows (refreshHorizonCulling).
for (const record of layerState._viewRecords) {
const id = record.camera.id;
if (id === activeId || !record.position) continue;
let inView = false;
@@ -187,7 +188,14 @@ export function createCards({ state: layerState, services, parts, source }) {
nowMs: Date.now(),
cardLimit: budgets.cardLimit,
});
layerState._cardIds = new Set(retention.keepIds);
// Grace keeps a card briefly after it loses selection, but never on a
// camera whose icon the view pass has hidden.
const shownIds = new Set(
layerState._viewRecords.map((record) => record.camera.id),
);
layerState._cardIds = new Set(
retention.keepIds.filter((id) => shownIds.has(id)),
);
layerState._cardGraceState = retention.graceState;
// Bounded thumbnail LRU: live cards (grace included) never lose their
@@ -371,7 +379,6 @@ export function createCards({ state: layerState, services, parts, source }) {
layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID);
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
parts.hover.clearHoverCard();
layerState._cameraMoving = false;
layerState._cardIds = new Set();
layerState._cardGraceState = new Map();
layerState._cardFrameSlots = new Map();
+8 -9
View File
@@ -205,22 +205,21 @@ export function createControls({ state: layerState, services, parts, source }) {
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._records.length === 0) return [];
// The cameras the view pass shows, not icons it has hidden.
const records = layerState._viewRecords;
if (!layerState._enabled || records.length === 0) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._records.length;
: records.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max(
1,
Math.ceil(layerState._records.length / maxCount),
);
const stride = Math.max(1, Math.ceil(records.length / maxCount));
const start = seed % stride;
const objects = [];
for (let i = start; i < layerState._records.length; i += stride) {
const camera = layerState._records[i].camera;
for (let i = start; i < records.length; i += stride) {
const camera = records[i].camera;
objects.push({
position: layerState._records[i].position,
position: records[i].position,
sourceId: camera.id,
// Short semantic code where the id is opaque (see cameraDisplayCode).
id: `CAM-${camera.code || camera.id}`,
+12
View File
@@ -581,6 +581,13 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function coverageNeighborCount(targetRecord) {
if (!targetRecord) return 0;
// The summary asks on every notification; scan the catalog only when the
// camera, its pose or the catalog changed.
const { id, lat, lon, rangeM } = targetRecord.camera;
const key = `${id}|${lat}|${lon}|${rangeM}|${layerState._records.length}`;
const cached = layerState._neighborCountCache;
if (cached?.key === key && cached.records === layerState._records)
return cached.count;
let count = 0;
for (const record of layerState._records) {
if (record === targetRecord) continue;
@@ -594,6 +601,11 @@ export function createGeometry({ state: layerState, services, parts, source }) {
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
if (dKm <= overlapKm) count++;
}
layerState._neighborCountCache = {
key,
records: layerState._records,
count,
};
return count;
}
+151 -17
View File
@@ -6,6 +6,8 @@ import {
GEO_TRACKING_BATCH_DELAY_MS,
GEO_LOAD_BATCH_SIZE,
GEO_LOAD_BATCH_DELAY_MS,
VIEW_GEOMETRY_ATTEMPTS,
VIEW_GEOMETRY_RETRY_MS,
} from './policy.js';
export function createGeometryQueue({
@@ -25,6 +27,8 @@ export function createGeometryQueue({
clearTimeout(layerState._geoQueueTimer);
layerState._geoQueueTimer = 0;
}
clearTimeout(layerState._viewGeometryRetryTimer);
layerState._viewGeometryRetryTimer = 0;
layerState._geoQueue = [];
layerState._geoProgressNotifier = null;
if (clearProgress) {
@@ -223,6 +227,7 @@ export function createGeometryQueue({
err?.message || err,
);
}
noteViewAttempt(record);
if (
layerState._geoLoading &&
layerState._geoLoadDone < layerState._geoLoadTotal
@@ -236,14 +241,15 @@ export function createGeometryQueue({
layerState._geoLoading = false;
if (wasInitialLoad) {
layerState._geoLoadDone = layerState._geoLoadTotal;
if (layerState._enabled) {
parts.rendering.refreshCoverageStyles();
// Geometry refinement may have replaced record.position objects — the
// one-shot drain completion re-anchors the card entries (event-driven,
// not a per-frame or timer pass).
parts.cards.refreshAmbientCards();
}
if (layerState._enabled) parts.rendering.refreshCoverageStyles();
}
// Geometry refinement may have replaced record.position objects — every
// drain's completion re-anchors the card entries (event-driven, not a
// per-frame or timer pass).
// While the camera moves, moveEnd reselects them instead.
if (layerState._enabled && !layerState._cameraMoving)
parts.cards.refreshAmbientCards();
scheduleViewGeometryRetry();
// Completion is never coalesced: subscribers must observe the final
// loading state even if the last progress tick just happened.
layerState._geoProgressNotifier?.finish();
@@ -261,16 +267,17 @@ export function createGeometryQueue({
/**
* Appends records to the geometry queue (no progress tracking) and starts
* the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion
* pass (records left `!groundResolved` by an enable-time drain that ran while
* tiles were still streaming) so it shares the same stagger machinery as the
* initial load. Fires at most once per enable — never on a recurring timer.
* the batch timer if idle. Used by update()'s one-shot tiles-ready
* completion pass and by enqueueViewGeometry, so both share the initial
* load's stagger machinery. Never driven by a recurring timer.
* @param {Object[]} records - Camera records needing geometry refresh.
*/
function enqueueGeometryRefresh(records) {
const queued = new Set(layerState._geoQueue);
for (const record of records) {
if (!layerState._geoQueue.includes(record)) {
if (!queued.has(record)) {
queued.add(record);
layerState._geoQueue.push(record);
}
}
@@ -283,10 +290,10 @@ export function createGeometryQueue({
}
/**
* Starts the initial staggered load: orders all records active-camera-first,
* then by distance from the current viewer position (nearest first, so
* cameras likely in view refine before off-screen ones), and exposes
* loaded/total progress through uiState()/getStats() while running.
* Starts the initial staggered load over the geometry working set: active
* camera first, then by distance from the current viewer position (nearest
* first), and exposes loaded/total progress through uiState()/getStats()
* while running. Cameras outside the view refine when they come into it.
*/
function startGeometryLoadQueue() {
@@ -303,7 +310,7 @@ export function createGeometryQueue({
const refLon = carto
? Cesium.Math.toDegrees(carto.longitude)
: (active?.camera.lon ?? 0);
const pending = layerState._records
const pending = geometryWorkingSet()
.filter((record) => record !== active)
.map((record) => ({
record,
@@ -325,7 +332,134 @@ export function createGeometryQueue({
);
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
}
/**
* The records whose geometry the view needs: the shown icons, the active
* camera and the coverage cohort drawn around it.
* @returns {Object[]}
*/
function geometryWorkingSet() {
const records = new Set(layerState._viewRecords);
const active = parts.selection.getActiveRecord();
if (active) records.add(active);
for (const id of layerState._coverageVisibleIds) {
const record = layerState._recordById.get(id);
if (record) records.add(record);
}
return [...records];
}
/** The regime key a view attempt counts against: interim while tiles stream. */
function viewAttemptKey() {
const regime = parts.ground.currentSurfaceRegime();
const tilesReady =
regime !== 'google-3d' || parts.model.projectionTilesReady();
return { key: tilesReady ? regime : `${regime}:interim`, tilesReady };
}
/**
* Counts a processed geometry attempt against the current regime key; each
* key keeps its own count, so tile streaming going on and off does not
* reset the ready-tiles budget.
*/
function noteViewAttempt(record) {
const { key } = viewAttemptKey();
record.viewAttempts ??= {};
record.viewAttempts[key] = (record.viewAttempts[key] || 0) + 1;
record.viewAttemptAt = performance.now();
}
/**
* Whether a record may be attempted again under `key`. After a first
* unresolved attempt, the second waits out VIEW_GEOMETRY_RETRY_MS so its
* elevation cell can warm, rather than going to the next motion tick.
*/
/** Whether a record still has an attempt left under the current key. */
function withinViewBudget(record) {
const { key } = viewAttemptKey();
return (record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
}
function attemptsLeft(record, key) {
const attempts = record.viewAttempts?.[key] || 0;
if (attempts >= VIEW_GEOMETRY_ATTEMPTS) return false;
return (
attempts === 0 ||
performance.now() - record.viewAttemptAt >= VIEW_GEOMETRY_RETRY_MS
);
}
/**
* After a drain, gives working-set cameras it left ungrounded one more
* attempt once their elevation cells have had time to warm. The timer is
* set for the earliest such camera and re-arms for the rest when it fires.
*/
function scheduleViewGeometryRetry() {
if (!layerState._enabled || layerState._viewGeometryRetryTimer) return;
const { key } = viewAttemptKey();
const active = parts.selection.getActiveRecord();
let due = Infinity;
for (const record of geometryWorkingSet()) {
const attempts = record.viewAttempts?.[key] || 0;
if (
record === active ||
attempts === 0 ||
attempts >= VIEW_GEOMETRY_ATTEMPTS ||
!parts.ground.needsGeometry(record)
)
continue;
due = Math.min(due, record.viewAttemptAt + VIEW_GEOMETRY_RETRY_MS);
}
if (due === Infinity) return;
layerState._viewGeometryRetryTimer = setTimeout(
() => {
layerState._viewGeometryRetryTimer = 0;
enqueueViewGeometry();
// A drain re-arms on completion; with none running, the cameras still
// cooling down need their own timer.
if (!layerState._geoQueueTimer) scheduleViewGeometryRetry();
},
Math.max(0, due - performance.now()),
);
}
/**
* Queues working-set cameras the current surface has not grounded yet,
* after a camera move brings new ones into view. Attempts are counted when
* a record is processed, not queued, so a drain cut short by disable() is
* not lost; each record gets VIEW_GEOMETRY_ATTEMPTS per surface regime,
* spaced by VIEW_GEOMETRY_RETRY_MS, so a sample the shared floor refuses
* (too far, too high) is not retried on every move. While photoreal tiles are still streaming the attempts are
* interim, and the update() completion pass is re-armed to retry these
* records when they land. The active camera is left to selection, which
* owns its re-sampling.
*/
function enqueueViewGeometry() {
if (!layerState._enabled) return;
const { key, tilesReady } = viewAttemptKey();
const active = parts.selection.getActiveRecord();
const pending = [];
let coolingDown = false;
for (const record of geometryWorkingSet()) {
if (record === active || !parts.ground.needsGeometry(record)) continue;
if (!attemptsLeft(record, key)) {
coolingDown ||=
(record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
continue;
}
pending.push(record);
}
// A camera back in view during its cooldown still needs its retry timer.
if (coolingDown && !layerState._geoQueueTimer) scheduleViewGeometryRetry();
if (!pending.length) return;
if (!tilesReady) layerState._tilesReadyReenqueued = false;
enqueueGeometryRefresh(pending);
}
return {
enqueueViewGeometry,
withinViewBudget,
geometryWorkingSet,
stopGeometryLoadQueue,
createGeometryProgressNotifier,
processCctvGeometryQueueBatch,
+40 -3
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import {
SUPPORT_KEYS,
planeSupportPoints,
@@ -78,6 +79,14 @@ export function createGround({ state: layerState, services, parts, source }) {
return record?.groundResolved?.[regime] === true;
}
/**
* True until the record has geometry grounded for the regime. Cameras are
* refined only once they enter the view, so many never have geometry.
*/
function needsGeometry(record, regime = currentSurfaceRegime()) {
return !record?.frustumGeometry || !isGroundResolved(record, regime);
}
/**
* Ground altitude used for pure geometry recomputes: the given regime's
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
@@ -184,8 +193,11 @@ export function createGround({ state: layerState, services, parts, source }) {
* Task 5: applies a LATE-arriving ground-prior batch (init's bounded race
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
* queries:
* - records without geometry yet: only the icon moves onto the prior;
* the frustum is built when the camera enters the view.
* - terrain-globe regime: the prior IS the resolution → re-run the
* resolution (updateRecordGeometry latches it) for every record.
* resolution (updateRecordGeometry latches it) for every record with
* geometry.
* - google-3d regime: records still awaiting a shared floor move from the
* catalog fallback onto the exact prior; records already holding a shared
* mesh/DEM floor keep it untouched.
@@ -198,6 +210,7 @@ export function createGround({ state: layerState, services, parts, source }) {
function applyLateGroundPriors(records, priors) {
if (!Array.isArray(records) || !Array.isArray(priors)) return;
let applied = 0;
let moved = 0;
for (let i = 0; i < records.length && i < priors.length; i++) {
const record = records[i];
const prior = priors[i];
@@ -212,6 +225,24 @@ export function createGround({ state: layerState, services, parts, source }) {
record.camera.absoluteHeightM =
prior.ellipsoid + record.camera.mountHeightM;
}
// Cameras without geometry only move their icon onto the prior, so the
// view pass projects the right point; their frustums are built when
// they enter the view, as recomputing the whole catalog here would
// stall a large one.
if (!record.frustumGeometry) {
if (record.position) {
Cesium.Cartesian3.fromDegrees(
record.camera.lon,
record.camera.lat,
record.camera.absoluteHeightM,
undefined,
record.position,
);
if (record.billboard) record.billboard.position = record.position;
moved += 1;
}
continue;
}
const regime = currentSurfaceRegime();
if (regime === 'terrain-globe') {
// Prior IS the resolution — re-latch onto the fresh value.
@@ -224,7 +255,9 @@ export function createGround({ state: layerState, services, parts, source }) {
applied += 1;
}
}
if (applied) parts.presentation.notifyListeners();
if (moved && parts.rendering.refreshHorizonCulling())
parts.geometryQueue.enqueueViewGeometry();
if (applied || moved) parts.presentation.notifyListeners();
}
/**
@@ -234,7 +267,7 @@ export function createGround({ state: layerState, services, parts, source }) {
* deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so
* on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and
* no-ops):
* 1. every record's geometry recomputes IMMEDIATELY from the new regime's
* 1. every record that has geometry recomputes IMMEDIATELY from the new regime's
* resolution — cached sample if that regime has one, else the Re:Earth
* prior (never blank, zero scene queries);
* 2. entering google-3d re-arms the one-shot tiles-ready completion latch so
@@ -250,6 +283,9 @@ export function createGround({ state: layerState, services, parts, source }) {
layerState._lastAppliedRegime = regime;
for (const record of layerState._records) {
// Only cameras that already have geometry are rewritten; the rest are
// grounded for the new regime when they enter the view.
if (!record.frustumGeometry) continue;
if (regime === 'terrain-globe') {
// Prior IS the resolution — latch it (zero scene queries).
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
@@ -353,6 +389,7 @@ export function createGround({ state: layerState, services, parts, source }) {
resolveFootprintGround,
groundPriorAltFor,
isGroundResolved,
needsGeometry,
groundAltFor,
rearmGroundResolution,
resolveCommittedGroundAnchor,
+3
View File
@@ -91,6 +91,9 @@ export function createCctvLayer({ services, source }) {
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
refreshHorizonCulling: parts.rendering.refreshHorizonCulling,
_viewRecordIdsForTest: () =>
state._viewRecords.map((record) => record.camera.id),
clearProbeClampOnDeactivation:
parts.geometry.clearProbeClampOnDeactivation,
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
+12 -3
View File
@@ -32,13 +32,22 @@ export function createIngestion({
// projectionTilesReady() is false while a (hidden) Google tileset
// exists, so this latch effectively fires for the google-3d regime —
// terrain-globe records resolve from the prior in their drain pass.
const unresolved = layerState._records.filter(
(record) => !parts.ground.isGroundResolved(record),
);
const unresolved = parts.geometryQueue
.geometryWorkingSet()
.filter(
(record) =>
!parts.ground.isGroundResolved(record) &&
parts.geometryQueue.withinViewBudget(record),
);
if (unresolved.length)
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
}
await parts.health.syncHealthState();
// New health can change which camera wins a crowded cell.
if (layerState._enabled && parts.rendering.refreshHorizonCulling()) {
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
}
parts.navigation.maybeAutoHop(now);
parts.presentation.notifyListeners();
},
+163 -3
View File
@@ -7,8 +7,14 @@ import {
CAMERA_ICON,
IDLE_CAMERA_COLOR,
CALIBRATION_RANGE_FLOOR_M,
VIEW_MOTION_REFRESH_MS,
VIEW_MOTION_MIN_MOVE_FRACTION,
VIEW_MOTION_MIN_TURN_DEG,
VIEW_MOTION_COST_FACTOR,
} from './policy.js';
const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180);
export function createLifecycle({
state: layerState,
services,
@@ -22,15 +28,94 @@ export function createLifecycle({
const { onFocusTargetAppear } = services.focus;
const { releaseContinuousRender } = services.render;
/**
* The settled-view pass: thin the icons, queue the newly visible geometry,
* reselect cards, and tell subscribers (the camera picker) when the shown
* set changed since they were last told, including by motion passes.
*/
function settleView() {
const changed = parts.rendering.refreshHorizonCulling();
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
if (changed || layerState._viewUnpublished) {
layerState._viewUnpublished = false;
parts.presentation.notifyListeners();
}
}
/** Records the camera pose the last view pass ran at. */
function noteViewPose() {
const camera = layerState._viewer?.camera;
if (!camera?.positionWC) return;
layerState._viewPose = {
position: Cesium.Cartesian3.clone(camera.positionWC),
direction:
camera.directionWC && Cesium.Cartesian3.clone(camera.directionWC),
};
}
/**
* Whether the camera moved or turned enough since the last view pass to
* change what is on screen. A camera that tracks or flies without settling
* would otherwise rerun the full pass on every tick.
*/
function viewPoseChanged() {
const camera = layerState._viewer?.camera;
const pose = layerState._viewPose;
if (!pose || !camera?.positionWC) return true;
const height = Math.max(1, camera.positionCartographic?.height ?? 0);
const moved = Cesium.Cartesian3.distance(camera.positionWC, pose.position);
if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true;
if (!pose.direction || !camera.directionWC) return false;
return (
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS
);
}
function viewMotionTick() {
if (!layerState._enabled) return;
const now = performance.now();
const pass = layerState._viewMotionPass;
if (pass && now - pass.at < pass.ms * VIEW_MOTION_COST_FACTOR) return;
if (!viewPoseChanged()) return;
if (parts.rendering.refreshHorizonCulling())
layerState._viewUnpublished = true;
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
layerState._viewMotionPass = { at: now, ms: performance.now() - now };
}
function startViewMotionTimer() {
if (layerState._viewMotionTimer) return;
layerState._viewMotionTimer = setInterval(
viewMotionTick,
VIEW_MOTION_REFRESH_MS,
);
}
function stopViewMotionTimer() {
clearInterval(layerState._viewMotionTimer);
layerState._viewMotionTimer = 0;
}
/** Resets all module-scoped runtime state to initial values. */
function clearRuntimeState() {
parts.geometryQueue.stopGeometryLoadQueue();
stopViewMotionTimer();
// Idempotent — also covers a re-init without a prior destroy().
parts.cards.teardownAmbientCards();
parts.projection.clearProjectionOverlay();
layerState._records = [];
layerState._recordById = new Map();
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
layerState._coverageVisibleIds = new Set();
layerState._neighborCountCache = null;
layerState._viewUnpublished = false;
layerState._viewPose = null;
layerState._viewMotionPass = null;
layerState._healthById = new Map();
layerState._count = 0;
layerState._lastUpdate = null;
@@ -71,6 +156,13 @@ export function createLifecycle({
layerState._calibrationById = parts.calibration.loadCalibrationStore();
layerState._billboards = new Cesium.BillboardCollection();
// Cesium switches a property's buffer-usage hint whenever it starts or
// stops changing from one frame to the next, and every switch rewrites
// the vertex data of every billboard. The view pass toggles `show` on a
// few icons of a large catalog at a time, so on a 33K catalog each pass
// cost two full rewrites. With the hint fixed, Cesium uploads only the
// icons that changed.
layerState._billboards.computeNewBuffersUsage = () => false;
layerState._viewer.scene.primitives.add(layerState._billboards);
registerSpriteCollection('cctv', layerState._billboards);
@@ -243,28 +335,76 @@ export function createLifecycle({
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
// the billboards' always-on-top depth setting) + one initial pass so the
// first paint is already culled.
// A re-init against a new viewer moves the move listeners to its camera.
if (
layerState._viewListenerCamera &&
layerState._viewListenerCamera !== layerState._viewer.camera
) {
const previous = layerState._viewListenerCamera;
if (layerState._horizonCullListener)
previous.moveEnd?.removeEventListener(
layerState._horizonCullListener,
);
if (layerState._moveStartListener)
previous.moveStart?.removeEventListener(
layerState._moveStartListener,
);
layerState._horizonCullListener = null;
layerState._moveStartListener = null;
layerState._cameraMoving = false;
}
layerState._viewListenerCamera = layerState._viewer.camera;
if (!layerState._horizonCullListener) {
// Ambient cards piggyback the same settle event: moveEnd-driven
// reselection only, never per frame (refreshAmbientCards no-ops while
// the layer is disabled).
layerState._horizonCullListener = () => {
layerState._cameraMoving = false;
parts.rendering.refreshHorizonCulling();
parts.cards.refreshAmbientCards();
stopViewMotionTimer();
settleView();
};
layerState._viewer.camera.moveEnd.addEventListener(
layerState._horizonCullListener,
);
}
if (!layerState._moveStartListener) {
// Item B: hover picking pauses while the camera is in motion.
// Item B: hover picking pauses while the camera is in motion. A long
// move (cockpit flight, a drag) never settles, so the view pass also
// reruns on an interval until it does.
layerState._moveStartListener = () => {
layerState._cameraMoving = true;
// enable() starts it for a move already under way.
if (layerState._enabled) startViewMotionTimer();
};
layerState._viewer.camera.moveStart.addEventListener(
layerState._moveStartListener,
);
}
// A resize or rotation changes what is on screen without moving the
// camera.
const canvas = layerState._viewer.scene?.canvas;
if (layerState._viewResizeCanvas !== canvas) {
// A re-init against a new viewer observes its canvas instead.
layerState._viewResizeObserver?.disconnect();
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
}
if (
!layerState._viewResizeObserver &&
typeof ResizeObserver === 'function' &&
canvas
) {
let frame = 0;
layerState._viewResizeObserver = new ResizeObserver(() => {
if (frame || !layerState._enabled) return;
frame = requestAnimationFrame(() => {
frame = 0;
if (layerState._enabled) settleView();
});
});
layerState._viewResizeObserver.observe(canvas);
layerState._viewResizeCanvas = canvas;
}
parts.rendering.refreshHorizonCulling();
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
@@ -325,6 +465,12 @@ export function createLifecycle({
);
await parts.health.syncHealthState(true);
// Health ranks crowded cells: thin again now that it has arrived (a
// no-op unless enable() ran while health was loading).
if (parts.rendering.refreshHorizonCulling()) {
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
}
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
restoreSpriteOrder(layerState._viewer);
@@ -370,6 +516,9 @@ export function createLifecycle({
parts.projection.ensureProjectionRuntime(activeRecord);
parts.frames.refreshProjectionImage(activeRecord, true);
}
parts.rendering.refreshHorizonCulling();
// Re-enabled mid-flight: moveStart has already fired.
if (layerState._cameraMoving) startViewMotionTimer();
parts.geometryQueue.startGeometryLoadQueue();
parts.rendering.refreshCoverageStyles();
parts.projection.startProjectionLoop();
@@ -402,10 +551,14 @@ export function createLifecycle({
layerState._removeFocusAppearListener = null;
parts.projection.stopProjectionLoop();
parts.geometryQueue.stopGeometryLoadQueue();
stopViewMotionTimer();
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
// source entries, pacer timer, in-flight handlers, and caches.
parts.cards.teardownAmbientCards();
parts.rendering.hideCctvVisuals();
// Nothing is in view while the layer is off.
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
parts.presentation.notifyListeners();
},
@@ -443,6 +596,13 @@ export function createLifecycle({
);
layerState._moveStartListener = null;
}
// The move listeners are gone; nothing will report the end of a move.
layerState._cameraMoving = false;
layerState._viewListenerCamera = null;
stopViewMotionTimer();
layerState._viewResizeObserver?.disconnect();
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
if (layerState._gizmo) {
layerState._gizmo.destroy();
layerState._gizmo = null;
+39
View File
@@ -60,6 +60,45 @@ export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300;
export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
// View thinning: camera icons show only on or near the screen, and each
// camera competes for a world-anchored grid cell sized to about VIEW_CELL_PX
// at its distance from the viewer (in power-of-two metre steps, so a pan
// keeps the same winners until a camera's distance crosses a step). Cells
// smaller than VIEW_MIN_CELL_M do not thin:
// close up every camera shows, co-located ones included.
export const VIEW_CELL_PX = 40;
export const VIEW_MIN_CELL_M = 60;
// Icons this far past the screen edge (fraction of its size) still show, so
// a pan does not reveal empty ground before the next view pass.
export const VIEW_ICON_MARGIN = 0.5;
// While the camera moves, the view pass reruns at most this often.
export const VIEW_MOTION_REFRESH_MS = 250;
// A motion tick is skipped unless the camera moved this fraction of its
// ellipsoid height, or turned this many degrees, since the last pass.
export const VIEW_MOTION_MIN_MOVE_FRACTION = 0.01;
export const VIEW_MOTION_MIN_TURN_DEG = 0.5;
// Motion passes are spaced at least this many times the last pass's cost, so
// a large catalog spends at most about a tenth of a long move on them.
export const VIEW_MOTION_COST_FACTOR = 10;
// A view camera's geometry is attempted at most this many times per surface
// regime (a cold elevation cell can leave the first attempt unresolved); the
// retry runs this long after the drain that left it unresolved.
export const VIEW_GEOMETRY_ATTEMPTS = 2;
export const VIEW_GEOMETRY_RETRY_MS = 1500;
// Throttle for placeholder repaints — the projection RAF loop must not
// re-fill a 1080p canvas on every frame while a feed image is still loading.
@@ -0,0 +1,58 @@
// The CCTV HUD summary shows the CAL badge, source kind and view band in the
// UI language; English keeps its codes, and unknown source codes stay codes.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { createPresentation } from './presentation.js';
function summary(sourceKind) {
const camera = {
id: 'c1',
city: 'Austin',
name: 'Cam',
rangeM: 100,
fovDeg: 60,
feedType: 'image',
};
const record = { camera };
const presentation = createPresentation({
state: {
_records: [record],
_healthById: new Map(sourceKind ? [['c1', { sourceKind }]] : []),
_showProjection: false,
_coverageMode: 'sector',
},
services: {},
parts: {
selection: { getActiveRecord: () => record },
model: {
sectorAreaKm2: () => 1,
currentViewContext: () => 'street:1:2',
},
geometry: { coverageNeighborCount: () => 0 },
calibration: { deriveCalBadge: () => 'raw-prior' },
},
source: {},
});
return presentation.buildSummaryText();
}
test('en: CAL, SRC and CONTEXT keep their English codes', () => {
setLocale('en');
assert.match(summary('upstream'), /CAL RAW PRIOR · SRC UPSTREAM/);
assert.match(summary(null), /SRC IMAGE · STREET CONTEXT/);
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
});
test('fr: CAL, SRC and CONTEXT values are translated', () => {
setLocale('fr');
try {
assert.match(
summary('upstream'),
/CAL ESTIM\. INITIALE · SRC AMONT · CONTEXTE RUE/,
);
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
} finally {
setLocale('en');
}
});
+42 -3
View File
@@ -274,14 +274,53 @@ export function createPresentation({
},
activeCameraId: activeId,
activeCamera: active ? getPublicCameraState(active, activeId) : null,
cameras: layerState._records.map((record) =>
getPublicCameraState(record, activeId),
),
cameraIndex: cameraIndex(),
summary: buildSummaryText(),
};
// The full per-camera state is built only when a subscriber reads it: a
// catalog of tens of thousands would otherwise be rebuilt on every
// progress tick for listeners that only want the active camera. It reads
// the records live at that moment, and spreading or serializing the
// payload builds it, so callers that want a count use `count`.
let cameras = null;
Object.defineProperty(payload, 'cameras', {
enumerable: true,
get() {
cameras ??= layerState._records.map((record) =>
getPublicCameraState(record, activeId),
);
return cameras;
},
});
return payload;
}
/**
* `{ id, city, name }` for the active camera, then every camera the view
* pass shows: what the camera picker lists. A whole catalog would put tens
* of thousands of options in the DOM and slow every layout. The same array
* is returned until the view or the active camera changes, so a consumer
* can compare it by identity.
* @returns {{id: string, city: string, name: string}[]}
*/
function cameraIndex() {
const view = layerState._viewRecords;
const active = parts.selection.getActiveRecord();
const cached = layerState._cameraIndexCache;
if (cached?.view === view && cached.active === active) return cached.list;
const records = active
? [active, ...view.filter((record) => record !== active)]
: view;
const list = records.map(({ camera }) => ({
id: camera.id,
city: camera.city,
name: camera.name,
}));
layerState._cameraIndexCache = { view, active, list };
return list;
}
/** Dispatches the current UI state to all registered subscriber callbacks. */
function notifyListeners() {
+189 -8
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
@@ -11,8 +10,21 @@ import {
ACTIVE_COVERAGE_EDGE,
IDLE_COVERAGE_EDGE_MUTED,
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
VIEW_CELL_PX,
VIEW_ICON_MARGIN,
VIEW_MIN_CELL_M,
} from './policy.js';
/** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */
function idHash(id) {
let hash = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function createRendering({
state: layerState,
services,
@@ -138,24 +150,186 @@ export function createRendering({
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
* fix), so without this pass London's cluster would shine through the planet
* from a US viewpoint. Pure math over ≤ catalog-size points; runs on
* camera.moveEnd + init only (event-driven — no steady-state work).
* from a US viewpoint.
*
* The same pass thins the view so a large catalog stays readable and cheap:
* icons well off screen hide, and each camera competes for a world-anchored
* cell sized to VIEW_CELL_PX at its distance (healthiest first, then a stable
* id order). The active camera shows whenever it is above the horizon
* and near the screen, even in a crowded cell. The shown records become
* `_viewRecords`, the working set for geometry, cards and the camera picker;
* the array keeps its identity while the shown set is unchanged.
*
* Runs on camera settle, throttled during motion, on resize, enable and
* activation changes — never per frame.
* @returns {boolean} Whether the shown set changed.
*/
function refreshHorizonCulling() {
if (
!layerState._enabled ||
!layerState._viewer ||
layerState._viewer.isDestroyed() ||
!layerState._records.length
)
return;
const occluder = horizonOccluder(layerState._viewer.camera);
return false;
const { camera, scene } = layerState._viewer;
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
const minX = -width * VIEW_ICON_MARGIN;
const maxX = width * (1 + VIEW_ICON_MARGIN);
const minY = -height * VIEW_ICON_MARGIN;
const maxY = height * (1 + VIEW_ICON_MARGIN);
// Ground metres one pixel covers per metre of distance from the viewer.
const fovy = Number.isFinite(camera.frustum?.fovy)
? camera.frustum.fovy
: Math.PI / 3;
const metresPerPixelPerMetre =
(2 * Math.tan(fovy / 2)) / Math.max(1, height);
const viewer = camera.positionWC;
// The horizon test of Cesium's EllipsoidalOccluder, inlined: this loop
// visits every camera in the catalog.
const inverseRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
const cx = viewer.x * inverseRadii.x;
const cy = viewer.y * inverseRadii.y;
const cz = viewer.z * inverseRadii.z;
const limbSquared = cx * cx + cy * cy + cz * cz - 1;
const activeId = layerState._activeCameraId;
const project = screenProjector(scene, camera, width, height);
const pass = (layerState._viewPassId = (layerState._viewPassId || 0) + 1);
const shown = [];
const cells = new Map();
for (const record of layerState._records) {
const position = record.billboard?.position;
if (!position) continue;
const vx = position.x * inverseRadii.x - cx;
const vy = position.y * inverseRadii.y - cy;
const vz = position.z * inverseRadii.z - cz;
const vtDotVc = -(vx * cx + vy * cy + vz * cz);
if (
limbSquared < 0
? vtDotVc > 0
: vtDotVc > limbSquared &&
(vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared
)
continue;
const screen = project(position);
if (
!screen ||
!(screen.x >= minX && screen.x <= maxX) ||
!(screen.y >= minY && screen.y <= maxY)
)
continue;
const dx = position.x - viewer.x;
const dy = position.y - viewer.y;
const dz = position.z - viewer.z;
const cellM =
VIEW_CELL_PX *
metresPerPixelPerMetre *
Math.sqrt(dx * dx + dy * dy + dz * dz);
if (record.camera.id === activeId || !(cellM >= VIEW_MIN_CELL_M)) {
record.viewPass = pass;
shown.push(record);
continue;
}
const key = worldCellKey(record, cellM);
const rank = viewRank(record);
const holder = cells.get(key);
if (!holder || rank < holder.rank) cells.set(key, { record, rank });
}
for (const { record } of cells.values()) {
record.viewPass = pass;
shown.push(record);
}
for (const record of layerState._records) {
const bb = record.billboard;
if (!bb) continue;
const visible = occluder.isPointVisible(bb.position);
const visible = record.viewPass === pass;
if (bb.show !== visible) bb.show = visible;
}
const previous = layerState._viewRecords;
const changed =
previous.length !== shown.length ||
previous.some((record) => record.viewPass !== pass);
if (changed) layerState._viewRecords = shown;
return changed;
}
/**
* Canvas coordinates for world positions. In 3D the pass multiplies by one
* view-projection matrix per call instead of Cesium's per-point
* cartesianToCanvasCoordinates, which dominated the pass on a large
* catalog; other scene modes keep Cesium's projection.
* @returns {(position: Cesium.Cartesian3) => ({x: number, y: number}|null)}
*/
function screenProjector(scene, camera, width, height) {
const out = layerState._scratchViewScreen;
const projection = camera.frustum?.projectionMatrix;
if (
scene.mode !== Cesium.SceneMode.SCENE3D ||
!projection ||
!camera.viewMatrix
)
return (position) => scene.cartesianToCanvasCoordinates(position, out);
const m = Cesium.Matrix4.multiply(
projection,
camera.viewMatrix,
layerState._scratchViewProjection,
);
return (p) => {
const w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
const z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
// Behind the eye or before the near plane: Cesium does not draw it.
if (!(w > 0) || z < 0) return null;
out.x =
((m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12]) / w + 1) * 0.5 * width;
out.y =
(1 - (m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13]) / w) * 0.5 * height;
return out;
};
}
/**
* A grid cell fixed to the ground: `cellM` rounded up to a power of two
* metres, so the cell (and its winner) only changes when the camera's
* distance crosses a doubling: a level pan keeps it, a tilted pan or a zoom
* can move cameras near the edge of a step to the next one.
*/
function worldCellKey(record, cellM) {
const level = Math.ceil(Math.log2(cellM));
const { lat, lon } = record.camera;
// A camera's key changes only with the level (or an edited position), so
// it is kept per level.
if (
record.viewCellLevel === level &&
record.viewCellLat === lat &&
record.viewCellLon === lon
)
return record.viewCellKey;
const latStep = 2 ** level / 111_320;
const row = Math.floor(lat / latStep);
const rowLat = Cesium.Math.toRadians((row + 0.5) * latStep);
const lonStep = latStep / Math.max(0.05, Math.cos(rowLat));
record.viewCellLevel = level;
record.viewCellLat = lat;
record.viewCellLon = lon;
record.viewCellKey = `${level}:${row}:${Math.floor(lon / lonStep)}`;
return record.viewCellKey;
}
/** Lower ranks win a crowded cell: healthy feeds first, then a stable id order. */
function viewRank(record) {
const status = layerState._healthById.get(record.camera.id)?.status;
const tier =
status === 'ok'
? 0
: status === 'degraded'
? 1
: status === 'offline'
? 3
: 2;
record.viewHash ??= idHash(record.camera.id);
return tier * 2 ** 32 + record.viewHash;
}
/**
@@ -204,6 +378,8 @@ export function createRendering({
const activeId = activeRecord?.camera.id || null;
const coverageVisible =
parts.geometry.buildCoverageVisibleSet(activeRecord);
// Drawn coverage needs geometry too (geometryQueue's working set).
layerState._coverageVisibleIds = coverageVisible;
const coverageOn = layerState._coverageMode !== 'off';
const viewshedOn = layerState._coverageMode === 'viewshed';
if (coverageOn) {
@@ -215,8 +391,13 @@ export function createRendering({
for (const record of layerState._records) {
const isActive = record.camera.id === activeId;
if (record.billboard) {
record.billboard.color = cameraColorFor(record);
record.billboard.scale = isActive ? 1.25 : 1.0;
// Unchanged writes still dirty the collection, which then rewrites
// every icon's vertices: across a large catalog, write only changes.
const color = cameraColorFor(record);
if (!Cesium.Color.equals(record.billboard.color, color))
record.billboard.color = color;
const scale = isActive ? 1.25 : 1.0;
if (record.billboard.scale !== scale) record.billboard.scale = scale;
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
// (set at creation) — see the field-test far-zoom submerge fix there.
}
+7
View File
@@ -141,6 +141,11 @@ export function createSelection({
void parts.ground.resolveFootprintGround(record);
record.activationDone = true;
parts.rendering.refreshCoverageStyles();
// The active camera's icon always shows; the previously active one
// competes for its cell again. Its coverage neighbours join the working
// set and are queued for grounding.
parts.rendering.refreshHorizonCulling();
parts.geometryQueue.enqueueViewGeometry();
// The newly active camera leaves the ambient ring (its monitor plane takes
// over); the freed slot re-fills on this same pass.
parts.cards.refreshAmbientCards();
@@ -171,7 +176,9 @@ export function createSelection({
parts.ground.groundAltFor(previous),
);
});
parts.rendering.refreshHorizonCulling();
parts.rendering.refreshCoverageStyles();
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
layerState._gizmo?.refresh();
parts.presentation.notifyListeners();
+57
View File
@@ -76,6 +76,63 @@ export function createState({ services }) {
layerState._scratchFocusScreen = new Cesium.Cartesian2();
layerState._scratchViewScreen = new Cesium.Cartesian2();
layerState._scratchViewProjection = new Cesium.Matrix4();
// Counts view passes; a record shown by the latest pass carries its number.
layerState._viewPassId = 0;
// Records whose icons the last view pass showed (see refreshHorizonCulling):
// geometry refinement, the tiles-ready completion pass and ambient cards
// work over these, not the whole catalog.
layerState._viewRecords = [];
// Coverage ids refreshCoverageStyles last drew: part of the geometry
// working set even when their icons are thinned out.
layerState._coverageVisibleIds = new Set();
// View pass triggers besides camera settle: an interval while the camera
// moves, and a canvas resize observer.
layerState._viewMotionTimer = 0;
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
// The camera the moveStart/moveEnd listeners are attached to.
layerState._viewListenerCamera = null;
// A motion pass changed the shown set and the settle pass has not yet told
// subscribers; the camera pose the last view pass ran at.
layerState._viewUnpublished = false;
layerState._viewPose = null;
// { at, ms } — when the last motion pass ran and what it cost.
layerState._viewMotionPass = null;
// Retries the view geometry a drain left unresolved, once cells warm.
layerState._viewGeometryRetryTimer = 0;
// { key, count } — the active camera's coverage-overlap count, which the
// summary reads on every notification.
layerState._neighborCountCache = null;
// { view, active, list } — the camera picker's index uiState publishes,
// rebuilt only when the view pass or the active camera changes.
layerState._cameraIndexCache = null;
// Staggered geometry-load queue state (see startGeometryLoadQueue).
layerState._geoQueue = [];
+7
View File
@@ -79,6 +79,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
* @param {boolean} [options.enabled=true] Layer enabled state.
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
* @param {boolean} [options.showProjection=false] Projection visibility.
* @param {Map<string, Object>} [options.healthById] Health rows by camera id.
* @returns {void}
*/
@@ -89,6 +90,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
enabled = true,
coverageMode = 'on',
showProjection = false,
healthById = new Map(),
} = {}) {
layerState._viewer = viewer;
layerState._records = Array.isArray(records) ? records : [];
@@ -108,6 +110,11 @@ export function createTesting({ state: layerState, services, parts, source }) {
'on',
);
layerState._showProjection = !!showProjection;
layerState._healthById = healthById;
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
layerState._coverageVisibleIds = new Set();
layerState._neighborCountCache = null;
}
return {
_pushAmbientCardEntriesForTest,
+26 -4
View File
@@ -18,9 +18,19 @@
import * as Cesium from 'cesium';
import { roadLabel } from '../../data/routeSteps.js';
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
import {
t,
msg,
formatNumber,
getLocale,
translateKnown,
} from '../../i18n/index.js';
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import {
formatQuantity,
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -125,7 +135,9 @@ export function routeDurationText(seconds) {
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
const rest = minutes % 60;
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
if (!rest) return hours;
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
}
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
@@ -394,6 +406,16 @@ export function normalizeDirectionsParams(params = {}) {
return out;
}
/**
* A travel-mode word inside a sentence: lower case, except in German, where
* the words are nouns and keep their capital ("Auto", "Fahrrad").
* @param {string} word Translated mode word.
* @returns {string}
*/
function modeWordInSentence(word) {
return getLocale() === 'de' ? word : word.toLowerCase();
}
/**
* Row chips for a given state. Pure so the chip logic is testable without a DOM.
* @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
@@ -409,7 +431,7 @@ export function directionsRowControls(state) {
state: mode === id ? 'active' : 'idle',
title: t('{mode} — reroute for {modeLower}', {
mode: t(spec.word),
modeLower: t(spec.word).toLowerCase(),
modeLower: modeWordInSentence(t(spec.word)),
}),
params: { mode: id },
}));