Merge remote-tracking branch 'origin/main' into fix/geojson-release-intake

This commit is contained in:
Sameh Khamis committed 2026-09-13 16:26:08 -07:00
commit 3aaa2afd48
32 files changed
+6873 -4835

No files matched your search

+6
View File
@@ -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.
+8
View File
@@ -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
View File
@@ -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"
}
}
+26 -1
View File
@@ -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"
]
+36
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+14 -9
View File
@@ -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'),
+309
View File
@@ -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,
};
}
+404
View File
@@ -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,
};
}
+210
View File
@@ -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,
};
}
+352
View File
@@ -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 };
}
+368
View File
@@ -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,
};
}
+719
View File
@@ -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,
};
}
+339
View File
@@ -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,
};
}
+283
View File
@@ -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,
};
}
+41
View File
@@ -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 };
}
+127
View File
@@ -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,
};
}
+130
View File
@@ -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';
+48
View File
@@ -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 };
}
+461
View File
@@ -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 };
}
+566
View File
@@ -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,
};
}
+157
View File
@@ -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,
};
}
+481
View File
@@ -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;
+207
View File
@@ -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,
};
}
+385
View File
@@ -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,
};
}
+312
View File
@@ -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,
};
}
+258
View File
@@ -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,
};
}
+55
View File
@@ -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,
};
}
+150
View File
@@ -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);
});
+208
View File
@@ -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;
}
+120
View File
@@ -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,
};
}
+4 -1
View File
@@ -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'))