mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(cctv): separate camera source and scene components
This commit is contained in:
1 parent
ca5f5fff93
commit
519bc6c362
32 files changed
+6873
-4835
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## Camera layer components
|
||||
|
||||
- Separate camera source requests, placement, frames, projection, cards and calibration.
|
||||
- Own visibility listeners and pending initialization within each layer lifetime.
|
||||
- Preserve existing camera catalogs, URL families, geometry and playback behavior.
|
||||
|
||||
## Traffic and bikeshare components
|
||||
|
||||
- Separate traffic loading, animation, styling and lifecycle into factory-owned components.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
CCTV exposes a factory through `./layers/cctv`. Catalog and health requests,
|
||||
frame/media URLs, camera records, ground placement, geometry queues, playback,
|
||||
projection, cards, calibration and interaction have separate components. The
|
||||
standalone entry supplies application-owned scene, ground and activation services.
|
||||
Each layer owns its state and visibility listener; destruction cancels source
|
||||
reads and pending initialization. Malformed health responses retain prior health.
|
||||
Existing catalog fallback, camera poses, frame pacing and coverage controls remain.
|
||||
|
||||
Traffic and bikeshare expose factories through `./layers/traffic` and
|
||||
`./layers/bikeshare`. Traffic separates road requests, ingestion, animation,
|
||||
flow matching, styling, viewport lifecycle and development timing. Each source
|
||||
|
||||
+2
-1
@@ -154,6 +154,7 @@
|
||||
"./layers/installations": "./src/layers/installations/index.js",
|
||||
"./layers/awareness": "./src/layers/awareness/index.js",
|
||||
"./layers/traffic": "./src/layers/traffic/index.js",
|
||||
"./layers/bikeshare": "./src/layers/bikeshare/index.js"
|
||||
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
||||
"./layers/cctv": "./src/layers/cctv/index.js"
|
||||
}
|
||||
}
|
||||
@@ -430,5 +430,30 @@
|
||||
"src/layers/traffic/navigation.test.mjs",
|
||||
"scripts/qa-traffic-navigation.mjs",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/frameRateMonitor.test.mjs"
|
||||
"src/ui/frameRateMonitor.test.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
"src/layers/cctv/source.js",
|
||||
"src/layers/cctv/catalog.js",
|
||||
"src/layers/cctv/state.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/cards.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/testing.js",
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/hover.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/index.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/geometryQueue.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/calibration.js",
|
||||
"src/layers/cctv/frames.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/data/cctv.js",
|
||||
"src/layers/cctv/source.test.mjs"
|
||||
]
|
||||
@@ -857,5 +857,41 @@
|
||||
"src/layers/bikeshare/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"cctv-layer": {
|
||||
"exports": ["./layers/cctv"],
|
||||
"modules": [
|
||||
"src/data/cctvCards.js",
|
||||
"src/data/cctvGizmo.js",
|
||||
"src/data/cctvLod.js",
|
||||
"src/data/cctvViewshed.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/cctv/calibration.js",
|
||||
"src/layers/cctv/cards.js",
|
||||
"src/layers/cctv/catalog.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/frames.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
"src/layers/cctv/geometryQueue.js",
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/layers/cctv/hover.js",
|
||||
"src/layers/cctv/index.js",
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
"src/layers/cctv/state.js",
|
||||
"src/layers/cctv/testing.js",
|
||||
"src/overlays/worldOverlayTokens.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
+87
-4823
File diff suppressed because it is too large.
Load diff
+14
-9
@@ -14,6 +14,11 @@
|
||||
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
|
||||
// plain node:test.
|
||||
import { test } from 'node:test';
|
||||
// Preserve structural pins across component qualification and formatter wrapping.
|
||||
function componentFunctionSource(fn) {
|
||||
return fn.toString().replace(/\blayerState\./g, '').replace(/\bparts\.\w+\./g, '')
|
||||
.replace(/,\s*(?=\))/g, '').replace(/\s+/g, ' ').replace(/\(\s+/g, '(');
|
||||
}
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -208,7 +213,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig
|
||||
});
|
||||
|
||||
assert.equal(insertCount, 0, 'building a 100-camera catalog must insert zero coverage entities');
|
||||
assert.doesNotMatch(cctvLayer.init.toString(), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/);
|
||||
assert.doesNotMatch(componentFunctionSource(cctvLayer.init), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/);
|
||||
|
||||
const activated = materializeCctvActiveCoverageEntities(records[42], build);
|
||||
assert.equal(activated.length, 5);
|
||||
@@ -224,7 +229,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig
|
||||
assert.equal(coverageOn.length, 14 * 5);
|
||||
assert.equal(insertCount, 15 * 5, 'COVERAGE ON builds only remaining visible/eligible sets');
|
||||
assert.equal(records.filter((record) => record.coverageEntities.length > 0).length, 15);
|
||||
assert.match(refreshCoverageStyles.toString(), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/);
|
||||
assert.match(componentFunctionSource(refreshCoverageStyles), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/);
|
||||
assert.equal(
|
||||
materializeCctvVisibleCoverageEntities(records, visibleIds, build).length,
|
||||
0,
|
||||
@@ -614,7 +619,7 @@ test('CCTV disable→enable defers the active-camera re-probe until its next act
|
||||
assert.equal(record.activationDone, false);
|
||||
assert.equal(cctvRecordNeedsActivation('active', 'active', record), true);
|
||||
assert.doesNotMatch(
|
||||
cctvLayer.enable.toString(),
|
||||
componentFunctionSource(cctvLayer.enable),
|
||||
/setActiveCamera|runActivationObstructionProbe|pickFromRay/,
|
||||
'enable must restore nominal visuals without entering the activation probe path',
|
||||
);
|
||||
@@ -724,11 +729,11 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
|
||||
|
||||
assert.deepEqual(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']);
|
||||
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
||||
assert.match(cctvLayer.init.toString(), /bindCctvWorldClickGesture\(_clickHandler/);
|
||||
assert.match(cctvLayer.init.toString(), /_cctvOverlayHost\.hitTest/);
|
||||
assert.match(cctvLayer.init.toString(), /sourceId: CCTV_OVERLAY_SOURCE_ID/);
|
||||
assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/);
|
||||
assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /bindCctvWorldClickGesture\(_clickHandler/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /_cctvOverlayHost\.hitTest/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /sourceId: CCTV_OVERLAY_SOURCE_ID/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/);
|
||||
});
|
||||
|
||||
// ─── Empty-space deselection and stable null-active state ──────────────────
|
||||
@@ -795,7 +800,7 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
|
||||
activeCameraId: 'cam-1',
|
||||
}), true, 'an ID-less surface pick remains true empty space');
|
||||
|
||||
const initSource = cctvLayer.init.toString();
|
||||
const initSource = componentFunctionSource(cctvLayer.init);
|
||||
assert.ok(
|
||||
initSource.indexOf('if (pickedId !== null) return')
|
||||
< initSource.indexOf('_cctvOverlayHost.hitTest'),
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { createCalibrationGizmo } from '../../data/cctvGizmo.js';
|
||||
import {
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V2,
|
||||
} from './policy.js';
|
||||
|
||||
export function createCalibration({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* Returns whether a calibration patch moves the camera's ground anchor.
|
||||
* Rotational, optical, range, and manual-height edits preserve the existing
|
||||
* ground reference; only north/east translation needs a new floor.
|
||||
* @param {Object|null|undefined} patch
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function calibrationPatchMovesAnchor(patch) {
|
||||
if (!patch || typeof patch !== 'object') return false;
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(patch, 'offsetNorthM') ||
|
||||
Object.prototype.hasOwnProperty.call(patch, 'offsetEastM')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and clamps a calibration object to valid ranges.
|
||||
* Missing or non-finite fields fall back to defaults.
|
||||
* @param {Object} [value={}] - Raw calibration values.
|
||||
* @returns {{ offsetNorthM: number, offsetEastM: number, headingDeg: number, pitchDeg: number, fovDeg: number, rangeScale: number, heightM: number }}
|
||||
*/
|
||||
|
||||
function normalizeCalibration(value = {}) {
|
||||
const raw = value && typeof value === 'object' ? value : {};
|
||||
return {
|
||||
offsetNorthM: parts.model.quantize(
|
||||
parts.model.clamp(
|
||||
parts.model.safeNumber(raw.offsetNorthM, 0),
|
||||
-900,
|
||||
900,
|
||||
),
|
||||
0.1,
|
||||
),
|
||||
offsetEastM: parts.model.quantize(
|
||||
parts.model.clamp(
|
||||
parts.model.safeNumber(raw.offsetEastM, 0),
|
||||
-900,
|
||||
900,
|
||||
),
|
||||
0.1,
|
||||
),
|
||||
headingDeg: parts.model.quantize(
|
||||
parts.model.clamp(parts.model.safeNumber(raw.headingDeg, 0), -180, 180),
|
||||
0.1,
|
||||
),
|
||||
pitchDeg: parts.model.quantize(
|
||||
parts.model.clamp(parts.model.safeNumber(raw.pitchDeg, 0), -45, 45),
|
||||
0.1,
|
||||
),
|
||||
fovDeg: parts.model.quantize(
|
||||
parts.model.clamp(parts.model.safeNumber(raw.fovDeg, 0), -50, 50),
|
||||
0.1,
|
||||
),
|
||||
rangeScale: parts.model.quantize(
|
||||
parts.model.clamp(parts.model.safeNumber(raw.rangeScale, 1), 0.35, 3.0),
|
||||
0.01,
|
||||
),
|
||||
heightM: parts.model.quantize(
|
||||
parts.model.clamp(parts.model.safeNumber(raw.heightM, 0), -120, 240),
|
||||
0.1,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given calibration is effectively the default (all offsets near zero).
|
||||
* @param {Object} calibration
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function isDefaultCalibration(calibration) {
|
||||
const probe = normalizeCalibration(calibration);
|
||||
return Object.keys(DEFAULT_CAMERA_CALIBRATION).every(
|
||||
(key) => Math.abs(probe[key] - DEFAULT_CAMERA_CALIBRATION[key]) < 0.0001,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all persisted per-camera calibration overrides from the v2 store.
|
||||
*
|
||||
* v2 entries carry provenance: `{ values: <7-field offsets>, source: 'manual',
|
||||
* savedAt: <epoch ms> }`. The v1 key (`CCTV_CALIBRATION_STORAGE_KEY_V1`) is
|
||||
* NEVER read here — owner decision #3 (§9.3): wipe clean, no legacy import.
|
||||
*
|
||||
* @param {{getItem:function}|null} [storage] - Injectable storage (defaults
|
||||
* to `window.localStorage`); lets the unit suite test this pure of a DOM.
|
||||
* @returns {Map<string, {values:Object, source:string, savedAt:number}>}
|
||||
*/
|
||||
|
||||
function readCalibrationStoreV2(
|
||||
storage = parts.model.safeWindowLocalStorage(),
|
||||
) {
|
||||
const map = new Map();
|
||||
if (!storage) return map;
|
||||
try {
|
||||
const raw = storage.getItem(CCTV_CALIBRATION_STORAGE_KEY_V2);
|
||||
if (!raw) return map;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return map;
|
||||
for (const [cameraId, entry] of Object.entries(parsed)) {
|
||||
if (!cameraId || !entry || typeof entry !== 'object') continue;
|
||||
if (!entry.values || typeof entry.values !== 'object') continue;
|
||||
// 'manual' is the only provenance v2 knows (§9.3 killed 'legacy'); a
|
||||
// malformed/foreign source string still normalizes to 'manual' rather
|
||||
// than surfacing an unrecognized value into the badge logic.
|
||||
map.set(cameraId, {
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, 0),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a calibration map to the v2 store.
|
||||
* @param {Map<string, {values:Object, source:string, savedAt:number}>} map
|
||||
* @param {{setItem:function}|null} [storage] - Injectable storage (defaults
|
||||
* to `window.localStorage`).
|
||||
*/
|
||||
|
||||
function writeCalibrationStoreV2(
|
||||
map,
|
||||
storage = parts.model.safeWindowLocalStorage(),
|
||||
) {
|
||||
if (!storage) return;
|
||||
try {
|
||||
const payload = {};
|
||||
for (const [cameraId, entry] of map.entries()) {
|
||||
payload[cameraId] = {
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, Date.now()),
|
||||
};
|
||||
}
|
||||
storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the v2 calibration store. `_calibrationById` holds these entries
|
||||
* directly (`{values, source:'manual', savedAt}`) — never bare offset values
|
||||
* — so it round-trips straight back through `writeCalibrationStoreV2`.
|
||||
* @returns {Map<string, {values:Object, source:string, savedAt:number}>}
|
||||
*/
|
||||
|
||||
function loadCalibrationStore() {
|
||||
return readCalibrationStoreV2();
|
||||
}
|
||||
|
||||
/** Persists the in-memory calibration entries (values + provenance) to the v2 store. */
|
||||
|
||||
function saveCalibrationStore() {
|
||||
writeCalibrationStoreV2(layerState._calibrationById);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the panel CAL badge state for a camera (design §3b, as amended by
|
||||
* the LOCKED §9.2 — panel-only, no in-world tint).
|
||||
*
|
||||
* Three states:
|
||||
* - 'calibrated' — a human explicitly saved a v2 calibration (`source:'manual'`).
|
||||
* - 'curated' — no manual save, but the catalog entry was hand-authored
|
||||
* (`poseSource:'curated'`, file/env sources only).
|
||||
* - 'raw-prior' — everything else (all Austin Open Data today).
|
||||
*
|
||||
* Pure — no scoring math, no raycasts. `confidenceFromScore` and score-based
|
||||
* quality seeding are retired; this replaces them.
|
||||
* @param {{calSource?: string|null, poseSource?: string|null}} camera
|
||||
* @returns {'calibrated'|'curated'|'raw-prior'}
|
||||
*/
|
||||
|
||||
function deriveCalBadge(camera) {
|
||||
if (camera?.calSource === 'manual') return 'calibrated';
|
||||
if (camera?.poseSource === 'curated') return 'curated';
|
||||
return 'raw-prior';
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a calibration patch to a record's IN-MEMORY pose (save-gated
|
||||
* persistence, design §3e: no localStorage write here — only the explicit
|
||||
* `calibration.save` action persists).
|
||||
*
|
||||
* Transient grade (gizmo mid-drag): recompute pose + frustum geometry only
|
||||
* (the cheap v2 path) with throttled notify — no ground re-arm, no frame
|
||||
* re-fetch, no store touch.
|
||||
* Commit grade (drag end, numeric entry, voice): only an E/N anchor move
|
||||
* resolves a new shared floor; all other edits keep the frozen reference.
|
||||
*
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {Object} patch - Partial 7-field calibration (absolute offset values).
|
||||
* @param {{transient?: boolean}} [options]
|
||||
* @returns {boolean} True when the patch applied.
|
||||
*/
|
||||
|
||||
function applyCalibrationPatch(record, patch, options = {}) {
|
||||
if (!record || !patch || typeof patch !== 'object') return false;
|
||||
record.camera.calibration = normalizeCalibration({
|
||||
...record.camera.calibration,
|
||||
...patch,
|
||||
});
|
||||
parts.model.ensureCameraPose(record.camera);
|
||||
// §9.1: touching range takes manual control — clear the activation clamp.
|
||||
if ('rangeScale' in patch) {
|
||||
record.probeClampRangeM = null;
|
||||
}
|
||||
const anchorMoved = calibrationPatchMovesAnchor(patch);
|
||||
if (options.transient === true && anchorMoved) {
|
||||
record.calibrationAnchorDirty = true;
|
||||
}
|
||||
record.calDirty = true;
|
||||
if (options.transient === true) {
|
||||
parts.geometry.applyFrustumGeometry(
|
||||
record,
|
||||
parts.ground.groundAltFor(record),
|
||||
);
|
||||
parts.presentation.notifyListenersThrottled();
|
||||
return true;
|
||||
}
|
||||
if (anchorMoved) {
|
||||
parts.ground.resolveCommittedGroundAnchor(record);
|
||||
} else {
|
||||
parts.geometry.applyFrustumGeometry(
|
||||
record,
|
||||
parts.ground.groundAltFor(record),
|
||||
);
|
||||
}
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates the calibration gizmo controller. The gizmo sees the layer
|
||||
* only through these callbacks: it attaches to the active record while the
|
||||
* layer is enabled AND ADJUST mode is on, funnels drags through
|
||||
* applyCalibrationPatch (transient), and runs the commit tail on release.
|
||||
*/
|
||||
|
||||
function ensureGizmo() {
|
||||
if (layerState._gizmo || !layerState._viewer) return;
|
||||
// Both patch callbacks receive the gizmo's PINNED drag record — never
|
||||
// re-resolve the active camera here: a mid-drag voice select or auto-hop
|
||||
// would route the captured offsets onto a camera with a different basePose.
|
||||
const liveRecord = (record) =>
|
||||
record && layerState._recordById.get(record.camera?.id) === record
|
||||
? record
|
||||
: null;
|
||||
layerState._gizmo = createCalibrationGizmo({
|
||||
viewer: layerState._viewer,
|
||||
getActiveRecord: () =>
|
||||
layerState._enabled && layerState._calibrationMode
|
||||
? parts.selection.getActiveRecord()
|
||||
: null,
|
||||
applyPatch: (patch, draggedRecord) => {
|
||||
const record =
|
||||
layerState._enabled && layerState._calibrationMode
|
||||
? liveRecord(draggedRecord)
|
||||
: null;
|
||||
if (record) applyCalibrationPatch(record, patch, { transient: true });
|
||||
},
|
||||
endPatch: (draggedRecord) => {
|
||||
const record = liveRecord(draggedRecord);
|
||||
if (!record) return;
|
||||
if (record.calibrationAnchorDirty) {
|
||||
record.calibrationAnchorDirty = false;
|
||||
parts.ground.resolveCommittedGroundAnchor(record);
|
||||
} else {
|
||||
parts.geometry.applyFrustumGeometry(
|
||||
record,
|
||||
parts.ground.groundAltFor(record),
|
||||
);
|
||||
}
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
calibrationPatchMovesAnchor,
|
||||
normalizeCalibration,
|
||||
isDefaultCalibration,
|
||||
readCalibrationStoreV2,
|
||||
writeCalibrationStoreV2,
|
||||
loadCalibrationStore,
|
||||
saveCalibrationStore,
|
||||
deriveCalBadge,
|
||||
applyCalibrationPatch,
|
||||
ensureGizmo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import {
|
||||
createFrameSlot,
|
||||
CCTV_OVERLAY_SOURCE_ID,
|
||||
declutterCctvCards,
|
||||
planFrameCachePrune,
|
||||
createCctvThumbnailOverlayEntry,
|
||||
CCTV_FRAME_CANVAS_W,
|
||||
CCTV_FRAME_CANVAS_H,
|
||||
applyFrameResult,
|
||||
} from '../../data/cctvCards.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js';
|
||||
import {
|
||||
CCTV_OVERLAY_SOURCE_OPTIONS,
|
||||
CARD_VIEW_MARGIN,
|
||||
CCTV_AMBIENT_CARD_DRAIN_CAP,
|
||||
CARD_GAP_PX,
|
||||
CARD_FETCH_TICK_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createCards({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Configures optional CCTV card presentation without changing card density.
|
||||
* The active-camera thumbnail defaults OFF, preserving the shipped behavior
|
||||
* where the monitor plane is the camera's sole active representation.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.activeCameraCardEnabled=false]
|
||||
* @returns {{activeCameraCardEnabled:boolean}}
|
||||
*/
|
||||
|
||||
function setCctvCardPresentationOptions({
|
||||
activeCameraCardEnabled = false,
|
||||
} = {}) {
|
||||
layerState._activeCameraCardEnabled = activeCameraCardEnabled === true;
|
||||
if (layerState._enabled) pushAmbientCardEntries();
|
||||
layerState._viewer?.scene?.requestRender?.();
|
||||
|
||||
return { activeCameraCardEnabled: layerState._activeCameraCardEnabled };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ambient card tier (2026-07-29 design — spec:
|
||||
// docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns (creating on demand) the stable frame slot for a camera id. */
|
||||
|
||||
function ensureCardFrameSlot(cameraId) {
|
||||
let slot = layerState._cardFrameSlots.get(cameraId);
|
||||
if (!slot) {
|
||||
slot = createFrameSlot();
|
||||
layerState._cardFrameSlots.set(cameraId, slot);
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the ambient card selection: horizon + in-view projection of the
|
||||
* catalog (pure math — no scene queries), the zoom-budgeted nearest-first
|
||||
* LOD pick, greedy screen-space declutter, and the eviction-grace pass that
|
||||
* keeps budget-edge cards alive across small camera moves (zero-flicker).
|
||||
* Runs on camera.moveEnd, enable, activation change, and the one-shot
|
||||
* geometry-drain completion — NEVER per frame. The active camera is excluded
|
||||
* from the ambient selection/quota. By default its monitor plane is the sole
|
||||
* active representation; the optional protected-card path is applied only by
|
||||
* `pushAmbientCardEntries`. Camera icons are never touched here (cards
|
||||
* annotate markers, they don't replace them).
|
||||
*/
|
||||
|
||||
function refreshAmbientCards() {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._viewer ||
|
||||
layerState._viewer.isDestroyed() ||
|
||||
!layerState._records.length
|
||||
) {
|
||||
layerState._cctvOverlayHost.setEntries(
|
||||
CCTV_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
CCTV_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const scene = layerState._viewer.scene;
|
||||
const carto = layerState._viewer.camera.positionCartographic;
|
||||
const viewerLat = carto ? Cesium.Math.toDegrees(carto.latitude) : 0;
|
||||
const viewerLon = carto ? Cesium.Math.toDegrees(carto.longitude) : 0;
|
||||
// The active camera is excluded from ambient selection ALWAYS (not just
|
||||
// after its async activation settles). It is published separately in the
|
||||
// protected lane below, and grace never applies to it.
|
||||
const activeId = layerState._activeCameraId;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
|
||||
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
|
||||
const marginX = width * CARD_VIEW_MARGIN;
|
||||
const marginY = height * CARD_VIEW_MARGIN;
|
||||
|
||||
const candidates = [];
|
||||
const screenById = new Map();
|
||||
for (const record of layerState._records) {
|
||||
const id = record.camera.id;
|
||||
if (id === activeId || !record.position) continue;
|
||||
let inView = false;
|
||||
let sx = NaN;
|
||||
let sy = NaN;
|
||||
if (occluder.isPointVisible(record.position)) {
|
||||
const screen = scene.cartesianToCanvasCoordinates(record.position);
|
||||
if (
|
||||
screen &&
|
||||
Number.isFinite(screen.x) &&
|
||||
Number.isFinite(screen.y) &&
|
||||
screen.x >= -marginX &&
|
||||
screen.x <= width + marginX &&
|
||||
screen.y >= -marginY &&
|
||||
screen.y <= height + marginY
|
||||
) {
|
||||
inView = true;
|
||||
sx = screen.x;
|
||||
sy = screen.y;
|
||||
screenById.set(id, { sx, sy });
|
||||
}
|
||||
}
|
||||
candidates.push({
|
||||
id,
|
||||
distanceKm: parts.model.haversineKm(
|
||||
viewerLat,
|
||||
viewerLon,
|
||||
record.camera.lat,
|
||||
record.camera.lon,
|
||||
),
|
||||
inView,
|
||||
isVideo: parts.model.isVideoFeedType(
|
||||
parts.model.normalizeFeedType(record.camera.feedType),
|
||||
),
|
||||
sx,
|
||||
sy,
|
||||
});
|
||||
}
|
||||
|
||||
// Owner finding 4: current card holders rank with the 20% incumbency
|
||||
// distance discount, so a small camera move never batch-swaps the ring.
|
||||
// Item C: passing the viewport dims + per-candidate screen anchors routes
|
||||
// the budget fill through the screen-distribution grid, so periphery
|
||||
// cells hold cards instead of everything clustering at screen center.
|
||||
const { cardIds, budgets } = selectCctvLod(candidates, {
|
||||
cameraHeightM: carto?.height,
|
||||
incumbentIds: layerState._cardIds,
|
||||
viewW: width,
|
||||
viewH: height,
|
||||
});
|
||||
// Like the cold-fill burst, card density yields to the staggered geometry
|
||||
// drain: painting the raised 20/28/40 budget per frame starves the
|
||||
// frame-paced mesh-floor queue on weak GPUs (qa-cctv-v2 N=800 drain-budget
|
||||
// regression). During the initial load the budget holds at the low tier;
|
||||
// full density arrives the moment the drain completes (which triggers its
|
||||
// own refreshAmbientCards pass).
|
||||
const cardLimit = layerState._geoLoading
|
||||
? Math.min(budgets.cardLimit, CCTV_AMBIENT_CARD_DRAIN_CAP)
|
||||
: budgets.cardLimit;
|
||||
const decluttered = declutterCctvCards(
|
||||
cardIds
|
||||
.filter((id) => screenById.has(id))
|
||||
.slice(0, cardLimit)
|
||||
.map((id, index) => ({
|
||||
id,
|
||||
...screenById.get(id),
|
||||
// Priority carrier, not kilometers: declutter sorts ascending on
|
||||
// this field, and the selection's order (distribution + incumbency)
|
||||
// must survive — a periphery cell-winner must not be re-outranked
|
||||
// by central proximity when two anchors contest the min separation.
|
||||
distanceKm: index,
|
||||
})),
|
||||
{ limit: cardLimit },
|
||||
);
|
||||
// Owner finding 2: grace must never apply to the active camera — drop any
|
||||
// lingering grace entry and keep it out of the retained-card baseline.
|
||||
if (activeId) {
|
||||
layerState._cardIds.delete(activeId);
|
||||
layerState._cardGraceState.delete(activeId);
|
||||
}
|
||||
const retention = applyEvictionGrace({
|
||||
selectedIds: decluttered,
|
||||
builtIds: [...layerState._cardIds],
|
||||
graceState: layerState._cardGraceState,
|
||||
nowMs: Date.now(),
|
||||
cardLimit: budgets.cardLimit,
|
||||
});
|
||||
layerState._cardIds = new Set(retention.keepIds);
|
||||
layerState._cardGraceState = retention.graceState;
|
||||
|
||||
// Bounded thumbnail LRU: live cards (grace included) never lose their
|
||||
// persisted frame — that persistence IS the no-flicker guarantee. The
|
||||
// hover card's slot is protected too while the gesture lasts.
|
||||
const keepFrames = new Set(layerState._cardIds);
|
||||
if (layerState._hoverCardId) keepFrames.add(layerState._hoverCardId);
|
||||
if (layerState._activeCameraCardEnabled && layerState._activeCameraId)
|
||||
keepFrames.add(layerState._activeCameraId);
|
||||
const drops = planFrameCachePrune(
|
||||
[...layerState._cardFrameSlots].map(([id, slot]) => ({
|
||||
id,
|
||||
stamp: slot.stamp,
|
||||
})),
|
||||
keepFrames,
|
||||
);
|
||||
for (const id of drops) layerState._cardFrameSlots.delete(id);
|
||||
|
||||
pushAmbientCardEntries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and publishes the card entry list from the current kept set and the
|
||||
* hover-summoned pin. The hover entry is budget-exempt and high-priority. The
|
||||
* active camera is absent by default; `activeCameraCardEnabled` retains the
|
||||
* migrated protected-publication path as an explicit product option. If the
|
||||
* LOD selection adopts the hovered camera it remains a normal budgeted entry
|
||||
* that keeps the pin while the gesture lasts.
|
||||
*/
|
||||
|
||||
function pushAmbientCardEntries() {
|
||||
const entries = [];
|
||||
let rank = 0;
|
||||
const push = (id, { pinned = false, active = false } = {}) => {
|
||||
const record = layerState._recordById.get(id);
|
||||
if (!record?.position) return;
|
||||
entries.push(
|
||||
createCctvThumbnailOverlayEntry({
|
||||
id,
|
||||
position: record.position,
|
||||
gapPx: CARD_GAP_PX,
|
||||
title: record.camera.name,
|
||||
frameSlot: ensureCardFrameSlot(id),
|
||||
rank: rank++,
|
||||
pinned,
|
||||
active,
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (const id of layerState._cardIds)
|
||||
push(id, { pinned: id === layerState._hoverCardId });
|
||||
if (
|
||||
layerState._hoverCardId &&
|
||||
!layerState._cardIds.has(layerState._hoverCardId) &&
|
||||
layerState._hoverCardId !== layerState._activeCameraId
|
||||
) {
|
||||
push(layerState._hoverCardId, { pinned: true });
|
||||
}
|
||||
if (layerState._activeCameraCardEnabled && layerState._activeCameraId) {
|
||||
push(layerState._activeCameraId, { active: true });
|
||||
}
|
||||
layerState._cctvOverlayHost.setEntries(
|
||||
CCTV_OVERLAY_SOURCE_ID,
|
||||
entries,
|
||||
CCTV_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one paced static frame and settles it into the stable slot via the
|
||||
* pure persistence rule (applyFrameResult): success replaces the thumbnail,
|
||||
* failure leaves the drawn frame untouched. The frame is downscaled once
|
||||
* into a 2x-thumb offscreen canvas; the renderer reads the slot live.
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {Object} slot - The camera's stable frame slot.
|
||||
* @param {number} refreshMs - Source cadence (also keys the frame-URL tick).
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.userGesture] - Hover fast-track (item B): the
|
||||
* launch bypasses the pacer gate, so its spacing sample would pollute the
|
||||
* pacing telemetry — skip the min-spacing sample only. The launch still
|
||||
* stamps `_cardLastFetchAt`, so the pacer waits a full interval after it.
|
||||
*/
|
||||
|
||||
function fetchCardFrame(
|
||||
record,
|
||||
slot,
|
||||
refreshMs,
|
||||
{ userGesture = false } = {},
|
||||
) {
|
||||
if (typeof document !== 'undefined' && document.hidden && !userGesture)
|
||||
return;
|
||||
const now = Date.now();
|
||||
const cameraId = record.camera.id;
|
||||
layerState._cardFetchInFlightCount += 1;
|
||||
layerState._cardFetchPendingIds.add(cameraId);
|
||||
if (layerState._cardLastFetchAt > 0 && !userGesture) {
|
||||
const spacing = now - layerState._cardLastFetchAt;
|
||||
// NOTE: cold-fill bursts legitimately push this to ~250 ms — read it
|
||||
// together with the ambientCards.fetchMode telemetry.
|
||||
layerState._cardMinFetchSpacingMs =
|
||||
layerState._cardMinFetchSpacingMs == null
|
||||
? spacing
|
||||
: Math.min(layerState._cardMinFetchSpacingMs, spacing);
|
||||
}
|
||||
layerState._cardLastFetchAt = now;
|
||||
layerState._cardFetchCount += 1;
|
||||
|
||||
const image = new Image();
|
||||
layerState._cardFetchImages.add(image);
|
||||
const settle = (ok) => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
if (layerState._cardFetchImages.delete(image)) {
|
||||
layerState._cardFetchInFlightCount = Math.max(
|
||||
0,
|
||||
layerState._cardFetchInFlightCount - 1,
|
||||
);
|
||||
layerState._cardFetchPendingIds.delete(cameraId);
|
||||
}
|
||||
let frame = null;
|
||||
if (ok) {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = CCTV_FRAME_CANVAS_W;
|
||||
canvas.height = CCTV_FRAME_CANVAS_H;
|
||||
canvas
|
||||
.getContext('2d')
|
||||
.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
frame = canvas;
|
||||
} catch {
|
||||
frame = null;
|
||||
}
|
||||
}
|
||||
Object.assign(
|
||||
slot,
|
||||
applyFrameResult(slot, { ok: !!frame, frame }, Date.now()),
|
||||
);
|
||||
layerState._viewer?.scene?.requestRender?.();
|
||||
};
|
||||
image.onload = () => settle(true);
|
||||
image.onerror = () => settle(false);
|
||||
image.src = parts.frames.frameUrlFor(record.camera, refreshMs);
|
||||
}
|
||||
|
||||
/** Starts the card-frame pacer (idempotent; policy-gated per tick). */
|
||||
|
||||
function startCardFrameLoop() {
|
||||
if (layerState._cardFetchTimer) return;
|
||||
layerState._cardFetchTimer = setInterval(
|
||||
parts.model.cardFrameTick,
|
||||
CARD_FETCH_TICK_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Stops the pacer and detaches all in-flight fetch handlers. */
|
||||
|
||||
function stopCardFrameLoop() {
|
||||
if (layerState._cardFetchTimer) {
|
||||
clearInterval(layerState._cardFetchTimer);
|
||||
layerState._cardFetchTimer = 0;
|
||||
}
|
||||
for (const image of layerState._cardFetchImages) {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.removeAttribute('src');
|
||||
}
|
||||
layerState._cardFetchImages.clear();
|
||||
layerState._cardFetchPendingIds.clear();
|
||||
layerState._cardFetchInFlightCount = 0;
|
||||
layerState._cardFetchMode = 'steady';
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete ambient-tier teardown (layer disable/destroy): pacer + in-flight
|
||||
* handlers, shared-host source entries, card set, grace state, thumbnail
|
||||
* cache, and fetch telemetry. Nothing leaks across a toggle.
|
||||
*/
|
||||
|
||||
function teardownAmbientCards() {
|
||||
stopCardFrameLoop();
|
||||
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();
|
||||
layerState._cardFetchCount = 0;
|
||||
layerState._cardLastFetchAt = 0;
|
||||
layerState._cardMinFetchSpacingMs = null;
|
||||
}
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) return;
|
||||
for (const image of layerState._cardFetchImages) {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.removeAttribute('src');
|
||||
}
|
||||
layerState._cardFetchImages.clear();
|
||||
layerState._cardFetchPendingIds.clear();
|
||||
layerState._cardFetchInFlightCount = 0;
|
||||
}
|
||||
return {
|
||||
handleVisibilityChange,
|
||||
setCctvCardPresentationOptions,
|
||||
ensureCardFrameSlot,
|
||||
refreshAmbientCards,
|
||||
pushAmbientCardEntries,
|
||||
fetchCardFrame,
|
||||
startCardFrameLoop,
|
||||
stopCardFrameLoop,
|
||||
teardownAmbientCards,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
||||
|
||||
export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
const { CITY_POIS } = services.locations;
|
||||
|
||||
/**
|
||||
* Builds the initial camera catalog from CAMERA_SEEDS definitions.
|
||||
* Each seed is resolved against its city's POI coordinates, offset, and
|
||||
* passed through ensureCameraPose to populate derived fields.
|
||||
* @returns {Object[]} Array of fully-initialized camera objects.
|
||||
*/
|
||||
|
||||
function seedCatalog() {
|
||||
const catalog = [];
|
||||
for (const seed of CAMERA_SEEDS) {
|
||||
const city = CITY_POIS[seed.cityId];
|
||||
const poi = city?.pois?.[seed.poiIndex];
|
||||
if (!city || !poi) continue;
|
||||
const { latOffset, lonOffset } = parts.model.offsetDegrees(
|
||||
poi.lat,
|
||||
seed.offsetNorthM || 0,
|
||||
seed.offsetEastM || 0,
|
||||
);
|
||||
const camera = {
|
||||
id: seed.id,
|
||||
name: seed.label,
|
||||
cityId: seed.cityId,
|
||||
city: city.name,
|
||||
provider: 'OSM Camera Grid',
|
||||
sourceKind: 'seed',
|
||||
feedType: 'image',
|
||||
feedConfigured: false,
|
||||
headingConfidence: 'medium',
|
||||
lat: poi.lat + latOffset,
|
||||
lon: poi.lon + lonOffset,
|
||||
headingDeg: parts.model.normalizeHeading(
|
||||
seed.headingDeg ?? poi.heading ?? 0,
|
||||
),
|
||||
fovDeg: parts.model.clamp(seed.fovDeg ?? 70, 20, 120),
|
||||
rangeM: parts.model.clamp(seed.rangeM ?? 700, 260, 1800),
|
||||
mountHeightM: parts.model.clamp(seed.elevationM ?? 22, 8, 80),
|
||||
groundElevationM: Number(city.groundElevation) || 0,
|
||||
absoluteHeightM:
|
||||
(Number(city.groundElevation) || 0) +
|
||||
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
|
||||
pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4),
|
||||
};
|
||||
parts.model.ensureCameraPose(camera);
|
||||
catalog.push(camera);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a city ID from CITY_POIS by exact or partial name match.
|
||||
* @param {string} cityName
|
||||
* @returns {string|null} Matching city ID or null.
|
||||
*/
|
||||
|
||||
function cityIdByName(cityName) {
|
||||
const probe = String(cityName || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!probe) return null;
|
||||
for (const [cityId, city] of Object.entries(CITY_POIS)) {
|
||||
if (city.name.toLowerCase() === probe) return cityId;
|
||||
}
|
||||
for (const [cityId, city] of Object.entries(CITY_POIS)) {
|
||||
if (
|
||||
city.name.toLowerCase().includes(probe) ||
|
||||
probe.includes(city.name.toLowerCase())
|
||||
)
|
||||
return cityId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches configured camera sources from the backend.
|
||||
* @returns {Promise<Object[]>} Array of raw source objects, or empty on failure.
|
||||
*/
|
||||
|
||||
async function loadCameraSources() {
|
||||
try {
|
||||
const signal = layerState._sourceAbort?.signal;
|
||||
const data = await source.getCatalog({ signal });
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(data?.sources)) return [];
|
||||
return data.sources;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') throw error;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges raw backend sources with seed data to produce the final camera catalog.
|
||||
* Seeds provide fallback values for heading, FOV, range, etc. when not specified
|
||||
* by the source. Each camera is passed through ensureCameraPose.
|
||||
* @param {Object[]} rawSources - Raw source objects from the backend.
|
||||
* @returns {Object[]} Array of fully-initialized camera objects.
|
||||
*/
|
||||
|
||||
function buildCatalogFromSources(rawSources) {
|
||||
const sources = Array.isArray(rawSources) ? rawSources : [];
|
||||
if (!sources.length) return [];
|
||||
|
||||
const seeded = seedCatalog();
|
||||
const seedById = new Map(seeded.map((camera) => [camera.id, camera]));
|
||||
|
||||
const catalog = [];
|
||||
for (const source of sources) {
|
||||
if (!source || typeof source !== 'object') continue;
|
||||
const id = String(source.id || '').trim();
|
||||
if (!id) continue;
|
||||
const seed = seedById.get(id);
|
||||
const cityId =
|
||||
String(source.cityId || '').trim() ||
|
||||
cityIdByName(source.city) ||
|
||||
seed?.cityId ||
|
||||
'';
|
||||
const city = cityId && CITY_POIS[cityId] ? CITY_POIS[cityId] : null;
|
||||
|
||||
const lat = parts.model.safeNumber(source.lat, seed?.lat ?? NaN);
|
||||
const lon = parts.model.safeNumber(source.lon, seed?.lon ?? NaN);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
const sourceHeading = parts.model.safeNumber(source.headingDeg, NaN);
|
||||
const headingDeg = parts.model.normalizeHeading(
|
||||
Number.isFinite(sourceHeading)
|
||||
? sourceHeading
|
||||
: (seed?.headingDeg ?? parts.model.headingFromId(id)),
|
||||
);
|
||||
const fovDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(source.fovDeg, seed?.fovDeg ?? 74),
|
||||
20,
|
||||
125,
|
||||
);
|
||||
const rangeM = parts.model.clamp(
|
||||
parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700),
|
||||
220,
|
||||
2200,
|
||||
);
|
||||
const mountHeightM = parts.model.clamp(
|
||||
parts.model.safeNumber(source.mountHeightM, seed?.mountHeightM ?? 24),
|
||||
6,
|
||||
120,
|
||||
);
|
||||
const pitchDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(source.pitchDeg, seed?.pitchDeg ?? -17),
|
||||
-55,
|
||||
-2,
|
||||
);
|
||||
const groundElevationM = parts.model.safeNumber(
|
||||
source.groundElevationM,
|
||||
city?.groundElevation ?? seed?.groundElevationM ?? 0,
|
||||
);
|
||||
const feedType = parts.model.normalizeFeedType(
|
||||
source.feedType || source.type || 'image',
|
||||
);
|
||||
const headingConfidence = String(
|
||||
source.headingConfidence || (seed ? 'high' : 'low'),
|
||||
).toLowerCase();
|
||||
// CAL badge input (design §3b passthrough): hand-authored file/env source
|
||||
// entries may carry poseSource:'curated'. Austin Open Data rows never set
|
||||
// this — they stay RAW PRIOR until a human manually calibrates them.
|
||||
const poseSource =
|
||||
source.poseSource === 'curated' ? 'curated' : seed?.poseSource || null;
|
||||
|
||||
const camera = {
|
||||
id,
|
||||
name: String(source.name || seed?.name || id),
|
||||
cityId,
|
||||
city: String(source.city || city?.name || seed?.city || 'Global'),
|
||||
provider: String(
|
||||
source.provider || seed?.provider || 'Configured CCTV Source',
|
||||
),
|
||||
sourceKind: String(
|
||||
source.sourceKind ||
|
||||
source.kind ||
|
||||
(source.url ? 'configured' : 'seed'),
|
||||
).toLowerCase(),
|
||||
feedType,
|
||||
feedConfigured: typeof source.url === 'string' && !!source.url.trim(),
|
||||
lat,
|
||||
lon,
|
||||
headingDeg,
|
||||
headingConfidence,
|
||||
fovDeg,
|
||||
rangeM,
|
||||
mountHeightM,
|
||||
groundElevationM,
|
||||
absoluteHeightM: groundElevationM + mountHeightM,
|
||||
pitchDeg,
|
||||
license: String(source.license || source.licenseNote || ''),
|
||||
poseSource,
|
||||
};
|
||||
parts.model.ensureCameraPose(camera);
|
||||
catalog.push(camera);
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
return {
|
||||
seedCatalog,
|
||||
cityIdByName,
|
||||
loadCameraSources,
|
||||
buildCatalogFromSources,
|
||||
};
|
||||
}
|
||||
Vendored
+352
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
DEFAULT_UPDATE_INTERVAL_MS,
|
||||
MIN_AUTO_HOP_SEC,
|
||||
MAX_AUTO_HOP_SEC,
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
} from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { CCTV_ACTIVATION_RESULT } = services.activation;
|
||||
|
||||
const methods = {
|
||||
id: 'cctv',
|
||||
|
||||
name: 'CCTV',
|
||||
|
||||
icon: '📹',
|
||||
|
||||
source: 'CCTV + Street View fallback',
|
||||
|
||||
updateInterval: DEFAULT_UPDATE_INTERVAL_MS,
|
||||
|
||||
/**
|
||||
* Applies runtime parameter changes: coverage/projection toggles, auto-hop
|
||||
* settings, camera selection, and calibration patches/resets.
|
||||
* @param {Object} [params={}] - Parameter object.
|
||||
* @param {boolean} [params.showCoverage] - Back-compat coverage toggle (true→'on', false→'off').
|
||||
* @param {'off'|'on'|'viewshed'} [params.coverageMode] - Full coverage-mode API.
|
||||
* @param {boolean} [params.showProjection] - Toggle projection overlay visibility.
|
||||
* @param {boolean} [params.autoHop] - Enable/disable auto-hop.
|
||||
* @param {number} [params.autoHopSec] - Auto-hop interval in seconds.
|
||||
* @param {string} [params.selectedCameraId] - Camera ID to activate.
|
||||
* @param {Object} [params.calibration] - Calibration config: `patch` edits
|
||||
* the live pose (save-gated — no persistence), `save` persists the current
|
||||
* calibration as manual, `reset` restores the base prior.
|
||||
* @param {boolean} [params.calibrationMode] - Toggle the ADJUST gizmo.
|
||||
* @param {boolean} [params.focusSelected] - Fly to the active camera.
|
||||
* @param {number} [params.focusDurationSec] - Fly-to duration.
|
||||
*/
|
||||
setParams(params = {}) {
|
||||
if (typeof params.showCoverage === 'boolean') {
|
||||
layerState._coverageMode = parts.model.normalizeCoverageMode(
|
||||
params.showCoverage,
|
||||
layerState._coverageMode,
|
||||
);
|
||||
}
|
||||
if (typeof params.coverageMode === 'string') {
|
||||
layerState._coverageMode = parts.model.normalizeCoverageMode(
|
||||
params.coverageMode,
|
||||
layerState._coverageMode,
|
||||
);
|
||||
}
|
||||
if (typeof params.showProjection === 'boolean') {
|
||||
layerState._showProjection = params.showProjection;
|
||||
if (layerState._showProjection) {
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (active) parts.projection.ensureProjectionRuntime(active);
|
||||
parts.projection.startProjectionLoop();
|
||||
}
|
||||
}
|
||||
if (typeof params.autoHop === 'boolean') {
|
||||
layerState._autoHop = params.autoHop;
|
||||
if (params.autoHop) layerState._autoHopSuspended = false;
|
||||
}
|
||||
if (
|
||||
typeof params.autoHopSec === 'number' &&
|
||||
Number.isFinite(params.autoHopSec)
|
||||
) {
|
||||
layerState._autoHopSec = parts.model.clamp(
|
||||
Math.round(params.autoHopSec),
|
||||
MIN_AUTO_HOP_SEC,
|
||||
MAX_AUTO_HOP_SEC,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof params.selectedCameraId === 'string' &&
|
||||
layerState._recordById.has(params.selectedCameraId)
|
||||
) {
|
||||
parts.selection.setActiveCamera(params.selectedCameraId);
|
||||
}
|
||||
if (params.calibration && typeof params.calibration === 'object') {
|
||||
const calibrationCfg = params.calibration;
|
||||
const targetCameraId =
|
||||
typeof calibrationCfg.cameraId === 'string' && calibrationCfg.cameraId
|
||||
? calibrationCfg.cameraId
|
||||
: layerState._activeCameraId;
|
||||
const targetRecord = targetCameraId
|
||||
? layerState._recordById.get(targetCameraId)
|
||||
: null;
|
||||
if (targetRecord) {
|
||||
if (calibrationCfg.reset) {
|
||||
// RESET: back to the base prior, delete the persisted entry, clear
|
||||
// the dirty flag (semantics unchanged from v2).
|
||||
targetRecord.camera.calibration =
|
||||
parts.calibration.normalizeCalibration(
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
);
|
||||
targetRecord.camera.calSource = null;
|
||||
targetRecord.calDirty = false;
|
||||
parts.model.ensureCameraPose(targetRecord.camera);
|
||||
layerState._calibrationById.delete(targetCameraId);
|
||||
parts.calibration.saveCalibrationStore();
|
||||
// Reset returns to the base lat/lon, so resolve that anchor once.
|
||||
parts.ground.resolveCommittedGroundAnchor(targetRecord);
|
||||
parts.frames.refreshProjectionImage(targetRecord, true);
|
||||
}
|
||||
if (
|
||||
calibrationCfg.patch &&
|
||||
typeof calibrationCfg.patch === 'object'
|
||||
) {
|
||||
// Save-gated persistence (design §3e): a patch edits the LIVE pose
|
||||
// only. The store — and the CALIBRATED badge's `calSource` — move
|
||||
// exclusively on the explicit `save` action below. (§9.1 range-slider
|
||||
// clamp override + B9b re-ground live inside applyCalibrationPatch.)
|
||||
parts.calibration.applyCalibrationPatch(
|
||||
targetRecord,
|
||||
calibrationCfg.patch,
|
||||
);
|
||||
}
|
||||
if (calibrationCfg.save) {
|
||||
// SAVE CAL: persist the current in-memory calibration with manual
|
||||
// provenance. Saving an all-default calibration clears the entry
|
||||
// (a no-op calibration is not a calibration).
|
||||
if (
|
||||
parts.calibration.isDefaultCalibration(
|
||||
targetRecord.camera.calibration,
|
||||
)
|
||||
) {
|
||||
targetRecord.camera.calSource = null;
|
||||
layerState._calibrationById.delete(targetCameraId);
|
||||
} else {
|
||||
targetRecord.camera.calSource = 'manual';
|
||||
layerState._calibrationById.set(targetCameraId, {
|
||||
values: { ...targetRecord.camera.calibration },
|
||||
source: 'manual',
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
targetRecord.calDirty = false;
|
||||
parts.calibration.saveCalibrationStore();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof params.calibrationMode === 'boolean') {
|
||||
layerState._calibrationMode = params.calibrationMode;
|
||||
if (layerState._calibrationMode) {
|
||||
parts.calibration.ensureGizmo();
|
||||
layerState._gizmo?.setEnabled(true);
|
||||
// ADJUST mode: gizmo drags mutate entity geometry from pointer events,
|
||||
// which don't trigger renders in requestRenderMode. (perf wave 2)
|
||||
holdContinuousRender('cctv-adjust');
|
||||
} else {
|
||||
releaseContinuousRender('cctv-adjust');
|
||||
layerState._gizmo?.setEnabled(false);
|
||||
}
|
||||
}
|
||||
if (params.focusSelected && layerState._activeCameraId) {
|
||||
parts.navigation.focusCamera(
|
||||
layerState._activeCameraId,
|
||||
Number(params.focusDurationSec) || 1.8,
|
||||
);
|
||||
}
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the current runtime parameters including toggle states,
|
||||
* active camera, and calibration values.
|
||||
* @returns {Object}
|
||||
*/
|
||||
getParams() {
|
||||
const active = parts.selection.getActiveRecord();
|
||||
return {
|
||||
showCoverage: layerState._coverageMode !== 'off',
|
||||
coverageMode: layerState._coverageMode,
|
||||
showProjection: layerState._showProjection,
|
||||
calibrationMode: layerState._calibrationMode,
|
||||
autoHop: layerState._autoHop,
|
||||
autoHopSec: layerState._autoHopSec,
|
||||
selectedCameraId: active?.camera.id || null,
|
||||
calibration: active?.camera
|
||||
? {
|
||||
cameraId: active.camera.id,
|
||||
values: {
|
||||
...parts.calibration.normalizeCalibration(
|
||||
active.camera.calibration,
|
||||
),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a sampled list of camera positions for the detection overlay system.
|
||||
* @param {Object} [options={}]
|
||||
* @param {number} [options.maxCount] - Maximum number of objects to return.
|
||||
* @param {number} [options.seed] - Offset seed for deterministic stride sampling.
|
||||
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (!layerState._enabled || layerState._records.length === 0) return [];
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: layerState._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 start = seed % stride;
|
||||
|
||||
const objects = [];
|
||||
for (let i = start; i < layerState._records.length; i += stride) {
|
||||
const camera = layerState._records[i].camera;
|
||||
objects.push({
|
||||
position: layerState._records[i].position,
|
||||
sourceId: camera.id,
|
||||
id: `CAM-${camera.id}`,
|
||||
type: 'CAM',
|
||||
});
|
||||
if (objects.length >= maxCount) break;
|
||||
}
|
||||
return objects;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns basic layer statistics, including initial-load progress while
|
||||
* the staggered geometry queue is draining.
|
||||
* @returns {{ count: number, lastUpdate: number|null, error: string|null, loading: boolean, loadingLoaded: number, loadingTotal: number }}
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
count: layerState._count,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
loading: layerState._geoLoading,
|
||||
loadingLoaded: Math.min(
|
||||
layerState._geoLoadDone,
|
||||
layerState._geoLoadTotal,
|
||||
),
|
||||
loadingTotal: layerState._geoLoadTotal,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Registers a callback that receives the full UI state on every change.
|
||||
* The callback is invoked immediately with the current state.
|
||||
* @param {Function} callback - Listener function receiving the UI state object.
|
||||
* @returns {Function} Unsubscribe function.
|
||||
*/
|
||||
subscribe(callback) {
|
||||
if (typeof callback !== 'function') return () => {};
|
||||
layerState._listeners.add(callback);
|
||||
callback(parts.presentation.uiState());
|
||||
return () => {
|
||||
layerState._listeners.delete(callback);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the current UI state snapshot without subscribing.
|
||||
* @returns {Object}
|
||||
*/
|
||||
getUIState() {
|
||||
return parts.presentation.uiState();
|
||||
},
|
||||
|
||||
/**
|
||||
* Opts the active camera into or out of protected thumbnail publication.
|
||||
* The default is false: the monitor plane remains the sole active-camera
|
||||
* representation while ambient and hover-pinned cards continue unchanged.
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.activeCameraCardEnabled=false]
|
||||
* @returns {{activeCameraCardEnabled:boolean}}
|
||||
*/
|
||||
setCardPresentationOptions(options = {}) {
|
||||
return parts.cards.setCctvCardPresentationOptions(options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Selects a camera by ID and optionally flies to it.
|
||||
* @param {string} cameraId - Camera ID to select.
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.focus] - If true, fly the viewer to the camera.
|
||||
* @param {number} [options.durationSec] - Fly-to duration in seconds.
|
||||
* @returns {boolean} True if the camera was found and selected.
|
||||
*/
|
||||
selectCamera(cameraId, options = {}) {
|
||||
const result = parts.selection.setActiveCamera(cameraId);
|
||||
if (result === CCTV_ACTIVATION_RESULT.NOT_FOUND) return false;
|
||||
if (options.focus) {
|
||||
parts.navigation.focusCamera(cameraId, options.durationSec || 1.8);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Flies the viewer to a specific camera.
|
||||
* @param {string} cameraId - Camera ID to focus on.
|
||||
* @param {number} [durationSec=2.2] - Flight duration in seconds.
|
||||
* @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result.
|
||||
*/
|
||||
focusCamera(cameraId, durationSec = 2.2) {
|
||||
return parts.navigation.focusCamera(cameraId, durationSec);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cycles the active camera forward or backward by `step` positions in the catalog.
|
||||
* @param {number} [step=1] - Number of positions to advance (negative to go back).
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.focus] - If true, fly to the new camera.
|
||||
* @param {number} [options.durationSec] - Fly-to duration in seconds.
|
||||
* @returns {string|null} The newly active camera ID, or null if catalog is empty.
|
||||
*/
|
||||
cycleCamera(step = 1, options = {}) {
|
||||
if (!layerState._records.length) return null;
|
||||
const current = parts.selection.getActiveRecord();
|
||||
const nextIdx = parts.navigation.cctvCycleIndex(
|
||||
layerState._records.findIndex((record) => record === current),
|
||||
step,
|
||||
layerState._records.length,
|
||||
);
|
||||
const nextId = layerState._records[nextIdx].camera.id;
|
||||
parts.selection.setActiveCamera(nextId);
|
||||
if (options.focus) {
|
||||
parts.navigation.focusCamera(nextId, options.durationSec || 1.8);
|
||||
}
|
||||
return nextId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Selects and flies to the camera nearest the current viewer position.
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.focus=true] Whether to fly after selection.
|
||||
* @param {number} [options.durationSec] - Fly-to duration in seconds.
|
||||
* @returns {string|null} The nearest camera ID, or null if none found.
|
||||
*/
|
||||
focusNearest(options = {}) {
|
||||
const nearest = parts.navigation.nearestCameraIdToViewer();
|
||||
if (!nearest) return null;
|
||||
parts.selection.setActiveCamera(nearest);
|
||||
if (options.focus !== false) {
|
||||
parts.navigation.focusCamera(nearest, options.durationSec || 1.8);
|
||||
}
|
||||
return nearest;
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
FRAME_SIGNATURE_W,
|
||||
FRAME_SIGNATURE_H,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
PROJECTION_ACTIVE_REFRESH_MS,
|
||||
PROJECTION_IDLE_REFRESH_MS,
|
||||
PLACEHOLDER_REPAINT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createFrames({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
||||
* pixel buffer, no DOM) so it is unit-testable.
|
||||
*
|
||||
* Alpha is skipped deliberately — CCTV stills are opaque, so hashing it would
|
||||
* cost a third more work to mix in a constant.
|
||||
*
|
||||
* @param {Uint8ClampedArray|number[]} data - RGBA pixels, 4 bytes per pixel.
|
||||
* @returns {number|null} Unsigned 32-bit signature, or null for empty input.
|
||||
*/
|
||||
|
||||
function frameSignatureFromPixels(data) {
|
||||
if (!data || typeof data.length !== 'number' || data.length < 4)
|
||||
return null;
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
hash = Math.imul(hash ^ data[i], 0x01000193);
|
||||
hash = Math.imul(hash ^ data[i + 1], 0x01000193);
|
||||
hash = Math.imul(hash ^ data[i + 2], 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature of the runtime's freshly decoded frame, via a reused 64x36
|
||||
* scratch canvas.
|
||||
*
|
||||
* @param {Object} runtime - Projection runtime holding the decoded `.image`.
|
||||
* @returns {number|null} Signature, or null when it cannot be computed (the
|
||||
* caller then treats the frame as changed — the pre-2026-07-30 behavior).
|
||||
*/
|
||||
|
||||
function projectionFrameSignature(runtime) {
|
||||
const image = runtime?.image;
|
||||
if (!image) return null;
|
||||
if (!runtime.signatureCtx) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = FRAME_SIGNATURE_W;
|
||||
canvas.height = FRAME_SIGNATURE_H;
|
||||
runtime.signatureCanvas = canvas;
|
||||
runtime.signatureCtx = canvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
}
|
||||
const ctx = runtime.signatureCtx;
|
||||
if (!ctx) return null;
|
||||
try {
|
||||
ctx.clearRect(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H);
|
||||
ctx.drawImage(image, 0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H);
|
||||
return frameSignatureFromPixels(
|
||||
ctx.getImageData(0, 0, FRAME_SIGNATURE_W, FRAME_SIGNATURE_H).data,
|
||||
);
|
||||
} catch {
|
||||
// Tainted canvas (a cross-origin source served without CORS) or a decode
|
||||
// race. Returning null means "assume changed", so behavior degrades to
|
||||
// the unconditional redraw this optimization replaced.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blits the latest projection canvas into the next of two alternating
|
||||
* offscreen buffer canvases and returns it (H5).
|
||||
*
|
||||
* Two buffers are required because Cesium's Material image path re-uploads a
|
||||
* canvas texture only when the uniform receives a NEW object reference —
|
||||
* redrawing the same canvas in place is invisible to the GPU. Alternating
|
||||
* references forces a texture recreate, which at <=1Hz and 1080p is cheap.
|
||||
*
|
||||
* @param {Object} runtime - Projection runtime with `.canvas`.
|
||||
* @returns {HTMLCanvasElement|null} The freshly painted buffer, or null.
|
||||
*/
|
||||
|
||||
function paintNextProjectionBuffer(runtime) {
|
||||
if (!runtime?.canvas) return null;
|
||||
if (!runtime.buffers) {
|
||||
runtime.buffers = [0, 1].map(() => {
|
||||
const buffer = document.createElement('canvas');
|
||||
buffer.width = PROJECTION_CANVAS_WIDTH;
|
||||
buffer.height = PROJECTION_CANVAS_HEIGHT;
|
||||
return buffer;
|
||||
});
|
||||
runtime.bufferIndex = 0;
|
||||
}
|
||||
runtime.bufferIndex = (runtime.bufferIndex + 1) % 2;
|
||||
const buffer = runtime.buffers[runtime.bufferIndex];
|
||||
const ctx = buffer.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.clearRect(0, 0, buffer.width, buffer.height);
|
||||
ctx.drawImage(runtime.canvas, 0, 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes fresh pixels into the monitor plane material. Called every
|
||||
* projection tick.
|
||||
*
|
||||
* Video feeds are skipped entirely — their HTMLVideoElement uniform is
|
||||
* updated per-frame by Cesium natively (H5). Image/webcam-frame feeds swap
|
||||
* the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS.
|
||||
*
|
||||
* @param {Object} record - Camera record with an initialized projection runtime.
|
||||
*/
|
||||
|
||||
function refreshProjectionTextures(record) {
|
||||
const runtime = record?.projection;
|
||||
if (!runtime || runtime.mode === 'video') return;
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) <
|
||||
PROJECTION_TEXTURE_SWAP_MS
|
||||
)
|
||||
return;
|
||||
|
||||
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
|
||||
if (!planeShowing) return;
|
||||
|
||||
// Only swap when the canvas content actually changed since the last swap.
|
||||
// Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED
|
||||
// canvas re-uploads the texture for nothing, and each material image
|
||||
// reassignment is a flash opportunity on the live plane (owner field test
|
||||
// 2026-07-04: intermittent white flashes on the monitor plane).
|
||||
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return;
|
||||
|
||||
const buffer = paintNextProjectionBuffer(runtime);
|
||||
if (!buffer) return;
|
||||
runtime.lastTextureSwapAt = now;
|
||||
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
||||
runtime.planeMaterial.image = buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the URL for fetching a camera frame image from the backend.
|
||||
* Includes a tick parameter to control cache invalidation cadence.
|
||||
* @param {Object} camera - Camera object.
|
||||
* @param {number} [refreshMs=ACTIVE_FRAME_REFRESH_MS] - Refresh interval used for tick bucketing.
|
||||
* @returns {string} Frame URL.
|
||||
*/
|
||||
|
||||
function frameUrlFor(...args) {
|
||||
return source.getFrameUrl(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the URL for fetching a camera's video/media stream.
|
||||
* @param {Object} camera - Camera object.
|
||||
* @returns {string} Media URL.
|
||||
*/
|
||||
|
||||
function mediaUrlFor(...args) {
|
||||
return source.getMediaUrl(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints a placeholder frame onto the projection canvas when no live feed
|
||||
* image or video is available. Shows camera name, city, and status text
|
||||
* over a dark gradient with tactical border lines.
|
||||
* @param {CanvasRenderingContext2D} ctx - 2D context for the projection canvas.
|
||||
* @param {Object} camera - Camera object for label info.
|
||||
* @param {Object|null} [health=null] - Health state for status message.
|
||||
*/
|
||||
|
||||
function paintProjectionPlaceholder(ctx, camera, health = null) {
|
||||
if (!ctx) return;
|
||||
const w = PROJECTION_CANVAS_WIDTH;
|
||||
const h = PROJECTION_CANVAS_HEIGHT;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const g = ctx.createLinearGradient(0, 0, w, h);
|
||||
g.addColorStop(0, '#05111a');
|
||||
g.addColorStop(1, '#01070c');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const label = String(camera?.name || 'CCTV');
|
||||
const city = String(camera?.city || 'GLOBAL');
|
||||
const status = String(
|
||||
health?.message || health?.status || camera?.feedType || 'NO FEED',
|
||||
).toUpperCase();
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 220, 255, 0.24)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(18, 18, w - 36, h - 36);
|
||||
ctx.strokeRect(36, 36, w - 72, h - 72);
|
||||
|
||||
ctx.fillStyle = 'rgba(170, 242, 255, 0.95)';
|
||||
ctx.font = '600 32px "JetBrains Mono", monospace';
|
||||
ctx.fillText(label.slice(0, 42), 46, 74);
|
||||
ctx.fillStyle = 'rgba(127, 216, 231, 0.8)';
|
||||
ctx.font = '500 24px "JetBrains Mono", monospace';
|
||||
ctx.fillText(city.toUpperCase(), 46, 112);
|
||||
ctx.font = '500 21px "JetBrains Mono", monospace';
|
||||
ctx.fillText(status.slice(0, 58), 46, h - 42);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a new frame fetch for an image-mode projection if the refresh
|
||||
* interval has elapsed. Active cameras refresh more frequently than idle ones.
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {boolean} [force=false] - Bypass the interval check.
|
||||
*/
|
||||
|
||||
function refreshProjectionImage(record, force = false) {
|
||||
const runtime = record?.projection;
|
||||
if (!runtime || runtime.mode !== 'image' || !runtime.image) return;
|
||||
// Hidden-state gate (perf wave 2): no new frame fetch/decode for a canvas
|
||||
// nobody can see. The refresh interval re-fills naturally on return.
|
||||
if (typeof document !== 'undefined' && document.hidden && !force) return;
|
||||
// Do not replace an in-flight URL on the 10-second refresh boundary. Slow
|
||||
// providers otherwise leave cancelled server requests behind and the plane
|
||||
// can remain permanently pending. The proxy bounds each attempt; load/error
|
||||
// clears this latch so the next normal tick can refresh.
|
||||
if (runtime.imageLoading) return;
|
||||
const now = Date.now();
|
||||
const refreshMs =
|
||||
record.camera.id === layerState._activeCameraId
|
||||
? PROJECTION_ACTIVE_REFRESH_MS
|
||||
: PROJECTION_IDLE_REFRESH_MS;
|
||||
if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
|
||||
runtime.lastImageRefreshAt = now;
|
||||
|
||||
const frameUrl = frameUrlFor(record.camera, refreshMs);
|
||||
const sep = frameUrl.includes('?') ? '&' : '?';
|
||||
runtime.imageLoading = true;
|
||||
runtime.imageReady = false;
|
||||
runtime.image.src = `${frameUrl}${sep}projTs=${Math.floor(now / refreshMs)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaints the projection placeholder at most once per PLACEHOLDER_REPAINT_MS.
|
||||
* The projection loop runs at RAF cadence — unthrottled, a pending feed would
|
||||
* re-fill the 1080p canvas (gradient + text) on every single frame.
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {Object} runtime - Projection runtime.
|
||||
* @param {Object|null} health - Health state for status text.
|
||||
*/
|
||||
|
||||
function paintPlaceholderThrottled(record, runtime, health) {
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - parts.model.safeNumber(runtime.lastPlaceholderPaintAt, 0) <
|
||||
PLACEHOLDER_REPAINT_MS
|
||||
)
|
||||
return;
|
||||
runtime.lastPlaceholderPaintAt = now;
|
||||
runtime.drawnImageStamp = -1;
|
||||
// The placeholder overwrites the canvas, so the last real frame is no longer
|
||||
// on it. Drop the signature or an identical frame returning after an outage
|
||||
// would be skipped as "unchanged" and leave the placeholder on the plane.
|
||||
runtime.lastFrameSignature = null;
|
||||
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
||||
paintProjectionPlaceholder(runtime.ctx, record.camera, health);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the current frame (video or image) onto the projection canvas.
|
||||
* Falls back to the placeholder if the media source is not yet ready.
|
||||
* Image feeds only repaint when a NEW image finished loading (stamp check):
|
||||
* re-blitting an unchanged 1080p image every RAF tick is pure waste since
|
||||
* texture uploads are already throttled to 1Hz buffer swaps.
|
||||
* @param {Object} record - Camera record with an initialized projection runtime.
|
||||
*/
|
||||
|
||||
function drawProjectionFrame(record) {
|
||||
const runtime = record?.projection;
|
||||
if (!runtime || !runtime.ctx) return;
|
||||
|
||||
const health = layerState._healthById.get(record.camera.id) || null;
|
||||
|
||||
if (runtime.mode === 'video' && runtime.video) {
|
||||
const video = runtime.video;
|
||||
if (
|
||||
video.readyState >= 2 &&
|
||||
video.videoWidth > 0 &&
|
||||
video.videoHeight > 0
|
||||
) {
|
||||
runtime.ctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.ctx.drawImage(
|
||||
video,
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
||||
return;
|
||||
}
|
||||
paintPlaceholderThrottled(record, runtime, health);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProjectionImage(record);
|
||||
if (runtime.image && runtime.imageReady) {
|
||||
if (runtime.drawnImageStamp !== runtime.imageStamp) {
|
||||
// The frame URL carries a 10s cache-buster tick, so a fresh Image
|
||||
// DECODES every PROJECTION_ACTIVE_REFRESH_MS whether or not the provider
|
||||
// actually published a new picture — measured 2026-07-30: a London
|
||||
// camera republished once in 5 minutes, an Austin one not at all.
|
||||
// Redrawing regardless bumped canvasStamp, which forced a buffer swap
|
||||
// and a fresh 1920x1080 texture upload; the plane renders its white
|
||||
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
||||
// two Cesium needs to rebind, which IS the periodic white flash from the
|
||||
// owner field tests (2026-07-04 and 2026-07-30).
|
||||
const signature = projectionFrameSignature(runtime);
|
||||
runtime.drawnImageStamp = runtime.imageStamp;
|
||||
if (signature !== null && signature === runtime.lastFrameSignature) {
|
||||
// Identical pixels — leave the canvas, and therefore the bound
|
||||
// texture, completely alone. No canvasStamp bump, no swap, no flash.
|
||||
return;
|
||||
}
|
||||
runtime.lastFrameSignature = signature;
|
||||
runtime.ctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.ctx.drawImage(
|
||||
runtime.image,
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
||||
runtime.lastPlaceholderPaintAt = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A refresh is in flight (imageReady=false): keep the last good frame on
|
||||
// the canvas instead of flashing the placeholder. Placeholder only paints
|
||||
// when nothing has ever been drawn for this camera.
|
||||
if (runtime.drawnImageStamp === -1) {
|
||||
paintPlaceholderThrottled(record, runtime, health);
|
||||
}
|
||||
}
|
||||
return {
|
||||
frameSignatureFromPixels,
|
||||
projectionFrameSignature,
|
||||
paintNextProjectionBuffer,
|
||||
refreshProjectionTextures,
|
||||
frameUrlFor,
|
||||
mediaUrlFor,
|
||||
paintProjectionPlaceholder,
|
||||
refreshProjectionImage,
|
||||
paintPlaceholderThrottled,
|
||||
drawProjectionFrame,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
|
||||
import {
|
||||
PROJECTION_VERT_ASPECT,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
PROBE_MIN_RANGE_M,
|
||||
PROBE_CLEARANCE_M,
|
||||
COVERAGE_NEIGHBOR_RADIUS_KM,
|
||||
COVERAGE_NEIGHBOR_LIMIT,
|
||||
IDLE_COVERAGE_COLOR,
|
||||
} from './policy.js';
|
||||
|
||||
export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
||||
const { sampleMeshFloorCells } = services.mesh;
|
||||
|
||||
/**
|
||||
* V2 core geometry (design §2a): computes the pitched frustum pyramid — mount
|
||||
* point, far-cap (monitor plane) center, and the 4 far-plane corners — purely
|
||||
* from the calibrated pose + a caller-supplied ground altitude. ZERO scene
|
||||
* queries: ground sampling happens in the caller (one-shot snap), and the
|
||||
* obstruction probe passes its clamp in as `rangeOverrideM`.
|
||||
*
|
||||
* Math (spherical small-angle offsets; sub-centimetre at ≤2.2 km ranges):
|
||||
* mountAlt = groundAltM + mountHeightM
|
||||
* capCenter = projectPoint(heading, R·cos(pitch)) @ alt mountAlt + R·sin(pitch)
|
||||
* halfW = R·tan(hFov/2)
|
||||
* vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2)
|
||||
* upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading
|
||||
* corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset
|
||||
* The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated
|
||||
* pitch must never bury the plane's anchor); corners derive rigidly from the
|
||||
* clamped center so the wireframe rays always terminate on the plane's
|
||||
* corners — the bottom pair may dip below ground (tiles occlude it).
|
||||
*
|
||||
* @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg,
|
||||
* rangeM, mountHeightM.
|
||||
* @param {number} groundAltM - Ground altitude at the mount (metres).
|
||||
* @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the
|
||||
* effective range (never lengthens it).
|
||||
* @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number,
|
||||
* mount: {lat:number,lon:number,alt:number},
|
||||
* capCenter: {lat:number,lon:number,alt:number},
|
||||
* corners: { tl: Object, tr: Object, br: Object, bl: Object },
|
||||
* topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }}
|
||||
*/
|
||||
|
||||
function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) {
|
||||
const ground = parts.model.safeNumber(groundAltM, 0);
|
||||
const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700));
|
||||
const override = parts.model.safeNumber(rangeOverrideM, NaN);
|
||||
const R =
|
||||
Number.isFinite(override) && override > 0
|
||||
? Math.min(poseRange, override)
|
||||
: poseRange;
|
||||
const pitch = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89),
|
||||
);
|
||||
const hFov = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.fovDeg, 74), 8, 160),
|
||||
);
|
||||
const heading = parts.model.safeNumber(camera.headingDeg, 0);
|
||||
const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24);
|
||||
|
||||
const horiz = R * Math.cos(pitch);
|
||||
const vert = R * Math.sin(pitch);
|
||||
const capLL = parts.model.projectPoint(
|
||||
camera.lat,
|
||||
camera.lon,
|
||||
heading,
|
||||
horiz,
|
||||
);
|
||||
const capAlt = mountAlt + vert;
|
||||
|
||||
const halfW = R * Math.tan(hFov / 2);
|
||||
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT);
|
||||
const halfH = R * Math.tan(vFovRad / 2);
|
||||
|
||||
// In-plane "up" of the pitched cap, decomposed into a vertical part and a
|
||||
// horizontal part along the heading (pitch < 0 tilts the cap's top forward).
|
||||
const upVert = Math.cos(pitch) * halfH;
|
||||
const upHoriz = -Math.sin(pitch) * halfH;
|
||||
|
||||
const capL = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading - 90,
|
||||
halfW,
|
||||
);
|
||||
const capR = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading + 90,
|
||||
halfW,
|
||||
);
|
||||
// Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch
|
||||
// never buries the plane's anchor — then derive the corners RIGIDLY from the
|
||||
// lifted center. Clamping each corner independently flattened the wireframe
|
||||
// into a ground-hugging fan while the rigid plane kept its height (owner
|
||||
// field test 2026-07-04): the corner rays must always terminate exactly on
|
||||
// the monitor plane's corners. The bottom pair may dip below ground; the 3D
|
||||
// tiles occlude that portion, exactly as they do for the plane itself.
|
||||
const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M;
|
||||
const capAltClamped = Math.max(minAlt, capAlt);
|
||||
const corner = (base, sign) => {
|
||||
const ll = parts.model.projectPoint(
|
||||
base.lat,
|
||||
base.lon,
|
||||
heading,
|
||||
sign * upHoriz,
|
||||
);
|
||||
return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert };
|
||||
};
|
||||
|
||||
const topCenter = corner(capLL, 1);
|
||||
return {
|
||||
rangeM: R,
|
||||
vFovDeg: Cesium.Math.toDegrees(vFovRad),
|
||||
halfW,
|
||||
halfH,
|
||||
mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt },
|
||||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped },
|
||||
corners: {
|
||||
tl: corner(capL, 1),
|
||||
tr: corner(capR, 1),
|
||||
br: corner(capR, -1),
|
||||
bl: corner(capL, -1),
|
||||
},
|
||||
topCenter,
|
||||
groundAltM: ground,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the obstruction probe's effective-range clamp just short of a hit,
|
||||
* with the field-derived 12 m floor used by the original H6 monitor.
|
||||
* @param {number} rangeM Nominal camera range.
|
||||
* @param {number} hitDistanceM Distance to the first obstruction.
|
||||
* @returns {number|null} Clamp range, or null when the hit does not shorten it.
|
||||
*/
|
||||
|
||||
function activationProbeClampRange(rangeM, hitDistanceM) {
|
||||
const nominalRange = Number(rangeM);
|
||||
const hitDistance = Number(hitDistanceM);
|
||||
if (!Number.isFinite(nominalRange) || nominalRange <= 0) return null;
|
||||
if (
|
||||
!Number.isFinite(hitDistance) ||
|
||||
hitDistance <= 0 ||
|
||||
hitDistance >= nominalRange
|
||||
)
|
||||
return null;
|
||||
return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a computeFrustumGeometry result into the Cartesian3 positions the
|
||||
* entities consume. Fresh objects per call (geometry updates are rare —
|
||||
* slider/save/activation — and entities must never share scratch objects).
|
||||
* @param {Object} geometry - Result of computeFrustumGeometry.
|
||||
* @returns {{ mount: Cesium.Cartesian3, capCenter: Cesium.Cartesian3,
|
||||
* tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, br: Cesium.Cartesian3,
|
||||
* bl: Cesium.Cartesian3, label: Cesium.Cartesian3 }}
|
||||
*/
|
||||
|
||||
function frustumCartesians(geometry) {
|
||||
const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt);
|
||||
return {
|
||||
mount: at(geometry.mount),
|
||||
capCenter: at(geometry.capCenter),
|
||||
tl: at(geometry.corners.tl),
|
||||
tr: at(geometry.corners.tr),
|
||||
br: at(geometry.corners.br),
|
||||
bl: at(geometry.corners.bl),
|
||||
label: Cesium.Cartesian3.fromDegrees(
|
||||
geometry.topCenter.lon,
|
||||
geometry.topCenter.lat,
|
||||
geometry.topCenter.alt + 1.2,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit ECEF direction of the frustum view axis (heading/pitch) at a position.
|
||||
* Used by the plane orientation and the activation obstruction probe — both
|
||||
* need the UNCLAMPED axis, so it comes from the pose, not from clamped points.
|
||||
* @param {Object} camera - Camera pose (headingDeg, pitchDeg).
|
||||
* @param {Cesium.Cartesian3} atPos - ECEF position defining the local ENU frame.
|
||||
* @returns {{ dir: Cesium.Cartesian3, up: Cesium.Cartesian3 }} View axis + the
|
||||
* frame's in-plane "up" (both unit, mutually perpendicular).
|
||||
*/
|
||||
|
||||
function frustumFrameEcef(camera, atPos) {
|
||||
const h = parts.model.toRad(camera.headingDeg);
|
||||
const p = parts.model.toRad(camera.pitchDeg);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(atPos);
|
||||
const rot = Cesium.Matrix4.getMatrix3(enu, new Cesium.Matrix3());
|
||||
// ENU components: view axis + the perpendicular "up" of the pitched cap.
|
||||
const dirEnu = new Cesium.Cartesian3(
|
||||
Math.sin(h) * Math.cos(p),
|
||||
Math.cos(h) * Math.cos(p),
|
||||
Math.sin(p),
|
||||
);
|
||||
const upEnu = new Cesium.Cartesian3(
|
||||
-Math.sin(p) * Math.sin(h),
|
||||
-Math.sin(p) * Math.cos(h),
|
||||
Math.cos(p),
|
||||
);
|
||||
return {
|
||||
dir: Cesium.Matrix3.multiplyByVector(
|
||||
rot,
|
||||
dirEnu,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
up: Cesium.Matrix3.multiplyByVector(rot, upEnu, new Cesium.Cartesian3()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the pure frustum geometry from the camera pose + the given ground
|
||||
* altitude and writes it into the scene: billboard position, the 5 wireframe
|
||||
* polylines (4 corner rays + closed far-plane rectangle), and the monitor
|
||||
* plane placement. This is the ONLY place v2 geometry is written — called on
|
||||
* slider input / save / activation / the one-shot ground snap, never per frame.
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {number} groundAltM - Ground altitude at the mount (metres).
|
||||
*/
|
||||
|
||||
function applyFrustumGeometry(record, groundAltM) {
|
||||
const geometry = computeFrustumGeometry(
|
||||
record.camera,
|
||||
groundAltM,
|
||||
record.probeClampRangeM,
|
||||
);
|
||||
const positions = frustumCartesians(geometry);
|
||||
record.frustumGeometry = geometry;
|
||||
record.frustumPositions = positions;
|
||||
record.position = positions.mount;
|
||||
record.camera.absoluteHeightM = geometry.mount.alt;
|
||||
if (record.billboard) {
|
||||
record.billboard.position = positions.mount;
|
||||
}
|
||||
if (record.coverageEntities?.length >= 5) {
|
||||
record.coverageEntities[0].polyline.positions = [
|
||||
positions.mount,
|
||||
positions.tl,
|
||||
];
|
||||
record.coverageEntities[1].polyline.positions = [
|
||||
positions.mount,
|
||||
positions.tr,
|
||||
];
|
||||
record.coverageEntities[2].polyline.positions = [
|
||||
positions.mount,
|
||||
positions.br,
|
||||
];
|
||||
record.coverageEntities[3].polyline.positions = [
|
||||
positions.mount,
|
||||
positions.bl,
|
||||
];
|
||||
record.coverageEntities[4].polyline.positions = [
|
||||
positions.tl,
|
||||
positions.tr,
|
||||
positions.br,
|
||||
positions.bl,
|
||||
positions.tl,
|
||||
];
|
||||
}
|
||||
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
|
||||
// positions (weld invariant). Only records currently showing a volume pay
|
||||
// this (6 triangles, synchronous — trivial even during slider/gizmo drags).
|
||||
// Tint derives from the live active id, not the cached viewshedActiveTint —
|
||||
// during an activation switch this runs BEFORE refreshCoverageStyles, and
|
||||
// the cache is stale for exactly that window.
|
||||
if (record.viewshedPrimitive) {
|
||||
rebuildViewshedVolume(
|
||||
record,
|
||||
record.camera.id === layerState._activeCameraId,
|
||||
);
|
||||
}
|
||||
parts.projection.updatePlanePlacement(record);
|
||||
// Gizmo handles track the pose they manipulate: refresh when the ACTIVE
|
||||
// camera's geometry rewrites (incl. during its own drag).
|
||||
if (
|
||||
layerState._gizmo?.isEnabled() &&
|
||||
record.camera.id === layerState._activeCameraId
|
||||
) {
|
||||
layerState._gizmo.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes a record's frustum geometry with a regime-aware, ONE-SHOT ground
|
||||
* resolution (Task 5, spec §2). Per regime:
|
||||
*
|
||||
* - `terrain-globe` (any globe stack): `cachedGroundFloor` returns its DEM
|
||||
* floor because mesh floors are regime-disabled. The exact Re:Earth prior
|
||||
* remains the immediate fallback while that coarse cell warms.
|
||||
* - `google-3d` (photoreal): the shared mesh-floor sampler may refine the
|
||||
* DEM cell once, subject to its existing tiles-ready, distance,
|
||||
* camera-height, and acceptance gates. Geometry reads only
|
||||
* `cachedGroundFloor`, never a CCTV-owned point sample.
|
||||
*
|
||||
* v2 samples ONLY the mount — the far cap hangs in the air off mountAlt. No
|
||||
* timer, no deadband: this function is called only from the staggered
|
||||
* geometry queue (the enable-time drain + update()'s one-shot tiles-ready
|
||||
* completion re-enqueue), from explicit pose-edit call sites, and from the
|
||||
* map-stack regime-change handler.
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.sampleGround=true] - When false, skip shared
|
||||
* mesh-floor refinement and use the cached/prior ground instead.
|
||||
*/
|
||||
|
||||
function updateRecordGeometry(record, options = {}) {
|
||||
const sampleGround = options.sampleGround !== false;
|
||||
const regime = parts.ground.currentSurfaceRegime();
|
||||
const point = { lat: record.camera.lat, lon: record.camera.lon };
|
||||
warmGroundFloor([point]);
|
||||
|
||||
if (regime === 'terrain-globe') {
|
||||
const cachedFloor = cachedGroundFloor(point.lat, point.lon);
|
||||
const ground = Number.isFinite(cachedFloor)
|
||||
? cachedFloor
|
||||
: parts.ground.groundPriorAltFor(record);
|
||||
record.groundSamples['terrain-globe'] = ground;
|
||||
record.groundResolved['terrain-globe'] = true;
|
||||
applyFrustumGeometry(record, ground);
|
||||
return;
|
||||
}
|
||||
|
||||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||||
if (sampleGround && parts.model.projectionTilesReady()) {
|
||||
record.groundMeshSampleRequestCount =
|
||||
(record.groundMeshSampleRequestCount || 0) + 1;
|
||||
const viewerCarto = layerState._viewer?.camera?.positionCartographic;
|
||||
const excludeObjects = [...(record.coverageEntities || [])];
|
||||
if (record.billboard) excludeObjects.push(record.billboard);
|
||||
if (record.projection?.planeEntity)
|
||||
excludeObjects.push(record.projection.planeEntity);
|
||||
sampleMeshFloorCells(layerState._viewer?.scene, [point], {
|
||||
excludeObjects: excludeObjects.filter(Boolean),
|
||||
viewerLat: viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.latitude)
|
||||
: undefined,
|
||||
viewerLon: viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.longitude)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const cachedFloor = cachedGroundFloor(point.lat, point.lon);
|
||||
const ground = Number.isFinite(cachedFloor)
|
||||
? cachedFloor
|
||||
: parts.ground.groundAltFor(record, 'google-3d');
|
||||
applyFrustumGeometry(record, ground);
|
||||
|
||||
record.groundResolved['google-3d'] = Number.isFinite(cachedFloor);
|
||||
if (Number.isFinite(cachedFloor)) {
|
||||
record.groundSamples['google-3d'] = ground;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which camera coverage overlays should be visible based on
|
||||
* proximity to the active camera. Limits visibility to
|
||||
* COVERAGE_NEIGHBOR_LIMIT cameras within COVERAGE_NEIGHBOR_RADIUS_KM.
|
||||
* @param {Object|null} activeRecord - The active camera record.
|
||||
* @returns {Set<string>} Set of visible camera IDs.
|
||||
*/
|
||||
|
||||
function buildCoverageVisibleSet(activeRecord) {
|
||||
if (!layerState._records.length) return new Set();
|
||||
// Coverage emphasis is relative to a selected camera. Without one, do not
|
||||
// invent an arbitrary catalog-order cohort.
|
||||
if (!activeRecord) return new Set();
|
||||
|
||||
const ranked = layerState._records.map((record) => {
|
||||
if (record === activeRecord) {
|
||||
return { record, distKm: -1 };
|
||||
}
|
||||
return {
|
||||
record,
|
||||
distKm: parts.model.haversineKm(
|
||||
activeRecord.camera.lat,
|
||||
activeRecord.camera.lon,
|
||||
record.camera.lat,
|
||||
record.camera.lon,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
ranked.sort((a, b) => a.distKm - b.distKm);
|
||||
|
||||
const primary = ranked.filter(
|
||||
(entry) =>
|
||||
entry.distKm <= COVERAGE_NEIGHBOR_RADIUS_KM || entry.distKm === -1,
|
||||
);
|
||||
const fallback = ranked;
|
||||
const chosen = (
|
||||
primary.length >= COVERAGE_NEIGHBOR_LIMIT ? primary : fallback
|
||||
)
|
||||
.slice(0, COVERAGE_NEIGHBOR_LIMIT)
|
||||
.map((entry) => entry.record.camera.id);
|
||||
return new Set(chosen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes (and destroys) a record's viewshed volume primitive, if any.
|
||||
* @param {Object} record - Camera record.
|
||||
*/
|
||||
|
||||
function destroyViewshedVolume(record) {
|
||||
if (!record) return;
|
||||
if (record.viewshedPrimitive && layerState._viewer) {
|
||||
layerState._viewer.scene.primitives.remove(record.viewshedPrimitive);
|
||||
}
|
||||
record.viewshedPrimitive = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)builds a record's translucent viewshed volume from its CURRENT
|
||||
* frustumPositions — the same 5 Cartesians the wireframe draws, so the volume
|
||||
* is welded to the cone by construction. Called only where the wireframe
|
||||
* already rewrites (style refresh on mode/visible-set/active changes,
|
||||
* applyFrustumGeometry on pose edits) — no new update cadence, zero scene
|
||||
* queries (viewshed design §3b).
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {boolean} isActive - Active camera gets the brighter fill.
|
||||
*/
|
||||
|
||||
function rebuildViewshedVolume(record, isActive) {
|
||||
destroyViewshedVolume(record);
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
!record?.frustumPositions ||
|
||||
!record.viewshedColors
|
||||
)
|
||||
return;
|
||||
const color = isActive
|
||||
? record.viewshedColors.fillActive
|
||||
: record.viewshedColors.fill;
|
||||
const primitive = createFrustumVolumePrimitive(
|
||||
record.frustumPositions,
|
||||
color,
|
||||
);
|
||||
// QA tag: the harness counts viewshed volumes by this marker.
|
||||
primitive._gevViewshed = record.camera.id;
|
||||
record.viewshedPrimitive =
|
||||
layerState._viewer.scene.primitives.add(primitive);
|
||||
record.viewshedActiveTint = !!isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts how many other cameras have overlapping coverage with the target.
|
||||
* Overlap is approximated by comparing inter-camera distance against the
|
||||
* combined range of both cameras (scaled by 0.92).
|
||||
* @param {Object} targetRecord - Camera record to check.
|
||||
* @returns {number} Number of overlapping neighbors.
|
||||
*/
|
||||
|
||||
function coverageNeighborCount(targetRecord) {
|
||||
if (!targetRecord) return 0;
|
||||
let count = 0;
|
||||
for (const record of layerState._records) {
|
||||
if (record === targetRecord) continue;
|
||||
const dKm = parts.model.haversineKm(
|
||||
targetRecord.camera.lat,
|
||||
targetRecord.camera.lon,
|
||||
record.camera.lat,
|
||||
record.camera.lon,
|
||||
);
|
||||
const overlapKm =
|
||||
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
|
||||
if (dKm <= overlapKm) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* §9.1 activation obstruction probe (LOCKED owner decision): on camera
|
||||
* ACTIVATION only, fire ONE scene.pickFromRay along the frustum axis
|
||||
* (mount → cap-center direction). If it hits the tiles closer than the pose
|
||||
* range, clamp the plane's effective range just short of the first hit so the
|
||||
* "big and dramatic" true end cap never clips into downtown buildings.
|
||||
*
|
||||
* This is the ONLY raycast in the whole CCTV subsystem — once per activation,
|
||||
* never per-frame (the zero-raycast invariant applies to steady state). The
|
||||
* per-camera range slider overrides the clamp: a user-set rangeScale skips the
|
||||
* probe entirely. Probe failure/miss keeps the unclamped range.
|
||||
* @param {Object} record - Camera record being activated.
|
||||
*/
|
||||
|
||||
function runActivationObstructionProbe(record) {
|
||||
record.probeClampRangeM = null;
|
||||
const scene = layerState._viewer?.scene;
|
||||
if (!scene || typeof scene.pickFromRay !== 'function') return;
|
||||
const camera = record.camera;
|
||||
const rangeScale = parts.calibration.normalizeCalibration(
|
||||
camera.calibration,
|
||||
).rangeScale;
|
||||
if (Math.abs(rangeScale - 1) > 0.0001) return; // slider overrides the clamp
|
||||
try {
|
||||
const mountAlt = parts.ground.groundAltFor(record) + camera.mountHeightM;
|
||||
const mountPos = Cesium.Cartesian3.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
mountAlt,
|
||||
);
|
||||
const { dir } = frustumFrameEcef(camera, mountPos);
|
||||
// Exclude everything the layer itself draws so the probe can only hit the
|
||||
// world (3D tiles), not our own billboards/polylines/planes.
|
||||
const exclude = [layerState._billboards, ...layerState._coverageEntities];
|
||||
for (const runtime of layerState._projectionEntities) {
|
||||
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
|
||||
}
|
||||
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude);
|
||||
if (!hit?.position) return;
|
||||
const dist = Cesium.Cartesian3.distance(mountPos, hit.position);
|
||||
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
|
||||
} catch {
|
||||
// probe failure → keep the unclamped range
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a deactivated camera's temporary obstruction clamp and rewrites its
|
||||
* geometry through the normal single-range path.
|
||||
* @param {Object|null} record Camera runtime record being deactivated.
|
||||
* @param {(record: Object) => void} rewriteGeometry Nominal geometry rewrite.
|
||||
* @returns {boolean} Whether a clamp was cleared.
|
||||
*/
|
||||
|
||||
function clearProbeClampOnDeactivation(record, rewriteGeometry) {
|
||||
if (!record || !Number.isFinite(record.probeClampRangeM)) return false;
|
||||
record.probeClampRangeM = null;
|
||||
rewriteGeometry?.(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the five Cesium polyline entities that visualize a camera's pitched
|
||||
* frustum: 4 corner rays (mount → far-plane corner) + the closed far-plane
|
||||
* rectangle. Entity ids stay in the `cctv-<id>-<role>` scheme (pick-owner
|
||||
* regex depends on it): roles ray-tl / ray-tr / ray-br / ray-bl / cap.
|
||||
* @param {Object} record - Camera record.
|
||||
* @returns {Cesium.Entity[]} Array of five coverage entities.
|
||||
*/
|
||||
|
||||
function buildCoverageEntities(record) {
|
||||
const { camera } = record;
|
||||
// Prefer the record's already-refined geometry. Lazy creation commonly
|
||||
// happens after the staggered ground pass; recomputing from the catalog
|
||||
// prior here would regress the camera to its pre-sampled datum.
|
||||
let geometry = record.frustumGeometry;
|
||||
let positions = record.frustumPositions;
|
||||
if (!geometry || !positions) {
|
||||
geometry = computeFrustumGeometry(
|
||||
camera,
|
||||
parts.ground.groundPriorAltFor(record),
|
||||
record.probeClampRangeM,
|
||||
);
|
||||
positions = frustumCartesians(geometry);
|
||||
record.frustumGeometry = geometry;
|
||||
record.frustumPositions = positions;
|
||||
record.position = positions.mount;
|
||||
}
|
||||
|
||||
const addPolyline = (role, linePositions) =>
|
||||
layerState._viewer.entities.add({
|
||||
id: `cctv-${camera.id}-${role}`,
|
||||
properties: { cctvCameraId: camera.id },
|
||||
polyline: {
|
||||
positions: linePositions,
|
||||
width: 1.2,
|
||||
material: IDLE_COVERAGE_COLOR,
|
||||
},
|
||||
});
|
||||
|
||||
const entities = [
|
||||
addPolyline('ray-tl', [positions.mount, positions.tl]),
|
||||
addPolyline('ray-tr', [positions.mount, positions.tr]),
|
||||
addPolyline('ray-br', [positions.mount, positions.br]),
|
||||
addPolyline('ray-bl', [positions.mount, positions.bl]),
|
||||
addPolyline('cap', [
|
||||
positions.tl,
|
||||
positions.tr,
|
||||
positions.br,
|
||||
positions.bl,
|
||||
positions.tl,
|
||||
]),
|
||||
];
|
||||
|
||||
entities[0]._coverageRole = 'edge';
|
||||
entities[1]._coverageRole = 'edge';
|
||||
entities[2]._coverageRole = 'edge';
|
||||
entities[3]._coverageRole = 'edge';
|
||||
entities[4]._coverageRole = 'cap';
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes coverage entities for eligible records exactly once.
|
||||
* The helper is dependency-injected so unit tests can prove the enable policy
|
||||
* without constructing Cesium entities.
|
||||
*
|
||||
* @param {Object[]} records CCTV runtime records.
|
||||
* @param {(record: Object) => boolean} [isEligible] Eligibility predicate.
|
||||
* @param {(record: Object) => Object[]} buildEntities Coverage builder.
|
||||
* @returns {Object[]} Newly created entities across all eligible records.
|
||||
*/
|
||||
|
||||
function materializeCctvCoverageEntities(
|
||||
records,
|
||||
isEligible = () => true,
|
||||
buildEntities,
|
||||
) {
|
||||
const created = [];
|
||||
if (typeof buildEntities !== 'function') return created;
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
if (!record || record.coverageEntities?.length || !isEligible(record))
|
||||
continue;
|
||||
const entities = buildEntities(record);
|
||||
record.coverageEntities = Array.isArray(entities)
|
||||
? entities.filter(Boolean)
|
||||
: [];
|
||||
created.push(...record.coverageEntities);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Materializes one active camera's coverage set. */
|
||||
|
||||
function materializeCctvActiveCoverageEntities(record, buildEntities) {
|
||||
return materializeCctvCoverageEntities([record], () => true, buildEntities);
|
||||
}
|
||||
|
||||
/** Materializes only records in the current coverage-visible ID set. */
|
||||
|
||||
function materializeCctvVisibleCoverageEntities(
|
||||
records,
|
||||
visibleIds,
|
||||
buildEntities,
|
||||
) {
|
||||
const eligibleIds =
|
||||
visibleIds instanceof Set ? visibleIds : new Set(visibleIds || []);
|
||||
return materializeCctvCoverageEntities(
|
||||
records,
|
||||
(record) => eligibleIds.has(record.camera?.id),
|
||||
buildEntities,
|
||||
);
|
||||
}
|
||||
|
||||
/** Registers newly built coverage entities with the layer-global collection. */
|
||||
|
||||
function registerCoverageEntities(created) {
|
||||
layerState._coverageEntities.push(...created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function ensureActiveCoverageEntities(record) {
|
||||
return registerCoverageEntities(
|
||||
materializeCctvActiveCoverageEntities(record, buildCoverageEntities),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureVisibleCoverageEntities(records, visibleIds) {
|
||||
return registerCoverageEntities(
|
||||
materializeCctvVisibleCoverageEntities(
|
||||
records,
|
||||
visibleIds,
|
||||
buildCoverageEntities,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all coverage entities and projection runtimes from the scene.
|
||||
*/
|
||||
|
||||
function destroyCoverageEntities() {
|
||||
if (!layerState._viewer) return;
|
||||
for (const entity of layerState._coverageEntities) {
|
||||
layerState._viewer.entities.remove(entity);
|
||||
}
|
||||
layerState._coverageEntities = [];
|
||||
|
||||
for (const record of layerState._records) {
|
||||
destroyViewshedVolume(record);
|
||||
}
|
||||
|
||||
for (const runtime of layerState._projectionEntities) {
|
||||
parts.projection.destroyProjectionRuntime(runtime);
|
||||
}
|
||||
layerState._projectionEntities = [];
|
||||
}
|
||||
return {
|
||||
computeFrustumGeometry,
|
||||
activationProbeClampRange,
|
||||
frustumCartesians,
|
||||
frustumFrameEcef,
|
||||
applyFrustumGeometry,
|
||||
updateRecordGeometry,
|
||||
buildCoverageVisibleSet,
|
||||
destroyViewshedVolume,
|
||||
rebuildViewshedVolume,
|
||||
coverageNeighborCount,
|
||||
runActivationObstructionProbe,
|
||||
clearProbeClampOnDeactivation,
|
||||
buildCoverageEntities,
|
||||
materializeCctvCoverageEntities,
|
||||
materializeCctvActiveCoverageEntities,
|
||||
materializeCctvVisibleCoverageEntities,
|
||||
registerCoverageEntities,
|
||||
ensureActiveCoverageEntities,
|
||||
ensureVisibleCoverageEntities,
|
||||
destroyCoverageEntities,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GEO_PROGRESS_NOTIFY_INTERVAL_MS,
|
||||
GEO_PROGRESS_NOTIFY_BATCH_LIMIT,
|
||||
GEO_TRACKING_BATCH_SIZE,
|
||||
GEO_TRACKING_BATCH_DELAY_MS,
|
||||
GEO_LOAD_BATCH_SIZE,
|
||||
GEO_LOAD_BATCH_DELAY_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createGeometryQueue({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* Stops the staggered geometry-load queue and optionally clears progress
|
||||
* counters (kept when pausing mid-flight is not needed — we always clear).
|
||||
* @param {boolean} [clearProgress=true]
|
||||
*/
|
||||
|
||||
function stopGeometryLoadQueue(clearProgress = true) {
|
||||
if (layerState._geoQueueTimer) {
|
||||
clearTimeout(layerState._geoQueueTimer);
|
||||
layerState._geoQueueTimer = 0;
|
||||
}
|
||||
layerState._geoQueue = [];
|
||||
layerState._geoProgressNotifier = null;
|
||||
if (clearProgress) {
|
||||
layerState._geoLoading = false;
|
||||
layerState._geoLoadTotal = 0;
|
||||
layerState._geoLoadDone = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the notification coalescer used by a staggered geometry drain.
|
||||
* Progress emits after roughly 300 ms or ten batches, whichever comes first;
|
||||
* finish always emits once even when the last progress tick just fired.
|
||||
*
|
||||
* @param {Function} notify Notification callback.
|
||||
* @param {Object} [options={}] Testable timing options.
|
||||
* @param {() => number} [options.now] Monotonic clock returning milliseconds.
|
||||
* @param {number} [options.intervalMs] Maximum progress-notification cadence.
|
||||
* @param {number} [options.batchLimit] Maximum batches between progress ticks.
|
||||
* @returns {{ progress: () => boolean, finish: () => void }} Drain notifier.
|
||||
*/
|
||||
|
||||
function createGeometryProgressNotifier(notify, options = {}) {
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now;
|
||||
const intervalMs = Number.isFinite(options.intervalMs)
|
||||
? Math.max(0, options.intervalMs)
|
||||
: GEO_PROGRESS_NOTIFY_INTERVAL_MS;
|
||||
const batchLimit = Number.isFinite(options.batchLimit)
|
||||
? Math.max(1, Math.floor(options.batchLimit))
|
||||
: GEO_PROGRESS_NOTIFY_BATCH_LIMIT;
|
||||
let lastNotifyAt = now();
|
||||
let batchesSinceNotify = 0;
|
||||
|
||||
return {
|
||||
progress() {
|
||||
batchesSinceNotify += 1;
|
||||
const current = now();
|
||||
if (
|
||||
current - lastNotifyAt < intervalMs &&
|
||||
batchesSinceNotify < batchLimit
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
batchesSinceNotify = 0;
|
||||
lastNotifyAt = current;
|
||||
notify?.();
|
||||
return true;
|
||||
},
|
||||
finish() {
|
||||
batchesSinceNotify = 0;
|
||||
lastNotifyAt = now();
|
||||
notify?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one geometry-queue batch and routes progress/completion through
|
||||
* the callbacks shared by production and the unit drain harness.
|
||||
*
|
||||
* @param {Object} options Batch inputs.
|
||||
* @param {Object[]} options.queue Mutable record queue.
|
||||
* @param {number} options.batchSize Maximum records to visit.
|
||||
* @param {(record: Object) => void} options.visit Per-record geometry work.
|
||||
* @param {() => void} options.progress Coalesced progress publication.
|
||||
* @param {() => void} options.complete Unconditional completion publication.
|
||||
* @returns {boolean} True when more records remain.
|
||||
*/
|
||||
|
||||
function processCctvGeometryQueueBatch({
|
||||
queue,
|
||||
batchSize,
|
||||
visit,
|
||||
progress,
|
||||
complete,
|
||||
}) {
|
||||
const safeQueue = Array.isArray(queue) ? queue : [];
|
||||
const take = Number.isFinite(batchSize)
|
||||
? Math.max(1, Math.floor(batchSize))
|
||||
: 1;
|
||||
const batch = safeQueue.splice(0, take);
|
||||
for (const record of batch) visit?.(record);
|
||||
if (safeQueue.length) {
|
||||
progress?.();
|
||||
return true;
|
||||
}
|
||||
complete?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects per-batch geometry-drain pacing from current camera ownership.
|
||||
* Called for every batch so releasing tracking immediately restores normal
|
||||
* throughput without restarting the queue.
|
||||
*
|
||||
* @param {Object} [ownership={}] Current camera-ownership state.
|
||||
* @param {*} [ownership.trackedEntity] Cesium tracked entity, if any.
|
||||
* @param {boolean} [ownership.cockpitActive] Whether cockpit owns the camera.
|
||||
* @returns {{ batchSize: number, delayMs: number }} Drain pacing.
|
||||
*/
|
||||
|
||||
function cctvGeometryDrainPacing({
|
||||
trackedEntity = null,
|
||||
cockpitActive = false,
|
||||
} = {}) {
|
||||
if (trackedEntity || cockpitActive) {
|
||||
return {
|
||||
batchSize: GEO_TRACKING_BATCH_SIZE,
|
||||
delayMs: GEO_TRACKING_BATCH_DELAY_MS,
|
||||
};
|
||||
}
|
||||
return { batchSize: GEO_LOAD_BATCH_SIZE, delayMs: GEO_LOAD_BATCH_DELAY_MS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one tracking-aware geometry-drain batch. Ownership is read inside
|
||||
* every call so a mid-drain tracking/cockpit transition changes the very next
|
||||
* batch's size and delay.
|
||||
*
|
||||
* @param {Object} options Batch inputs.
|
||||
* @param {Object[]} options.queue Mutable record queue.
|
||||
* @param {() => Object} [options.readOwnership] Current camera ownership.
|
||||
* @param {(record: Object) => void} options.visit Per-record geometry work.
|
||||
* @param {() => void} options.progress Coalesced progress publication.
|
||||
* @param {() => void} options.complete Unconditional completion publication.
|
||||
* @returns {{ hasMore: boolean, batchSize: number, delayMs: number }} Batch result and pacing.
|
||||
*/
|
||||
|
||||
function processCctvGeometryDrainBatch({
|
||||
queue,
|
||||
readOwnership,
|
||||
visit,
|
||||
progress,
|
||||
complete,
|
||||
}) {
|
||||
const pacing = cctvGeometryDrainPacing(readOwnership?.() || {});
|
||||
const hasMore = processCctvGeometryQueueBatch({
|
||||
queue,
|
||||
batchSize: pacing.batchSize,
|
||||
visit,
|
||||
progress,
|
||||
complete,
|
||||
});
|
||||
return { hasMore, ...pacing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the current active record to the front of a live drain queue.
|
||||
* @param {Object[]} queue Mutable geometry queue.
|
||||
* @param {Object|null} activeRecord Current active CCTV record.
|
||||
* @returns {boolean} Whether the queue order changed.
|
||||
*/
|
||||
|
||||
function prioritizeActiveCctvGeometryRecord(queue, activeRecord) {
|
||||
if (!Array.isArray(queue) || !activeRecord) return false;
|
||||
const index = queue.indexOf(activeRecord);
|
||||
if (index <= 0) return false;
|
||||
queue.splice(index, 1);
|
||||
queue.unshift(activeRecord);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one batch (GEO_LOAD_BATCH_SIZE records) of the geometry queue:
|
||||
* full ground-sampled coverage geometry per record, then yields back to the
|
||||
* event loop before the next batch so tile rendering never stalls. When the
|
||||
* initial-load pass completes it clears the loading flag and refreshes styles.
|
||||
*/
|
||||
|
||||
function processGeometryBatch() {
|
||||
layerState._geoQueueTimer = 0;
|
||||
if (!layerState._viewer) {
|
||||
stopGeometryLoadQueue();
|
||||
return;
|
||||
}
|
||||
// Active-camera-first is re-established every batch because the operator
|
||||
// can select a new camera while a long catalog drain is in flight.
|
||||
prioritizeActiveCctvGeometryRecord(
|
||||
layerState._geoQueue,
|
||||
parts.selection.getActiveRecord(),
|
||||
);
|
||||
const batchResult = processCctvGeometryDrainBatch({
|
||||
queue: layerState._geoQueue,
|
||||
readOwnership: () => ({
|
||||
trackedEntity: layerState._viewer.trackedEntity,
|
||||
cockpitActive:
|
||||
typeof document !== 'undefined' &&
|
||||
document.body?.classList.contains('cockpit-mode'),
|
||||
}),
|
||||
visit: (record) => {
|
||||
try {
|
||||
parts.geometry.updateRecordGeometry(record);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[Data:CCTV] geometry refresh error:',
|
||||
err?.message || err,
|
||||
);
|
||||
}
|
||||
if (
|
||||
layerState._geoLoading &&
|
||||
layerState._geoLoadDone < layerState._geoLoadTotal
|
||||
) {
|
||||
layerState._geoLoadDone += 1;
|
||||
}
|
||||
},
|
||||
progress: () => layerState._geoProgressNotifier?.progress(),
|
||||
complete: () => {
|
||||
const wasInitialLoad = layerState._geoLoading;
|
||||
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();
|
||||
}
|
||||
}
|
||||
// Completion is never coalesced: subscribers must observe the final
|
||||
// loading state even if the last progress tick just happened.
|
||||
layerState._geoProgressNotifier?.finish();
|
||||
layerState._geoProgressNotifier = null;
|
||||
},
|
||||
});
|
||||
if (batchResult.hasMore) {
|
||||
layerState._geoQueueTimer = setTimeout(
|
||||
processGeometryBatch,
|
||||
batchResult.delayMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {Object[]} records - Camera records needing geometry refresh.
|
||||
*/
|
||||
|
||||
function enqueueGeometryRefresh(records) {
|
||||
for (const record of records) {
|
||||
if (!layerState._geoQueue.includes(record)) {
|
||||
layerState._geoQueue.push(record);
|
||||
}
|
||||
}
|
||||
if (!layerState._geoQueueTimer && layerState._geoQueue.length) {
|
||||
layerState._geoProgressNotifier = createGeometryProgressNotifier(
|
||||
parts.presentation.notifyListeners,
|
||||
);
|
||||
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function startGeometryLoadQueue() {
|
||||
stopGeometryLoadQueue();
|
||||
// Fresh drain → fresh one-shot completion pass: re-arm the tiles-ready
|
||||
// latch so update() can complete any records this drain leaves unresolved.
|
||||
layerState._tilesReadyReenqueued = false;
|
||||
if (!layerState._records.length) return;
|
||||
const active = parts.selection.getActiveRecord();
|
||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
||||
const refLat = carto
|
||||
? Cesium.Math.toDegrees(carto.latitude)
|
||||
: (active?.camera.lat ?? 0);
|
||||
const refLon = carto
|
||||
? Cesium.Math.toDegrees(carto.longitude)
|
||||
: (active?.camera.lon ?? 0);
|
||||
const pending = layerState._records
|
||||
.filter((record) => record !== active)
|
||||
.map((record) => ({
|
||||
record,
|
||||
distKm: parts.model.haversineKm(
|
||||
refLat,
|
||||
refLon,
|
||||
record.camera.lat,
|
||||
record.camera.lon,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distKm - b.distKm)
|
||||
.map((entry) => entry.record);
|
||||
layerState._geoQueue = active ? [active, ...pending] : pending;
|
||||
layerState._geoLoadTotal = layerState._geoQueue.length;
|
||||
layerState._geoLoadDone = 0;
|
||||
layerState._geoLoading = true;
|
||||
layerState._geoProgressNotifier = createGeometryProgressNotifier(
|
||||
parts.presentation.notifyListeners,
|
||||
);
|
||||
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
|
||||
}
|
||||
return {
|
||||
stopGeometryLoadQueue,
|
||||
createGeometryProgressNotifier,
|
||||
processCctvGeometryQueueBatch,
|
||||
cctvGeometryDrainPacing,
|
||||
processCctvGeometryDrainBatch,
|
||||
prioritizeActiveCctvGeometryRecord,
|
||||
processGeometryBatch,
|
||||
enqueueGeometryRefresh,
|
||||
startGeometryLoadQueue,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
export function createGround({ state: layerState, services, parts, source }) {
|
||||
const { resolveGroundFloorCells } = services.ground;
|
||||
const { resolveEllipsoidalGround } = services.terrain;
|
||||
|
||||
/**
|
||||
* Task 5 (height-datum fix): maps the scene's `globe.show` flag to the surface
|
||||
* regime key the per-camera ground cache is keyed by (spec §2 "cache by
|
||||
* surface regime", collapsed to two keys — ion World Terrain and Re:Earth
|
||||
* globe terrain get the same handling):
|
||||
*
|
||||
* - `google-3d` — photoreal stack: globe hidden, the visible Google 3D
|
||||
* tileset IS the surface → one-shot scene sampling refines.
|
||||
* - `terrain-globe` — any globe stack: the Re:Earth point-height prior IS the
|
||||
* resolution (zero scene queries).
|
||||
*
|
||||
* Only an explicit `false` (the photoreal stack hides the globe) selects
|
||||
* `google-3d`; undefined/null (no viewer / torn down) must fall to the regime
|
||||
* that never touches the scene. Pure — exported for the unit suite.
|
||||
* @param {boolean|undefined|null} globeShow - `viewer.scene.globe.show`.
|
||||
* @returns {'google-3d'|'terrain-globe'}
|
||||
*/
|
||||
|
||||
function surfaceRegimeKey(globeShow) {
|
||||
return globeShow === false ? 'google-3d' : 'terrain-globe';
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 5: the surface regime the scene is CURRENTLY rendering, derived live
|
||||
* from `globe.show` (mapStackController's `_activatePhotoreal` /
|
||||
* `_activateGlobeStack` flip exactly this flag). Reading scene state directly
|
||||
* — rather than caching the map-stack id — means the regime is correct even
|
||||
* for stack changes this module never got an event for.
|
||||
* @returns {'google-3d'|'terrain-globe'}
|
||||
*/
|
||||
|
||||
function currentSurfaceRegime() {
|
||||
return surfaceRegimeKey(layerState._viewer?.scene?.globe?.show);
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 5: the record's ellipsoidal ground PRIOR (Re:Earth point-height batch,
|
||||
* `record.groundPrior.ellipsoid`), falling back to the catalog's fabricated
|
||||
* orthometric `groundElevationM` only when the prior batch hasn't landed yet.
|
||||
* This is the value that replaces every previous
|
||||
* `Number(camera.groundElevationM) || 0` ground fallback — it alone lifts
|
||||
* London's cameras from the fabricated 15 m to ~52.7 m ellipsoidal in every
|
||||
* regime, on first paint.
|
||||
* @param {Object} record - Camera record.
|
||||
* @returns {number} Ellipsoidal ground altitude in metres.
|
||||
*/
|
||||
|
||||
function groundPriorAltFor(record) {
|
||||
const prior = record?.groundPrior?.ellipsoid;
|
||||
return Number.isFinite(prior)
|
||||
? prior
|
||||
: Number(record?.camera?.groundElevationM) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 5: whether the record's one-shot ground resolution has completed for
|
||||
* the given regime (per-regime latch — replaces the old boolean
|
||||
* `groundResolved`).
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {string} [regime] - Defaults to the current surface regime.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function isGroundResolved(record, regime = currentSurfaceRegime()) {
|
||||
return record?.groundResolved?.[regime] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground altitude used for pure geometry recomputes: the given regime's
|
||||
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
|
||||
* in google-3d, the DEM/prior in terrain-globe) when it exists, else the prior
|
||||
* itself. Never queries the scene.
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {string} [regime] - Defaults to the current surface regime.
|
||||
* @returns {number} Ground altitude in metres.
|
||||
*/
|
||||
|
||||
function groundAltFor(record, regime = currentSurfaceRegime()) {
|
||||
const cached = record?.groundSamples?.[regime];
|
||||
return Number.isFinite(cached) ? cached : groundPriorAltFor(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arms a record for a fresh one-shot ground resolution after a GENUINE
|
||||
* pose change (an explicit user select/move or manual calibration edit).
|
||||
* Clears the CURRENT regime's resolved latch so the next real pass in
|
||||
* updateRecordGeometry always applies, then the record re-freezes. Never
|
||||
* called on a timer.
|
||||
*
|
||||
* The cached `groundSamples` entries are deliberately KEPT (only the latch is
|
||||
* cleared). They are the record's "has ever resolved" memory: the B9c
|
||||
* fallback guard in updateRecordGeometry reads the google-3d entry so a
|
||||
* rearmed camera whose tiles are mid-stream (e.g. select → flyTo →
|
||||
* tilesLoaded false) is not yanked back to prior/catalog heights before its
|
||||
* fresh shared floor lands. In the terrain-globe regime the re-arm is
|
||||
* effectively free: the next pass re-latches from the DEM/prior with zero
|
||||
* scene queries.
|
||||
* @param {Object} record - Camera record.
|
||||
*/
|
||||
|
||||
function rearmGroundResolution(record) {
|
||||
if (!record) return;
|
||||
record.groundResolved[currentSurfaceRegime()] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a user-moved ground anchor exactly once at commit. The synchronous
|
||||
* pass uses a warm shared floor immediately, or preserves the pre-drag floor
|
||||
* while the new cell is cold. A revision and coordinate check prevent an
|
||||
* older asynchronous release from rewriting a newer edit.
|
||||
* @param {Object} record - Camera record whose lat/lon just committed.
|
||||
*/
|
||||
|
||||
function resolveCommittedGroundAnchor(record) {
|
||||
if (!record?.camera) return;
|
||||
record.calibrationGroundResolveCount =
|
||||
(record.calibrationGroundResolveCount || 0) + 1;
|
||||
const revision = (record.calibrationGroundRevision || 0) + 1;
|
||||
record.calibrationGroundRevision = revision;
|
||||
const point = { lat: record.camera.lat, lon: record.camera.lon };
|
||||
|
||||
rearmGroundResolution(record);
|
||||
parts.geometry.updateRecordGeometry(record);
|
||||
if (isGroundResolved(record)) return;
|
||||
|
||||
resolveGroundFloorCells([point]).then(() => {
|
||||
if (layerState._recordById.get(record.camera.id) !== record) return;
|
||||
if (record.calibrationGroundRevision !== revision) return;
|
||||
if (record.camera.lat !== point.lat || record.camera.lon !== point.lon)
|
||||
return;
|
||||
rearmGroundResolution(record);
|
||||
parts.geometry.updateRecordGeometry(record);
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 5: batches every catalog camera's coords through the Re:Earth
|
||||
* ellipsoidal ground resolver (`/api/terrain/heights` proxy — network-cached,
|
||||
* chunked, geoid fallback; NOT a scene query). The catalog's orthometric
|
||||
* `groundElevationM` rides along as `sourceOrthometricM` so the geoid
|
||||
* fallback chain is meaningful where the catalog value is real (Caltrans).
|
||||
* Never rejects — a total failure resolves null and geometry stays on
|
||||
* catalog fallbacks (no worse than pre-Task-5).
|
||||
* @param {Object[]} catalog - Camera objects (post-ensureCameraPose).
|
||||
* @returns {Promise<Array<{ellipsoid:number, source:string}>|null>}
|
||||
*/
|
||||
|
||||
async function resolveGroundPriors(catalog) {
|
||||
try {
|
||||
const coords = catalog.map((camera) => {
|
||||
const ortho = Number(camera.groundElevationM);
|
||||
return {
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
...(Number.isFinite(ortho) ? { sourceOrthometricM: ortho } : {}),
|
||||
};
|
||||
});
|
||||
return await resolveEllipsoidalGround(coords);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[Data:CCTV] ground-prior batch failed (keeping catalog fallbacks):',
|
||||
error?.message || error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* - terrain-globe regime: the prior IS the resolution → re-run the
|
||||
* resolution (updateRecordGeometry latches it) for every record.
|
||||
* - 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.
|
||||
* Guarded per record against a torn-down/re-inited layer (records are only
|
||||
* touched while they are still the live catalog entries).
|
||||
* @param {Object[]} records - The record array captured at init time.
|
||||
* @param {Array<{ellipsoid:number, source:string}>} priors - Aligned by index.
|
||||
*/
|
||||
|
||||
function applyLateGroundPriors(records, priors) {
|
||||
if (!Array.isArray(records) || !Array.isArray(priors)) return;
|
||||
let applied = 0;
|
||||
for (let i = 0; i < records.length && i < priors.length; i++) {
|
||||
const record = records[i];
|
||||
const prior = priors[i];
|
||||
if (!record || !prior || !Number.isFinite(prior.ellipsoid)) continue;
|
||||
// Stale-record guard: init() may have re-run (destroy/init cycle) while
|
||||
// the batch was in flight — only touch records still live in the map.
|
||||
if (layerState._recordById.get(record.camera.id) !== record) continue;
|
||||
record.groundPrior = prior;
|
||||
// Keep the cheap pre-enable altitude consistent for records whose
|
||||
// geometry hasn't been applied yet (applyFrustumGeometry overwrites it).
|
||||
if (!record.frustumPositions) {
|
||||
record.camera.absoluteHeightM =
|
||||
prior.ellipsoid + record.camera.mountHeightM;
|
||||
}
|
||||
const regime = currentSurfaceRegime();
|
||||
if (regime === 'terrain-globe') {
|
||||
// Prior IS the resolution — re-latch onto the fresh value.
|
||||
parts.geometry.updateRecordGeometry(record, { sampleGround: false });
|
||||
applied += 1;
|
||||
} else if (!Number.isFinite(record.groundSamples['google-3d'])) {
|
||||
// Still awaiting the shared floor: snap interim geometry onto the exact
|
||||
// prior (pure recompute; the shared cell may refine later).
|
||||
parts.geometry.applyFrustumGeometry(record, prior.ellipsoid);
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
if (applied) parts.presentation.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 5: surface-regime change handler ('gev:map-stack-changed'
|
||||
* CustomEvent, dispatched by main.js from MapStackController.onChange). The
|
||||
* surface HEIGHT at a camera differs between regimes (a photogrammetric
|
||||
* 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
|
||||
* 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
|
||||
* update()'s existing event-driven machinery refines records that never
|
||||
* took their sample, through the same staggered queue.
|
||||
* Event-driven only — never called on a timer.
|
||||
*/
|
||||
|
||||
function handleMapStackChanged() {
|
||||
if (!layerState._viewer || !layerState._records.length) return;
|
||||
const regime = currentSurfaceRegime();
|
||||
if (regime === layerState._lastAppliedRegime) return;
|
||||
layerState._lastAppliedRegime = regime;
|
||||
|
||||
for (const record of layerState._records) {
|
||||
if (regime === 'terrain-globe') {
|
||||
// Prior IS the resolution — latch it (zero scene queries).
|
||||
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
|
||||
record.groundResolved['terrain-globe'] = true;
|
||||
}
|
||||
const ground = groundAltFor(record, regime);
|
||||
// Skip the entity rewrite when the applied ground already matches (e.g.
|
||||
// entering google-3d before any sample: prior → prior is a no-op).
|
||||
if (
|
||||
record.frustumGeometry &&
|
||||
Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
parts.geometry.applyFrustumGeometry(record, ground);
|
||||
}
|
||||
|
||||
if (regime === 'google-3d') {
|
||||
// Fresh google-3d session: let update()'s ONE-SHOT completion pass
|
||||
// re-enqueue records without an accepted sample once the (re-shown)
|
||||
// tileset reports tilesLoaded. Records already sampled in a previous
|
||||
// google-3d session keep their cached resolution — 0 new samples, well
|
||||
// under the ≤1-per-(camera, session) ceiling.
|
||||
layerState._tilesReadyReenqueued = false;
|
||||
}
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
return {
|
||||
surfaceRegimeKey,
|
||||
currentSurfaceRegime,
|
||||
groundPriorAltFor,
|
||||
isGroundResolved,
|
||||
groundAltFor,
|
||||
rearmGroundResolution,
|
||||
resolveCommittedGroundAnchor,
|
||||
resolveGroundPriors,
|
||||
applyLateGroundPriors,
|
||||
handleMapStackChanged,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js';
|
||||
|
||||
export function createHealth({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Fetches per-camera health status from the backend and updates _healthById.
|
||||
* Rate-limited to HEALTH_SYNC_INTERVAL_MS unless forced.
|
||||
* @param {boolean} [force=false] - Bypass the interval check.
|
||||
*/
|
||||
|
||||
async function syncHealthState(force = false) {
|
||||
const now = Date.now();
|
||||
if (!force && now - layerState._lastHealthSyncAt < HEALTH_SYNC_INTERVAL_MS)
|
||||
return;
|
||||
layerState._lastHealthSyncAt = now;
|
||||
|
||||
try {
|
||||
const signal = layerState._sourceAbort?.signal;
|
||||
const data = await source.getHealth({ signal });
|
||||
signal?.throwIfAborted();
|
||||
const rows = Array.isArray(data?.cameras) ? data.cameras : [];
|
||||
const next = new Map();
|
||||
for (const row of rows) {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) continue;
|
||||
next.set(id, {
|
||||
status: String(row.status || '').toLowerCase() || 'unknown',
|
||||
sourceKind: String(
|
||||
row.sourceKind || row.feedType || '',
|
||||
).toLowerCase(),
|
||||
label: String(row.label || row.provider || ''),
|
||||
message: String(row.message || ''),
|
||||
updatedAt: parts.model.safeNumber(row.updatedAt, now),
|
||||
});
|
||||
}
|
||||
layerState._healthById = next;
|
||||
} catch {
|
||||
// keep previous health map
|
||||
}
|
||||
}
|
||||
return { syncHealthState };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
CCTV_CARD_FETCH_BURST_LIMIT,
|
||||
frameFetchDue,
|
||||
} from '../../data/cctvCards.js';
|
||||
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
||||
import { HOVER_PICK_THROTTLE_MS, HOVER_RELEASE_MS } from './policy.js';
|
||||
|
||||
export function createHover({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Throttled MOUSE_MOVE hover pass (owner round 2, item B): pointing at a
|
||||
* camera icon that has no card summons its card immediately. This is
|
||||
* EVENT-DRIVEN picking on a user gesture, not steady-state work — the
|
||||
* ≥120 ms throttle caps it at ~8 scene.pick calls/s while the pointer is
|
||||
* actually moving (a still pointer costs nothing), so it can never approach
|
||||
* per-frame cost. Skipped while the camera is in motion, while ADJUST mode
|
||||
* owns the pointer (gizmo drags), and while the layer is disabled.
|
||||
* @param {Cesium.Cartesian2} position - Pointer position (CSS px).
|
||||
*/
|
||||
|
||||
function handleHoverMove(position) {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
layerState._cameraMoving ||
|
||||
layerState._calibrationMode ||
|
||||
!position
|
||||
)
|
||||
return;
|
||||
if (!layerState._viewer || layerState._viewer.isDestroyed()) return;
|
||||
const now = Date.now();
|
||||
if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return;
|
||||
layerState._hoverLastPickAt = now;
|
||||
let picked = null;
|
||||
try {
|
||||
picked = layerState._viewer.scene.pick(position);
|
||||
} catch {
|
||||
picked = null;
|
||||
}
|
||||
const cameraId = parts.selection.extractPickedCameraId(picked);
|
||||
if (cameraId && cameraId === layerState._hoverCardId) {
|
||||
// Still on the hovered camera — keep the card alive.
|
||||
cancelHoverRelease();
|
||||
return;
|
||||
}
|
||||
const record = cameraId ? layerState._recordById.get(cameraId) : null;
|
||||
// Hovering the active camera or a camera that already has a card is a
|
||||
// no-op; video feeds stay icon-only until activated (ambient tier is
|
||||
// stills-only — same rule as the LOD selection).
|
||||
const eligible =
|
||||
!!record &&
|
||||
cameraId !== layerState._activeCameraId &&
|
||||
!layerState._cardIds.has(cameraId) &&
|
||||
!parts.model.isVideoFeedType(
|
||||
parts.model.normalizeFeedType(record.camera.feedType),
|
||||
);
|
||||
if (eligible) {
|
||||
cancelHoverRelease();
|
||||
layerState._hoverCardId = cameraId;
|
||||
// Immediacy: the pinned entry publishes now — chrome may paint before
|
||||
// the first frame arrives (documented exception in cctvCards.js).
|
||||
parts.cards.pushAmbientCardEntries();
|
||||
hoverFetchCardFrame(record);
|
||||
} else if (layerState._hoverCardId) {
|
||||
// Pointer left the hovered icon: linger ~1 s, then release.
|
||||
scheduleHoverRelease();
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels a pending hover-card release. */
|
||||
|
||||
function cancelHoverRelease() {
|
||||
if (layerState._hoverReleaseTimer) {
|
||||
clearTimeout(layerState._hoverReleaseTimer);
|
||||
layerState._hoverReleaseTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the hover card's release ~1 s after unhover. On release the pin
|
||||
* drops; the card stays only if the LOD selection has adopted the camera
|
||||
* (it is then a normal budgeted entry in `_cardIds`).
|
||||
*/
|
||||
|
||||
function scheduleHoverRelease() {
|
||||
if (layerState._hoverReleaseTimer) return;
|
||||
layerState._hoverReleaseTimer = setTimeout(() => {
|
||||
layerState._hoverReleaseTimer = 0;
|
||||
layerState._hoverCardId = null;
|
||||
parts.cards.pushAmbientCardEntries();
|
||||
}, HOVER_RELEASE_MS);
|
||||
}
|
||||
|
||||
/** Clears the hover-card state (teardown / activation of the hovered camera). */
|
||||
|
||||
function clearHoverCard() {
|
||||
cancelHoverRelease();
|
||||
layerState._hoverCardId = null;
|
||||
layerState._hoverLastPickAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast-tracked frame fetch for the hover-summoned card: launches
|
||||
* immediately, bypassing the pacer's launch-spacing gate (a single
|
||||
* user-gesture fetch is fine even during the geometry drain), but still
|
||||
* respecting the per-camera failure backoff / freshness check
|
||||
* (frameFetchDue), the no-double-fetch pending set, and the burst in-flight
|
||||
* cap of 4.
|
||||
* @param {Object} record - The hovered camera's record.
|
||||
*/
|
||||
|
||||
function hoverFetchCardFrame(record) {
|
||||
const cameraId = record.camera.id;
|
||||
if (layerState._cardFetchPendingIds.has(cameraId)) return;
|
||||
if (layerState._cardFetchInFlightCount >= CCTV_CARD_FETCH_BURST_LIMIT)
|
||||
return;
|
||||
const slot = parts.cards.ensureCardFrameSlot(cameraId);
|
||||
const refreshMs = staticFrameRefreshMs(record.camera);
|
||||
if (!frameFetchDue(slot, refreshMs, Date.now())) return;
|
||||
parts.cards.fetchCardFrame(record, slot, refreshMs, { userGesture: true });
|
||||
}
|
||||
return {
|
||||
handleHoverMove,
|
||||
cancelHoverRelease,
|
||||
scheduleHoverRelease,
|
||||
clearHoverCard,
|
||||
hoverFetchCardFrame,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createCalibration } from './calibration.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createProjection } from './projection.js';
|
||||
import { createCards } from './cards.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createGround } from './ground.js';
|
||||
import { createGeometry } from './geometry.js';
|
||||
import { createCatalog } from './catalog.js';
|
||||
import { createFrames } from './frames.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createGeometryQueue } from './geometryQueue.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createHover } from './hover.js';
|
||||
import { createNavigation } from './navigation.js';
|
||||
import { createPresentation } from './presentation.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createHealth } from './health.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createCctvLayer({ services, source }) {
|
||||
if (
|
||||
!['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every(
|
||||
(key) => typeof source?.[key] === 'function',
|
||||
)
|
||||
)
|
||||
throw new TypeError('A camera source is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.calibration = createCalibration(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.projection = createProjection(context);
|
||||
parts.cards = createCards(context);
|
||||
parts.model = createModel(context);
|
||||
parts.ground = createGround(context);
|
||||
parts.geometry = createGeometry(context);
|
||||
parts.catalog = createCatalog(context);
|
||||
parts.frames = createFrames(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.geometryQueue = createGeometryQueue(context);
|
||||
parts.selection = createSelection(context);
|
||||
parts.hover = createHover(context);
|
||||
parts.navigation = createNavigation(context);
|
||||
parts.presentation = createPresentation(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.health = createHealth(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
return Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion?.methods,
|
||||
{
|
||||
calibrationPatchMovesAnchor:
|
||||
parts.calibration.calibrationPatchMovesAnchor,
|
||||
_pushAmbientCardEntriesForTest:
|
||||
parts.testing._pushAmbientCardEntriesForTest,
|
||||
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
|
||||
createCctvProjectionOverlayEntry:
|
||||
parts.projection.createCctvProjectionOverlayEntry,
|
||||
setCctvCardPresentationOptions:
|
||||
parts.cards.setCctvCardPresentationOptions,
|
||||
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
|
||||
normalizeCoverageMode: parts.model.normalizeCoverageMode,
|
||||
readCalibrationStoreV2: parts.calibration.readCalibrationStoreV2,
|
||||
writeCalibrationStoreV2: parts.calibration.writeCalibrationStoreV2,
|
||||
deriveCalBadge: parts.calibration.deriveCalBadge,
|
||||
computeFrustumGeometry: parts.geometry.computeFrustumGeometry,
|
||||
activationProbeClampRange: parts.geometry.activationProbeClampRange,
|
||||
frameSignatureFromPixels: parts.frames.frameSignatureFromPixels,
|
||||
_createCctvProjectionPlaneForTest:
|
||||
parts.testing._createCctvProjectionPlaneForTest,
|
||||
_updateCctvProjectionPlaneForTest:
|
||||
parts.testing._updateCctvProjectionPlaneForTest,
|
||||
applyCctvFocusDeemphasis: parts.rendering.applyCctvFocusDeemphasis,
|
||||
createGeometryProgressNotifier:
|
||||
parts.geometryQueue.createGeometryProgressNotifier,
|
||||
processCctvGeometryQueueBatch:
|
||||
parts.geometryQueue.processCctvGeometryQueueBatch,
|
||||
cctvGeometryDrainPacing: parts.geometryQueue.cctvGeometryDrainPacing,
|
||||
processCctvGeometryDrainBatch:
|
||||
parts.geometryQueue.processCctvGeometryDrainBatch,
|
||||
prioritizeActiveCctvGeometryRecord:
|
||||
parts.geometryQueue.prioritizeActiveCctvGeometryRecord,
|
||||
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
|
||||
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
|
||||
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
|
||||
clearProbeClampOnDeactivation:
|
||||
parts.geometry.clearProbeClampOnDeactivation,
|
||||
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
||||
bindCctvWorldClickGesture: parts.selection.bindCctvWorldClickGesture,
|
||||
setActiveCamera: parts.selection.setActiveCamera,
|
||||
deactivateActiveCamera: parts.selection.deactivateActiveCamera,
|
||||
cctvEmptyClickDeselects: parts.selection.cctvEmptyClickDeselects,
|
||||
materializeCctvCoverageEntities:
|
||||
parts.geometry.materializeCctvCoverageEntities,
|
||||
materializeCctvActiveCoverageEntities:
|
||||
parts.geometry.materializeCctvActiveCoverageEntities,
|
||||
materializeCctvVisibleCoverageEntities:
|
||||
parts.geometry.materializeCctvVisibleCoverageEntities,
|
||||
_extractPickedCameraIdForTest:
|
||||
parts.testing._extractPickedCameraIdForTest,
|
||||
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
|
||||
focusCctvRecord: parts.navigation.focusCctvRecord,
|
||||
maybeAutoHop: parts.navigation.maybeAutoHop,
|
||||
cctvCycleIndex: parts.navigation.cctvCycleIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
export {
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V1,
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V2,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
CCTV_FOCUS_RESULT,
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
} from './policy.js';
|
||||
|
||||
export { createCctvSource } from './source.js';
|
||||
|
||||
export { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
export {
|
||||
CCTV_OVERLAY_SOURCE_ID,
|
||||
createCctvThumbnailOverlayEntry,
|
||||
createFrameSlot,
|
||||
} from '../../data/cctvCards.js';
|
||||
@@ -0,0 +1,48 @@
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const methods = {
|
||||
/**
|
||||
* Periodic update tick: syncs health state and runs auto-hop logic. Ground
|
||||
* geometry is NOT resampled here — v2 grounds each camera once via the
|
||||
* staggered load queue (see startGeometryLoadQueue/updateRecordGeometry)
|
||||
* and never resamples on a timer. The ONE exception is a one-shot
|
||||
* completion pass: the enable-time drain can run while 3D tiles are still
|
||||
* streaming (each such pass keeps the fabricated catalog height and leaves
|
||||
* the record `!groundResolved`), so the FIRST tick that sees
|
||||
* projectionTilesReady() re-enqueues those records once — each then takes
|
||||
* its single real sample and freezes (design §4). Guarded by a boolean
|
||||
* latch (`_tilesReadyReenqueued`), NOT a timer loop: after it fires, no
|
||||
* tick ever samples anything again.
|
||||
*/
|
||||
async update() {
|
||||
if (!layerState._enabled) return;
|
||||
const now = Date.now();
|
||||
layerState._lastUpdate = now;
|
||||
if (
|
||||
!layerState._tilesReadyReenqueued &&
|
||||
parts.model.projectionTilesReady()
|
||||
) {
|
||||
layerState._tilesReadyReenqueued = true;
|
||||
// Per-regime resolution (Task 5): only records unresolved for the
|
||||
// CURRENT surface regime need the completion pass. On globe stacks
|
||||
// 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),
|
||||
);
|
||||
if (unresolved.length)
|
||||
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
|
||||
}
|
||||
await parts.health.syncHealthState();
|
||||
parts.navigation.maybeAutoHop(now);
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js';
|
||||
import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js';
|
||||
import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js';
|
||||
import {
|
||||
GROUND_PRIOR_INIT_WAIT_MS,
|
||||
CAMERA_ICON,
|
||||
IDLE_CAMERA_COLOR,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
|
||||
const { activateCctvCameraFromWorldClick } = services.activation;
|
||||
const { resolvePickId, registerPickOwner, unregisterPickOwner } =
|
||||
services.picking;
|
||||
const { onFocusTargetAppear } = services.focus;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
|
||||
/** Resets all module-scoped runtime state to initial values. */
|
||||
|
||||
function clearRuntimeState() {
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
// Idempotent — also covers a re-init without a prior destroy().
|
||||
parts.cards.teardownAmbientCards();
|
||||
parts.projection.clearProjectionOverlay();
|
||||
layerState._records = [];
|
||||
layerState._recordById = new Map();
|
||||
layerState._healthById = new Map();
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._lastHealthSyncAt = 0;
|
||||
layerState._lastError = null;
|
||||
layerState._lastFocusStyleAt = 0;
|
||||
layerState._activeFocusStyleCount = 0;
|
||||
// FIX ①/③: the discovered tileset handle is scene-scoped — drop it so a fresh
|
||||
// init re-discovers against the current scene primitives.
|
||||
layerState._activeTileset = null;
|
||||
// Task 5: the applied-regime tracker is record-set-scoped — a fresh init
|
||||
// recomputes it against the then-current scene.
|
||||
layerState._lastAppliedRegime = null;
|
||||
}
|
||||
const methods = {
|
||||
/**
|
||||
* Initializes the CCTV layer: loads camera sources, builds the catalog,
|
||||
* restores calibration from localStorage, creates billboards, sets up click
|
||||
* handling, and performs initial health sync. Coverage entities stay lazy.
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
|
||||
*/
|
||||
async init(viewer) {
|
||||
layerState._sourceAbort?.abort();
|
||||
const sourceAbort = new AbortController();
|
||||
layerState._sourceAbort = sourceAbort;
|
||||
if (typeof document !== 'undefined')
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
parts.cards.handleVisibilityChange,
|
||||
);
|
||||
layerState._viewer = viewer;
|
||||
clearRuntimeState();
|
||||
layerState._enabled = false;
|
||||
layerState._activeCameraId = null;
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._lastHopAt = 0;
|
||||
layerState._lastViewContext = '';
|
||||
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
||||
|
||||
layerState._billboards = new Cesium.BillboardCollection();
|
||||
layerState._viewer.scene.primitives.add(layerState._billboards);
|
||||
registerSpriteCollection('cctv', layerState._billboards);
|
||||
|
||||
const sources = await parts.catalog.loadCameraSources();
|
||||
const catalogFromSources = parts.catalog.buildCatalogFromSources(sources);
|
||||
const catalog = catalogFromSources.length
|
||||
? catalogFromSources
|
||||
: parts.catalog.seedCatalog();
|
||||
|
||||
// Viewshed color identity (design §3a): golden-angle hue over the
|
||||
// id-SORTED catalog index — deterministic across sessions for a stable
|
||||
// catalog, maximally separated for neighboring cameras.
|
||||
const hueIndexById = new Map(
|
||||
catalog
|
||||
.map((camera) => camera.id)
|
||||
.sort()
|
||||
.map((id, index) => [id, index]),
|
||||
);
|
||||
|
||||
for (const camera of catalog) {
|
||||
const savedEntry = layerState._calibrationById.get(camera.id);
|
||||
if (savedEntry) {
|
||||
camera.calibration = parts.calibration.normalizeCalibration(
|
||||
savedEntry.values,
|
||||
);
|
||||
camera.calSource = savedEntry.source;
|
||||
}
|
||||
parts.model.ensureCameraPose(camera);
|
||||
}
|
||||
|
||||
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
|
||||
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
|
||||
// the catalog's orthometric groundElevationM feeds the geoid fallback
|
||||
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
|
||||
// records are normally built WITH their prior (correct first paint in
|
||||
// every regime); a cold/slow upstream loses the race and the batch
|
||||
// applies post-hoc via applyLateGroundPriors instead of hanging init.
|
||||
const priorsPromise = parts.ground.resolveGroundPriors(catalog);
|
||||
const priors = await Promise.race([
|
||||
priorsPromise,
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS),
|
||||
),
|
||||
]);
|
||||
sourceAbort.signal.throwIfAborted();
|
||||
|
||||
for (let i = 0; i < catalog.length; i++) {
|
||||
const camera = catalog[i];
|
||||
// Ellipsoidal ground prior (or null while the batch is still in
|
||||
// flight). Geometry falls back to the catalog value only until the
|
||||
// batch lands.
|
||||
const groundPrior = priors?.[i] || null;
|
||||
// Cheap first-pass altitude from the ellipsoidal prior (catalog value
|
||||
// only as the pre-prior fallback) — the staggered geometry queue
|
||||
// refines with sampled tile heights after enable so the init path
|
||||
// never raycasts the scene once per camera.
|
||||
const priorGround = Number.isFinite(groundPrior?.ellipsoid)
|
||||
? groundPrior.ellipsoid
|
||||
: Number(camera.groundElevationM) || 0;
|
||||
camera.absoluteHeightM = priorGround + camera.mountHeightM;
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.absoluteHeightM,
|
||||
);
|
||||
const billboard = layerState._billboards.add({
|
||||
id: camera.id,
|
||||
image: CAMERA_ICON,
|
||||
position,
|
||||
color: IDLE_CAMERA_COLOR,
|
||||
width: 24,
|
||||
height: 24,
|
||||
// Field-test fix (2026-07-06): always-on-top. The old finite value
|
||||
// (1800 m) re-engaged the depth test at far zoom, where the COARSE
|
||||
// far-LOD Google-3D mesh sits above the true ground and swallowed
|
||||
// ground-anchored icons ("submerged" pills over SF). Far-side-of-globe
|
||||
// icons are handled by refreshHorizonCulling() (the flights-layer
|
||||
// EllipsoidalOccluder pattern), not by the depth test.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42),
|
||||
});
|
||||
|
||||
const record = {
|
||||
camera,
|
||||
position,
|
||||
billboard,
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
// Task 5 (height-datum fix): regime-aware ground resolution state.
|
||||
// groundPrior — { ellipsoid, source } from the Re:Earth batch
|
||||
// (null until a late batch lands). The prior applies in EVERY
|
||||
// regime and is the terrain-globe resolution outright.
|
||||
// groundResolved — PER-REGIME one-shot latch (regime key →
|
||||
// boolean): true once this record's resolution completed for that
|
||||
// regime; such records are excluded from the completion pass so
|
||||
// their geometry freezes. Re-armed only on a genuine pose change,
|
||||
// explicit user select/move, or a surface-regime change — never
|
||||
// on the 10s timer.
|
||||
// groundSamples — PER-REGIME resolved ground (regime key →
|
||||
// metres): the accepted one-shot scene sample in google-3d, the
|
||||
// mirrored prior in terrain-globe. Kept across re-arms as the
|
||||
// "has ever resolved" memory for the B9c mid-stream guard.
|
||||
// frustumPositions — cached Cartesians for pure recomputes (so
|
||||
// plane placement never re-derives geometry it already has).
|
||||
groundPrior,
|
||||
groundResolved: {},
|
||||
groundSamples: {},
|
||||
frustumGeometry: null,
|
||||
frustumPositions: null,
|
||||
// §9.1 activation obstruction probe result: effective-range clamp so
|
||||
// the far-cap plane never clips into the tiles. Null = unclamped.
|
||||
// Reset + re-probed on every activation; cleared when the user takes
|
||||
// the range slider (slider overrides the clamp).
|
||||
probeClampRangeM: null,
|
||||
// Viewshed (design §3a/§3b): per-camera color identity + the volume
|
||||
// primitive handle (exists only in viewshed mode for the visible set).
|
||||
viewshedColors: viewshedColors(
|
||||
cameraHue(hueIndexById.get(camera.id) ?? 0),
|
||||
),
|
||||
viewshedPrimitive: null,
|
||||
viewshedActiveTint: false,
|
||||
};
|
||||
layerState._records.push(record);
|
||||
layerState._recordById.set(camera.id, record);
|
||||
}
|
||||
|
||||
layerState._count = layerState._records.length;
|
||||
if (layerState._records.length > 0) {
|
||||
// Projection runtime + first frame fetch are deferred to enable() so
|
||||
// initializing the catalog stays render-cheap.
|
||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||
}
|
||||
|
||||
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
|
||||
// when it lands (pure recomputes — applyLateGroundPriors guards against
|
||||
// a torn-down/re-inited catalog).
|
||||
if (!priors) {
|
||||
const initRecords = layerState._records.slice();
|
||||
priorsPromise
|
||||
.then((late) => {
|
||||
if (late) parts.ground.applyLateGroundPriors(initRecords, late);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Task 5: track the surface regime the initial geometry was computed for
|
||||
// and listen for map-stack changes (main.js re-dispatches
|
||||
// MapStackController.onChange as this CustomEvent). The handler compares
|
||||
// regimes itself, so 'switching'/'error' emissions and same-regime stack
|
||||
// swaps (bing→osm) no-op.
|
||||
layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime();
|
||||
if (!layerState._mapStackListener && typeof window !== 'undefined') {
|
||||
layerState._mapStackListener = () =>
|
||||
parts.ground.handleMapStackChanged();
|
||||
window.addEventListener(
|
||||
'gev:map-stack-changed',
|
||||
layerState._mapStackListener,
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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();
|
||||
};
|
||||
layerState._viewer.camera.moveEnd.addEventListener(
|
||||
layerState._horizonCullListener,
|
||||
);
|
||||
}
|
||||
if (!layerState._moveStartListener) {
|
||||
// Item B: hover picking pauses while the camera is in motion.
|
||||
layerState._moveStartListener = () => {
|
||||
layerState._cameraMoving = true;
|
||||
};
|
||||
layerState._viewer.camera.moveStart.addEventListener(
|
||||
layerState._moveStartListener,
|
||||
);
|
||||
}
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
|
||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
layerState._viewer.scene.canvas,
|
||||
);
|
||||
parts.selection.bindCctvWorldClickGesture(
|
||||
layerState._clickHandler,
|
||||
(click) => {
|
||||
if (!layerState._enabled) return;
|
||||
const picked = layerState._viewer.scene.pick(click.position);
|
||||
const cameraId = parts.selection.extractPickedCameraId(picked);
|
||||
if (cameraId) {
|
||||
activateCctvCameraFromWorldClick(
|
||||
cameraId,
|
||||
parts.selection.setActiveCamera,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Any identified scene object owns this click even if its layer does not
|
||||
// register a shared pick predicate. This keeps selectable siblings ahead
|
||||
// of an overlapping CCTV card while ID-less globe/terrain/tile surfaces
|
||||
// remain eligible for true empty-space deselection.
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId !== null) return;
|
||||
// Item A (owner round 2): the scene pick found no camera — try the
|
||||
// painted ambient cards. The cards canvas is pointer-events:none (this
|
||||
// handler owns the events), so a click landing on a card's rect selects
|
||||
// its camera exactly like a click on the icon. Cesium click positions
|
||||
// and the recorded rects are both CSS px — direct comparison.
|
||||
const cardId = layerState._cctvOverlayHost.hitTest(
|
||||
click.position.x,
|
||||
click.position.y,
|
||||
{ sourceId: CCTV_OVERLAY_SOURCE_ID },
|
||||
)?.entryId;
|
||||
if (cardId && layerState._recordById.has(cardId)) {
|
||||
activateCctvCameraFromWorldClick(
|
||||
cardId,
|
||||
parts.selection.setActiveCamera,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parts.selection.cctvEmptyClickDeselects(picked, {
|
||||
activeCameraId: layerState._activeCameraId,
|
||||
calibrationMode: layerState._calibrationMode,
|
||||
})
|
||||
) {
|
||||
parts.selection.deactivateActiveCamera();
|
||||
}
|
||||
},
|
||||
{
|
||||
// Item B: hover summons a card on a cardless camera icon. The gesture
|
||||
// classifier owns MOUSE_MOVE too, so chain hover work through its seam
|
||||
// instead of replacing the travel accumulator's handler.
|
||||
onMouseMove: (movement) =>
|
||||
parts.hover.handleHoverMove(movement?.endPosition),
|
||||
},
|
||||
);
|
||||
|
||||
await parts.health.syncHealthState(true);
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
restoreSpriteOrder(layerState._viewer);
|
||||
console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras');
|
||||
},
|
||||
|
||||
/**
|
||||
* Enables the layer: shows entities, starts the projection loop, and kicks
|
||||
* the staggered geometry-load queue. Heavy work (per-camera ground
|
||||
* sampling) is deferred/batched so the frame budget never collapses at
|
||||
* enable time.
|
||||
*/
|
||||
enable() {
|
||||
layerState._enabled = true;
|
||||
layerState._lastUpdate = Date.now();
|
||||
// Pick-ownership (H2): camera billboards use the camera id directly;
|
||||
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
|
||||
registerPickOwner('cctv', (pickedId) => {
|
||||
if (layerState._recordById.has(pickedId)) return true;
|
||||
if (
|
||||
typeof pickedId === 'string' &&
|
||||
pickedId.startsWith(GIZMO_ID_PREFIX)
|
||||
)
|
||||
return true;
|
||||
const coverage =
|
||||
/^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec(
|
||||
pickedId,
|
||||
);
|
||||
return Boolean(coverage && layerState._recordById.has(coverage[1]));
|
||||
});
|
||||
if (!layerState._activeCameraId && layerState._records.length) {
|
||||
layerState._activeCameraId = layerState._records[0].camera.id;
|
||||
layerState._autoHopSuspended = false;
|
||||
}
|
||||
const activeRecord = parts.selection.getActiveRecord();
|
||||
if (activeRecord) {
|
||||
parts.projection.ensureProjectionRuntime(activeRecord);
|
||||
parts.frames.refreshProjectionImage(activeRecord, true);
|
||||
}
|
||||
parts.geometryQueue.startGeometryLoadQueue();
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.projection.startProjectionLoop();
|
||||
// The projection loop self-stops when idle; a focus target appearing
|
||||
// (user starts tracking a contact) is the one edge it can't see while
|
||||
// stopped, so re-arm on it. Removed on disable.
|
||||
layerState._removeFocusAppearListener?.();
|
||||
layerState._removeFocusAppearListener = onFocusTargetAppear(() =>
|
||||
parts.projection.startProjectionLoop(),
|
||||
);
|
||||
// Ambient card tier: shared host source + policy-gated frame pacer + the
|
||||
// initial selection pass (moveEnd drives every later reselection).
|
||||
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, true);
|
||||
parts.cards.startCardFrameLoop();
|
||||
parts.cards.refreshAmbientCards();
|
||||
parts.presentation.notifyListeners();
|
||||
restoreSpriteOrder(layerState._viewer);
|
||||
},
|
||||
|
||||
/** Disables the layer: hides entities, stops the projection loop and load queue. */
|
||||
disable() {
|
||||
layerState._enabled = false;
|
||||
unregisterPickOwner('cctv');
|
||||
// ADJUST mode does not survive a layer toggle — predictable re-entry.
|
||||
layerState._calibrationMode = false;
|
||||
releaseContinuousRender('cctv-adjust');
|
||||
layerState._gizmo?.setEnabled(false);
|
||||
layerState._removeFocusAppearListener?.();
|
||||
layerState._removeFocusAppearListener = null;
|
||||
parts.projection.stopProjectionLoop();
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
// 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();
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Tears down the layer: destroys click handler, projection loop, coverage
|
||||
* entities, billboards, and clears all runtime state and subscribers.
|
||||
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref).
|
||||
*/
|
||||
destroy(viewer) {
|
||||
layerState._sourceAbort?.abort();
|
||||
if (typeof document !== 'undefined')
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
parts.cards.handleVisibilityChange,
|
||||
);
|
||||
unregisterPickOwner('cctv');
|
||||
if (layerState._mapStackListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener(
|
||||
'gev:map-stack-changed',
|
||||
layerState._mapStackListener,
|
||||
);
|
||||
layerState._mapStackListener = null;
|
||||
}
|
||||
const teardownViewer = viewer || layerState._viewer;
|
||||
if (layerState._horizonCullListener && teardownViewer?.camera?.moveEnd) {
|
||||
teardownViewer.camera.moveEnd.removeEventListener(
|
||||
layerState._horizonCullListener,
|
||||
);
|
||||
layerState._horizonCullListener = null;
|
||||
}
|
||||
if (layerState._moveStartListener && teardownViewer?.camera?.moveStart) {
|
||||
teardownViewer.camera.moveStart.removeEventListener(
|
||||
layerState._moveStartListener,
|
||||
);
|
||||
layerState._moveStartListener = null;
|
||||
}
|
||||
if (layerState._gizmo) {
|
||||
layerState._gizmo.destroy();
|
||||
layerState._gizmo = null;
|
||||
}
|
||||
layerState._calibrationMode = false;
|
||||
releaseContinuousRender('cctv-adjust');
|
||||
if (layerState._clickHandler) {
|
||||
layerState._clickHandler.destroy();
|
||||
layerState._clickHandler = null;
|
||||
}
|
||||
if (teardownViewer?.scene?.screenSpaceCameraController) {
|
||||
teardownViewer.scene.screenSpaceCameraController.enableInputs = true;
|
||||
}
|
||||
parts.projection.stopProjectionLoop();
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
parts.cards.teardownAmbientCards();
|
||||
parts.geometry.destroyCoverageEntities();
|
||||
if (layerState._billboards && teardownViewer) {
|
||||
teardownViewer.scene.primitives.remove(layerState._billboards);
|
||||
layerState._billboards = null;
|
||||
}
|
||||
clearRuntimeState();
|
||||
layerState._viewer = null;
|
||||
layerState._enabled = false;
|
||||
layerState._activeCameraId = null;
|
||||
layerState._autoHopSuspended = false;
|
||||
// Clear existing subscribers rather than replacing the Set —
|
||||
// replacing would silently orphan any unsubscribe() closures
|
||||
layerState._listeners.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return { clearRuntimeState, methods };
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
||||
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
||||
import { DEFAULT_CAMERA_CALIBRATION } from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
const { focusPassIsNeeded, getFocusTarget } = services.focus;
|
||||
|
||||
/**
|
||||
* Converts degrees to radians.
|
||||
* @param {number} deg
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
function toRad(deg) {
|
||||
return Cesium.Math.toRadians(deg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a heading angle to the [0, 360) range.
|
||||
* @param {number} deg
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
function normalizeHeading(deg) {
|
||||
let v = deg % 360;
|
||||
if (v < 0) v += 360;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a value to [min, max].
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an angle to the (-180, 180] range.
|
||||
* @param {number} deg
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
function normalizeSignedAngle(deg) {
|
||||
let value = deg % 360;
|
||||
if (value > 180) value -= 360;
|
||||
if (value <= -180) value += 360;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute angular difference between two headings in degrees.
|
||||
* @param {number} aDeg
|
||||
* @param {number} bDeg
|
||||
* @returns {number} Value in [0, 180].
|
||||
*/
|
||||
|
||||
function angularDeltaAbs(aDeg, bDeg) {
|
||||
return Math.abs(normalizeSignedAngle(aDeg - bDeg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a raw feed-type string to a canonical type (image, mjpeg, mp4, hls, webm).
|
||||
* @param {string|*} value - Raw feed type from source config.
|
||||
* @returns {string} Canonical feed type.
|
||||
*/
|
||||
|
||||
function normalizeFeedType(value) {
|
||||
const raw = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!raw) return 'image';
|
||||
if (raw === 'mjpg') return 'mjpeg';
|
||||
if (raw === 'jpeg') return 'image';
|
||||
if (raw === 'jpg') return 'image';
|
||||
if (raw === 'video') return 'mp4';
|
||||
if (raw === 'stream') return 'hls';
|
||||
if (raw === 'png') return 'image';
|
||||
if (raw === 'gif') return 'image';
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the feed type requires a <video> element rather than an <img>.
|
||||
* @param {string} feedType
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function isVideoFeedType(feedType) {
|
||||
return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a value to a finite number or returns the fallback.
|
||||
* @param {*} value
|
||||
* @param {number} [fallback=NaN]
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
function safeNumber(value, fallback = NaN) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a deterministic heading from a camera ID string via a simple hash.
|
||||
* Produces one of 16 evenly-spaced headings (0, 22.5, 45, ..., 337.5).
|
||||
* @param {string} id
|
||||
* @returns {number} Heading in degrees [0, 360).
|
||||
*/
|
||||
|
||||
function headingFromId(id) {
|
||||
const text = String(id || '');
|
||||
let acc = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
acc = (acc * 33 + text.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return normalizeHeading((acc % 16) * 22.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds a value to the nearest multiple of `step`.
|
||||
* @param {number} value
|
||||
* @param {number} [step=0.1]
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
function quantize(value, step = 0.1) {
|
||||
return Math.round(value / step) * step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a coverage-mode request (viewshed design §3b). Accepts the three
|
||||
* mode strings plus booleans for `setParams({showCoverage})` back-compat
|
||||
* (true → 'on', false → 'off'); anything else keeps the current mode.
|
||||
* @param {*} value - Requested mode ('off'|'on'|'viewshed') or boolean.
|
||||
* @param {'off'|'on'|'viewshed'} current - Mode to keep when the request is invalid.
|
||||
* @returns {'off'|'on'|'viewshed'}
|
||||
*/
|
||||
|
||||
function normalizeCoverageMode(value, current) {
|
||||
if (value === true) return 'on';
|
||||
if (value === false) return 'off';
|
||||
if (value === 'off' || value === 'on' || value === 'viewshed') return value;
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts north/east metre offsets to lat/lon degree deltas at a given latitude.
|
||||
* Uses the equirectangular approximation (111320 m/deg).
|
||||
* @param {number} latDeg - Reference latitude (degrees).
|
||||
* @param {number} northMeters - Offset northward (metres).
|
||||
* @param {number} eastMeters - Offset eastward (metres).
|
||||
* @returns {{ latOffset: number, lonOffset: number }} Degree deltas.
|
||||
*/
|
||||
|
||||
function offsetDegrees(latDeg, northMeters, eastMeters) {
|
||||
const latOffset = northMeters / 111320;
|
||||
const lonDivisor = Math.max(0.15, Math.cos(toRad(latDeg)));
|
||||
const lonOffset = eastMeters / (111320 * lonDivisor);
|
||||
return { latOffset, lonOffset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `window.localStorage` when it is safely accessible, else null.
|
||||
* Split out so store IO can be exercised under plain node:test with an
|
||||
* injected storage-like object (getItem/setItem/removeItem) instead.
|
||||
* @returns {Storage|null}
|
||||
*/
|
||||
|
||||
function safeWindowLocalStorage() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
// NB: the window.localStorage property ACCESS itself throws SecurityError
|
||||
// under "block all cookies", so it has to live inside the try (M11).
|
||||
return window.localStorage || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes or recomputes a camera's derived pose fields from its base pose
|
||||
* and calibration offsets. Also sets intrinsics, extrinsics, and anchor.
|
||||
*
|
||||
* On first call for a camera, captures the raw values as `basePose`.
|
||||
* Subsequent calls re-derive lat/lon/heading/pitch/fov/range by applying
|
||||
* calibration deltas to the frozen base pose.
|
||||
*
|
||||
* Note: the old score-based quality system (`confidenceFromScore`, seeded
|
||||
* `camera.quality.score`) is retired — panel trust signal is now the 3-state
|
||||
* CAL badge (`deriveCalBadge`, driven by `calSource`/`poseSource`), not a
|
||||
* fabricated confidence score.
|
||||
*
|
||||
* @param {Object} camera - Mutable camera record.
|
||||
*/
|
||||
|
||||
function ensureCameraPose(camera) {
|
||||
if (!camera) return;
|
||||
if (!camera.basePose) {
|
||||
camera.basePose = {
|
||||
lat: safeNumber(camera.lat, 0),
|
||||
lon: safeNumber(camera.lon, 0),
|
||||
headingDeg: normalizeHeading(safeNumber(camera.headingDeg, 0)),
|
||||
pitchDeg: clamp(safeNumber(camera.pitchDeg, -17), -70, 10),
|
||||
fovDeg: clamp(safeNumber(camera.fovDeg, 74), 20, 130),
|
||||
rangeM: clamp(safeNumber(camera.rangeM, 700), 120, 5000),
|
||||
mountHeightM: clamp(safeNumber(camera.mountHeightM, 24), 2, 240),
|
||||
};
|
||||
}
|
||||
|
||||
const nextCalibration = parts.calibration.normalizeCalibration(
|
||||
camera.calibration || DEFAULT_CAMERA_CALIBRATION,
|
||||
);
|
||||
camera.calibration = nextCalibration;
|
||||
|
||||
const base = camera.basePose;
|
||||
const offsets = offsetDegrees(
|
||||
base.lat,
|
||||
nextCalibration.offsetNorthM,
|
||||
nextCalibration.offsetEastM,
|
||||
);
|
||||
camera.lat = base.lat + offsets.latOffset;
|
||||
camera.lon = base.lon + offsets.lonOffset;
|
||||
camera.headingDeg = normalizeHeading(
|
||||
base.headingDeg + nextCalibration.headingDeg,
|
||||
);
|
||||
camera.pitchDeg = clamp(base.pitchDeg + nextCalibration.pitchDeg, -70, 10);
|
||||
camera.fovDeg = clamp(base.fovDeg + nextCalibration.fovDeg, 20, 130);
|
||||
camera.rangeM = clamp(base.rangeM * nextCalibration.rangeScale, 120, 5000);
|
||||
camera.mountHeightM = clamp(
|
||||
base.mountHeightM + nextCalibration.heightM,
|
||||
2,
|
||||
240,
|
||||
);
|
||||
|
||||
camera.intrinsics = {
|
||||
fovDeg: camera.fovDeg,
|
||||
principalPoint: [0.5, 0.5],
|
||||
};
|
||||
camera.extrinsics = {
|
||||
headingDeg: camera.headingDeg,
|
||||
pitchDeg: camera.pitchDeg,
|
||||
rollDeg: 0,
|
||||
heightM: camera.mountHeightM,
|
||||
};
|
||||
camera.anchor = {
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
elevM: safeNumber(camera.groundElevationM, 0),
|
||||
targetLatLon: camera.anchor?.targetLatLon || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a point along a bearing from a given lat/lon by a distance.
|
||||
* Uses the spherical-earth direct geodesic formula (R = 6371 km).
|
||||
* @param {number} latDeg - Origin latitude (degrees).
|
||||
* @param {number} lonDeg - Origin longitude (degrees).
|
||||
* @param {number} bearingDeg - Azimuth from north (degrees).
|
||||
* @param {number} distanceM - Distance in metres.
|
||||
* @returns {{ lat: number, lon: number }} Destination in degrees.
|
||||
*/
|
||||
|
||||
function projectPoint(latDeg, lonDeg, bearingDeg, distanceM) {
|
||||
const angular = distanceM / 6371000;
|
||||
const bearing = toRad(bearingDeg);
|
||||
const lat1 = toRad(latDeg);
|
||||
const lon1 = toRad(lonDeg);
|
||||
|
||||
const sinLat2 =
|
||||
Math.sin(lat1) * Math.cos(angular) +
|
||||
Math.cos(lat1) * Math.sin(angular) * Math.cos(bearing);
|
||||
const lat2 = Math.asin(sinLat2);
|
||||
|
||||
const y = Math.sin(bearing) * Math.sin(angular) * Math.cos(lat1);
|
||||
const x = Math.cos(angular) - Math.sin(lat1) * sinLat2;
|
||||
const lon2 = lon1 + Math.atan2(y, x);
|
||||
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(lat2),
|
||||
lon: Cesium.Math.toDegrees(lon2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the great-circle distance between two points using the haversine formula.
|
||||
* @param {number} lat1 - Latitude of point 1 (degrees).
|
||||
* @param {number} lon1 - Longitude of point 1 (degrees).
|
||||
* @param {number} lat2 - Latitude of point 2 (degrees).
|
||||
* @param {number} lon2 - Longitude of point 2 (degrees).
|
||||
* @returns {number} Distance in kilometres.
|
||||
*/
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the area of a circular sector (camera FOV wedge).
|
||||
* @param {number} rangeM - Radius in metres.
|
||||
* @param {number} fovDeg - Field of view in degrees.
|
||||
* @returns {number} Area in km^2.
|
||||
*/
|
||||
|
||||
function sectorAreaKm2(rangeM, fovDeg) {
|
||||
const theta = toRad(clamp(fovDeg, 12, 170));
|
||||
const areaM2 = 0.5 * rangeM * rangeM * theta;
|
||||
return areaM2 / 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a coarse grid key describing the viewer's current position and zoom
|
||||
* level. Used to detect meaningful view changes for auto-hop camera switching.
|
||||
* @returns {string} Grid key in the form "zoomBucket:latGrid:lonGrid".
|
||||
*/
|
||||
|
||||
function currentViewContext() {
|
||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
||||
if (!carto) return 'none';
|
||||
const lat = Cesium.Math.toDegrees(carto.latitude);
|
||||
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||||
const alt = carto.height || 0;
|
||||
const zoomBucket =
|
||||
alt < 1500
|
||||
? 'street'
|
||||
: alt < 12000
|
||||
? 'city'
|
||||
: alt < 75000
|
||||
? 'regional'
|
||||
: 'global';
|
||||
const grid =
|
||||
zoomBucket === 'street'
|
||||
? 0.045
|
||||
: zoomBucket === 'city'
|
||||
? 0.24
|
||||
: zoomBucket === 'regional'
|
||||
? 1.0
|
||||
: 4.5;
|
||||
return `${zoomBucket}:${Math.floor(lat / grid)}:${Math.floor(lon / grid)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the active Google Photorealistic 3D Tileset (if any) has
|
||||
* finished loading the tiles in view. Shared mesh-floor sampling is gated on
|
||||
* this so a one-shot cell never bakes in a miss from still-streaming tiles.
|
||||
* Discovers + caches the tileset lazily from scene
|
||||
* primitives (the CCTV module holds only a `_viewer` reference). When no
|
||||
* tileset is present (OSM fallback) this returns true so ground sampling is
|
||||
* not permanently blocked.
|
||||
*
|
||||
* Task 5 (review correction, spec §2): a HIDDEN tileset (`show === false`,
|
||||
* i.e. a globe stack is active) must NOT report ready — Cesium 1.138's
|
||||
* the shared sampler can only inspect *visible* 3D tilesets, so a sample taken
|
||||
* against the hidden Google tileset would silently miss.
|
||||
*
|
||||
* @returns {boolean} True when tiles are loaded AND visible (or no tileset
|
||||
* exists to wait on).
|
||||
*/
|
||||
|
||||
function projectionTilesReady() {
|
||||
if (
|
||||
!layerState._activeTileset ||
|
||||
layerState._activeTileset.isDestroyed?.()
|
||||
) {
|
||||
layerState._activeTileset = null;
|
||||
const primitives = layerState._viewer?.scene?.primitives;
|
||||
if (primitives && typeof primitives.get === 'function') {
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const p = primitives.get(i);
|
||||
if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) {
|
||||
layerState._activeTileset = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!layerState._activeTileset) return true;
|
||||
if (layerState._activeTileset.show === false) return false;
|
||||
return layerState._activeTileset.tilesLoaded === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orientation quaternion for the monitor plane entity: local +Z is the plane
|
||||
* normal, pointing BACK along the view axis toward the mount so the textured
|
||||
* front face reads correctly from the natural viewpoint (focusCamera flies the
|
||||
* viewer to look along the camera heading). Local +X = viewer-right, +Y =
|
||||
* frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry —
|
||||
* computed only on slider/save/activation, never per frame (§2b).
|
||||
* @param {Object} camera - Camera pose.
|
||||
* @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF.
|
||||
* @returns {Cesium.Quaternion}
|
||||
*/
|
||||
|
||||
function planeOrientationFor(camera, capCenterPos) {
|
||||
const frame = parts.geometry.frustumFrameEcef(camera, capCenterPos);
|
||||
const right = Cesium.Cartesian3.cross(
|
||||
frame.dir,
|
||||
frame.up,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const normal = Cesium.Cartesian3.negate(frame.dir, new Cesium.Cartesian3());
|
||||
const m = new Cesium.Matrix3(
|
||||
right.x,
|
||||
frame.up.x,
|
||||
normal.x,
|
||||
right.y,
|
||||
frame.up.y,
|
||||
normal.y,
|
||||
right.z,
|
||||
frame.up.z,
|
||||
normal.z,
|
||||
);
|
||||
return Cesium.Quaternion.fromRotationMatrix(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the requestAnimationFrame loop that drives projection canvas updates
|
||||
* (frame draw + texture swap) for the active camera.
|
||||
*/
|
||||
/**
|
||||
* The projection rAF has real work only while a camera is actively projected
|
||||
* or a focus fade is in flight — otherwise it burned a wakeup + style poll
|
||||
* every rendered frame for the whole enabled lifetime of the layer. The tick
|
||||
* self-stops when idle; every state edge that creates work re-arms it
|
||||
* (enable, setActiveCamera, showProjection, focus-target appearance).
|
||||
* (perf wave 1)
|
||||
* @returns {boolean} Whether the loop currently has work.
|
||||
*/
|
||||
|
||||
function projectionLoopIsNeeded() {
|
||||
if (!layerState._enabled || !layerState._viewer) return false;
|
||||
if (layerState._showProjection && parts.selection.getActiveRecord())
|
||||
return true;
|
||||
return focusPassIsNeeded(
|
||||
getFocusTarget(),
|
||||
layerState._activeFocusStyleCount,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-frame pacer tick (owner finding 3): launches AT MOST one fetch per
|
||||
* tick, with cardFetchPolicy deciding whether a launch is allowed. Cold fill
|
||||
* — any selected card still missing its FIRST frame — bursts up to 4
|
||||
* in-flight fetches at 250 ms spacing so arriving in a new area populates
|
||||
* in a few seconds instead of 16-32 s; once every selected card has a first
|
||||
* frame the layer drops back to the salvaged steady-state gate (single
|
||||
* flight, one request per second). Priority: frameless cards first in ring
|
||||
* order (nearest-first — they're what makes a card appear at all), then the
|
||||
* stalest refresh-overdue card by its source cadence (staticFrameRefreshMs).
|
||||
* Failures back off per camera (frameFetchDue) instead of hammering a dead
|
||||
* source, so a dead upstream never eats the burst slots.
|
||||
*/
|
||||
|
||||
function cardFrameTick() {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
(!layerState._cardIds.size &&
|
||||
!(layerState._activeCameraCardEnabled && layerState._activeCameraId))
|
||||
)
|
||||
return;
|
||||
const now = Date.now();
|
||||
let frameless = null;
|
||||
let stalest = null;
|
||||
let coldFill = false;
|
||||
const consider = (id) => {
|
||||
const record = layerState._recordById.get(id);
|
||||
if (!record) return;
|
||||
const slot = parts.cards.ensureCardFrameSlot(id);
|
||||
if (layerState._cardFetchPendingIds.has(id)) {
|
||||
// An in-flight first-frame fetch keeps cold-fill mode active without
|
||||
// being re-launchable.
|
||||
if (!(slot.stamp > 0)) coldFill = true;
|
||||
return;
|
||||
}
|
||||
const refreshMs = staticFrameRefreshMs(record.camera);
|
||||
if (!frameFetchDue(slot, refreshMs, now)) return;
|
||||
if (!(slot.stamp > 0)) {
|
||||
coldFill = true;
|
||||
if (!frameless) frameless = { record, slot, refreshMs };
|
||||
return;
|
||||
}
|
||||
if (!stalest || slot.stamp < stalest.slot.stamp) {
|
||||
stalest = { record, slot, refreshMs };
|
||||
}
|
||||
};
|
||||
// The optional protected active card stays outside the 40-card ambient
|
||||
// quota and uses the same source-owned pacing/retry/cache lifecycle.
|
||||
if (layerState._activeCameraCardEnabled && layerState._activeCameraId)
|
||||
consider(layerState._activeCameraId);
|
||||
for (const id of layerState._cardIds) consider(id);
|
||||
const policy = cardFetchPolicy({
|
||||
// The cold-fill burst yields to the staggered geometry drain: 4 concurrent
|
||||
// image fetch+decodes mid-drain starve the mesh-floor queue on weak GPUs
|
||||
// (qa-cctv-v2 drain-budget regression). Steady 1/s trickle still runs;
|
||||
// the burst fires the moment the drain completes.
|
||||
coldFill: coldFill && !layerState._geoLoading,
|
||||
inFlight: layerState._cardFetchInFlightCount,
|
||||
sinceLastLaunchMs:
|
||||
layerState._cardLastFetchAt > 0
|
||||
? now - layerState._cardLastFetchAt
|
||||
: Infinity,
|
||||
});
|
||||
layerState._cardFetchMode = policy.mode;
|
||||
if (!policy.launch) return;
|
||||
const pick = frameless || stalest;
|
||||
if (pick)
|
||||
parts.cards.fetchCardFrame(pick.record, pick.slot, pick.refreshMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden-state gate (perf wave 2): detach in-flight card frame decodes when
|
||||
* the document hides — a hidden canvas has no reader, and image decode is
|
||||
* the expensive half. New fetches are gated at fetchCardFrame; the steady
|
||||
* pacer refills naturally on return. Owned by the initialized layer and detached on destruction.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reports whether selecting a record must run the full activation path.
|
||||
* Disable re-arms the still-active record so its obstruction probe runs again
|
||||
* on its next real activation after the temporary clamp is cleared.
|
||||
* @param {string} cameraId Requested camera ID.
|
||||
* @param {string|null} activeCameraId Current active camera ID.
|
||||
* @param {Object|null} record Requested camera runtime record.
|
||||
* @returns {boolean} Whether activation work must run.
|
||||
*/
|
||||
|
||||
function cctvRecordNeedsActivation(cameraId, activeCameraId, record) {
|
||||
return cameraId !== activeCameraId || record?.activationDone !== true;
|
||||
}
|
||||
return {
|
||||
toRad,
|
||||
normalizeHeading,
|
||||
clamp,
|
||||
normalizeSignedAngle,
|
||||
angularDeltaAbs,
|
||||
normalizeFeedType,
|
||||
isVideoFeedType,
|
||||
safeNumber,
|
||||
headingFromId,
|
||||
quantize,
|
||||
normalizeCoverageMode,
|
||||
offsetDegrees,
|
||||
safeWindowLocalStorage,
|
||||
ensureCameraPose,
|
||||
projectPoint,
|
||||
haversineKm,
|
||||
sectorAreaKm2,
|
||||
currentViewContext,
|
||||
projectionTilesReady,
|
||||
planeOrientationFor,
|
||||
projectionLoopIsNeeded,
|
||||
cardFrameTick,
|
||||
cctvRecordNeedsActivation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_FOCUS_RESULT } from './policy.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* Finds the camera closest to the Cesium viewer's current position.
|
||||
* @returns {string|null} Camera ID of the nearest camera, or null.
|
||||
*/
|
||||
|
||||
function nearestCameraIdToViewer() {
|
||||
const carto = layerState._viewer?.camera?.positionCartographic;
|
||||
if (!carto || !layerState._records.length) return null;
|
||||
const lat = Cesium.Math.toDegrees(carto.latitude);
|
||||
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||||
|
||||
let best = null;
|
||||
for (const record of layerState._records) {
|
||||
const distKm = parts.model.haversineKm(
|
||||
lat,
|
||||
lon,
|
||||
record.camera.lat,
|
||||
record.camera.lon,
|
||||
);
|
||||
if (!best || distKm < best.distKm) {
|
||||
best = { id: record.camera.id, distKm };
|
||||
}
|
||||
}
|
||||
return best?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flies the Cesium viewer camera to frame the specified CCTV camera,
|
||||
* looking along its heading from above.
|
||||
* @param {Cesium.Viewer|null} viewer Cesium viewer that owns the camera.
|
||||
* @param {Object|null} record CCTV camera runtime record.
|
||||
* @param {number} [duration=2.2] - Flight duration in seconds.
|
||||
* @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result.
|
||||
*/
|
||||
|
||||
function focusCctvRecord(viewer, record, duration = 2.2) {
|
||||
if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
document.body?.classList.contains('cockpit-mode')
|
||||
) {
|
||||
console.debug('[Data:CCTV] focus ignored while cockpit owns the camera');
|
||||
return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE;
|
||||
}
|
||||
if (viewer.trackedEntity) {
|
||||
console.debug(
|
||||
'[Data:CCTV] focus ignored while a tracked entity owns the camera',
|
||||
);
|
||||
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
|
||||
}
|
||||
const { camera } = record;
|
||||
const range = Math.max(280, camera.rangeM * 1.18);
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
record.position,
|
||||
Math.max(40, camera.rangeM * 0.36),
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
parts.model.toRad(camera.headingDeg),
|
||||
parts.model.toRad(-22),
|
||||
range,
|
||||
),
|
||||
duration: Math.max(0.2, duration || 0),
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
},
|
||||
);
|
||||
return CCTV_FOCUS_RESULT.FOCUSED;
|
||||
}
|
||||
|
||||
function focusCamera(cameraId, duration = 2.2) {
|
||||
return focusCctvRecord(
|
||||
layerState._viewer,
|
||||
layerState._recordById.get(cameraId),
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances to the next camera if auto-hop is enabled and the hop interval
|
||||
* has elapsed. If the viewer has panned to a new region since the last hop,
|
||||
* snaps to the nearest camera instead of cycling sequentially.
|
||||
* @param {number} nowMs - Current timestamp in milliseconds.
|
||||
*/
|
||||
|
||||
function maybeAutoHop(nowMs) {
|
||||
if (
|
||||
!layerState._autoHop ||
|
||||
layerState._autoHopSuspended ||
|
||||
!layerState._enabled ||
|
||||
layerState._records.length < 2
|
||||
)
|
||||
return;
|
||||
if (nowMs - layerState._lastHopAt < layerState._autoHopSec * 1000) return;
|
||||
|
||||
const viewKey = parts.model.currentViewContext();
|
||||
const viewChanged = viewKey !== layerState._lastViewContext;
|
||||
layerState._lastViewContext = viewKey;
|
||||
|
||||
if (viewChanged) {
|
||||
const nearest = nearestCameraIdToViewer();
|
||||
if (nearest && nearest !== layerState._activeCameraId) {
|
||||
// Use setActiveCamera so the full activation path runs (obstruction
|
||||
// probe, projection runtime, geometry rewrite) — previously bypassed
|
||||
// with a bare assignment
|
||||
parts.selection.setActiveCamera(nearest);
|
||||
layerState._lastHopAt = nowMs;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextIdx = cctvCycleIndex(
|
||||
layerState._records.findIndex(
|
||||
(record) => record.camera.id === layerState._activeCameraId,
|
||||
),
|
||||
1,
|
||||
layerState._records.length,
|
||||
);
|
||||
parts.selection.setActiveCamera(layerState._records[nextIdx].camera.id);
|
||||
layerState._lastHopAt = nowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a catalog cycle target, including the explicit no-selection state.
|
||||
* NEXT from null selects the first record; PREV selects the last.
|
||||
* @param {number} currentIdx
|
||||
* @param {number} step
|
||||
* @param {number} count
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
function cctvCycleIndex(currentIdx, step, count) {
|
||||
const total = Number.isFinite(count) ? Math.floor(count) : 0;
|
||||
if (total <= 0) return -1;
|
||||
const delta = Number.isFinite(step) ? Math.trunc(step) : 1;
|
||||
if (!Number.isFinite(currentIdx) || currentIdx < 0) {
|
||||
return delta < 0 ? total - 1 : 0;
|
||||
}
|
||||
return (((Math.floor(currentIdx) + delta) % total) + total) % total;
|
||||
}
|
||||
return {
|
||||
nearestCameraIdToViewer,
|
||||
focusCctvRecord,
|
||||
focusCamera,
|
||||
maybeAutoHop,
|
||||
cctvCycleIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FRAME_ENDPOINT = '/api/cctv/frame';
|
||||
|
||||
export const SOURCE_ENDPOINT = '/api/cctv/sources';
|
||||
|
||||
export const HEALTH_ENDPOINT = '/api/cctv/health';
|
||||
|
||||
export const MEDIA_ENDPOINT = '/api/cctv/media';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timing and geometry constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_UPDATE_INTERVAL_MS = 10000;
|
||||
|
||||
export const MIN_AUTO_HOP_SEC = 8;
|
||||
|
||||
export const MAX_AUTO_HOP_SEC = 90;
|
||||
|
||||
export const HEALTH_SYNC_INTERVAL_MS = 7000;
|
||||
|
||||
export const ACTIVE_FRAME_REFRESH_MS = 10000;
|
||||
|
||||
export const IDLE_FRAME_REFRESH_MS = 60000;
|
||||
|
||||
export const PROJECTION_ACTIVE_REFRESH_MS = 10000;
|
||||
|
||||
export const PROJECTION_IDLE_REFRESH_MS = 60000;
|
||||
|
||||
export const PROJECTION_CANVAS_WIDTH = 1920;
|
||||
|
||||
export const PROJECTION_CANVAS_HEIGHT = 1080;
|
||||
|
||||
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
|
||||
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
|
||||
// full-resolution compare would touch.
|
||||
|
||||
export const FRAME_SIGNATURE_W = 64;
|
||||
|
||||
export const FRAME_SIGNATURE_H = 36;
|
||||
|
||||
export const COVERAGE_NEIGHBOR_LIMIT = 14;
|
||||
|
||||
export const COVERAGE_NEIGHBOR_RADIUS_KM = 1.8;
|
||||
|
||||
// Staggered geometry/frame loading: ground-sampled coverage geometry is
|
||||
// refined in small batches (active camera first, then nearest-to-viewer) so
|
||||
// enabling the layer never raycasts every camera in a single frame.
|
||||
|
||||
export const GEO_LOAD_BATCH_SIZE = 4;
|
||||
|
||||
export const GEO_LOAD_BATCH_DELAY_MS = 120;
|
||||
|
||||
export const GEO_TRACKING_BATCH_SIZE = 2;
|
||||
|
||||
export const GEO_TRACKING_BATCH_DELAY_MS = 250;
|
||||
|
||||
export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300;
|
||||
|
||||
export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
|
||||
|
||||
// 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.
|
||||
|
||||
export const PLACEHOLDER_REPAINT_MS = 750;
|
||||
|
||||
// v1 key is retired dead data (owner decision #3, §9.3 — WIPE CLEAN, no
|
||||
// legacy import): kept here only as a documented constant so nothing ever
|
||||
// re-reads it by accident. Exported for the unit suite's "v1 is ignored"
|
||||
// assertion; there is NO read path for this key anywhere in the module.
|
||||
|
||||
export const CCTV_CALIBRATION_STORAGE_KEY_V1 =
|
||||
'godsEyeView.cctv.calibration.v1';
|
||||
|
||||
/** v2 store key. Entries: { values: <7-field calibration offsets>, source: 'manual', savedAt: <epoch ms> }. */
|
||||
|
||||
export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
|
||||
'godsEyeView.cctv.calibration.v2';
|
||||
|
||||
// H5: throttle for double-buffered canvas texture swaps (<=1Hz; each swap is a
|
||||
// full 1080p texture re-upload because Cesium re-uploads only on a NEW image
|
||||
// object reference).
|
||||
|
||||
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
|
||||
|
||||
export const PROJECTION_VERT_ASPECT =
|
||||
PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT;
|
||||
|
||||
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
|
||||
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
export const FRUSTUM_GROUND_CLEARANCE_M = 2;
|
||||
|
||||
/** Public result codes for explicit CCTV camera flights. */
|
||||
|
||||
export const CCTV_FOCUS_RESULT = Object.freeze({
|
||||
FOCUSED: 'focused',
|
||||
NO_ACTIVE_CAMERA: 'no-active-camera',
|
||||
TRACKING_HOLDS_VIEW: 'tracking-holds-view',
|
||||
COCKPIT_ACTIVE: 'cockpit-active',
|
||||
});
|
||||
|
||||
// §9.1 activation obstruction probe: clamp the plane's effective range to just
|
||||
// short of the first pickFromRay hit along the frustum axis, with a floor so a
|
||||
// point-blank obstruction never collapses the frustum to zero. The floor is
|
||||
// the old H6 monitor's 8-15 m distance band: small enough that a pitched-down
|
||||
// camera whose axis meets the street ~25 m out still clamps SHORT of the hit
|
||||
// (a larger floor would push the plane back through the obstruction).
|
||||
|
||||
export const PROBE_CLEARANCE_M = 4;
|
||||
|
||||
export const PROBE_MIN_RANGE_M = 12;
|
||||
|
||||
// Bounded wait for the enable-time ground-prior batch: warm proxy disk cache
|
||||
// resolves in milliseconds; a cold/slow upstream must never hang layer init,
|
||||
// so past this budget init proceeds on catalog fallbacks and the batch applies
|
||||
// post-hoc (applyLateGroundPriors) when it lands.
|
||||
|
||||
export const GROUND_PRIOR_INIT_WAIT_MS = 8000;
|
||||
|
||||
/** Default calibration offsets — all zeroed, range scale 1x. */
|
||||
|
||||
export const DEFAULT_CAMERA_CALIBRATION = Object.freeze({
|
||||
offsetNorthM: 0,
|
||||
offsetEastM: 0,
|
||||
headingDeg: 0,
|
||||
pitchDeg: 0,
|
||||
fovDeg: 0,
|
||||
rangeScale: 1,
|
||||
heightM: 0,
|
||||
});
|
||||
|
||||
/** Base64-encoded SVG camera icon for billboard rendering. */
|
||||
|
||||
export const CAMERA_ICON = (() => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
|
||||
<defs>
|
||||
<linearGradient id="lens" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#c9f6ff"/>
|
||||
<stop offset="45%" stop-color="#6fd9ff"/>
|
||||
<stop offset="100%" stop-color="#1a5f78"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(4 6)">
|
||||
<rect x="0" y="8" width="20" height="9" rx="2.5" fill="#0e1720" stroke="#75e7ff" stroke-width="1.2"/>
|
||||
<rect x="17" y="10" width="10" height="5" rx="1.5" fill="#132433" stroke="#75e7ff" stroke-width="1"/>
|
||||
<circle cx="24" cy="12.5" r="3.1" fill="url(#lens)" stroke="#dbfbff" stroke-width="0.8"/>
|
||||
<rect x="6.4" y="17" width="4.2" height="8.5" rx="1.2" fill="#10212d" stroke="#75e7ff" stroke-width="1"/>
|
||||
<rect x="4.2" y="24" width="8.6" height="2.5" rx="1.1" fill="#0b151d" stroke="#4ecde7" stroke-width="0.8"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Seed camera definitions used when no live sources are available.
|
||||
* Each seed references a city from CITY_POIS and a POI index within that city,
|
||||
* plus offsets to place the camera near the POI.
|
||||
*/
|
||||
|
||||
export const CAMERA_SEEDS = [
|
||||
{
|
||||
id: 'nyc-midtown-w',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Midtown West @ 34th',
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: -70,
|
||||
headingDeg: 206,
|
||||
fovDeg: 74,
|
||||
rangeM: 880,
|
||||
elevationM: 26,
|
||||
},
|
||||
{
|
||||
id: 'nyc-wtc-n',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 2,
|
||||
label: 'WTC North Plaza',
|
||||
offsetNorthM: 95,
|
||||
offsetEastM: 34,
|
||||
headingDeg: 164,
|
||||
fovDeg: 68,
|
||||
rangeM: 760,
|
||||
elevationM: 32,
|
||||
},
|
||||
{
|
||||
id: 'nyc-times-square-ne',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Times Sq Northeast',
|
||||
offsetNorthM: 230,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 218,
|
||||
fovDeg: 66,
|
||||
rangeM: 640,
|
||||
elevationM: 24,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sf-market-5th',
|
||||
cityId: 'sf',
|
||||
poiIndex: 2,
|
||||
label: 'Market & 5th',
|
||||
offsetNorthM: -160,
|
||||
offsetEastM: 80,
|
||||
headingDeg: 320,
|
||||
fovDeg: 70,
|
||||
rangeM: 780,
|
||||
elevationM: 20,
|
||||
},
|
||||
{
|
||||
id: 'sf-financial-district',
|
||||
cityId: 'sf',
|
||||
poiIndex: 1,
|
||||
label: 'SF Financial Core',
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: 52,
|
||||
headingDeg: 205,
|
||||
fovDeg: 72,
|
||||
rangeM: 760,
|
||||
elevationM: 24,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'tokyo-shibuya-scramble',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 4,
|
||||
label: 'Shibuya Crossing',
|
||||
offsetNorthM: 180,
|
||||
offsetEastM: 46,
|
||||
headingDeg: 18,
|
||||
fovDeg: 82,
|
||||
rangeM: 640,
|
||||
elevationM: 30,
|
||||
},
|
||||
{
|
||||
id: 'tokyo-ginza-core',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 0,
|
||||
label: 'Ginza Core',
|
||||
offsetNorthM: -180,
|
||||
offsetEastM: 150,
|
||||
headingDeg: 245,
|
||||
fovDeg: 70,
|
||||
rangeM: 690,
|
||||
elevationM: 28,
|
||||
},
|
||||
{
|
||||
id: 'tokyo-asakusa-n',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 3,
|
||||
label: 'Asakusa North Gate',
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: -65,
|
||||
headingDeg: 192,
|
||||
fovDeg: 68,
|
||||
rangeM: 620,
|
||||
elevationM: 24,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'london-city-a1',
|
||||
cityId: 'london',
|
||||
poiIndex: 4,
|
||||
label: 'City Cluster A1',
|
||||
offsetNorthM: 80,
|
||||
offsetEastM: 65,
|
||||
headingDeg: 220,
|
||||
fovDeg: 71,
|
||||
rangeM: 720,
|
||||
elevationM: 27,
|
||||
},
|
||||
{
|
||||
id: 'london-soho-core',
|
||||
cityId: 'london',
|
||||
poiIndex: 2,
|
||||
label: 'Soho Core',
|
||||
offsetNorthM: 210,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 206,
|
||||
fovDeg: 70,
|
||||
rangeM: 700,
|
||||
elevationM: 22,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'paris-rivoli',
|
||||
cityId: 'paris',
|
||||
poiIndex: 4,
|
||||
label: 'Rue de Rivoli',
|
||||
offsetNorthM: 55,
|
||||
offsetEastM: 85,
|
||||
headingDeg: 248,
|
||||
fovDeg: 66,
|
||||
rangeM: 640,
|
||||
elevationM: 22,
|
||||
},
|
||||
{
|
||||
id: 'paris-champs-n',
|
||||
cityId: 'paris',
|
||||
poiIndex: 1,
|
||||
label: 'Champs-Élysées North',
|
||||
offsetNorthM: 130,
|
||||
offsetEastM: -38,
|
||||
headingDeg: 175,
|
||||
fovDeg: 68,
|
||||
rangeM: 700,
|
||||
elevationM: 26,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dc-mall-center',
|
||||
cityId: 'dc',
|
||||
poiIndex: 1,
|
||||
label: 'National Mall Center',
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: 20,
|
||||
headingDeg: 258,
|
||||
fovDeg: 78,
|
||||
rangeM: 940,
|
||||
elevationM: 24,
|
||||
},
|
||||
{
|
||||
id: 'dc-pentagon-s',
|
||||
cityId: 'dc',
|
||||
poiIndex: 3,
|
||||
label: 'Pentagon South',
|
||||
offsetNorthM: -100,
|
||||
offsetEastM: 92,
|
||||
headingDeg: 14,
|
||||
fovDeg: 66,
|
||||
rangeM: 620,
|
||||
elevationM: 21,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dubai-difc-loop',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 4,
|
||||
label: 'DIFC Loop',
|
||||
offsetNorthM: 92,
|
||||
offsetEastM: -45,
|
||||
headingDeg: 196,
|
||||
fovDeg: 70,
|
||||
rangeM: 720,
|
||||
elevationM: 26,
|
||||
},
|
||||
{
|
||||
id: 'dubai-downtown-east',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 0,
|
||||
label: 'Downtown East',
|
||||
offsetNorthM: -130,
|
||||
offsetEastM: 190,
|
||||
headingDeg: 322,
|
||||
fovDeg: 72,
|
||||
rangeM: 760,
|
||||
elevationM: 28,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'austin-congress-s',
|
||||
cityId: 'austin',
|
||||
poiIndex: 0,
|
||||
label: 'Congress Southbound',
|
||||
offsetNorthM: -165,
|
||||
offsetEastM: 40,
|
||||
headingDeg: 12,
|
||||
fovDeg: 74,
|
||||
rangeM: 760,
|
||||
elevationM: 24,
|
||||
},
|
||||
{
|
||||
id: 'austin-downtown-west',
|
||||
cityId: 'austin',
|
||||
poiIndex: 1,
|
||||
label: 'Downtown West',
|
||||
offsetNorthM: -120,
|
||||
offsetEastM: -160,
|
||||
headingDeg: 120,
|
||||
fovDeg: 69,
|
||||
rangeM: 700,
|
||||
elevationM: 20,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual style constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const IDLE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88);
|
||||
|
||||
export const ACTIVE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
|
||||
|
||||
export const IDLE_COVERAGE_COLOR =
|
||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24);
|
||||
|
||||
export const IDLE_COVERAGE_CENTER_MUTED =
|
||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2);
|
||||
|
||||
export const IDLE_COVERAGE_EDGE_MUTED =
|
||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.18);
|
||||
|
||||
export const ACTIVE_COVERAGE_EDGE =
|
||||
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58);
|
||||
|
||||
export const ACTIVE_COVERAGE_CENTER =
|
||||
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.82);
|
||||
|
||||
// H6: dimmer depth-fail materials let the active frustum wireframe read
|
||||
// through buildings while in monitor fallback mode.
|
||||
|
||||
export const ACTIVE_COVERAGE_EDGE_DEPTHFAIL =
|
||||
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.18);
|
||||
|
||||
export const ACTIVE_COVERAGE_CENTER_DEPTHFAIL =
|
||||
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26);
|
||||
|
||||
export const PLANE_OUTLINE_COLOR =
|
||||
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.55);
|
||||
|
||||
/**
|
||||
* Card budget while the staggered geometry drain is running — the raised
|
||||
* 20/28/40 tiers resume when loading completes (see refreshAmbientCards).
|
||||
*/
|
||||
|
||||
export const CCTV_AMBIENT_CARD_DRAIN_CAP = 16;
|
||||
|
||||
// Global static-frame pacing (owner finding 3): the pacer ticks at the burst
|
||||
// spacing (250 ms) but cardFetchPolicy gates launches — cold fill (selected
|
||||
// cards still missing their FIRST frame) allows up to 4 in-flight fetches at
|
||||
// 250 ms spacing; steady state keeps the salvaged Part C gate of at most one
|
||||
// request per second with an in-flight fetch blocking the tick, so slow
|
||||
// responses only lower the rate.
|
||||
|
||||
export const CARD_FETCH_TICK_MS = CCTV_CARD_FETCH_BURST_SPACING_MS;
|
||||
|
||||
// In-view margin so cards whose anchors sit just beyond an edge don't churn
|
||||
// while the operator makes minor camera adjustments (Part C recordIsInView).
|
||||
|
||||
export const CARD_VIEW_MARGIN = 0.06;
|
||||
|
||||
/** Card leader gap: clears the 24px icon (12px half + breathing room). */
|
||||
|
||||
export const CARD_GAP_PX = 16;
|
||||
|
||||
export const CCTV_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: CCTV_AMBIENT_CARD_MAX,
|
||||
collisionCapacity: CCTV_AMBIENT_CARD_MAX,
|
||||
moving: true,
|
||||
solveIntervalMs: 125,
|
||||
});
|
||||
|
||||
export const CCTV_PROJECTION_OVERLAY_SOURCE_ID = 'cctv-projection';
|
||||
|
||||
export const CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 0,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
// Hover-summoned card (owner round 2, item B): pointing at a cardless camera
|
||||
// icon shows its card immediately as a PINNED entry (budget-exempt, top
|
||||
// draw-pass declutter priority).
|
||||
/** Min spacing between hover scene.pick calls (event-driven, user gesture). */
|
||||
|
||||
export const HOVER_PICK_THROTTLE_MS = 120;
|
||||
|
||||
/** How long the hover card lingers after the pointer leaves the icon. */
|
||||
|
||||
export const HOVER_RELEASE_MS = 1_000;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* Builds a single-line summary string for the active camera, including city,
|
||||
* heading, FOV, coverage area, overlap count, projection mode, alignment
|
||||
* confidence, source type, and view context.
|
||||
* @returns {string} Summary text separated by mid-dots.
|
||||
*/
|
||||
|
||||
function buildSummaryText() {
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (!active) {
|
||||
return layerState._records.length
|
||||
? `${layerState._records.length} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE`
|
||||
: 'No cameras available in catalog.';
|
||||
}
|
||||
|
||||
const area = parts.model.sectorAreaKm2(
|
||||
active.camera.rangeM,
|
||||
active.camera.fovDeg,
|
||||
);
|
||||
const overlapCount = parts.geometry.coverageNeighborCount(active);
|
||||
const viewKey = parts.model.currentViewContext();
|
||||
const viewBand = viewKey.split(':')[0] || 'global';
|
||||
const health = layerState._healthById.get(active.camera.id) || null;
|
||||
const calBadge = parts.calibration.deriveCalBadge(active.camera);
|
||||
|
||||
return [
|
||||
`${active.camera.city.toUpperCase()} CCTV`,
|
||||
`${active.camera.name.toUpperCase()}`,
|
||||
`HDG ${Math.round(active.camera.headingDeg)}°`,
|
||||
`FOV ${Math.round(active.camera.fovDeg)}°`,
|
||||
`COVERAGE ${area.toFixed(2)}km²`,
|
||||
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
|
||||
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
|
||||
layerState._coverageMode === 'viewshed' ? 'VIEWSHED' : null,
|
||||
`CAL ${calBadge.replace('-', ' ').toUpperCase()}`,
|
||||
health?.sourceKind
|
||||
? `SRC ${String(health.sourceKind).toUpperCase()}`
|
||||
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
|
||||
`${viewBand.toUpperCase()} CONTEXT`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a public-facing camera state object for UI consumption.
|
||||
* Includes all pose, calibration, CAL badge, projection, and feed metadata.
|
||||
* @param {Object} record - Camera record.
|
||||
* @param {string|null} [activeId=null] - Active camera ID for the `active` flag.
|
||||
* @returns {Object} Public camera state.
|
||||
*/
|
||||
|
||||
function getPublicCameraState(record, activeId = null) {
|
||||
const resolvedActiveId =
|
||||
activeId || parts.selection.getActiveRecord()?.camera.id || null;
|
||||
const camera = record.camera;
|
||||
const health = layerState._healthById.get(camera.id) || null;
|
||||
const isActive = camera.id === resolvedActiveId;
|
||||
const refreshMs = isActive
|
||||
? ACTIVE_FRAME_REFRESH_MS
|
||||
: IDLE_FRAME_REFRESH_MS;
|
||||
return {
|
||||
id: camera.id,
|
||||
name: camera.name,
|
||||
city: camera.city,
|
||||
provider: camera.provider,
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
headingDeg: camera.headingDeg,
|
||||
pitchDeg: camera.pitchDeg,
|
||||
fovDeg: camera.fovDeg,
|
||||
rangeM: camera.rangeM,
|
||||
elevationM: camera.absoluteHeightM,
|
||||
mountHeightM: camera.mountHeightM,
|
||||
active: isActive,
|
||||
feedType: camera.feedType,
|
||||
sourceKind:
|
||||
health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
(camera.feedConfigured ? 'configured' : 'seed'),
|
||||
sourceStatus: health?.status || 'unknown',
|
||||
sourceMessage: health?.message || '',
|
||||
sourceLabel: health?.label || camera.provider || '',
|
||||
calibration: {
|
||||
...parts.calibration.normalizeCalibration(camera.calibration),
|
||||
},
|
||||
// Save-gated persistence (design §3e): true while the live pose carries
|
||||
// edits that have not been SAVEd (or RESET). Drives the CAL · EDITED chip.
|
||||
calDirty: !!record.calDirty,
|
||||
// Deterministic QA seam: counts commit-grade anchor resolutions (E/N drag
|
||||
// release, numeric E/N edit, or reset), never transient gizmo moves.
|
||||
groundResolveCount: record.calibrationGroundResolveCount || 0,
|
||||
// Per-record QA seam for proving transient gizmo moves never enter the
|
||||
// shared mesh-floor sampler while unrelated catalog cells finish.
|
||||
groundMeshSampleRequestCount: record.groundMeshSampleRequestCount || 0,
|
||||
// Datum QA seam: expose the immutable Re:Earth ellipsoidal prior
|
||||
// separately from the currently applied frustum ground. Google-3D may
|
||||
// legitimately refine the latter to the rendered mesh, so callers must
|
||||
// not infer the prior by subtracting mount height from live geometry.
|
||||
groundPriorM: Number.isFinite(record.groundPrior?.ellipsoid)
|
||||
? record.groundPrior.ellipsoid
|
||||
: null,
|
||||
intrinsics: camera.intrinsics ? { ...camera.intrinsics } : null,
|
||||
extrinsics: camera.extrinsics ? { ...camera.extrinsics } : null,
|
||||
anchor: camera.anchor ? { ...camera.anchor } : null,
|
||||
// Panel-only trust signal (design §3b, amended by LOCKED §9.2/§9.3): no
|
||||
// in-world rendering reads this, no score-based quality math backs it.
|
||||
calBadge: parts.calibration.deriveCalBadge(camera),
|
||||
poseSource: camera.poseSource || null,
|
||||
basePose: camera.basePose ? { ...camera.basePose } : null,
|
||||
frameUrl: parts.frames.frameUrlFor(camera, refreshMs),
|
||||
mediaUrl: parts.frames.mediaUrlFor(camera),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the full UI state payload containing layer toggles, camera list,
|
||||
* active camera details, summary text, and error state.
|
||||
* @returns {Object} Complete UI state for subscribers.
|
||||
*/
|
||||
|
||||
function uiState() {
|
||||
const active = parts.selection.getActiveRecord();
|
||||
const activeId = active?.camera.id || null;
|
||||
const payload = {
|
||||
enabled: layerState._enabled,
|
||||
// Compat boolean + the full tri-state (viewshed design §3b).
|
||||
showCoverage: layerState._coverageMode !== 'off',
|
||||
coverageMode: layerState._coverageMode,
|
||||
showProjection: layerState._showProjection,
|
||||
calibrationMode: layerState._calibrationMode,
|
||||
autoHop: layerState._autoHop,
|
||||
autoHopSuspended: layerState._autoHopSuspended,
|
||||
autoHopSec: layerState._autoHopSec,
|
||||
count: layerState._count,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
loading: {
|
||||
active: layerState._geoLoading,
|
||||
loaded: Math.min(layerState._geoLoadDone, layerState._geoLoadTotal),
|
||||
total: layerState._geoLoadTotal,
|
||||
},
|
||||
// Ambient card tier telemetry (QA harnesses assert the fetch pacing —
|
||||
// minFrameFetchSpacingMs reads together with fetchMode: cold-fill bursts
|
||||
// legitimately reach ~250 ms, steady state stays >=1000 ms).
|
||||
ambientCards: {
|
||||
count: layerState._cardIds.size,
|
||||
limit: CCTV_AMBIENT_CARD_MAX,
|
||||
frameFetches: layerState._cardFetchCount,
|
||||
minFrameFetchSpacingMs: layerState._cardMinFetchSpacingMs,
|
||||
fetchMode: layerState._cardFetchMode,
|
||||
fetchesInFlight: layerState._cardFetchInFlightCount,
|
||||
// Item B QA seam: the hover-summoned pinned card, if any.
|
||||
hoverId: layerState._hoverCardId,
|
||||
},
|
||||
activeCameraId: activeId,
|
||||
activeCamera: active ? getPublicCameraState(active, activeId) : null,
|
||||
cameras: layerState._records.map((record) =>
|
||||
getPublicCameraState(record, activeId),
|
||||
),
|
||||
summary: buildSummaryText(),
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Dispatches the current UI state to all registered subscriber callbacks. */
|
||||
|
||||
function notifyListeners() {
|
||||
const payload = uiState();
|
||||
for (const callback of layerState._listeners) {
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (error) {
|
||||
console.warn('[Data:CCTV] listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled notifyListeners for transient (mid-drag) calibration patches —
|
||||
* the panel re-render is DOM-heavy, so live gizmo drags publish state at
|
||||
* ≤10 Hz while the in-world geometry still tracks every processed move.
|
||||
*/
|
||||
|
||||
function notifyListenersThrottled() {
|
||||
const now = Date.now();
|
||||
if (now - layerState._lastTransientNotifyAt < 100) return;
|
||||
layerState._lastTransientNotifyAt = now;
|
||||
notifyListeners();
|
||||
}
|
||||
return {
|
||||
buildSummaryText,
|
||||
getPublicCameraState,
|
||||
uiState,
|
||||
notifyListeners,
|
||||
notifyListenersThrottled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
PLANE_OUTLINE_COLOR,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
} from './policy.js';
|
||||
|
||||
export function createProjection({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* Build the protected label associated with one active monitor plane.
|
||||
* @param {{cameraId: string, name: string, position: Cesium.Cartesian3|Function}} input
|
||||
* @returns {Object} Shared-host presentation entry.
|
||||
*/
|
||||
|
||||
function createCctvProjectionOverlayEntry({ cameraId, name, position }) {
|
||||
return {
|
||||
id: String(cameraId),
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER - 1,
|
||||
title: String(name || cameraId || 'CAMERA'),
|
||||
details: [],
|
||||
accent: '#6be8ff',
|
||||
interactive: false,
|
||||
gapPx: 6,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives the monitor plane entity's placement (position, orientation,
|
||||
* dimensions) + label from the record's current frustum geometry, so the plane
|
||||
* always caps the wireframe exactly (corner rays terminate on its corners).
|
||||
* No-op when the record has no plane runtime (idle neighbors have no plane).
|
||||
* @param {Object} record - Camera record.
|
||||
*/
|
||||
|
||||
function updatePlanePlacement(record) {
|
||||
const runtime = record?.projection;
|
||||
if (!runtime?.planeEntity) return;
|
||||
const geometry =
|
||||
record.frustumGeometry ||
|
||||
parts.geometry.computeFrustumGeometry(
|
||||
record.camera,
|
||||
parts.ground.groundAltFor(record),
|
||||
record.probeClampRangeM,
|
||||
);
|
||||
const positions =
|
||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||
runtime.planeEntity.position = positions.capCenter;
|
||||
runtime.planeEntity.orientation = parts.model.planeOrientationFor(
|
||||
record.camera,
|
||||
positions.capCenter,
|
||||
);
|
||||
if (runtime.planeEntity.plane) {
|
||||
runtime.planeEntity.plane.dimensions = new Cesium.Cartesian2(
|
||||
geometry.halfW * 2,
|
||||
geometry.halfH * 2,
|
||||
);
|
||||
}
|
||||
if (runtime.labelPosition) {
|
||||
Cesium.Cartesian3.clone(positions.label, runtime.labelPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active monitor-plane label source and ownership marker.
|
||||
*/
|
||||
|
||||
function clearProjectionOverlay() {
|
||||
layerState._cctvOverlayHost.clearSource(CCTV_PROJECTION_OVERLAY_SOURCE_ID);
|
||||
layerState._cctvOverlayHost.setVisible(
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
layerState._projectionOverlayOwnerId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows/hides the monitor plane and its associated shared-host label.
|
||||
* @param {Object} runtime - Projection runtime.
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
|
||||
function setPlaneVisible(runtime, visible) {
|
||||
if (!runtime) return;
|
||||
if (runtime.planeEntity) runtime.planeEntity.show = !!visible;
|
||||
if (visible && runtime.overlayEntry && runtime.cameraId) {
|
||||
if (layerState._projectionOverlayOwnerId !== runtime.cameraId) {
|
||||
layerState._cctvOverlayHost.setEntries(
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
[runtime.overlayEntry],
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
layerState._cctvOverlayHost.setVisible(
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
true,
|
||||
);
|
||||
layerState._projectionOverlayOwnerId = runtime.cameraId;
|
||||
}
|
||||
} else if (layerState._projectionOverlayOwnerId === runtime.cameraId) {
|
||||
clearProjectionOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the native monitor plane plus its cached host-label presentation. */
|
||||
|
||||
function createProjectionPlane(record, runtime, geometry, positions) {
|
||||
runtime.labelPosition ||= new Cesium.Cartesian3();
|
||||
Cesium.Cartesian3.clone(positions.label, runtime.labelPosition);
|
||||
runtime.cameraId = String(record.camera.id);
|
||||
runtime.overlayEntry = createCctvProjectionOverlayEntry({
|
||||
cameraId: runtime.cameraId,
|
||||
name: record.camera.name,
|
||||
position: () => runtime.labelPosition,
|
||||
});
|
||||
runtime.planeEntity = layerState._viewer.entities.add({
|
||||
id: `cctv-${record.camera.id}-plane`,
|
||||
properties: { cctvCameraId: record.camera.id },
|
||||
show: false,
|
||||
position: positions.capCenter,
|
||||
orientation: parts.model.planeOrientationFor(
|
||||
record.camera,
|
||||
positions.capCenter,
|
||||
),
|
||||
plane: {
|
||||
plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0),
|
||||
dimensions: new Cesium.Cartesian2(
|
||||
geometry.halfW * 2,
|
||||
geometry.halfH * 2,
|
||||
),
|
||||
material: runtime.planeMaterial,
|
||||
outline: true,
|
||||
outlineColor: PLANE_OUTLINE_COLOR,
|
||||
},
|
||||
});
|
||||
return runtime.planeEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the projection runtime for a camera record: an offscreen canvas,
|
||||
* the monitor plane plus associated host label, and either an
|
||||
* <img> or <video> element depending on the feed type.
|
||||
*
|
||||
* The plane is the only projection representation (v2): the frustum's far cap,
|
||||
* perpendicular to the view axis (§2b — never billboarded; a wall primitive
|
||||
* can't pitch, the plane can). It is textured with the live frame: video
|
||||
* element direct, canvas double-buffer otherwise.
|
||||
*
|
||||
* @param {Object} record - Camera record.
|
||||
* @returns {Object|null} Projection runtime, or null if no viewer.
|
||||
*/
|
||||
|
||||
function createProjectionRuntime(record) {
|
||||
if (!layerState._viewer) return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = PROJECTION_CANVAS_WIDTH;
|
||||
canvas.height = PROJECTION_CANVAS_HEIGHT;
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
|
||||
const feedType = parts.model.normalizeFeedType(record.camera.feedType);
|
||||
const mode = parts.model.isVideoFeedType(feedType) ? 'video' : 'image';
|
||||
const runtime = {
|
||||
mode,
|
||||
canvas,
|
||||
ctx,
|
||||
image: null,
|
||||
video: null,
|
||||
planeEntity: null,
|
||||
cameraId: String(record.camera.id),
|
||||
labelPosition: new Cesium.Cartesian3(),
|
||||
overlayEntry: null,
|
||||
planeMaterial: null,
|
||||
buffers: null,
|
||||
bufferIndex: 0,
|
||||
lastTextureSwapAt: 0,
|
||||
lastImageRefreshAt: 0,
|
||||
imageReady: false,
|
||||
imageLoading: false,
|
||||
imageStamp: 0,
|
||||
drawnImageStamp: -1,
|
||||
// Signature of the pixels currently ON the canvas, plus the reused 64x36
|
||||
// scratch used to compute it. null = "nothing known", which always redraws.
|
||||
lastFrameSignature: null,
|
||||
signatureCanvas: null,
|
||||
signatureCtx: null,
|
||||
lastPlaceholderPaintAt: 0,
|
||||
// canvasStamp increments on every canvas write (frame blit / placeholder
|
||||
// paint); lastSwappedCanvasStamp trails it so refreshProjectionTextures
|
||||
// only re-uploads the plane texture when there is genuinely new content.
|
||||
canvasStamp: 1,
|
||||
lastSwappedCanvasStamp: 0,
|
||||
};
|
||||
|
||||
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
|
||||
|
||||
if (mode === 'video') {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'auto';
|
||||
video.src = parts.frames.mediaUrlFor(record.camera);
|
||||
video.addEventListener('canplay', () => {
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
runtime.video = video;
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = true;
|
||||
runtime.imageStamp = Date.now();
|
||||
};
|
||||
img.onerror = () => {
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = false;
|
||||
};
|
||||
runtime.image = img;
|
||||
}
|
||||
|
||||
// Monitor plane = the frustum's far cap: video feeds bind the video element
|
||||
// directly (Cesium updates video-backed entity materials per frame); image
|
||||
// feeds start on the placeholder canvas and switch to double-buffer swaps
|
||||
// at <=1Hz.
|
||||
const geometry =
|
||||
record.frustumGeometry ||
|
||||
parts.geometry.computeFrustumGeometry(
|
||||
record.camera,
|
||||
parts.ground.groundAltFor(record),
|
||||
record.probeClampRangeM,
|
||||
);
|
||||
const positions =
|
||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
|
||||
image: mode === 'video' && runtime.video ? runtime.video : canvas,
|
||||
transparent: true,
|
||||
color: Cesium.Color.WHITE.withAlpha(0.95),
|
||||
});
|
||||
createProjectionPlane(record, runtime, geometry, positions);
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily initializes the projection runtime for a record if it doesn't exist yet.
|
||||
* @param {Object} record - Camera record.
|
||||
* @returns {Object|null} The record's projection runtime.
|
||||
*/
|
||||
|
||||
function ensureProjectionRuntime(record) {
|
||||
if (!record) return null;
|
||||
if (record.projection) return record.projection;
|
||||
const runtime = createProjectionRuntime(record);
|
||||
record.projection = runtime;
|
||||
if (runtime) {
|
||||
layerState._projectionEntities.push(runtime);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down a projection runtime: stops video playback, removes the monitor
|
||||
* plane, and clears its host label if it owns the active source.
|
||||
* @param {Object} runtime - Projection runtime to destroy.
|
||||
*/
|
||||
|
||||
function destroyProjectionRuntime(runtime) {
|
||||
if (!runtime) return;
|
||||
if (runtime.video) {
|
||||
runtime.video.pause();
|
||||
runtime.video.removeAttribute('src');
|
||||
runtime.video.load();
|
||||
}
|
||||
if (runtime.planeEntity && layerState._viewer) {
|
||||
layerState._viewer.entities.remove(runtime.planeEntity);
|
||||
runtime.planeEntity = null;
|
||||
}
|
||||
if (layerState._projectionOverlayOwnerId === runtime.cameraId)
|
||||
clearProjectionOverlay();
|
||||
runtime.overlayEntry = null;
|
||||
runtime.labelPosition = null;
|
||||
runtime.planeMaterial = null;
|
||||
}
|
||||
|
||||
function startProjectionLoop() {
|
||||
if (layerState._projectionRaf) return;
|
||||
if (!parts.model.projectionLoopIsNeeded()) return;
|
||||
// The armed projection loop uploads video textures / runs focus fades per
|
||||
// frame — the scene must render continuously while it runs. Released when
|
||||
// the tick self-stops. (perf wave 2)
|
||||
holdContinuousRender('cctv-projection');
|
||||
|
||||
const tick = () => {
|
||||
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
|
||||
layerState._projectionRaf = 0;
|
||||
releaseContinuousRender('cctv-projection');
|
||||
return;
|
||||
}
|
||||
|
||||
parts.rendering.refreshCctvFocusStyles(performance.now());
|
||||
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (layerState._enabled && layerState._showProjection && active) {
|
||||
ensureProjectionRuntime(active);
|
||||
if (active.projection?.video) {
|
||||
active.projection.video.play().catch(() => {});
|
||||
}
|
||||
if (active.projection) {
|
||||
parts.frames.drawProjectionFrame(active);
|
||||
parts.frames.refreshProjectionTextures(active);
|
||||
}
|
||||
}
|
||||
|
||||
layerState._projectionRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
layerState._projectionRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
/** Cancels the projection animation loop. */
|
||||
|
||||
function stopProjectionLoop() {
|
||||
if (layerState._projectionRaf) {
|
||||
cancelAnimationFrame(layerState._projectionRaf);
|
||||
layerState._projectionRaf = 0;
|
||||
}
|
||||
releaseContinuousRender('cctv-projection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses video playback on all non-active camera projections and resumes
|
||||
* the active one (if projection is enabled).
|
||||
* @param {string|null} activeId - ID of the currently active camera.
|
||||
*/
|
||||
|
||||
function pauseInactiveProjectionFeeds(activeId) {
|
||||
for (const record of layerState._records) {
|
||||
if (!record.projection?.video) continue;
|
||||
if (
|
||||
record.camera.id === activeId &&
|
||||
layerState._enabled &&
|
||||
layerState._showProjection
|
||||
) {
|
||||
record.projection.video.play().catch(() => {});
|
||||
} else {
|
||||
record.projection.video.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
createCctvProjectionOverlayEntry,
|
||||
updatePlanePlacement,
|
||||
clearProjectionOverlay,
|
||||
setPlaneVisible,
|
||||
createProjectionPlane,
|
||||
createProjectionRuntime,
|
||||
ensureProjectionRuntime,
|
||||
destroyProjectionRuntime,
|
||||
startProjectionLoop,
|
||||
stopProjectionLoop,
|
||||
pauseInactiveProjectionFeeds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
ACTIVE_COVERAGE_CENTER,
|
||||
IDLE_COVERAGE_CENTER_MUTED,
|
||||
ACTIVE_COVERAGE_CENTER_DEPTHFAIL,
|
||||
ACTIVE_COVERAGE_EDGE,
|
||||
IDLE_COVERAGE_EDGE_MUTED,
|
||||
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
|
||||
} from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const {
|
||||
focusNowMs,
|
||||
getFocusTarget,
|
||||
focusPassIsNeeded,
|
||||
advanceSpriteFocus,
|
||||
focusAlphaNeedsWrite,
|
||||
} = services.focus;
|
||||
|
||||
/**
|
||||
* Focus modulation rides the layer's existing animation loop; no additional
|
||||
* scene listener is installed. Only camera-icon alpha changes here — coverage
|
||||
* geometry and monitor-plane styling retain their established cadence.
|
||||
*/
|
||||
|
||||
function refreshCctvFocusStyles(nowMs) {
|
||||
nowMs = focusNowMs(nowMs);
|
||||
const target = getFocusTarget();
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._viewer ||
|
||||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
||||
)
|
||||
return;
|
||||
if (nowMs - layerState._lastFocusStyleAt < 80) return;
|
||||
layerState._lastFocusStyleAt = nowMs;
|
||||
const scene = layerState._viewer.scene;
|
||||
const camera = layerState._viewer.camera;
|
||||
const result = applyCctvFocusDeemphasis({
|
||||
records: layerState._records,
|
||||
target,
|
||||
previousActiveCount: layerState._activeFocusStyleCount,
|
||||
nowMs,
|
||||
screenPositionFor: (position) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
layerState._scratchFocusScreen,
|
||||
),
|
||||
cameraDistanceFor: (position) =>
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
baseColorFor: (record) =>
|
||||
record.camera.id === layerState._activeCameraId
|
||||
? ACTIVE_CAMERA_COLOR
|
||||
: IDLE_CAMERA_COLOR,
|
||||
});
|
||||
layerState._activeFocusStyleCount = result.activeCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the gated CCTV focus pass through the production color path.
|
||||
* @param {object} input
|
||||
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
|
||||
*/
|
||||
|
||||
function applyCctvFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
previousActiveCount = 0,
|
||||
nowMs,
|
||||
screenPositionFor,
|
||||
cameraDistanceFor,
|
||||
baseColorFor,
|
||||
params,
|
||||
}) {
|
||||
if (!focusPassIsNeeded(target, previousActiveCount)) {
|
||||
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
|
||||
}
|
||||
let writes = 0;
|
||||
let transitioning = false;
|
||||
let activeCount = 0;
|
||||
for (const record of records || []) {
|
||||
const bb = record.billboard;
|
||||
if (!bb) continue;
|
||||
const position = bb.position;
|
||||
// CCTV never publishes a tracked focus target, so every icon is ambient.
|
||||
const focus = advanceSpriteFocus(bb, {
|
||||
// Keep hidden icons in the state/release pass so the active count cannot
|
||||
// drop while a stale dim alpha remains waiting to reappear.
|
||||
screenPosition:
|
||||
bb.show === false || !position ? null : screenPositionFor(position),
|
||||
cameraDistance: position ? cameraDistanceFor(position) : Number.NaN,
|
||||
nowMs,
|
||||
target,
|
||||
params,
|
||||
spriteHalfWidthPx: (bb.width || 24) * (bb.scale || 1) * 0.5,
|
||||
spriteHalfHeightPx: (bb.height || 24) * (bb.scale || 1) * 0.5,
|
||||
});
|
||||
transitioning ||= focus.transitioning;
|
||||
if (focus.active) activeCount += 1;
|
||||
const base = baseColorFor(record);
|
||||
const alpha = base.alpha * focus.factor;
|
||||
if (focusAlphaNeedsWrite(bb.color?.alpha, alpha, params)) {
|
||||
// Order-independent narrow amendment to always-visible icons: CCTV
|
||||
// contacts retain a non-zero floor while yielding near the tracked target.
|
||||
bb.color = base.withAlpha(alpha);
|
||||
writes += 1;
|
||||
}
|
||||
}
|
||||
return { writes, transitioning, activeCount, ran: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates visual styles (colors, widths, visibility) for all camera billboards,
|
||||
* coverage polylines, viewshed volumes, and projection entities based on which
|
||||
* camera is active, whether the layer is enabled, and the current
|
||||
* coverage-mode/projection toggle states.
|
||||
*/
|
||||
/**
|
||||
* Field-test fix (2026-07-06): horizon-culls camera billboards, mirroring the
|
||||
* flights layer's EllipsoidalOccluder pass. With the Cesium globe hidden
|
||||
* (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).
|
||||
*/
|
||||
|
||||
function refreshHorizonCulling() {
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
layerState._viewer.isDestroyed() ||
|
||||
!layerState._records.length
|
||||
)
|
||||
return;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
for (const record of layerState._records) {
|
||||
const bb = record.billboard;
|
||||
if (!bb) continue;
|
||||
const visible = occluder.isPointVisible(bb.position);
|
||||
if (bb.show !== visible) bb.show = visible;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides every per-record CCTV visual without recomputing coverage membership or
|
||||
* styles. Disabling makes every visibility branch false, so a direct sweep is
|
||||
* sufficient; live viewshed primitives still require explicit destruction.
|
||||
*
|
||||
* @param {Object[]} records CCTV runtime records.
|
||||
* @param {(record: Object) => void} destroyVolume Viewshed teardown callback.
|
||||
* @param {string|null} [activeCameraId=null] Active camera whose activation probe must be re-armed.
|
||||
*/
|
||||
|
||||
function hideCctvRecordVisuals(
|
||||
records,
|
||||
destroyVolume,
|
||||
activeCameraId = null,
|
||||
) {
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
if (record) {
|
||||
record.probeClampRangeM = null;
|
||||
if (record.camera?.id === activeCameraId) record.activationDone = false;
|
||||
}
|
||||
for (const entity of record?.coverageEntities || []) entity.show = false;
|
||||
if (record?.viewshedPrimitive) destroyVolume?.(record);
|
||||
if (record?.projection?.planeEntity)
|
||||
record.projection.planeEntity.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hideCctvVisuals() {
|
||||
hideCctvRecordVisuals(
|
||||
layerState._records,
|
||||
parts.geometry.destroyViewshedVolume,
|
||||
layerState._activeCameraId,
|
||||
);
|
||||
parts.projection.clearProjectionOverlay();
|
||||
if (layerState._billboards) layerState._billboards.show = false;
|
||||
parts.projection.pauseInactiveProjectionFeeds(null);
|
||||
}
|
||||
|
||||
/** Applies coverage visibility/style state and lazily builds eligible sets. */
|
||||
|
||||
function refreshCoverageStyles() {
|
||||
const activeRecord = parts.selection.getActiveRecord();
|
||||
parts.geometry.ensureActiveCoverageEntities(activeRecord);
|
||||
const activeId = activeRecord?.camera.id || null;
|
||||
const coverageVisible =
|
||||
parts.geometry.buildCoverageVisibleSet(activeRecord);
|
||||
const coverageOn = layerState._coverageMode !== 'off';
|
||||
const viewshedOn = layerState._coverageMode === 'viewshed';
|
||||
if (coverageOn) {
|
||||
parts.geometry.ensureVisibleCoverageEntities(
|
||||
layerState._records,
|
||||
coverageVisible,
|
||||
);
|
||||
}
|
||||
for (const record of layerState._records) {
|
||||
const isActive = record.camera.id === activeId;
|
||||
if (record.billboard) {
|
||||
record.billboard.color = isActive
|
||||
? ACTIVE_CAMERA_COLOR
|
||||
: IDLE_CAMERA_COLOR;
|
||||
record.billboard.scale = isActive ? 1.25 : 1.0;
|
||||
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||
}
|
||||
|
||||
if (layerState._enabled && layerState._showProjection && isActive) {
|
||||
parts.projection.ensureProjectionRuntime(record);
|
||||
}
|
||||
// One live plane in the world at a time (§2c): only the active camera's
|
||||
// far cap carries the monitor plane; idle neighbors get the faint
|
||||
// wireframe only.
|
||||
const planeShowing = !!(
|
||||
layerState._enabled &&
|
||||
layerState._showProjection &&
|
||||
isActive
|
||||
);
|
||||
|
||||
const inVisibleSet = coverageVisible.has(record.camera.id);
|
||||
for (const entity of record.coverageEntities || []) {
|
||||
// The frustum wireframe is part of the projection representation —
|
||||
// force it on for the active camera and let it read through geometry
|
||||
// via depthFailMaterial (polylines have no disableDepthTestDistance).
|
||||
entity.show = !!(
|
||||
layerState._enabled &&
|
||||
((coverageOn && inVisibleSet) || planeShowing)
|
||||
);
|
||||
if (!entity.polyline) continue;
|
||||
// Viewshed mode swaps the cyan/green scheme for the camera's own hue so
|
||||
// adjacent cones read as distinct coverage claims (design §3b); the
|
||||
// active camera keeps its width/alpha emphasis in both schemes.
|
||||
const hue = viewshedOn ? record.viewshedColors : null;
|
||||
if (entity._coverageRole === 'cap') {
|
||||
entity.polyline.material = hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_CENTER
|
||||
: IDLE_COVERAGE_CENTER_MUTED;
|
||||
entity.polyline.width = isActive ? 2.2 : 1.0;
|
||||
entity.polyline.depthFailMaterial = planeShowing
|
||||
? hue
|
||||
? hue.line.withAlpha(0.26)
|
||||
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL
|
||||
: undefined;
|
||||
} else {
|
||||
entity.polyline.material = hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line.withAlpha(0.6)
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_EDGE
|
||||
: IDLE_COVERAGE_EDGE_MUTED;
|
||||
entity.polyline.width = isActive ? 1.8 : 0.9;
|
||||
entity.polyline.depthFailMaterial = planeShowing
|
||||
? hue
|
||||
? hue.line.withAlpha(0.18)
|
||||
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the
|
||||
// visible set. Rebuild on active-tint flips (rare); otherwise leave the
|
||||
// primitive alone so idle refreshes never churn geometry.
|
||||
const wantVolume = !!(
|
||||
layerState._enabled &&
|
||||
viewshedOn &&
|
||||
inVisibleSet &&
|
||||
record.frustumPositions
|
||||
);
|
||||
if (wantVolume) {
|
||||
if (
|
||||
!record.viewshedPrimitive ||
|
||||
record.viewshedActiveTint !== isActive
|
||||
) {
|
||||
parts.geometry.rebuildViewshedVolume(record, isActive);
|
||||
}
|
||||
} else if (record.viewshedPrimitive) {
|
||||
parts.geometry.destroyViewshedVolume(record);
|
||||
}
|
||||
|
||||
if (record.projection) {
|
||||
parts.projection.setPlaneVisible(record.projection, planeShowing);
|
||||
}
|
||||
}
|
||||
|
||||
if (layerState._billboards)
|
||||
layerState._billboards.show = !!layerState._enabled;
|
||||
parts.projection.pauseInactiveProjectionFeeds(activeId);
|
||||
}
|
||||
return {
|
||||
refreshCctvFocusStyles,
|
||||
applyCctvFocusDeemphasis,
|
||||
refreshHorizonCulling,
|
||||
hideCctvRecordVisuals,
|
||||
hideCctvVisuals,
|
||||
refreshCoverageStyles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
bindTrackingClickGesture,
|
||||
isTrackingClickGesture,
|
||||
} from '../../data/trackingClickGesture.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { CCTV_ACTIVATION_RESULT } = services.activation;
|
||||
const { resolvePickId } = services.picking;
|
||||
|
||||
/**
|
||||
* Returns the camera record for the currently active camera. A stale ID falls
|
||||
* back to the first record, but an intentional null remains an honest
|
||||
* deselected state.
|
||||
* @returns {Object|null} Active camera record, or null when none is active.
|
||||
*/
|
||||
|
||||
function getActiveRecord() {
|
||||
if (!layerState._activeCameraId) return null;
|
||||
if (layerState._recordById.has(layerState._activeCameraId)) {
|
||||
return layerState._recordById.get(layerState._activeCameraId);
|
||||
}
|
||||
// Sync _activeCameraId when falling back to first record to prevent ID mismatch
|
||||
const fallback = layerState._records[0] || null;
|
||||
if (fallback && fallback.camera?.id) {
|
||||
layerState._activeCameraId = fallback.camera.id;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind CCTV activation to clean taps while preserving the layer's hover-move
|
||||
* callback on the shared Cesium handler. Drag-like and long-press gestures do
|
||||
* not reach camera activation or focus dispatch.
|
||||
* @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler.
|
||||
* @param {(click: Object) => void} onClick - Accepted CCTV click callback.
|
||||
* @param {Object} [options] - Gesture test seams and optional onMouseMove hook.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function bindCctvWorldClickGesture(handler, onClick, options = {}) {
|
||||
bindTrackingClickGesture(
|
||||
handler,
|
||||
(click, gesture) => {
|
||||
if (!isTrackingClickGesture(gesture)) return;
|
||||
onClick(click);
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active camera by ID, initializes its projection runtime, refreshes
|
||||
* its frame, and updates styles.
|
||||
* @param {string} cameraId - ID of the camera to activate.
|
||||
* @returns {'activated'|'unchanged'|'not-found'} Discriminated activation result.
|
||||
*/
|
||||
|
||||
function setActiveCamera(cameraId) {
|
||||
if (!cameraId || !layerState._recordById.has(cameraId))
|
||||
return CCTV_ACTIVATION_RESULT.NOT_FOUND;
|
||||
const record = layerState._recordById.get(cameraId);
|
||||
const previousActiveRecord = getActiveRecord();
|
||||
// Re-selecting the already-active camera is a no-op: re-running the
|
||||
// activation path re-probes and rewrites the plane entity's geometry, and
|
||||
// that async primitive rebuild visibly flashes the monitor plane (owner
|
||||
// field test 2026-07-04 — every click ON the plane picks its own camera).
|
||||
// `activationDone` distinguishes a real activation from the enable()-time
|
||||
// default `_activeCameraId` assignment, which never ran this path.
|
||||
if (
|
||||
!parts.model.cctvRecordNeedsActivation(
|
||||
cameraId,
|
||||
layerState._activeCameraId,
|
||||
record,
|
||||
)
|
||||
) {
|
||||
return CCTV_ACTIVATION_RESULT.UNCHANGED;
|
||||
}
|
||||
layerState._activeCameraId = cameraId;
|
||||
layerState._autoHopSuspended = false;
|
||||
// A real activation creates projection work — wake the self-stopping loop.
|
||||
parts.projection.startProjectionLoop();
|
||||
if (previousActiveRecord && previousActiveRecord !== record) {
|
||||
parts.geometry.clearProbeClampOnDeactivation(
|
||||
previousActiveRecord,
|
||||
(previous) => {
|
||||
parts.geometry.applyFrustumGeometry(
|
||||
previous,
|
||||
parts.ground.groundAltFor(previous),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
// The clicked camera leaves the ambient quota immediately (never graced).
|
||||
// Shipped behavior publishes no card for it because the monitor plane is
|
||||
// now the active representation; the opt-in protected-card path republishes
|
||||
// it synchronously through refreshAmbientCards() below.
|
||||
layerState._cardIds.delete(cameraId);
|
||||
layerState._cardGraceState.delete(cameraId);
|
||||
// Activating the hovered camera consumes the transient pin; the active
|
||||
// monitor plane replaces it from this moment.
|
||||
if (layerState._hoverCardId === cameraId) parts.hover.clearHoverCard();
|
||||
// If the record's geometry refinement is still queued, jump it to the front
|
||||
// so the newly active camera resolves before idle neighbors.
|
||||
const queueIdx = layerState._geoQueue.indexOf(record);
|
||||
if (queueIdx > 0) {
|
||||
layerState._geoQueue.splice(queueIdx, 1);
|
||||
layerState._geoQueue.unshift(record);
|
||||
}
|
||||
// §9.1: one obstruction probe per activation, BEFORE the geometry pass so
|
||||
// the range clamp lands in the same rewrite. A FIRST-EVER activation (no
|
||||
// real ground sample yet) probes from the catalog-prior altitude — if that
|
||||
// prior is off, the clamp is measured from a shifted origin, but a
|
||||
// re-activation after the one-shot snap lands re-probes from real ground,
|
||||
// so it self-corrects.
|
||||
parts.geometry.runActivationObstructionProbe(record);
|
||||
// Coverage is activation-lazy even while the mode is OFF: the active
|
||||
// camera's projection representation must be ready without materializing
|
||||
// any idle neighbor. The following geometry rewrite welds these entities to
|
||||
// the current sampled positions.
|
||||
parts.geometry.ensureActiveCoverageEntities(record);
|
||||
parts.projection.ensureProjectionRuntime(record);
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
// FIX B9b: an explicit user (re)select re-arms one real ground sample for the
|
||||
// newly-active camera, then freezes. Idle neighbors keep their resolved state.
|
||||
// FIX B9c: if tiles are ready this real pass applies + re-resolves
|
||||
// immediately; if tiles are still streaming, updateRecordGeometry's fallback
|
||||
// guard recomputes purely from the cached real ground (the pose + the probe
|
||||
// clamp above still land — no scene queries) and the record stays unresolved
|
||||
// until update()'s one-shot tiles-ready completion pass re-grounds it.
|
||||
parts.ground.rearmGroundResolution(record);
|
||||
parts.geometry.updateRecordGeometry(record);
|
||||
record.activationDone = true;
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// 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();
|
||||
// ADJUST mode follows the active camera.
|
||||
layerState._gizmo?.refresh();
|
||||
parts.presentation.notifyListeners();
|
||||
return CCTV_ACTIVATION_RESULT.ACTIVATED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active CCTV camera in place without moving the viewer or
|
||||
* disabling the layer. The normal selection-refresh path releases the active
|
||||
* projection, emphasis, and probe state while ambient cards remain available.
|
||||
* @returns {boolean} True when a camera was deactivated.
|
||||
*/
|
||||
|
||||
function deactivateActiveCamera() {
|
||||
const record = layerState._activeCameraId
|
||||
? layerState._recordById.get(layerState._activeCameraId)
|
||||
: null;
|
||||
if (!record) return false;
|
||||
layerState._activeCameraId = null;
|
||||
layerState._autoHopSuspended = true;
|
||||
record.activationDone = false;
|
||||
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
|
||||
parts.geometry.applyFrustumGeometry(
|
||||
previous,
|
||||
parts.ground.groundAltFor(previous),
|
||||
);
|
||||
});
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.cards.refreshAmbientCards();
|
||||
layerState._gizmo?.refresh();
|
||||
parts.presentation.notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for a clean click that is empty from CCTV's perspective: an
|
||||
* active camera exists, ADJUST does not own the pointer, and the scene pick
|
||||
* carries no canonical object ID. Any identified scene object is non-empty,
|
||||
* including selectable siblings that do not participate in the pick registry.
|
||||
* @param {Object|null} picked - `scene.pick()` result.
|
||||
* @param {Object} [context]
|
||||
* @param {string|null} [context.activeCameraId]
|
||||
* @param {boolean} [context.calibrationMode]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function cctvEmptyClickDeselects(
|
||||
picked,
|
||||
{ activeCameraId = null, calibrationMode = false } = {},
|
||||
) {
|
||||
if (!activeCameraId || calibrationMode) return false;
|
||||
return resolvePickId(picked) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a camera ID from a Cesium pick result by checking billboard IDs,
|
||||
* primitive IDs, and entity cctvCameraId properties.
|
||||
* @param {Object|null} picked - Result from scene.pick().
|
||||
* @returns {string|null} Camera ID, or null if the pick is not a CCTV entity.
|
||||
*/
|
||||
|
||||
function extractPickedCameraId(picked) {
|
||||
if (!picked) return null;
|
||||
|
||||
const entity = picked.id?.properties
|
||||
? picked.id
|
||||
: picked.primitive?.id?.properties
|
||||
? picked.primitive.id
|
||||
: null;
|
||||
const maybeProp = entity?.properties?.cctvCameraId;
|
||||
if (maybeProp) {
|
||||
const value =
|
||||
typeof maybeProp.getValue === 'function'
|
||||
? maybeProp.getValue(Cesium.JulianDate.now())
|
||||
: maybeProp;
|
||||
const record =
|
||||
typeof value === 'string' ? layerState._recordById.get(value) : null;
|
||||
const ownsCoverageEntity = Boolean(
|
||||
record?.coverageEntities?.includes(entity),
|
||||
);
|
||||
const ownsProjectionEntity =
|
||||
record?.projection?.planeEntity === entity ||
|
||||
layerState._projectionEntities.some(
|
||||
(runtime) =>
|
||||
runtime?.cameraId === value && runtime.planeEntity === entity,
|
||||
);
|
||||
if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value;
|
||||
}
|
||||
|
||||
// Camera billboard IDs are intentionally the upstream camera ID, so a
|
||||
// sibling may legitimately use the same string. A bare ID match is not
|
||||
// ownership proof: require this layer's billboard collection or the exact
|
||||
// billboard stored on the record.
|
||||
const directId =
|
||||
typeof picked.id === 'string'
|
||||
? picked.id
|
||||
: typeof picked.primitive?.id === 'string'
|
||||
? picked.primitive.id
|
||||
: null;
|
||||
const record =
|
||||
directId === null ? null : layerState._recordById.get(directId);
|
||||
if (!record) return null;
|
||||
return picked.primitive === layerState._billboards ||
|
||||
picked.primitive === record.billboard
|
||||
? directId
|
||||
: null;
|
||||
}
|
||||
return {
|
||||
getActiveRecord,
|
||||
bindCctvWorldClickGesture,
|
||||
setActiveCamera,
|
||||
deactivateActiveCamera,
|
||||
cctvEmptyClickDeselects,
|
||||
extractPickedCameraId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
} from './policy.js';
|
||||
function safeNumber(value, fallback = NaN) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
function frameUrlFor(camera, refreshMs = ACTIVE_FRAME_REFRESH_MS) {
|
||||
const cadenceMs = Math.max(
|
||||
1000,
|
||||
safeNumber(refreshMs, ACTIVE_FRAME_REFRESH_MS),
|
||||
);
|
||||
const tick = Math.floor(Date.now() / cadenceMs);
|
||||
const params = new URLSearchParams({
|
||||
label: camera.name,
|
||||
city: camera.city,
|
||||
lat: camera.lat.toFixed(6),
|
||||
lon: camera.lon.toFixed(6),
|
||||
heading: String(Math.round(camera.headingDeg)),
|
||||
fov: String(Math.round(camera.fovDeg)),
|
||||
pitch: String(Math.round(camera.pitchDeg || -10)),
|
||||
ts: String(tick),
|
||||
});
|
||||
return `${FRAME_ENDPOINT}/${encodeURIComponent(camera.id)}?${params.toString()}`;
|
||||
}
|
||||
function mediaUrlFor(camera) {
|
||||
return `${MEDIA_ENDPOINT}/${encodeURIComponent(camera.id)}?ts=${Math.floor(Date.now() / 15000)}`;
|
||||
}
|
||||
/** Supply catalog/health records and the existing registered camera URL families. */
|
||||
export function createCctvSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
async function read(path, key, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(path, { cache: 'no-store', signal });
|
||||
if (!response.ok) throw new Error('Camera source HTTP ' + response.status);
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.[key]))
|
||||
throw new Error('Malformed camera ' + key + ' snapshot');
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
getCatalog(options) {
|
||||
return read('/api/cctv/sources', 'sources', options);
|
||||
},
|
||||
getHealth(options) {
|
||||
return read('/api/cctv/health', 'cameras', options);
|
||||
},
|
||||
getFrameUrl: frameUrlFor,
|
||||
getMediaUrl: mediaUrlFor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCctvSource, createCctvLayer } from './index.js';
|
||||
|
||||
const camera = {
|
||||
id: 'pack/camera ?x',
|
||||
name: 'Camera & road',
|
||||
city: 'Austin',
|
||||
lat: 30.267,
|
||||
lon: -97.744,
|
||||
headingDeg: 45,
|
||||
fovDeg: 60,
|
||||
pitchDeg: -12,
|
||||
};
|
||||
|
||||
test('camera catalog and health use fixed source routes and caller cancellation', async () => {
|
||||
const calls = [];
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async (path, options) => {
|
||||
calls.push({ path, options });
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
path.endsWith('/sources') ? { sources: [] } : { cameras: [] },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
await source.getCatalog({ signal: controller.signal });
|
||||
await source.getHealth({ signal: controller.signal });
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.path),
|
||||
['/api/cctv/sources', '/api/cctv/health'],
|
||||
);
|
||||
for (const { options } of calls) {
|
||||
assert.equal(options.signal, controller.signal);
|
||||
assert.equal(options.cache, 'no-store');
|
||||
}
|
||||
});
|
||||
|
||||
test('camera sources reject malformed snapshots and failures', async () => {
|
||||
for (const method of ['getCatalog', 'getHealth']) {
|
||||
const malformed = createCctvSource({
|
||||
fetchImpl: async () => new Response('{}'),
|
||||
});
|
||||
await assert.rejects(malformed[method](), /Malformed camera/);
|
||||
const denied = createCctvSource({
|
||||
fetchImpl: async () => new Response('', { status: 403 }),
|
||||
});
|
||||
await assert.rejects(denied[method](), /HTTP 403/);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation while reading a camera response body prevents publication', async () => {
|
||||
const controller = new AbortController();
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
controller.abort();
|
||||
return { sources: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getCatalog({ signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('frame and media URLs preserve registered camera identity and encoded metadata', () => {
|
||||
const source = createCctvSource();
|
||||
const frame = new URL(source.getFrameUrl(camera), 'https://example.test');
|
||||
const media = new URL(source.getMediaUrl(camera), 'https://example.test');
|
||||
assert.equal(
|
||||
frame.pathname,
|
||||
'/api/cctv/frame/' + encodeURIComponent(camera.id),
|
||||
);
|
||||
assert.equal(
|
||||
media.pathname,
|
||||
'/api/cctv/media/' + encodeURIComponent(camera.id),
|
||||
);
|
||||
assert.equal(frame.searchParams.get('label'), camera.name);
|
||||
assert.equal(frame.searchParams.get('city'), camera.city);
|
||||
assert.equal(frame.searchParams.get('lat'), '30.267000');
|
||||
assert.equal(frame.searchParams.get('lon'), '-97.744000');
|
||||
assert.equal(frame.searchParams.get('heading'), '45');
|
||||
assert.equal(frame.searchParams.get('pitch'), '-12');
|
||||
assert.deepEqual([...media.searchParams.keys()], ['ts']);
|
||||
});
|
||||
|
||||
test('camera construction is inert and destruction cancels a pending catalog and its visibility listener', async (t) => {
|
||||
const original = globalThis.document;
|
||||
const listeners = new Set();
|
||||
globalThis.document = {
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'visibilitychange') listeners.add(handler);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
if (type === 'visibilitychange') listeners.delete(handler);
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = original;
|
||||
});
|
||||
const noop = () => {};
|
||||
const services = {
|
||||
overlays: {
|
||||
clearOverlaySource: noop,
|
||||
hitTestWorldOverlay: noop,
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
},
|
||||
sprites: { registerSpriteCollection: noop },
|
||||
activation: {},
|
||||
locations: {},
|
||||
picking: { unregisterPickOwner: noop },
|
||||
terrain: {},
|
||||
ground: {},
|
||||
mesh: {},
|
||||
focus: {},
|
||||
render: { releaseContinuousRender: noop },
|
||||
};
|
||||
let resolveCatalog;
|
||||
let signal;
|
||||
const source = {
|
||||
...createCctvSource(),
|
||||
getCatalog(options) {
|
||||
signal = options.signal;
|
||||
return new Promise((resolve) => {
|
||||
resolveCatalog = resolve;
|
||||
});
|
||||
},
|
||||
};
|
||||
const a = createCctvLayer({ services, source });
|
||||
const b = createCctvLayer({ services, source });
|
||||
assert.equal(listeners.size, 0);
|
||||
const viewer = {
|
||||
scene: { primitives: { add: (value) => value, remove: () => true } },
|
||||
};
|
||||
const initializing = a.init(viewer);
|
||||
assert.equal(listeners.size, 1);
|
||||
assert.equal(signal.aborted, false);
|
||||
a.destroy(viewer);
|
||||
assert.equal(listeners.size, 0);
|
||||
assert.equal(signal.aborted, true);
|
||||
resolveCatalog({ sources: [] });
|
||||
await assert.rejects(initializing, { name: 'AbortError' });
|
||||
assert.equal(a.getStats().count, 0);
|
||||
assert.equal(b.getStats().count, 0);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createState({ services }) {
|
||||
const {
|
||||
clearOverlaySource,
|
||||
hitTestWorldOverlay,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} = services.overlays;
|
||||
const layerState = {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-scoped mutable state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
layerState._billboards = null;
|
||||
|
||||
layerState._records = [];
|
||||
|
||||
layerState._recordById = new Map();
|
||||
|
||||
layerState._coverageEntities = [];
|
||||
|
||||
layerState._projectionEntities = [];
|
||||
|
||||
layerState._enabled = false;
|
||||
|
||||
layerState._activeCameraId = null;
|
||||
|
||||
layerState._coverageMode = 'on';
|
||||
// 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes)
|
||||
|
||||
layerState._showProjection = true;
|
||||
|
||||
layerState._autoHop = false;
|
||||
|
||||
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its
|
||||
// timer from silently choosing a replacement. A later explicit activation or
|
||||
// AUTO HOP toggle-on releases the hold.
|
||||
|
||||
layerState._autoHopSuspended = false;
|
||||
|
||||
layerState._autoHopSec = 18;
|
||||
|
||||
layerState._lastHopAt = 0;
|
||||
|
||||
layerState._lastViewContext = '';
|
||||
|
||||
layerState._clickHandler = null;
|
||||
|
||||
layerState._count = 0;
|
||||
|
||||
layerState._lastUpdate = null;
|
||||
|
||||
layerState._lastHealthSyncAt = 0;
|
||||
|
||||
layerState._lastError = null;
|
||||
|
||||
layerState._healthById = new Map();
|
||||
|
||||
layerState._calibrationById = new Map();
|
||||
|
||||
layerState._listeners = new Set();
|
||||
|
||||
layerState._projectionRaf = 0;
|
||||
|
||||
layerState._removeFocusAppearListener = null;
|
||||
|
||||
layerState._lastFocusStyleAt = 0;
|
||||
|
||||
/** Icons whose animated emphasis remains outside the 1.0 deadband. */
|
||||
|
||||
layerState._activeFocusStyleCount = 0;
|
||||
|
||||
layerState._scratchFocusScreen = new Cesium.Cartesian2();
|
||||
|
||||
// Staggered geometry-load queue state (see startGeometryLoadQueue).
|
||||
|
||||
layerState._geoQueue = [];
|
||||
|
||||
layerState._geoQueueTimer = 0;
|
||||
|
||||
layerState._geoLoading = false;
|
||||
|
||||
layerState._geoLoadTotal = 0;
|
||||
|
||||
layerState._geoLoadDone = 0;
|
||||
|
||||
layerState._geoProgressNotifier = null;
|
||||
|
||||
// One-shot completion latch for shared floor resolution: the enable-time queue
|
||||
// can drain while DEM cells or 3D tiles are still loading. The first update()
|
||||
// tick that sees projectionTilesReady() re-enqueues unresolved records ONCE;
|
||||
// shared mesh cells remain one-shot and idle ticks stay sample-free. Reset by
|
||||
// startGeometryLoadQueue so each enable-time drain gets its own completion pass.
|
||||
|
||||
layerState._tilesReadyReenqueued = false;
|
||||
|
||||
// Calibration ADJUST mode (viewshed/gizmo design §3c): while true, the active
|
||||
// camera renders the direct-manipulation gizmo. Reset on layer disable.
|
||||
|
||||
layerState._calibrationMode = false;
|
||||
|
||||
layerState._gizmo = null;
|
||||
|
||||
layerState._lastTransientNotifyAt = 0;
|
||||
|
||||
// Cached handle on the active Google Photorealistic 3D Tileset, discovered
|
||||
// lazily from scene.primitives. Shared mesh-floor sampling is gated on its
|
||||
// tilesLoaded flag so a coarse-LOD miss is never baked in. Cleared when the
|
||||
// tileset is destroyed / the layer tears down.
|
||||
|
||||
layerState._activeTileset = null;
|
||||
|
||||
// Task 5: last surface regime the record geometry was recomputed for. The
|
||||
// map-stack change listener compares the CURRENT regime (derived live from
|
||||
// scene.globe.show) against this so bing→osm switches (same 'terrain-globe'
|
||||
// regime) don't trigger a pointless full-catalog rewrite.
|
||||
|
||||
layerState._lastAppliedRegime = null;
|
||||
|
||||
// Task 5: window listener handle for the 'gev:map-stack-changed' CustomEvent
|
||||
// main.js dispatches from MapStackController's onChange (removed in destroy).
|
||||
|
||||
layerState._mapStackListener = null;
|
||||
|
||||
// Field-test fix (2026-07-06): camera.moveEnd handle for the horizon-culling
|
||||
// pass (removed in destroy). Event-driven only — never a per-frame loop, so
|
||||
// the zero-steady-state-work invariant holds.
|
||||
|
||||
layerState._horizonCullListener = null;
|
||||
|
||||
// Ambient card tier state (2026-07-29 design). The card set is rebuilt only
|
||||
// on moveEnd/enable/activation (refreshAmbientCards); frame slots are STABLE
|
||||
// objects shared with the overlay host so landed frames appear without an
|
||||
// entry rebuild.
|
||||
|
||||
layerState._cardIds = new Set();
|
||||
|
||||
/** @type {Map<string,{misses:number,since:number}>} */
|
||||
|
||||
layerState._cardGraceState = new Map();
|
||||
|
||||
/** @type {Map<string,{frame:*, stamp:number, failCount:number, lastAttemptAt:number}>} */
|
||||
|
||||
layerState._cardFrameSlots = new Map();
|
||||
|
||||
layerState._cardFetchTimer = 0;
|
||||
|
||||
/** In-flight card-frame fetch count (burst allows up to 4, steady is 1). */
|
||||
|
||||
layerState._cardFetchInFlightCount = 0;
|
||||
|
||||
/** @type {Set<HTMLImageElement>} in-flight fetches, detached on teardown. */
|
||||
|
||||
layerState._cardFetchImages = new Set();
|
||||
|
||||
/** @type {Set<string>} camera ids with an in-flight fetch (no double-fetch). */
|
||||
|
||||
layerState._cardFetchPendingIds = new Set();
|
||||
|
||||
layerState._cardFetchCount = 0;
|
||||
|
||||
layerState._cardLastFetchAt = 0;
|
||||
|
||||
layerState._cardMinFetchSpacingMs = null;
|
||||
|
||||
/** Pacer mode telemetry: 'burst' during cold fill, 'steady' after. */
|
||||
|
||||
layerState._cardFetchMode = 'steady';
|
||||
|
||||
layerState.DEFAULT_CCTV_OVERLAY_HOST = Object.freeze({
|
||||
clearSource: clearOverlaySource,
|
||||
hitTest: hitTestWorldOverlay,
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
});
|
||||
|
||||
layerState._cctvOverlayHost = layerState.DEFAULT_CCTV_OVERLAY_HOST;
|
||||
|
||||
layerState._projectionOverlayOwnerId = null;
|
||||
|
||||
/**
|
||||
* Product presentation option. Shipped behavior keeps the active camera's
|
||||
* thumbnail absent because its monitor plane is the active representation.
|
||||
*/
|
||||
|
||||
layerState._activeCameraCardEnabled = false;
|
||||
|
||||
/** Camera id currently holding the hover-summoned pinned card (or null). */
|
||||
|
||||
layerState._hoverCardId = null;
|
||||
|
||||
layerState._hoverReleaseTimer = 0;
|
||||
|
||||
layerState._hoverLastPickAt = 0;
|
||||
|
||||
// True between camera.moveStart and moveEnd — hover picking pauses while the
|
||||
// camera is in motion (picks during a flight would fight the reselection).
|
||||
|
||||
layerState._cameraMoving = false;
|
||||
|
||||
layerState._moveStartListener = null;
|
||||
layerState._sourceAbort = null;
|
||||
return layerState;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createTesting({ state: layerState, services, parts, source }) {
|
||||
/** Test seam: republishes host entries through the real push path, so tests
|
||||
* can observe the pristine module default without touching the setter. */
|
||||
|
||||
function _pushAmbientCardEntriesForTest() {
|
||||
parts.cards.pushAmbientCardEntries();
|
||||
}
|
||||
|
||||
/** Test seam for exercising real layer lifecycle paths without a DOM host. */
|
||||
|
||||
function _setCctvOverlayHostForTest(host = null) {
|
||||
layerState._cctvOverlayHost = host
|
||||
? { ...layerState.DEFAULT_CCTV_OVERLAY_HOST, ...host }
|
||||
: layerState.DEFAULT_CCTV_OVERLAY_HOST;
|
||||
layerState._projectionOverlayOwnerId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the production monitor-plane/host-label pair without media setup.
|
||||
* @param {Object} viewer Cesium viewer seam.
|
||||
* @param {Object} record CCTV runtime record.
|
||||
* @returns {Object} Projection runtime.
|
||||
*/
|
||||
|
||||
function _createCctvProjectionPlaneForTest(viewer, record) {
|
||||
layerState._viewer = viewer;
|
||||
const geometry =
|
||||
record.frustumGeometry ||
|
||||
parts.geometry.computeFrustumGeometry(
|
||||
record.camera,
|
||||
parts.ground.groundAltFor(record),
|
||||
record.probeClampRangeM,
|
||||
);
|
||||
const positions =
|
||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||
record.frustumGeometry = geometry;
|
||||
record.frustumPositions = positions;
|
||||
const runtime = {
|
||||
cameraId: String(record.camera.id),
|
||||
planeEntity: null,
|
||||
labelPosition: new Cesium.Cartesian3(),
|
||||
overlayEntry: null,
|
||||
planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE),
|
||||
};
|
||||
parts.projection.createProjectionPlane(
|
||||
record,
|
||||
runtime,
|
||||
geometry,
|
||||
positions,
|
||||
);
|
||||
record.projection = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercise the production geometry-to-plane-and-label cache update.
|
||||
* @param {Object} record CCTV runtime record.
|
||||
*/
|
||||
|
||||
function _updateCctvProjectionPlaneForTest(record) {
|
||||
parts.projection.updatePlanePlacement(record);
|
||||
}
|
||||
|
||||
/** Test-only seam for the CCTV ownership proof used by the world-click route. */
|
||||
|
||||
function _extractPickedCameraIdForTest(picked) {
|
||||
return parts.selection.extractPickedCameraId(picked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Primes the minimum module state needed to exercise the production coverage
|
||||
* refresh path in unit tests.
|
||||
* @param {Object} [options={}] Test state values.
|
||||
* @param {Object|null} [options.viewer] Viewer-like entity owner.
|
||||
* @param {Object[]} [options.records] Seeded CCTV records.
|
||||
* @param {string|null} [options.activeCameraId] Active record id.
|
||||
* @param {boolean} [options.enabled=true] Layer enabled state.
|
||||
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
|
||||
* @param {boolean} [options.showProjection=false] Projection visibility.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function _setCctvCoverageStateForTest({
|
||||
viewer = null,
|
||||
records = [],
|
||||
activeCameraId = null,
|
||||
enabled = true,
|
||||
coverageMode = 'on',
|
||||
showProjection = false,
|
||||
} = {}) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._records = Array.isArray(records) ? records : [];
|
||||
layerState._recordById = new Map(
|
||||
layerState._records
|
||||
.filter((record) => record?.camera?.id)
|
||||
.map((record) => [record.camera.id, record]),
|
||||
);
|
||||
layerState._coverageEntities = [];
|
||||
layerState._projectionEntities = [];
|
||||
layerState._billboards = null;
|
||||
layerState._activeCameraId = activeCameraId;
|
||||
layerState._autoHopSuspended = false;
|
||||
layerState._enabled = !!enabled;
|
||||
layerState._coverageMode = parts.model.normalizeCoverageMode(
|
||||
coverageMode,
|
||||
'on',
|
||||
);
|
||||
layerState._showProjection = !!showProjection;
|
||||
}
|
||||
return {
|
||||
_pushAmbientCardEntriesForTest,
|
||||
_setCctvOverlayHostForTest,
|
||||
_createCctvProjectionPlaneForTest,
|
||||
_updateCctvProjectionPlaneForTest,
|
||||
_extractPickedCameraIdForTest,
|
||||
_setCctvCoverageStateForTest,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function readLayerSource(file) {
|
||||
'militaryAwareness.js',
|
||||
'traffic.js',
|
||||
'bikeshare.js',
|
||||
'cctv.js',
|
||||
].includes(basename(path))
|
||||
)
|
||||
return readFileSync(path, 'utf8');
|
||||
@@ -40,7 +41,9 @@ export function readLayerSource(file) {
|
||||
? '../layers/traffic'
|
||||
: basename(path) === 'bikeshare.js'
|
||||
? '../layers/bikeshare'
|
||||
: '../layers/vessels',
|
||||
: basename(path) === 'cctv.js'
|
||||
? '../layers/cctv'
|
||||
: '../layers/vessels',
|
||||
);
|
||||
return readdirSync(directory)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
|
||||
Reference in new issue
Block a user