diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 1cfa077c4..9aabdd75c 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -82,10 +82,13 @@ try { observersReleased: observed.every( (name) => counts[name] === 1 && ui[name] === null, ), - subscriptionReleased: counts.cctv === 1 && ui._cctvControls._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._cctvControls.destroyed, + ui._radioControls.destroyed && + ui._locationControls.destroyed && + ui._cctvControls.destroyed, idempotent: once === JSON.stringify(counts), }; } finally { diff --git a/src/ui/cctvBindings.js b/src/ui/cctvBindings.js index 5c70c2c65..612e4eb37 100644 --- a/src/ui/cctvBindings.js +++ b/src/ui/cctvBindings.js @@ -1,130 +1,155 @@ export function _initCctvPanel() { - if (!this._cctvPanel) return; + if (!this._cctvPanel) return; - this.listen(this._cctvEnableBtn, 'click', async () => { - this._actionGeneration++; - await this.actions.toggleEnabled(); - }); + 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._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._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._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._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._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._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._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._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' }); - }); + 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); - }); + // 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({ + 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'); - }); + }, + { origin: 'user' }, + ); + this.actions.showToast('CCTV calibration saved'); + }); - this.listen(this._cctvCalibResetBtn, 'click', () => { - this._resetCctvCalibration(); - }); + this.listen(this._cctvCalibResetBtn, 'click', () => { + this._resetCctvCalibration(); + }); - this._renderCctvState(null); - this.actions.syncViewport(); + this._renderCctvState(null); + this.actions.syncViewport(); } diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js index 6d2d4487b..533c2615d 100644 --- a/src/ui/cctvCalibration.js +++ b/src/ui/cctvCalibration.js @@ -10,131 +10,176 @@ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; */ const CCTV_CAL_FIELDS = { heading: { - label: 'HDG', unit: '°', decimals: 1, + label: 'HDG', + unit: '°', + decimals: 1, get: (cam) => cam.headingDeg, - toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), + toPatch: (value, base) => ({ + headingDeg: signedNormalizeDeg(value - base.headingDeg), + }), }, pitch: { - label: 'PITCH', unit: '°', decimals: 1, + label: 'PITCH', + unit: '°', + decimals: 1, get: (cam) => cam.pitchDeg, toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), }, fov: { - label: 'FOV', unit: '°', decimals: 0, + label: 'FOV', + unit: '°', + decimals: 0, get: (cam) => cam.fovDeg, toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), }, range: { - label: 'RANGE', unit: 'm', decimals: 0, + label: 'RANGE', + unit: 'm', + decimals: 0, get: (cam) => cam.rangeM, - toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), + toPatch: (value, base) => ({ + rangeScale: base.rangeM > 0 ? value / base.rangeM : 1, + }), }, height: { - label: 'HGT', unit: 'm', decimals: 0, + label: 'HGT', + unit: 'm', + decimals: 0, get: (cam) => cam.mountHeightM, toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), }, north: { - label: 'ΔN', unit: 'm', decimals: 1, + label: 'ΔN', + unit: 'm', + decimals: 1, get: (cam) => cam.calibration?.offsetNorthM || 0, toPatch: (value) => ({ offsetNorthM: value }), }, east: { - label: 'ΔE', unit: 'm', decimals: 1, + 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 || ''; + return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; } export function _resetCctvCalibration() { - const cameraId = this._activeCctvCameraId(); - if (!cameraId || !this.actions.setParams) return; - this.actions.setParams({ + 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'); + }, + { 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(); + 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({ + 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 - } + }, + { 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) => { + } + 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); } + 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 }); + }, + { 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; + 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 index 46805ad69..75ab0be98 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -1,6 +1,21 @@ -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 { + _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. */ @@ -25,29 +40,62 @@ export class CctvControls { this._initCctvPanel(); } listen(target, type, handler, options = {}) { - target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); + target?.addEventListener(type, handler, { + ...options, + signal: this.listeners.signal, + }); + } + getState() { + return this._cctvState; } - getState() { return this._cctvState; } connect() { this._cctvUnsubscribe?.(); this._cctvUnsubscribe = null; if (this.destroyed) return; - this._cctvUnsubscribe = this.cctv.subscribe?.((state) => this._renderCctvState(state)); + 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); } + _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; diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 8d93a7ae1..5ab5a3280 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -5,24 +5,43 @@ import { CctvControls } from './cctvControls.js'; function element() { const classes = new Set(); return { - dataset: {}, src: '', - removeAttribute(name) { if (name === 'src') this.src = ''; }, + 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); }, + 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; }); + 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 }, + cctv: {}, + actions: { isEnabled: () => true }, }); t.after(() => controls.destroy()); return { controls, requests }; @@ -56,7 +75,9 @@ test('failed refresh preserves settled pixels, but changing cameras clears them' test('destroy invalidates image callbacks and releases each subscription once', (t) => { const { controls, requests } = fixture(t); let unsubscribed = 0; - controls.cctv.subscribe = () => () => { unsubscribed++; }; + controls.cctv.subscribe = () => () => { + unsubscribed++; + }; controls.connect(); controls.connect(); assert.equal(unsubscribed, 1); @@ -78,27 +99,46 @@ 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; }); + 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; }, + 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', + enabled: true, + activeCameraId: 'a', activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } }, }; - const key = (name) => Object.assign(new Event('keydown', { cancelable: true }), { key: name }); + const key = (name) => + Object.assign(new Event('keydown', { cancelable: true }), { key: name }); return { controls, chip, inputs, patches, key }; } @@ -109,7 +149,12 @@ test('calibration commits against the captured camera base and releases its edit 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.deepEqual(patches, [ + { + selectedCameraId: 'a', + calibration: { cameraId: 'a', patch: { headingDeg: 80 } }, + }, + ]); assert.equal(controls._calibrationEdit, null); }); @@ -117,7 +162,11 @@ test('a camera switch cancels calibration before old blur can change the new cam 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 } } }); + 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); @@ -145,17 +194,26 @@ test('disposing during camera enable prevents the delayed focus and future click let enables = 0; let focuses = 0; const controls = new CctvControls({ - elements: { _cctvPanel: {}, _cctvNextBtn: button }, cctv: {}, + elements: { _cctvPanel: {}, _cctvNextBtn: button }, + cctv: {}, actions: { - isEnabled: () => true, syncViewport() {}, - toggleEnabled() { enables++; return new Promise(resolve => { resolveEnable = resolve; }); }, - runExplicitFocus() { focuses++; }, + 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)); + 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 index fcf4ff379..7dce71dd7 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -1,102 +1,111 @@ 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'); + 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 (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 (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') - ); + 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; + 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'); + 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( + const syncBadge = () => + this._syncCctvSourceBadge( this._cctvState?.activeCamera, - !!this._cctvState?.enabled && !!this.actions.isEnabled() + !!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'); + 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'; + 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 index 7f05706d4..0818c1209 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -1,212 +1,254 @@ export function _calBadgeLabel(badge) { - switch (badge) { - case 'calibrated': return 'CALIBRATED'; - case 'curated': return 'CURATED'; - case 'raw-prior': return 'RAW PRIOR'; - default: return '--'; - } + 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; + 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; + // 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); + this._updateCctvSyncChip(state?.loading, enabled); - if (this._cctvEnableBtn) { - this._cctvEnableBtn.classList.toggle('active', enabled); - this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; - } + 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; + 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); } } - - 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; + 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; } + } - 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' + 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; + : 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._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._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 (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 (!nextSrc) { + this._clearCctvFrame(); } + } - 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.'); + 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; + 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); + 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 (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); + 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._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'); - } + }, 1500); + return; + } + + if (!this._cctvChipHideTimer) { + this._cctvChipWasBusy = false; + this._cctvSyncChip.classList.remove('visible'); + } }