From 519bc6c362a6bd9e7005facc1bfe5e8636bf83e1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 15:01:46 -0700 Subject: [PATCH] refactor(cctv): separate camera source and scene components --- CHANGELOG.md | 6 + docs/CURRENT-STATE.md | 8 + package.json | 3 +- scripts/format-scope.json | 27 +- scripts/package-boundaries.json | 36 + src/data/cctv.js | 4910 +-------------------------- src/data/cctv.test.mjs | 23 +- src/layers/cctv/calibration.js | 309 ++ src/layers/cctv/cards.js | 404 +++ src/layers/cctv/catalog.js | 210 ++ src/layers/cctv/controls.js | 352 ++ src/layers/cctv/frames.js | 368 ++ src/layers/cctv/geometry.js | 719 ++++ src/layers/cctv/geometryQueue.js | 339 ++ src/layers/cctv/ground.js | 283 ++ src/layers/cctv/health.js | 41 + src/layers/cctv/hover.js | 127 + src/layers/cctv/index.js | 130 + src/layers/cctv/ingestion.js | 48 + src/layers/cctv/lifecycle.js | 461 +++ src/layers/cctv/model.js | 566 +++ src/layers/cctv/navigation.js | 157 + src/layers/cctv/policy.js | 481 +++ src/layers/cctv/presentation.js | 207 ++ src/layers/cctv/projection.js | 385 +++ src/layers/cctv/rendering.js | 312 ++ src/layers/cctv/selection.js | 258 ++ src/layers/cctv/source.js | 55 + src/layers/cctv/source.test.mjs | 150 + src/layers/cctv/state.js | 208 ++ src/layers/cctv/testing.js | 120 + src/testSupport/readLayerSource.mjs | 5 +- 32 files changed, 6873 insertions(+), 4835 deletions(-) create mode 100644 src/layers/cctv/calibration.js create mode 100644 src/layers/cctv/cards.js create mode 100644 src/layers/cctv/catalog.js create mode 100644 src/layers/cctv/controls.js create mode 100644 src/layers/cctv/frames.js create mode 100644 src/layers/cctv/geometry.js create mode 100644 src/layers/cctv/geometryQueue.js create mode 100644 src/layers/cctv/ground.js create mode 100644 src/layers/cctv/health.js create mode 100644 src/layers/cctv/hover.js create mode 100644 src/layers/cctv/index.js create mode 100644 src/layers/cctv/ingestion.js create mode 100644 src/layers/cctv/lifecycle.js create mode 100644 src/layers/cctv/model.js create mode 100644 src/layers/cctv/navigation.js create mode 100644 src/layers/cctv/policy.js create mode 100644 src/layers/cctv/presentation.js create mode 100644 src/layers/cctv/projection.js create mode 100644 src/layers/cctv/rendering.js create mode 100644 src/layers/cctv/selection.js create mode 100644 src/layers/cctv/source.js create mode 100644 src/layers/cctv/source.test.mjs create mode 100644 src/layers/cctv/state.js create mode 100644 src/layers/cctv/testing.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b7c4407..dfa8b533e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0ffd17bc4..5834a063c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/package.json b/package.json index daa475afa..da56ce8e5 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 708a319bc..4af247603 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e48997e2a..a32644391 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] } } diff --git a/src/data/cctv.js b/src/data/cctv.js index 884323477..6f86880fa 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -1,4825 +1,89 @@ -/** - * @module cctv - * - * CCTV camera data layer for God's Eye View. - * - * Architecture: - * - Camera catalog: built from seed definitions (CAMERA_SEEDS) merged with live - * sources fetched from the backend (/api/cctv/sources). Each camera record - * holds a base pose, a calibration offset, computed intrinsics/extrinsics, - * and an anchor position on the globe. - * - * - Coverage geometry (v2): a true pitched frustum pyramid per camera — 4 - * corner rays from the mount to the far-plane corners plus the closed - * far-plane rectangle (5 polylines), rendered as Cesium entities with - * neighbor-limited visibility. All geometry is pure pose math - * (computeFrustumGeometry) — zero steady-state scene queries. - * - * - Monitor plane (v2): the live frame renders on a plane entity capping the - * far end of the frustum ("monitor at the end of the cone"), oriented - * perpendicular to the view axis (static — never billboarded). Video feeds - * bind the HTMLVideoElement directly (Cesium updates video textures - * per-frame); image feeds alternate two offscreen canvases so the texture - * re-uploads on every repaint tick (<=1Hz). One live plane at a time. On - * activation a single scene.pickFromRay obstruction probe (§9.1 — the ONLY - * raycast in the subsystem) clamps the plane's range short of the first - * tile hit so the end cap never clips into buildings. - * - * - Ambient card tier (2026-07-29 design): the LOD-selected nearby static - * cameras (cctvLod.js — zoom-scaled 16/24/32 budget, eviction grace) - * additionally get a screen-space thumbnail card (cctvCards.js) showing - * their latest paced static frame. Reselection is camera.moveEnd-driven - * (never per frame); frame fetches go through a global 1-per-second gate; - * camera icons stay visible at every zoom (cards annotate, never replace). - * The active camera keeps the monitor plane and is excluded from the card - * ring by default. An opt-in presentation option can also publish its - * protected thumbnail without changing the ambient quota. - * - * - Health sync: periodic fetch of /api/cctv/health to update per-camera - * source status shown in the UI. - * - * - Auto-hop: timed camera cycling with view-context awareness (snaps to - * nearest camera when the viewer pans to a new region). - * - * All mutable state is module-scoped. The exported `cctvLayer` object - * implements the standard layer interface (init/enable/disable/update/destroy) - * plus CCTV-specific methods (selectCamera, cycleCamera, focusNearest, etc.). - */ -import * as Cesium from 'cesium'; -import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js'; -import { - CCTV_ACTIVATION_RESULT, - activateCctvCameraFromWorldClick, -} from '../cctvFocusRequest.js'; -import { bindTrackingClickGesture, isTrackingClickGesture } from './trackingClickGesture.js'; -import { - clearOverlaySource, - hitTestWorldOverlay, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { CITY_POIS } from '../locations.js'; -import { - registerPickOwner, - resolvePickId, - unregisterPickOwner, -} from './pickRegistry.js'; -import { resolveEllipsoidalGround } from './terrainHeights.js'; -import { cachedGroundFloor, resolveGroundFloorCells, warmGroundFloor } from './groundFloor.js'; -import { sampleMeshFloorCells } from './meshFloorSampler.js'; -import { horizonOccluder } from './iconOrientation.js'; -import { cameraHue, viewshedColors, createFrustumVolumePrimitive } from './cctvViewshed.js'; -import { createCalibrationGizmo, GIZMO_ID_PREFIX } from './cctvGizmo.js'; -import { - CCTV_AMBIENT_CARD_MAX, - applyEvictionGrace, - selectCctvLod, - staticFrameRefreshMs, -} from './cctvLod.js'; -import { - CCTV_CARD_FETCH_BURST_LIMIT, - CCTV_CARD_FETCH_BURST_SPACING_MS, - CCTV_FRAME_CANVAS_W, - CCTV_FRAME_CANVAS_H, - applyFrameResult, - cardFetchPolicy, - createCctvThumbnailOverlayEntry, - createFrameSlot, - declutterCctvCards, - CCTV_OVERLAY_SOURCE_ID, - planFrameCachePrune, - frameFetchDue, -} from './cctvCards.js'; -import { - advanceSpriteFocus, - focusAlphaNeedsWrite, - focusNowMs, - focusPassIsNeeded, - getFocusTarget, - onFocusTargetAppear, -} from './focusDeemphasis.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; +import { createCctvSource } from '../layers/cctv/source.js'; +import { createCctvLayer } from '../layers/cctv/index.js'; +import * as sprites from './spriteOrder.js'; +import * as activation from '../cctvFocusRequest.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as locations from '../locations.js'; +import * as picking from './pickRegistry.js'; +import * as terrain from './terrainHeights.js'; +import * as ground from './groundFloor.js'; +import * as mesh from './meshFloorSampler.js'; +import * as focus from './focusDeemphasis.js'; +import * as render from '../renderGovernor.js'; -// --------------------------------------------------------------------------- -// API endpoints -// --------------------------------------------------------------------------- -const FRAME_ENDPOINT = '/api/cctv/frame'; -const SOURCE_ENDPOINT = '/api/cctv/sources'; -const HEALTH_ENDPOINT = '/api/cctv/health'; -const MEDIA_ENDPOINT = '/api/cctv/media'; - -// --------------------------------------------------------------------------- -// Timing and geometry constants -// --------------------------------------------------------------------------- -const DEFAULT_UPDATE_INTERVAL_MS = 10000; -const MIN_AUTO_HOP_SEC = 8; -const MAX_AUTO_HOP_SEC = 90; -const HEALTH_SYNC_INTERVAL_MS = 7000; -const ACTIVE_FRAME_REFRESH_MS = 10000; -const IDLE_FRAME_REFRESH_MS = 60000; -const PROJECTION_ACTIVE_REFRESH_MS = 10000; -const PROJECTION_IDLE_REFRESH_MS = 60000; -const PROJECTION_CANVAS_WIDTH = 1920; -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. -const FRAME_SIGNATURE_W = 64; -const FRAME_SIGNATURE_H = 36; -const COVERAGE_NEIGHBOR_LIMIT = 14; -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. -const GEO_LOAD_BATCH_SIZE = 4; -const GEO_LOAD_BATCH_DELAY_MS = 120; -const GEO_TRACKING_BATCH_SIZE = 2; -const GEO_TRACKING_BATCH_DELAY_MS = 250; -const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300; -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. -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: }. */ -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). -const PROJECTION_TEXTURE_SWAP_MS = 1000; -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', +const layer = createCctvLayer({ + source: createCctvSource(), + services: { + sprites, + activation, + overlays, + locations, + picking, + terrain, + ground, + mesh, + focus, + render, + }, }); -// §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). -const PROBE_CLEARANCE_M = 4; -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. -const GROUND_PRIOR_INIT_WAIT_MS = 8000; -/** Default calibration offsets — all zeroed, range scale 1x. */ -const DEFAULT_CAMERA_CALIBRATION = Object.freeze({ - offsetNorthM: 0, - offsetEastM: 0, - headingDeg: 0, - pitchDeg: 0, - fovDeg: 0, - rangeScale: 1, - heightM: 0, -}); - -/** - * 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} - */ -export function calibrationPatchMovesAnchor(patch) { - if (!patch || typeof patch !== 'object') return false; - return Object.prototype.hasOwnProperty.call(patch, 'offsetNorthM') || - Object.prototype.hasOwnProperty.call(patch, 'offsetEastM'); -} - -/** Base64-encoded SVG camera icon for billboard rendering. */ -const CAMERA_ICON = (() => { - const 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. - */ -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 -// --------------------------------------------------------------------------- -const IDLE_CAMERA_COLOR = Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88); -const ACTIVE_CAMERA_COLOR = Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95); -const IDLE_COVERAGE_COLOR = Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24); -const IDLE_COVERAGE_CENTER_MUTED = Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2); -const IDLE_COVERAGE_EDGE_MUTED = Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.18); -const ACTIVE_COVERAGE_EDGE = Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58); -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. -const ACTIVE_COVERAGE_EDGE_DEPTHFAIL = Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.18); -const ACTIVE_COVERAGE_CENTER_DEPTHFAIL = Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26); -const PLANE_OUTLINE_COLOR = Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.55); - -// --------------------------------------------------------------------------- -// Module-scoped mutable state -// --------------------------------------------------------------------------- -let _viewer = null; -let _billboards = null; -let _records = []; -let _recordById = new Map(); -let _coverageEntities = []; -let _projectionEntities = []; -let _enabled = false; -let _activeCameraId = null; -let _coverageMode = 'on'; // 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes) -let _showProjection = true; -let _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. -let _autoHopSuspended = false; -let _autoHopSec = 18; -let _lastHopAt = 0; -let _lastViewContext = ''; -let _clickHandler = null; -let _count = 0; -let _lastUpdate = null; -let _lastHealthSyncAt = 0; -let _lastError = null; -let _healthById = new Map(); -let _calibrationById = new Map(); -let _listeners = new Set(); -let _projectionRaf = 0; -let _removeFocusAppearListener = null; -let _lastFocusStyleAt = 0; -/** Icons whose animated emphasis remains outside the 1.0 deadband. */ -let _activeFocusStyleCount = 0; -const _scratchFocusScreen = new Cesium.Cartesian2(); -// Staggered geometry-load queue state (see startGeometryLoadQueue). -let _geoQueue = []; -let _geoQueueTimer = 0; -let _geoLoading = false; -let _geoLoadTotal = 0; -let _geoLoadDone = 0; -let _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. -let _tilesReadyReenqueued = false; -// Calibration ADJUST mode (viewshed/gizmo design §3c): while true, the active -// camera renders the direct-manipulation gizmo. Reset on layer disable. -let _calibrationMode = false; -let _gizmo = null; -let _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. -let _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. -let _lastAppliedRegime = null; -// Task 5: window listener handle for the 'gev:map-stack-changed' CustomEvent -// main.js dispatches from MapStackController's onChange (removed in destroy). -let _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. -let _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. -let _cardIds = new Set(); -/** @type {Map} */ -let _cardGraceState = new Map(); -/** @type {Map} */ -let _cardFrameSlots = new Map(); -let _cardFetchTimer = 0; -/** In-flight card-frame fetch count (burst allows up to 4, steady is 1). */ -let _cardFetchInFlightCount = 0; -/** @type {Set} in-flight fetches, detached on teardown. */ -const _cardFetchImages = new Set(); -/** @type {Set} camera ids with an in-flight fetch (no double-fetch). */ -const _cardFetchPendingIds = new Set(); -let _cardFetchCount = 0; -let _cardLastFetchAt = 0; -let _cardMinFetchSpacingMs = null; -/** Pacer mode telemetry: 'burst' during cold fill, 'steady' after. */ -let _cardFetchMode = 'steady'; -/** - * Card budget while the staggered geometry drain is running — the raised - * 20/28/40 tiers resume when loading completes (see refreshAmbientCards). - */ -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. -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). -const CARD_VIEW_MARGIN = 0.06; -/** Card leader gap: clears the 24px icon (12px half + breathing room). */ -const CARD_GAP_PX = 16; -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, -}); -const DEFAULT_CCTV_OVERLAY_HOST = Object.freeze({ - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, -}); -let _cctvOverlayHost = DEFAULT_CCTV_OVERLAY_HOST; -let _projectionOverlayOwnerId = null; -/** - * Product presentation option. Shipped behavior keeps the active camera's - * thumbnail absent because its monitor plane is the active representation. - */ -let _activeCameraCardEnabled = 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). */ -const HOVER_PICK_THROTTLE_MS = 120; -/** How long the hover card lingers after the pointer leaves the icon. */ -const HOVER_RELEASE_MS = 1_000; -/** Camera id currently holding the hover-summoned pinned card (or null). */ -let _hoverCardId = null; -let _hoverReleaseTimer = 0; -let _hoverLastPickAt = 0; - -/** Test seam: republishes host entries through the real push path, so tests - * can observe the pristine module default without touching the setter. */ -export function _pushAmbientCardEntriesForTest() { - pushAmbientCardEntries(); -} - -/** Test seam for exercising real layer lifecycle paths without a DOM host. */ -export function _setCctvOverlayHostForTest(host = null) { - _cctvOverlayHost = host ? { ...DEFAULT_CCTV_OVERLAY_HOST, ...host } : DEFAULT_CCTV_OVERLAY_HOST; - _projectionOverlayOwnerId = null; -} - -/** - * 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. - */ -export 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, - }; -} - -/** - * 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}} - */ -export function setCctvCardPresentationOptions({ activeCameraCardEnabled = false } = {}) { - _activeCameraCardEnabled = activeCameraCardEnabled === true; - if (_enabled) pushAmbientCardEntries(); - _viewer?.scene?.requestRender?.(); - return { activeCameraCardEnabled: _activeCameraCardEnabled }; -} -// True between camera.moveStart and moveEnd — hover picking pauses while the -// camera is in motion (picks during a flight would fight the reselection). -let _cameraMoving = false; -let _moveStartListener = null; - -/** - * 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)); -} - -/** - * 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'} - */ -export function surfaceRegimeKey(globeShow) { - return globeShow === false ? 'google-3d' : 'terrain-globe'; -} - -/** - * 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