diff --git a/CHANGELOG.md b/CHANGELOG.md index a83bbbe08..9137393db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal. + - Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction. - Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index f11735a43..4e705a627 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -295,3 +295,10 @@ receives the existing Radio port and explicit layer/layout actions, without importing the renderer or station providers. Pure tuner calculations retain compatibility exports from the data layer. Disposal revokes DOM listeners and subscriptions before ending the active tuning interaction. + +### Camera panel controls + +`ui/cctv` composes camera controls, frame loading, calibration editing and status +presentation. It receives DOM elements, the existing camera port and explicit +application actions; it imports no provider, layer or camera engine. Selection, +placement, navigation and storage policy remain outside the component family. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb2616ef0..fcce42e0e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,16 @@ # God's Eye View Current State +## Camera panel ownership + +CCTV controls receive the existing camera port and explicit application actions. +Frame loading, calibration editing and status display have separate modules; +providers, camera placement and navigation policy retain their existing owners. +Changing cameras invalidates old image callbacks and cancels an unfinished +calibration edit. Failed refreshes preserve settled pixels for the same camera, +while a newly selected camera never shows the prior camera's image. Disposal +releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. + + ## UI disposal UI disposal releases the CCTV subscription, command-dock observer, legacy drag diff --git a/package.json b/package.json index c88533b76..62c51ed5e 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "./ui/layers": "./src/ui/layers.js", "./ui/layers/feedback": "./src/loadingFeedback.js", "./ui/location": "./src/ui/location.js", - "./ui/radio": "./src/ui/radio.js" + "./ui/radio": "./src/ui/radio.js", + "./ui/cctv": "./src/ui/cctv.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 91918223e..c523193b8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -173,5 +173,12 @@ "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", "src/ui/radioControls.test.mjs", - "scripts/qa-ui-disposal.mjs" + "scripts/qa-ui-disposal.mjs", + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvControls.test.mjs", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b1435a095..83d976631 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -357,5 +357,17 @@ "src/ui/radioTunerModel.js" ], "external": [] + }, + "cctv-controls": { + "exports": ["./ui/cctv"], + "modules": [ + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js" + ], + "external": [] } } diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 0b636f21b..bfb15f8ca 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -94,7 +94,9 @@ const getOpt = (name, dflt) => { const getFlag = (name) => argv.includes(name); const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; -const APP_URL = getOpt('--url', BASE_URL); +const CCTV_URL = new URL(getOpt('--url', BASE_URL)); +CCTV_URL.searchParams.set('welcome', '0'); +const APP_URL = CCTV_URL.href; const HEADFUL = getFlag('--headful'); const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2'); diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 155d9116a..1cfa077c4 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -51,10 +51,10 @@ try { '_leftStackMutationObserver', '_rightStackMutationObserver', ].filter(watchObserver); - const cctvUnsubscribe = ui._cctvUnsubscribe; + const cctvUnsubscribe = ui._cctvControls._cctvUnsubscribe; counts.cctv = 0; if (cctvUnsubscribe) - ui._cctvUnsubscribe = () => { + ui._cctvControls._cctvUnsubscribe = () => { counts.cctv++; return cctvUnsubscribe(); }; @@ -82,10 +82,10 @@ try { observersReleased: observed.every( (name) => counts[name] === 1 && ui[name] === null, ), - subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null, + subscriptionReleased: counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null, resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null, controlsReleased: - ui._radioControls.destroyed && ui._locationControls.destroyed, + ui._radioControls.destroyed && ui._locationControls.destroyed && ui._cctvControls.destroyed, idempotent: once === JSON.stringify(counts), }; } finally { @@ -109,7 +109,7 @@ try { result.resizeReleased, ); check( - 'Radio and Location controls are disposed with the UI', + 'Radio, Location and CCTV controls are disposed with the UI', result.controlsReleased, ); check('repeated UI disposal is inert', result.idempotent); diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 404831aa0..5f4abd82e 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -95,3 +95,25 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async assert.deepEqual(transitions, [false]); assert.deepEqual(diagnostics, []); }); + + +test('a disposed UI cannot activate a camera when enabling finishes late', async () => { + let release; + let disposed = false; + let activations = 0; + const pending = runCctvLayerEnableTransition({ + target: true, + setEnabled: () => new Promise((resolve) => { release = resolve; }), + readOwnership: () => ({}), + shouldFocus: () => !disposed, + activate: () => { activations++; return 'camera-a'; }, + fly: () => assert.fail('disposed UI must not fly'), + debug: () => {}, + }); + disposed = true; + release(); + await pending; + assert.equal(activations, 0); + const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + assert.match(uiSource, /shouldFocus: \(\) => !this\._disposed && this\._dataManager\.isEnabled\('cctv'\)/); +}); diff --git a/src/ui.js b/src/ui.js index d8bf096bc..fa0637c2a 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { CctvControls } from './ui/cctv.js'; import { RadioControls } from './ui/radio.js'; import { LocationControls, LocationSearch } from './ui/location.js'; import { bindClearLayersControl } from './ui/layers.js'; @@ -370,54 +371,6 @@ const STYLE_STATUS_LABELS = { * - Intel HUD lifecycle and variant switching. */ -/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ -const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; - -/** - * Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the - * camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old - * "HEADING 0°" nonsense); typed values convert back to calibration offsets - * against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon - * typing is user-hostile). - */ -const CCTV_CAL_FIELDS = { - heading: { - label: 'HDG', unit: '°', decimals: 1, - get: (cam) => cam.headingDeg, - toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), - }, - pitch: { - label: 'PITCH', unit: '°', decimals: 1, - get: (cam) => cam.pitchDeg, - toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), - }, - fov: { - label: 'FOV', unit: '°', decimals: 0, - get: (cam) => cam.fovDeg, - toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), - }, - range: { - label: 'RANGE', unit: 'm', decimals: 0, - get: (cam) => cam.rangeM, - toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), - }, - height: { - label: 'HGT', unit: 'm', decimals: 0, - get: (cam) => cam.mountHeightM, - toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), - }, - north: { - label: 'ΔN', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetNorthM || 0, - toPatch: (value) => ({ offsetNorthM: value }), - }, - east: { - label: 'ΔE', unit: 'm', decimals: 1, - get: (cam) => cam.calibration?.offsetEastM || 0, - toPatch: (value) => ({ offsetEastM: value }), - }, -}; - function formatCockpitBriefAge(value) { const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN'; @@ -2084,15 +2037,7 @@ export class StyleManager { this._clearSelectedLayersPromise = null; this._clearSelectedLayersManagerPromise = null; this._dataManager = null; - this._cctvUnsubscribe = null; - this._cctvState = null; - this._cctvSummaryTypingTimer = null; - this._lastCctvSummaryText = ''; - // Auto-expand guard: last active camera id seen while the layer was - // enabled; routine state notifications with the same id never re-expand. - this._lastSeenCctvActiveId = null; - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = false; + this._leftStackLayoutFrame = null; this._leftStackReconsiderAutoCollapse = false; this._leftStackResizeObserver = null; @@ -2261,8 +2206,6 @@ export class StyleManager { this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn'); this._cctvFrame = document.getElementById('cctv-frame'); this._cctvFrameWrap = document.getElementById('cctv-frame-wrap'); - this._cctvFrameRequestToken = 0; - this._cctvFramePreloader = null; this._cctvSourceBadge = document.getElementById('cctv-source-badge'); this._cctvMeta = document.getElementById('cctv-meta'); this._cctvSummary = document.getElementById('cctv-summary'); @@ -3674,52 +3617,6 @@ export class StyleManager { this._trafficSyncChip.classList.toggle('visible', presentation.visible); } - /** - * Updates the CCTV loading chip (same pattern as the traffic sync chip) - * with staggered initial-load progress, e.g. "LOADING FRAMES 12/36". - * Shows a brief "camera grid ready" confirmation, then hides. - * @param {{active: boolean, loaded: number, total: number}|null|undefined} loading - * Loading progress from the CCTV layer UI state. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @returns {void} - */ - _updateCctvSyncChip(loading, enabled) { - if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; - const total = Number(loading?.total) || 0; - const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); - const busy = !!enabled && !!loading?.active && total > 0; - - if (busy) { - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = null; - this._cctvChipWasBusy = true; - setSplitFlapText(this._cctvSyncLabel, 'loading frames'); - // The counter is left plain on purpose: it ticks every few frames - // during a grid load, and flapping it would read as a slot machine. - this._cctvSyncProgress.textContent = `${loaded}/${total}`; - this._cctvSyncChip.classList.add('visible'); - return; - } - - if (this._cctvChipWasBusy && enabled && total > 0) { - // Load just completed — flash the final count, then auto-hide. - this._cctvChipWasBusy = false; - setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); - this._cctvSyncProgress.textContent = `${total}/${total}`; - this._cctvSyncChip.classList.add('visible'); - clearTimeout(this._cctvChipHideTimer); - this._cctvChipHideTimer = window.setTimeout(() => { - this._cctvChipHideTimer = null; - this._cctvSyncChip.classList.remove('visible'); - }, 1500); - return; - } - - if (!this._cctvChipHideTimer) { - this._cctvChipWasBusy = false; - this._cctvSyncChip.classList.remove('visible'); - } - } /** * Initializes panel collapse buttons and restores persisted collapsed state. @@ -4173,18 +4070,7 @@ export class StyleManager { }); } this._syncContextModeButtons(); - if (this._cctvUnsubscribe) { - this._cctvUnsubscribe(); - this._cctvUnsubscribe = null; - } - if (typeof cctvLayer.subscribe === 'function') { - this._cctvUnsubscribe = cctvLayer.subscribe((state) => { - this._renderCctvState(state); - }); - } - if (typeof cctvLayer.getUIState === 'function') { - this._renderCctvState(cctvLayer.getUIState()); - } + this._cctvControls.connect(); this._radioControls.connect(); if (!this._awarenessSelectedHandler) { this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false); @@ -4999,6 +4885,7 @@ export class StyleManager { /** Wire the independent Radio companion controls. */ _initRadioPanel() { + this._cctvControls?.destroy(); this._radioControls?.destroy(); this._radioControls = new RadioControls({ elements: { @@ -5091,351 +4978,50 @@ export class StyleManager { return this._runExplicitNavigation('camera', () => focus(cameraId)); } - /** - * Wires up all CCTV panel controls: enable/disable, nearest/prev/next camera, - * camera select dropdown, focus, coverage, auto-hop, projection, - * manual calibration sliders, and save/reset buttons. - * @returns {void} - */ + /** Compose camera panel controls from the existing camera port and application actions. */ _initCctvPanel() { - if (!this._cctvPanel) return; - - this._cctvEnableBtn?.addEventListener('click', async () => { - await this._toggleCctvEnabled(); - }); - - this._cctvNearestBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.focusNearest({ focus: false }), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.8), - ); - }); - - this._cctvPrevBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(-1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvNextBtn?.addEventListener('click', async () => { - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => cctvLayer.cycleCamera(1), - (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), - ); - }); - - this._cctvSelect?.addEventListener('change', async () => { - const cameraId = this._cctvSelect.value; - if (!cameraId) return; - if (!await this._toggleCctvEnabled(true)) return; - // Picking a camera from the dropdown flies to it. The catalog spans - // three metros, so a bare selection used to leave the view in the old - // city with a camera active thousands of km away. - this._runExplicitCctvFocus( - () => (cctvLayer.selectCamera(cameraId) ? cameraId : null), - (selectedId) => cctvLayer.focusCamera(selectedId, 2.2), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: cameraId }, { origin: 'user' }); - }); - - this._cctvFocusBtn?.addEventListener('click', async () => { - const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; - if (!selected) return; - if (!await this._toggleCctvEnabled(true)) return; - this._runExplicitCctvFocus( - () => selected, - (cameraId) => cctvLayer.focusCamera(cameraId, 1.9), - ); - this._dataManager?.setLayerParams('cctv', { selectedCameraId: selected }, { origin: 'user' }); - }); - - this._cctvCoverageBtn?.addEventListener('click', () => { - const current = this._cctvState?.coverageMode - || (this._cctvState?.showCoverage ? 'on' : 'off'); - const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; - this._dataManager?.setLayerParams('cctv', { coverageMode: next }, { origin: 'user' }); - }); - - this._cctvAutoHopBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.autoHop; - this._dataManager?.setLayerParams('cctv', { autoHop: !current }, { origin: 'user' }); - }); - - this._cctvProjectionBtn?.addEventListener('click', () => { - const current = this._cctvState?.showProjection !== false; - this._dataManager?.setLayerParams('cctv', { showProjection: !current }, { origin: 'user' }); - }); - - this._cctvAdjustBtn?.addEventListener('click', () => { - const current = !!this._cctvState?.calibrationMode; - this._dataManager?.setLayerParams('cctv', { calibrationMode: !current }, { origin: 'user' }); - }); - - // Click-to-edit pose readout: each chip swaps to a number input; Enter or - // blur commits (converted to a calibration offset against basePose), - // Escape cancels. Delegated so re-renders never re-bind. - this._cctvCalReadout?.addEventListener('click', (event) => { - const chip = event.target.closest?.('.cctv-cal-value'); - if (!chip || chip.disabled || chip.querySelector('input')) return; - this._beginCctvCalValueEdit(chip); - }); - - this._cctvCalibSaveBtn?.addEventListener('click', () => { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, save: true }, - }, { origin: 'user' }); - this._showToast('CCTV calibration saved'); - }); - - this._cctvCalibResetBtn?.addEventListener('click', () => { - this._resetCctvCalibration(); - }); - - this._renderCctvState(null); - this._syncCctvPanelViewport(); - } - - /** - * Returns the currently active CCTV camera ID from state or the select dropdown. - * @returns {string} Camera ID, or empty string if none. - */ - _activeCctvCameraId() { - return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; - } - - /** - * Clears the preview and invalidates any in-flight preload. - * @returns {void} - */ - _clearCctvFrame() { - this._cctvFrameRequestToken += 1; - this._cctvFramePreloader = null; - if (this._cctvFrame) { - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrame.dataset.cameraId = ''; - this._cctvFrame.dataset.currentSrc = ''; - this._cctvFrame.dataset.loading = ''; - this._cctvFrame.dataset.error = ''; - } - this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); - } - - /** - * Fetches a replacement frame OFF-DOM and assigns it to the live element - * only once it has decoded. - * - * The live is never pointed at an unresolved URL. A completed - * preload is already in the HTTP cache, so assigning `src` swaps in a - * single paint — the browser's own atomic behavior. A slow or failed - * fetch never reaches the element at all, so settled pixels survive. - * - * (A two-slot crossfade was tried and reverted: with no z-index the slots - * paint in DOM order, so promotion was asymmetric and yanked the visible - * layer once per refresh — a flicker on every feed cycle. Measured against - * main, which never blanked on a successful refresh in the first place.) - * - * @param {string} src - * @param {string} cameraId - * @param {boolean} cameraChanged - * @returns {void} - */ - _queueCctvFrame(src, cameraId, cameraChanged) { - if (!this._cctvFrame || !src) return; - - if (cameraChanged) { - // A different camera gets an honest acquisition state. Never retain - // the prior camera's pixels under the newly selected metadata. - this._cctvFrame.classList.remove('active'); - this._cctvFrame.removeAttribute('src'); - this._cctvFrameWrap?.classList.remove('has-frame'); - } - - const token = ++this._cctvFrameRequestToken; - this._cctvFrame.dataset.cameraId = cameraId; - this._cctvFrame.dataset.currentSrc = src; - this._cctvFrame.dataset.loading = 'true'; - this._cctvFrame.dataset.error = ''; - this._cctvFrameWrap?.classList.toggle( - 'loading', - !this._cctvFrameWrap?.classList.contains('has-frame') - ); - - const preloader = new Image(); - this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); - preloader.onerror = () => this._settleCctvFrame(token, src, false); - preloader.src = src; - } - - /** - * Commits a decoded frame to the live element, or records the failure - * without disturbing whatever is already on screen. - * @param {number} token - Request token; a stale one is ignored. - * @param {string} src - * @param {boolean} ok - * @returns {void} - */ - _settleCctvFrame(token, src, ok) { - if (!this._cctvFrame || token !== this._cctvFrameRequestToken) return; - this._cctvFramePreloader = null; - this._cctvFrame.dataset.loading = ''; - this._cctvFrameWrap?.classList.remove('loading'); - - const syncBadge = () => this._syncCctvSourceBadge( - this._cctvState?.activeCamera, - !!this._cctvState?.enabled && !!this._dataManager?.isEnabled('cctv') - ); - - if (!ok) { - // Leave the element untouched — a settled frame stays on screen. - this._cctvFrame.dataset.error = 'true'; - syncBadge(); - return; - } - - this._cctvFrame.dataset.error = ''; - this._cctvFrame.src = src; - this._cctvFrame.classList.add('active'); - this._cctvFrameWrap?.classList.add('has-frame'); - syncBadge(); - } - - /** - * Keeps the source badge truthful about the visible frame lifecycle. Health - * may already be OK while the browser is still decoding the requested image. - * @param {object|null} activeCamera - * @param {boolean} enabled - * @returns {void} - */ - _syncCctvSourceBadge(activeCamera, enabled) { - if (!this._cctvSourceBadge) return; - if (!enabled || !activeCamera) { - this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; - this._cctvSourceBadge.dataset.frameState = 'idle'; - return; - } - const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); - if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · LOADING'; - this._cctvSourceBadge.dataset.frameState = 'loading'; - return; - } - if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { - this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; - this._cctvSourceBadge.dataset.frameState = 'error'; - return; - } - const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); - const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); - this._cctvSourceBadge.textContent = `${kind} · ${status}`; - this._cctvSourceBadge.dataset.frameState = 'ready'; - } - - /** - * Resets calibration for the active CCTV camera to its server defaults. - * @returns {void} - */ - _resetCctvCalibration() { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this._dataManager) return; - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { - cameraId, - reset: true, + this._cctvControls?.destroy(); + this._cctvControls = new CctvControls({ + elements: { + _cctvAdjustBtn: this._cctvAdjustBtn, + _cctvAutoHopBtn: this._cctvAutoHopBtn, + _cctvCalReadout: this._cctvCalReadout, + _cctvCalibResetBtn: this._cctvCalibResetBtn, + _cctvCalibSaveBtn: this._cctvCalibSaveBtn, + _cctvCoverageBtn: this._cctvCoverageBtn, + _cctvEnableBtn: this._cctvEnableBtn, + _cctvFocusBtn: this._cctvFocusBtn, + _cctvFrame: this._cctvFrame, + _cctvFrameWrap: this._cctvFrameWrap, + _cctvMeta: this._cctvMeta, + _cctvNearestBtn: this._cctvNearestBtn, + _cctvNextBtn: this._cctvNextBtn, + _cctvPanel: this._cctvPanel, + _cctvPrevBtn: this._cctvPrevBtn, + _cctvProjectionBtn: this._cctvProjectionBtn, + _cctvQualityChip: this._cctvQualityChip, + _cctvSelect: this._cctvSelect, + _cctvSourceBadge: this._cctvSourceBadge, + _cctvSummary: this._cctvSummary, + _cctvSyncChip: this._cctvSyncChip, + _cctvSyncLabel: this._cctvSyncLabel, + _cctvSyncProgress: this._cctvSyncProgress, + }, + cctv: cctvLayer, + actions: { + isEnabled: () => this._dataManager?.isEnabled('cctv'), + setParams: (params, options) => this._dataManager?.setLayerParams('cctv', params, options), + toggleEnabled: (...args) => this._toggleCctvEnabled(...args), + runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), + setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + showToast: (message) => this._showToast(message), + syncViewport: () => this._syncCctvPanelViewport(), + setSplitFlapText, }, - }, { origin: 'user' }); - this._showToast('CCTV calibration reset'); - } - - /** - * Swaps a readout chip's text for an inline number input. Enter/blur commits - * (converted to a calibration offset patch), Escape cancels. The next state - * re-render restores the chip text either way. - * @param {HTMLButtonElement} chip - The clicked `.cctv-cal-value` element. - * @returns {void} - */ - _beginCctvCalValueEdit(chip) { - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - const activeCamera = this._cctvState?.activeCamera; - if (!field || !activeCamera?.basePose) return; - const startValue = field.get(activeCamera); - const input = document.createElement('input'); - input.type = 'number'; - input.step = field.decimals > 0 ? '0.1' : '1'; - input.value = Number(startValue).toFixed(field.decimals); - input.className = 'cctv-cal-input'; - chip.textContent = `${field.label} `; - chip.appendChild(input); - input.focus(); - input.select(); - - let finished = false; - const finish = (commit) => { - if (finished) return; - finished = true; - const typed = parseFloat(input.value); - input.remove(); - if (commit && Number.isFinite(typed)) { - const cameraId = this._activeCctvCameraId(); - if (cameraId && this._dataManager) { - this._dataManager.setLayerParams('cctv', { - selectedCameraId: cameraId, - calibration: { cameraId, patch: field.toPatch(typed, activeCamera.basePose) }, - }, { origin: 'user' }); - return; // re-render restores the chip text from fresh state - } - } - this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); - }; - input.addEventListener('keydown', (event) => { - if (event.key === 'Enter') finish(true); - else if (event.key === 'Escape') finish(false); - event.stopPropagation(); }); - input.addEventListener('blur', () => finish(true)); - input.addEventListener('click', (event) => event.stopPropagation()); } - /** - * Synchronizes the pose readout chips, ADJUST button, and SAVE/RESET - * disabled states with the active camera. - * @param {boolean} enabled - Whether the CCTV layer is currently enabled. - * @param {object|null} activeCamera - The active camera data object (may be null). - * @returns {void} - */ - _syncCctvCalReadout(enabled, activeCamera) { - const canCalibrate = !!enabled && !!activeCamera; - if (this._cctvAdjustBtn) { - const adjustOn = !!this._cctvState?.calibrationMode; - this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); - this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; - this._cctvAdjustBtn.disabled = !canCalibrate; - } - if (this._cctvCalReadout) { - for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { - if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber - const field = CCTV_CAL_FIELDS[chip.dataset.calField]; - if (!field) continue; - const value = canCalibrate ? field.get(activeCamera) : null; - chip.textContent = Number.isFinite(value) - ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` - : `${field.label} --`; - chip.disabled = !canCalibrate; - } - } - for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { - if (el) el.disabled = !canCalibrate; - } - } + /** * Toggles the CCTV layer enabled state. When enabling and no camera is active, @@ -5444,6 +5030,7 @@ export class StyleManager { * @returns {Promise} True if the layer is now in the requested state. */ async _toggleCctvEnabled(forceState) { + if (this._disposed) return false; if (!this._dataManager || !this._dataManager.layers?.has('cctv')) { this._showToast('CCTV layer unavailable'); return false; @@ -5458,7 +5045,8 @@ export class StyleManager { trackedEntity: this.viewer?.trackedEntity, cockpitActive: !!this.cockpitView?.active, }), - shouldFocus: () => !this._cctvState?.activeCameraId, + shouldFocus: () => !this._disposed && this._dataManager.isEnabled('cctv') + && !this._cctvControls?.getState()?.activeCameraId, activate: () => cctvLayer.focusNearest({ focus: false }), fly: (cameraId) => this._runExplicitCctvFocus( () => cameraId, @@ -5468,195 +5056,6 @@ export class StyleManager { return true; } - /** - * Maps a `deriveCalBadge` value (cctv.js) to its panel copy. Single source - * of truth for CAL-badge casing — used by both the quality chip and the - * meta line so the two never drift onto different label conventions. - * @param {'calibrated'|'curated'|'raw-prior'|null} badge - Badge state from cctv.js. - * @returns {string} Display label, or '--' when there is no active camera. - */ - _calBadgeLabel(badge) { - switch (badge) { - case 'calibrated': return 'CALIBRATED'; - case 'curated': return 'CURATED'; - case 'raw-prior': return 'RAW PRIOR'; - default: return '--'; - } - } - - /** - * Full re-render of the CCTV panel UI from a CCTV layer state snapshot. - * Updates enable button, camera select dropdown, navigation buttons, - * coverage/auto-hop/projection toggles, quality chip, source badge, - * metadata line, frame image, calibration controls, and summary text. - * @param {object|null} state - CCTV layer UI state, or null to render empty. - * @returns {void} - */ - _renderCctvState(state) { - this._cctvState = state || null; - const cameras = state?.cameras || []; - const enabled = !!state?.enabled && !!this._dataManager?.isEnabled('cctv'); - const activeId = state?.activeCameraId || ''; - const activeCamera = state?.activeCamera || null; - - // Auto-expand the panel when the active camera CHANGES to a new non-null - // id while the layer is enabled. Covers click-on-globe, panel controls, - // and voice (selectCamera/cycleCamera/focusNearest all notify through - // this subscription). The last-seen guard keeps routine notifications - // from re-expanding a panel the user deliberately collapsed, and timed - // auto-hop transitions only expand on the first activation so the panel - // does not pop open on every hop. - const effectiveActiveId = enabled ? (activeId || null) : null; - const isFirstActivation = this._lastSeenCctvActiveId === null; - if (effectiveActiveId - && effectiveActiveId !== this._lastSeenCctvActiveId - && (!state?.autoHop || isFirstActivation)) { - this.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); - } - this._lastSeenCctvActiveId = effectiveActiveId; - - this._updateCctvSyncChip(state?.loading, enabled); - - if (this._cctvEnableBtn) { - this._cctvEnableBtn.classList.toggle('active', enabled); - this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; - } - - if (this._cctvSelect) { - const shouldRebuild = this._cctvSelect.options.length !== cameras.length - || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); - if (shouldRebuild) { - this._cctvSelect.innerHTML = ''; - for (const camera of cameras) { - const option = document.createElement('option'); - option.value = camera.id; - option.textContent = `${camera.city} · ${camera.name}`; - this._cctvSelect.appendChild(option); - } - } - this._cctvSelect.disabled = !enabled || cameras.length === 0; - if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { - this._cctvSelect.value = activeId; - } else if (!activeId) { - this._cctvSelect.selectedIndex = -1; - } - } - - for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { - if (!btn) continue; - btn.disabled = !enabled || cameras.length === 0; - } - if (this._cctvFocusBtn) { - this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; - } - - if (this._cctvCoverageBtn) { - // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed - // (color-coded volumes). The click handler cycles; this renders. - const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); - this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); - this._cctvCoverageBtn.textContent = mode === 'viewshed' - ? 'VIEWSHED ON' - : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; - this._cctvCoverageBtn.disabled = !enabled; - } - - if (this._cctvAutoHopBtn) { - const autoHop = !!state?.autoHop; - this._cctvAutoHopBtn.classList.toggle('active', autoHop); - this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; - this._cctvAutoHopBtn.disabled = !enabled; - } - - if (this._cctvProjectionBtn) { - const showProjection = state?.showProjection !== false; - this._cctvProjectionBtn.classList.toggle('active', showProjection); - this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; - this._cctvProjectionBtn.disabled = !enabled; - } - - if (this._cctvQualityChip) { - // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, - // no in-world tint): three states driven by cctv.js's deriveCalBadge, - // no client-side scoring math. Casing is unified via _calBadgeLabel so - // the chip and the meta line never drift onto different conventions. - // Save-gated persistence (viewshed design §3e): unsaved live edits show - // EDITED on top of whatever the persisted badge state is — SAVE CAL - // promotes to CALIBRATED, RESET CAL clears. - const badge = activeCamera?.calBadge || null; - const dirty = !!activeCamera?.calDirty; - this._cctvQualityChip.textContent = dirty - ? 'CAL · EDITED (UNSAVED)' - : `CAL · ${this._calBadgeLabel(badge)}`; - this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); - } - - this._syncCctvCalReadout(enabled, activeCamera); - - if (this._cctvMeta) { - if (activeCamera) { - const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; - const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; - const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; - const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; - this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; - } else if (cameras.length > 0) { - this._cctvMeta.textContent = enabled - ? `${cameras.length} cameras loaded · click a camera to activate` - : `${cameras.length} cameras loaded · enable CCTV to activate`; - } else { - this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; - } - } - - if (this._cctvFrame) { - const nextSrc = enabled ? activeCamera?.frameUrl : null; - const nextCameraId = enabled ? (activeCamera?.id || '') : ''; - const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; - const frameLoading = this._cctvFrame.dataset.loading === 'true'; - // A same-camera refresh waits for the current image to settle. Replacing - // src every 10 seconds can cancel a slow but healthy decode forever and - // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are - // immediate so navigation never waits on the prior camera's request. - if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { - this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); - } - if (!nextSrc) { - this._clearCctvFrame(); - } - } - - this._syncCctvSourceBadge(activeCamera, enabled); - this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); - } - - /** - * Typewriter-animates CCTV summary text into the summary element. - * Skips animation if the text hasn't changed since the last call. - * Advances 3 characters per 20ms tick for a fast teletype effect. - * @param {string} text - Summary text to display. - * @returns {void} - */ - _typeCctvSummary(text) { - if (!this._cctvSummary) return; - const nextText = String(text || '').trim() || 'No summary available.'; - if (nextText === this._lastCctvSummaryText) return; - this._lastCctvSummaryText = nextText; - - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummary.textContent = ''; - let idx = 0; - this._cctvSummaryTypingTimer = setInterval(() => { - idx += 3; - if (idx >= nextText.length) { - this._cctvSummary.textContent = nextText; - clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummaryTypingTimer = null; - return; - } - this._cctvSummary.textContent = nextText.slice(0, idx); - }, 20); - } /** * Returns the versioned localStorage key for a panel's saved position. @@ -8508,6 +7907,7 @@ export class StyleManager { this._mapSourceControls?.destroy(); this._clearLayersControl?.destroy(); this._locationControls?.destroy(); + this._cctvControls?.destroy(); this._radioControls?.destroy(); this._visualEffects.stop(); this._styleParameters?.destroy(); @@ -8594,8 +7994,6 @@ export class StyleManager { this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; - this._cctvUnsubscribe?.(); - this._cctvUnsubscribe = null; this._commandDockTrayObserver?.disconnect?.(); this._commandDockTrayObserver = null; this._draggableResizeObserver?.disconnect(); diff --git a/src/ui/cctv.js b/src/ui/cctv.js new file mode 100644 index 000000000..9cfcb9760 --- /dev/null +++ b/src/ui/cctv.js @@ -0,0 +1 @@ +export { CctvControls } from './cctvControls.js'; diff --git a/src/ui/cctvBindings.js b/src/ui/cctvBindings.js new file mode 100644 index 000000000..5c70c2c65 --- /dev/null +++ b/src/ui/cctvBindings.js @@ -0,0 +1,130 @@ +export function _initCctvPanel() { + if (!this._cctvPanel) return; + + this.listen(this._cctvEnableBtn, 'click', async () => { + this._actionGeneration++; + await this.actions.toggleEnabled(); + }); + + this.listen(this._cctvNearestBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => this.cctv.focusNearest({ focus: false }), + (cameraId) => this.cctv.focusCamera(cameraId, 1.8), + ); + }); + + this.listen(this._cctvPrevBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => this.cctv.cycleCamera(-1), + (cameraId) => this.cctv.focusCamera(cameraId, 1.4), + ); + }); + + this.listen(this._cctvNextBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => this.cctv.cycleCamera(1), + (cameraId) => this.cctv.focusCamera(cameraId, 1.4), + ); + }); + + this.listen(this._cctvSelect, 'change', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + const cameraId = this._cctvSelect.value; + if (!cameraId) return; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + // Picking a camera from the dropdown flies to it. The catalog spans + // three metros, so a bare selection used to leave the view in the old + // city with a camera active thousands of km away. + this.actions.runExplicitFocus( + () => (this.cctv.selectCamera(cameraId) ? cameraId : null), + (selectedId) => this.cctv.focusCamera(selectedId, 2.2), + ); + this.actions.setParams({ selectedCameraId: cameraId }, { origin: 'user' }); + }); + + this.listen(this._cctvFocusBtn, 'click', async () => { + const generation = ++this._actionGeneration; + const activeId = this._cctvState?.activeCameraId; + const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; + if (!selected) return; + if (!await this.actions.toggleEnabled(true)) return; + if (this.destroyed || generation !== this._actionGeneration + || !this.actions.isEnabled() + || (activeId && activeId !== this._cctvState?.activeCameraId)) return; + this.actions.runExplicitFocus( + () => selected, + (cameraId) => this.cctv.focusCamera(cameraId, 1.9), + ); + this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' }); + }); + + this.listen(this._cctvCoverageBtn, 'click', () => { + const current = this._cctvState?.coverageMode + || (this._cctvState?.showCoverage ? 'on' : 'off'); + const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; + this.actions.setParams({ coverageMode: next }, { origin: 'user' }); + }); + + this.listen(this._cctvAutoHopBtn, 'click', () => { + const current = !!this._cctvState?.autoHop; + this.actions.setParams({ autoHop: !current }, { origin: 'user' }); + }); + + this.listen(this._cctvProjectionBtn, 'click', () => { + const current = this._cctvState?.showProjection !== false; + this.actions.setParams({ showProjection: !current }, { origin: 'user' }); + }); + + this.listen(this._cctvAdjustBtn, 'click', () => { + const current = !!this._cctvState?.calibrationMode; + this.actions.setParams({ calibrationMode: !current }, { origin: 'user' }); + }); + + // Click-to-edit pose readout: each chip swaps to a number input; Enter or + // blur commits (converted to a calibration offset against basePose), + // Escape cancels. Delegated so re-renders never re-bind. + this.listen(this._cctvCalReadout, 'click', (event) => { + const chip = event.target.closest?.('.cctv-cal-value'); + if (!chip || chip.disabled || chip.querySelector('input')) return; + this._beginCctvCalValueEdit(chip); + }); + + this.listen(this._cctvCalibSaveBtn, 'click', () => { + const cameraId = this._activeCctvCameraId(); + if (!cameraId || !this.actions.setParams) return; + this.actions.setParams({ + selectedCameraId: cameraId, + calibration: { cameraId, save: true }, + }, { origin: 'user' }); + this.actions.showToast('CCTV calibration saved'); + }); + + this.listen(this._cctvCalibResetBtn, 'click', () => { + this._resetCctvCalibration(); + }); + + this._renderCctvState(null); + this.actions.syncViewport(); +} diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js new file mode 100644 index 000000000..6d2d4487b --- /dev/null +++ b/src/ui/cctvCalibration.js @@ -0,0 +1,140 @@ +/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ +const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; + +/** + * Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the + * camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old + * "HEADING 0°" nonsense); typed values convert back to calibration offsets + * against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon + * typing is user-hostile). + */ +const CCTV_CAL_FIELDS = { + heading: { + label: 'HDG', unit: '°', decimals: 1, + get: (cam) => cam.headingDeg, + toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), + }, + pitch: { + label: 'PITCH', unit: '°', decimals: 1, + get: (cam) => cam.pitchDeg, + toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), + }, + fov: { + label: 'FOV', unit: '°', decimals: 0, + get: (cam) => cam.fovDeg, + toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), + }, + range: { + label: 'RANGE', unit: 'm', decimals: 0, + get: (cam) => cam.rangeM, + toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), + }, + height: { + label: 'HGT', unit: 'm', decimals: 0, + get: (cam) => cam.mountHeightM, + toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), + }, + north: { + label: 'ΔN', unit: 'm', decimals: 1, + get: (cam) => cam.calibration?.offsetNorthM || 0, + toPatch: (value) => ({ offsetNorthM: value }), + }, + east: { + label: 'ΔE', unit: 'm', decimals: 1, + get: (cam) => cam.calibration?.offsetEastM || 0, + toPatch: (value) => ({ offsetEastM: value }), + }, +}; + +export function _activeCctvCameraId() { + return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; +} + +export function _resetCctvCalibration() { + const cameraId = this._activeCctvCameraId(); + if (!cameraId || !this.actions.setParams) return; + this.actions.setParams({ + selectedCameraId: cameraId, + calibration: { + cameraId, + reset: true, + }, + }, { origin: 'user' }); + this.actions.showToast('CCTV calibration reset'); +} + +export function _beginCctvCalValueEdit(chip) { + if (this.destroyed) return; + this._calibrationEdit?.(false); + const field = CCTV_CAL_FIELDS[chip.dataset.calField]; + const activeCamera = this._cctvState?.activeCamera; + if (!field || !activeCamera?.basePose) return; + const cameraId = this._activeCctvCameraId(); + const basePose = { ...activeCamera.basePose }; + const editListeners = new AbortController(); + const startValue = field.get(activeCamera); + const input = document.createElement('input'); + input.type = 'number'; + input.step = field.decimals > 0 ? '0.1' : '1'; + input.value = Number(startValue).toFixed(field.decimals); + input.className = 'cctv-cal-input'; + chip.textContent = `${field.label} `; + chip.appendChild(input); + input.focus(); + input.select(); + + let finished = false; + const finish = (commit) => { + if (finished) return; + finished = true; + editListeners.abort(); + this._calibrationEdit = null; + const typed = parseFloat(input.value); + input.remove(); + if (commit && !this.destroyed && Number.isFinite(typed) + && this._cctvState?.enabled && this.actions.isEnabled() + && cameraId === this._activeCctvCameraId()) { + if (cameraId && this.actions.setParams) { + this.actions.setParams({ + selectedCameraId: cameraId, + calibration: { cameraId, patch: field.toPatch(typed, basePose) }, + }, { origin: 'user' }); + return; // re-render restores the chip text from fresh state + } + } + this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); + }; + this._calibrationEdit = finish; + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') finish(true); + else if (event.key === 'Escape') { event.preventDefault(); finish(false); } + event.stopPropagation(); + }, { signal: editListeners.signal }); + input.addEventListener('blur', () => finish(true), { signal: editListeners.signal }); + input.addEventListener('click', (event) => event.stopPropagation(), { signal: editListeners.signal }); +} + +export function _syncCctvCalReadout(enabled, activeCamera) { + const canCalibrate = !!enabled && !!activeCamera; + if (this._cctvAdjustBtn) { + const adjustOn = !!this._cctvState?.calibrationMode; + this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); + this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; + this._cctvAdjustBtn.disabled = !canCalibrate; + } + if (this._cctvCalReadout) { + for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { + if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber + const field = CCTV_CAL_FIELDS[chip.dataset.calField]; + if (!field) continue; + const value = canCalibrate ? field.get(activeCamera) : null; + chip.textContent = Number.isFinite(value) + ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` + : `${field.label} --`; + chip.disabled = !canCalibrate; + } + } + for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { + if (el) el.disabled = !canCalibrate; + } +} diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js new file mode 100644 index 000000000..46805ad69 --- /dev/null +++ b/src/ui/cctvControls.js @@ -0,0 +1,66 @@ +import { _clearCctvFrame, _queueCctvFrame, _settleCctvFrame, _syncCctvSourceBadge } from './cctvFrames.js'; +import { _activeCctvCameraId, _resetCctvCalibration, _beginCctvCalValueEdit, _syncCctvCalReadout } from './cctvCalibration.js'; +import { _calBadgeLabel, _renderCctvState, _typeCctvSummary, _updateCctvSyncChip } from './cctvPresentation.js'; +import { _initCctvPanel } from './cctvBindings.js'; + +/** Own camera-panel interaction and presentation; receive the camera port and application actions. */ +export class CctvControls { + constructor({ elements, cctv, actions }) { + Object.assign(this, elements); + this.cctv = cctv; + this.actions = actions; + this.destroyed = false; + this.listeners = new AbortController(); + this._cctvUnsubscribe = null; + this._cctvState = null; + this._cctvSummaryTypingTimer = null; + this._lastCctvSummaryText = ''; + this._lastSeenCctvActiveId = null; + this._cctvChipHideTimer = null; + this._cctvChipWasBusy = false; + this._cctvFrameRequestToken = 0; + this._cctvFramePreloader = null; + this._calibrationEdit = null; + this._actionGeneration = 0; + this._initCctvPanel(); + } + listen(target, type, handler, options = {}) { + target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); + } + getState() { return this._cctvState; } + connect() { + this._cctvUnsubscribe?.(); + this._cctvUnsubscribe = null; + if (this.destroyed) return; + this._cctvUnsubscribe = this.cctv.subscribe?.((state) => this._renderCctvState(state)); + if (this.cctv.getUIState) this._renderCctvState(this.cctv.getUIState()); + } + _clearCctvFrame(...args) { return _clearCctvFrame.call(this, ...args); } + _queueCctvFrame(...args) { return _queueCctvFrame.call(this, ...args); } + _settleCctvFrame(...args) { return _settleCctvFrame.call(this, ...args); } + _syncCctvSourceBadge(...args) { return _syncCctvSourceBadge.call(this, ...args); } + _activeCctvCameraId(...args) { return _activeCctvCameraId.call(this, ...args); } + _resetCctvCalibration(...args) { return _resetCctvCalibration.call(this, ...args); } + _beginCctvCalValueEdit(...args) { return _beginCctvCalValueEdit.call(this, ...args); } + _syncCctvCalReadout(...args) { return _syncCctvCalReadout.call(this, ...args); } + _calBadgeLabel(...args) { return _calBadgeLabel.call(this, ...args); } + _renderCctvState(...args) { return _renderCctvState.call(this, ...args); } + _typeCctvSummary(...args) { return _typeCctvSummary.call(this, ...args); } + _updateCctvSyncChip(...args) { return _updateCctvSyncChip.call(this, ...args); } + _initCctvPanel(...args) { return _initCctvPanel.call(this, ...args); } + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this._actionGeneration++; + this.listeners.abort(); + this._cctvUnsubscribe?.(); + this._cctvUnsubscribe = null; + this._calibrationEdit?.(false); + this._clearCctvFrame(); + clearInterval(this._cctvSummaryTypingTimer); + clearTimeout(this._cctvChipHideTimer); + this._cctvSummaryTypingTimer = null; + this._cctvChipHideTimer = null; + this._cctvSyncChip?.classList.remove('visible'); + } +} diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs new file mode 100644 index 000000000..8d93a7ae1 --- /dev/null +++ b/src/ui/cctvControls.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { CctvControls } from './cctvControls.js'; + +function element() { + const classes = new Set(); + return { + dataset: {}, src: '', + removeAttribute(name) { if (name === 'src') this.src = ''; }, + classList: { + add(...values) { values.forEach(value => classes.add(value)); }, + remove(...values) { values.forEach(value => classes.delete(value)); }, + contains(value) { return classes.has(value); }, + toggle(value, enabled) { if (enabled) classes.add(value); else classes.delete(value); }, + }, + }; +} +function fixture(t) { + const prior = globalThis.Image; + const requests = []; + globalThis.Image = class { constructor() { requests.push(this); } }; + t.after(() => { globalThis.Image = prior; }); + const controls = new CctvControls({ + elements: { _cctvFrame: element(), _cctvFrameWrap: element() }, + cctv: {}, actions: { isEnabled: () => true }, + }); + t.after(() => controls.destroy()); + return { controls, requests }; +} + +test('a late image completion cannot replace a newer camera preview', (t) => { + const { controls, requests } = fixture(t); + controls._queueCctvFrame('first.jpg', 'a', true); + const stale = requests[0].onload; + controls._queueCctvFrame('second.jpg', 'b', true); + assert.equal(requests[0].onload, null); + requests[1].onload(); + stale(); + assert.equal(controls._cctvFrame.src, 'second.jpg'); + assert.equal(controls._cctvFrame.dataset.cameraId, 'b'); +}); + +test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => { + const { controls, requests } = fixture(t); + controls._queueCctvFrame('first.jpg', 'a', true); + requests[0].onload(); + controls._queueCctvFrame('refresh.jpg', 'a', false); + requests[1].onerror(); + assert.equal(controls._cctvFrame.src, 'first.jpg'); + assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), true); + controls._queueCctvFrame('other.jpg', 'b', true); + assert.equal(controls._cctvFrame.src, ''); + assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), false); +}); + +test('destroy invalidates image callbacks and releases each subscription once', (t) => { + const { controls, requests } = fixture(t); + let unsubscribed = 0; + controls.cctv.subscribe = () => () => { unsubscribed++; }; + controls.connect(); + controls.connect(); + assert.equal(unsubscribed, 1); + controls._queueCctvFrame('first.jpg', 'a', true); + const late = requests[0].onload; + controls.destroy(); + controls.destroy(); + controls.connect(); + late(); + assert.equal(unsubscribed, 2); + assert.equal(requests[0].onload, null); + assert.equal(requests[0].onerror, null); + assert.equal(controls._cctvFrame.src, ''); + controls._queueCctvFrame('late.jpg', 'b', true); + assert.equal(requests.length, 1); +}); + +function calibrationFixture(t) { + const { controls } = fixture(t); + const prior = globalThis.document; + const inputs = []; + globalThis.document = { createElement() { + const input = new EventTarget(); + Object.assign(input, { focus() {}, select() {}, remove() { this.parent.input = null; } }); + inputs.push(input); + return input; + } }; + t.after(() => { globalThis.document = prior; }); + const chip = { + dataset: { calField: 'heading' }, textContent: '', input: null, + appendChild(input) { this.input = input; input.parent = this; }, + querySelector() { return this.input; }, + }; + const patches = []; + controls.actions.setParams = (params) => patches.push(params); + controls.actions.setPanelCollapsed = () => {}; + controls._cctvCalReadout = { querySelectorAll: () => [chip] }; + controls._cctvState = { + enabled: true, activeCameraId: 'a', + activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } }, + }; + const key = (name) => Object.assign(new Event('keydown', { cancelable: true }), { key: name }); + return { controls, chip, inputs, patches, key }; +} + +test('calibration commits against the captured camera base and releases its editor', (t) => { + const { controls, chip, inputs, patches, key } = calibrationFixture(t); + controls._beginCctvCalValueEdit(chip); + inputs[0].value = '100'; + controls._cctvState.activeCamera.basePose.headingDeg = 60; + inputs[0].dispatchEvent(key('Enter')); + inputs[0].dispatchEvent(new Event('blur')); + assert.deepEqual(patches, [{ selectedCameraId: 'a', calibration: { cameraId: 'a', patch: { headingDeg: 80 } } }]); + assert.equal(controls._calibrationEdit, null); +}); + +test('a camera switch cancels calibration before old blur can change the new camera', (t) => { + const { controls, chip, inputs, patches } = calibrationFixture(t); + controls._beginCctvCalValueEdit(chip); + inputs[0].value = '100'; + controls._renderCctvState({ enabled: true, activeCameraId: 'b', activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } } }); + inputs[0].dispatchEvent(new Event('blur')); + assert.deepEqual(patches, []); + assert.equal(chip.input, null); + assert.equal(chip.textContent, 'HDG 200.0°'); +}); + +test('Escape claims calibration cancellation and disposal prevents late commits', (t) => { + const { controls, chip, inputs, patches, key } = calibrationFixture(t); + controls._beginCctvCalValueEdit(chip); + inputs[0].value = '100'; + const escape = key('Escape'); + inputs[0].dispatchEvent(escape); + inputs[0].dispatchEvent(new Event('blur')); + assert.equal(escape.defaultPrevented, true); + controls._beginCctvCalValueEdit(chip); + inputs[1].value = '120'; + controls.destroy(); + inputs[1].dispatchEvent(new Event('blur')); + assert.deepEqual(patches, []); +}); + +test('disposing during camera enable prevents the delayed focus and future clicks', async () => { + const button = new EventTarget(); + let resolveEnable; + let enables = 0; + let focuses = 0; + const controls = new CctvControls({ + elements: { _cctvPanel: {}, _cctvNextBtn: button }, cctv: {}, + actions: { + isEnabled: () => true, syncViewport() {}, + toggleEnabled() { enables++; return new Promise(resolve => { resolveEnable = resolve; }); }, + runExplicitFocus() { focuses++; }, + }, + }); + button.dispatchEvent(new Event('click')); + controls.destroy(); + resolveEnable(true); + await new Promise(resolve => setImmediate(resolve)); + button.dispatchEvent(new Event('click')); + assert.equal(enables, 1); + assert.equal(focuses, 0); +}); diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js new file mode 100644 index 000000000..fcf4ff379 --- /dev/null +++ b/src/ui/cctvFrames.js @@ -0,0 +1,102 @@ +export function _clearCctvFrame() { + this._cctvFrameRequestToken += 1; + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + this._cctvFramePreloader = null; + if (this._cctvFrame) { + this._cctvFrame.classList.remove('active'); + this._cctvFrame.removeAttribute('src'); + this._cctvFrame.dataset.cameraId = ''; + this._cctvFrame.dataset.currentSrc = ''; + this._cctvFrame.dataset.loading = ''; + this._cctvFrame.dataset.error = ''; + } + this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); +} + +export function _queueCctvFrame(src, cameraId, cameraChanged) { + if (this.destroyed || !this._cctvFrame || !src) return; + + if (cameraChanged) { + // A different camera gets an honest acquisition state. Never retain + // the prior camera's pixels under the newly selected metadata. + this._cctvFrame.classList.remove('active'); + this._cctvFrame.removeAttribute('src'); + this._cctvFrameWrap?.classList.remove('has-frame'); + } + + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + const token = ++this._cctvFrameRequestToken; + this._cctvFrame.dataset.cameraId = cameraId; + this._cctvFrame.dataset.currentSrc = src; + this._cctvFrame.dataset.loading = 'true'; + this._cctvFrame.dataset.error = ''; + this._cctvFrameWrap?.classList.toggle( + 'loading', + !this._cctvFrameWrap?.classList.contains('has-frame') + ); + + const preloader = new Image(); + this._cctvFramePreloader = preloader; + preloader.onload = () => this._settleCctvFrame(token, src, true); + preloader.onerror = () => this._settleCctvFrame(token, src, false); + preloader.src = src; +} + +export function _settleCctvFrame(token, src, ok) { + if (this.destroyed || !this._cctvFrame || token !== this._cctvFrameRequestToken) return; + if (this._cctvFramePreloader) { + this._cctvFramePreloader.onload = null; + this._cctvFramePreloader.onerror = null; + } + this._cctvFramePreloader = null; + this._cctvFrame.dataset.loading = ''; + this._cctvFrameWrap?.classList.remove('loading'); + + const syncBadge = () => this._syncCctvSourceBadge( + this._cctvState?.activeCamera, + !!this._cctvState?.enabled && !!this.actions.isEnabled() + ); + + if (!ok) { + // Leave the element untouched — a settled frame stays on screen. + this._cctvFrame.dataset.error = 'true'; + syncBadge(); + return; + } + + this._cctvFrame.dataset.error = ''; + this._cctvFrame.src = src; + this._cctvFrame.classList.add('active'); + this._cctvFrameWrap?.classList.add('has-frame'); + syncBadge(); +} + +export function _syncCctvSourceBadge(activeCamera, enabled) { + if (!this._cctvSourceBadge) return; + if (!enabled || !activeCamera) { + this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; + this._cctvSourceBadge.dataset.frameState = 'idle'; + return; + } + const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); + if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { + this._cctvSourceBadge.textContent = 'FRAME · LOADING'; + this._cctvSourceBadge.dataset.frameState = 'loading'; + return; + } + if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { + this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; + this._cctvSourceBadge.dataset.frameState = 'error'; + return; + } + const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); + const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); + this._cctvSourceBadge.textContent = `${kind} · ${status}`; + this._cctvSourceBadge.dataset.frameState = 'ready'; +} diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js new file mode 100644 index 000000000..7f05706d4 --- /dev/null +++ b/src/ui/cctvPresentation.js @@ -0,0 +1,212 @@ +export function _calBadgeLabel(badge) { + switch (badge) { + case 'calibrated': return 'CALIBRATED'; + case 'curated': return 'CURATED'; + case 'raw-prior': return 'RAW PRIOR'; + default: return '--'; + } +} + +export function _renderCctvState(state) { + if (this.destroyed) return; + if (!state?.enabled || !this.actions.isEnabled() || state?.activeCameraId !== this._cctvState?.activeCameraId) { + this._calibrationEdit?.(false); + } + this._cctvState = state || null; + const cameras = state?.cameras || []; + const enabled = !!state?.enabled && !!this.actions.isEnabled(); + const activeId = state?.activeCameraId || ''; + const activeCamera = state?.activeCamera || null; + + // Auto-expand the panel when the active camera CHANGES to a new non-null + // id while the layer is enabled. Covers click-on-globe, panel controls, + // and voice (selectCamera/cycleCamera/focusNearest all notify through + // this subscription). The last-seen guard keeps routine notifications + // from re-expanding a panel the user deliberately collapsed, and timed + // auto-hop transitions only expand on the first activation so the panel + // does not pop open on every hop. + const effectiveActiveId = enabled ? (activeId || null) : null; + const isFirstActivation = this._lastSeenCctvActiveId === null; + if (effectiveActiveId + && effectiveActiveId !== this._lastSeenCctvActiveId + && (!state?.autoHop || isFirstActivation)) { + this.actions.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); + } + this._lastSeenCctvActiveId = effectiveActiveId; + + this._updateCctvSyncChip(state?.loading, enabled); + + if (this._cctvEnableBtn) { + this._cctvEnableBtn.classList.toggle('active', enabled); + this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; + } + + if (this._cctvSelect) { + const shouldRebuild = this._cctvSelect.options.length !== cameras.length + || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); + if (shouldRebuild) { + this._cctvSelect.innerHTML = ''; + for (const camera of cameras) { + const option = document.createElement('option'); + option.value = camera.id; + option.textContent = `${camera.city} · ${camera.name}`; + this._cctvSelect.appendChild(option); + } + } + this._cctvSelect.disabled = !enabled || cameras.length === 0; + if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { + this._cctvSelect.value = activeId; + } else if (!activeId) { + this._cctvSelect.selectedIndex = -1; + } + } + + for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { + if (!btn) continue; + btn.disabled = !enabled || cameras.length === 0; + } + if (this._cctvFocusBtn) { + this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; + } + + if (this._cctvCoverageBtn) { + // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed + // (color-coded volumes). The click handler cycles; this renders. + const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); + this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); + this._cctvCoverageBtn.textContent = mode === 'viewshed' + ? 'VIEWSHED ON' + : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; + this._cctvCoverageBtn.disabled = !enabled; + } + + if (this._cctvAutoHopBtn) { + const autoHop = !!state?.autoHop; + this._cctvAutoHopBtn.classList.toggle('active', autoHop); + this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; + this._cctvAutoHopBtn.disabled = !enabled; + } + + if (this._cctvProjectionBtn) { + const showProjection = state?.showProjection !== false; + this._cctvProjectionBtn.classList.toggle('active', showProjection); + this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; + this._cctvProjectionBtn.disabled = !enabled; + } + + if (this._cctvQualityChip) { + // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, + // no in-world tint): three states driven by cctv.js's deriveCalBadge, + // no client-side scoring math. Casing is unified via _calBadgeLabel so + // the chip and the meta line never drift onto different conventions. + // Save-gated persistence (viewshed design §3e): unsaved live edits show + // EDITED on top of whatever the persisted badge state is — SAVE CAL + // promotes to CALIBRATED, RESET CAL clears. + const badge = activeCamera?.calBadge || null; + const dirty = !!activeCamera?.calDirty; + this._cctvQualityChip.textContent = dirty + ? 'CAL · EDITED (UNSAVED)' + : `CAL · ${this._calBadgeLabel(badge)}`; + this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); + } + + this._syncCctvCalReadout(enabled, activeCamera); + + if (this._cctvMeta) { + if (activeCamera) { + const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; + const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; + const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; + const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; + this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; + } else if (cameras.length > 0) { + this._cctvMeta.textContent = enabled + ? `${cameras.length} cameras loaded · click a camera to activate` + : `${cameras.length} cameras loaded · enable CCTV to activate`; + } else { + this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; + } + } + + if (this._cctvFrame) { + const nextSrc = enabled ? activeCamera?.frameUrl : null; + const nextCameraId = enabled ? (activeCamera?.id || '') : ''; + const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; + const frameLoading = this._cctvFrame.dataset.loading === 'true'; + // A same-camera refresh waits for the current image to settle. Replacing + // src every 10 seconds can cancel a slow but healthy decode forever and + // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are + // immediate so navigation never waits on the prior camera's request. + if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { + this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); + } + if (!nextSrc) { + this._clearCctvFrame(); + } + } + + this._syncCctvSourceBadge(activeCamera, enabled); + this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); +} + +export function _typeCctvSummary(text) { + if (this.destroyed || !this._cctvSummary) return; + const nextText = String(text || '').trim() || 'No summary available.'; + if (nextText === this._lastCctvSummaryText) return; + this._lastCctvSummaryText = nextText; + + clearInterval(this._cctvSummaryTypingTimer); + this._cctvSummary.textContent = ''; + let idx = 0; + this._cctvSummaryTypingTimer = setInterval(() => { + if (this.destroyed) return; + idx += 3; + if (idx >= nextText.length) { + this._cctvSummary.textContent = nextText; + clearInterval(this._cctvSummaryTypingTimer); + this._cctvSummaryTypingTimer = null; + return; + } + this._cctvSummary.textContent = nextText.slice(0, idx); + }, 20); +} + +export function _updateCctvSyncChip(loading, enabled) { + if (this.destroyed) return; + if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; + const total = Number(loading?.total) || 0; + const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); + const busy = !!enabled && !!loading?.active && total > 0; + + if (busy) { + clearTimeout(this._cctvChipHideTimer); + this._cctvChipHideTimer = null; + this._cctvChipWasBusy = true; + this.actions.setSplitFlapText(this._cctvSyncLabel, 'loading frames'); + // The counter is left plain on purpose: it ticks every few frames + // during a grid load, and flapping it would read as a slot machine. + this._cctvSyncProgress.textContent = `${loaded}/${total}`; + this._cctvSyncChip.classList.add('visible'); + return; + } + + if (this._cctvChipWasBusy && enabled && total > 0) { + // Load just completed — flash the final count, then auto-hide. + this._cctvChipWasBusy = false; + this.actions.setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); + this._cctvSyncProgress.textContent = `${total}/${total}`; + this._cctvSyncChip.classList.add('visible'); + clearTimeout(this._cctvChipHideTimer); + this._cctvChipHideTimer = window.setTimeout(() => { + if (this.destroyed) return; + this._cctvChipHideTimer = null; + this._cctvSyncChip.classList.remove('visible'); + }, 1500); + return; + } + + if (!this._cctvChipHideTimer) { + this._cctvChipWasBusy = false; + this._cctvSyncChip.classList.remove('visible'); + } +}