style: format camera panel component modules

This commit is contained in:
Sameh Khamis committed 2026-09-13 00:38:01 -07:00
1 parent f45f399c31
commit 1a7e4d51d9
7 files changed
+736 -506

No files matched your search

+5 -2
View File
@@ -82,10 +82,13 @@ try {
observersReleased: observed.every( observersReleased: observed.every(
(name) => counts[name] === 1 && ui[name] === null, (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, resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null,
controlsReleased: controlsReleased:
ui._radioControls.destroyed && ui._locationControls.destroyed && ui._cctvControls.destroyed, ui._radioControls.destroyed &&
ui._locationControls.destroyed &&
ui._cctvControls.destroyed,
idempotent: once === JSON.stringify(counts), idempotent: once === JSON.stringify(counts),
}; };
} finally { } finally {
+137 -112
View File
@@ -1,130 +1,155 @@
export function _initCctvPanel() { export function _initCctvPanel() {
if (!this._cctvPanel) return; if (!this._cctvPanel) return;
this.listen(this._cctvEnableBtn, 'click', async () => { this.listen(this._cctvEnableBtn, 'click', async () => {
this._actionGeneration++; this._actionGeneration++;
await this.actions.toggleEnabled(); await this.actions.toggleEnabled();
}); });
this.listen(this._cctvNearestBtn, 'click', async () => { this.listen(this._cctvNearestBtn, 'click', async () => {
const generation = ++this._actionGeneration; const generation = ++this._actionGeneration;
const activeId = this._cctvState?.activeCameraId; const activeId = this._cctvState?.activeCameraId;
if (!await this.actions.toggleEnabled(true)) return; if (!(await this.actions.toggleEnabled(true))) return;
if (this.destroyed || generation !== this._actionGeneration if (
|| !this.actions.isEnabled() this.destroyed ||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return; generation !== this._actionGeneration ||
this.actions.runExplicitFocus( !this.actions.isEnabled() ||
() => this.cctv.focusNearest({ focus: false }), (activeId && activeId !== this._cctvState?.activeCameraId)
(cameraId) => this.cctv.focusCamera(cameraId, 1.8), )
); return;
}); this.actions.runExplicitFocus(
() => this.cctv.focusNearest({ focus: false }),
(cameraId) => this.cctv.focusCamera(cameraId, 1.8),
);
});
this.listen(this._cctvPrevBtn, 'click', async () => { this.listen(this._cctvPrevBtn, 'click', async () => {
const generation = ++this._actionGeneration; const generation = ++this._actionGeneration;
const activeId = this._cctvState?.activeCameraId; const activeId = this._cctvState?.activeCameraId;
if (!await this.actions.toggleEnabled(true)) return; if (!(await this.actions.toggleEnabled(true))) return;
if (this.destroyed || generation !== this._actionGeneration if (
|| !this.actions.isEnabled() this.destroyed ||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return; generation !== this._actionGeneration ||
this.actions.runExplicitFocus( !this.actions.isEnabled() ||
() => this.cctv.cycleCamera(-1), (activeId && activeId !== this._cctvState?.activeCameraId)
(cameraId) => this.cctv.focusCamera(cameraId, 1.4), )
); return;
}); this.actions.runExplicitFocus(
() => this.cctv.cycleCamera(-1),
(cameraId) => this.cctv.focusCamera(cameraId, 1.4),
);
});
this.listen(this._cctvNextBtn, 'click', async () => { this.listen(this._cctvNextBtn, 'click', async () => {
const generation = ++this._actionGeneration; const generation = ++this._actionGeneration;
const activeId = this._cctvState?.activeCameraId; const activeId = this._cctvState?.activeCameraId;
if (!await this.actions.toggleEnabled(true)) return; if (!(await this.actions.toggleEnabled(true))) return;
if (this.destroyed || generation !== this._actionGeneration if (
|| !this.actions.isEnabled() this.destroyed ||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return; generation !== this._actionGeneration ||
this.actions.runExplicitFocus( !this.actions.isEnabled() ||
() => this.cctv.cycleCamera(1), (activeId && activeId !== this._cctvState?.activeCameraId)
(cameraId) => this.cctv.focusCamera(cameraId, 1.4), )
); return;
}); this.actions.runExplicitFocus(
() => this.cctv.cycleCamera(1),
(cameraId) => this.cctv.focusCamera(cameraId, 1.4),
);
});
this.listen(this._cctvSelect, 'change', async () => { this.listen(this._cctvSelect, 'change', async () => {
const generation = ++this._actionGeneration; const generation = ++this._actionGeneration;
const activeId = this._cctvState?.activeCameraId; const activeId = this._cctvState?.activeCameraId;
const cameraId = this._cctvSelect.value; const cameraId = this._cctvSelect.value;
if (!cameraId) return; if (!cameraId) return;
if (!await this.actions.toggleEnabled(true)) return; if (!(await this.actions.toggleEnabled(true))) return;
if (this.destroyed || generation !== this._actionGeneration if (
|| !this.actions.isEnabled() this.destroyed ||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return; generation !== this._actionGeneration ||
// Picking a camera from the dropdown flies to it. The catalog spans !this.actions.isEnabled() ||
// three metros, so a bare selection used to leave the view in the old (activeId && activeId !== this._cctvState?.activeCameraId)
// city with a camera active thousands of km away. )
this.actions.runExplicitFocus( return;
() => (this.cctv.selectCamera(cameraId) ? cameraId : null), // Picking a camera from the dropdown flies to it. The catalog spans
(selectedId) => this.cctv.focusCamera(selectedId, 2.2), // 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.setParams({ selectedCameraId: cameraId }, { origin: 'user' }); 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 () => { this.listen(this._cctvFocusBtn, 'click', async () => {
const generation = ++this._actionGeneration; const generation = ++this._actionGeneration;
const activeId = this._cctvState?.activeCameraId; const activeId = this._cctvState?.activeCameraId;
const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value;
if (!selected) return; if (!selected) return;
if (!await this.actions.toggleEnabled(true)) return; if (!(await this.actions.toggleEnabled(true))) return;
if (this.destroyed || generation !== this._actionGeneration if (
|| !this.actions.isEnabled() this.destroyed ||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return; generation !== this._actionGeneration ||
this.actions.runExplicitFocus( !this.actions.isEnabled() ||
() => selected, (activeId && activeId !== this._cctvState?.activeCameraId)
(cameraId) => this.cctv.focusCamera(cameraId, 1.9), )
); return;
this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' }); this.actions.runExplicitFocus(
}); () => selected,
(cameraId) => this.cctv.focusCamera(cameraId, 1.9),
);
this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' });
});
this.listen(this._cctvCoverageBtn, 'click', () => { this.listen(this._cctvCoverageBtn, 'click', () => {
const current = this._cctvState?.coverageMode const current =
|| (this._cctvState?.showCoverage ? 'on' : 'off'); this._cctvState?.coverageMode ||
const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; (this._cctvState?.showCoverage ? 'on' : 'off');
this.actions.setParams({ coverageMode: next }, { origin: 'user' }); const next =
}); current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off';
this.actions.setParams({ coverageMode: next }, { origin: 'user' });
});
this.listen(this._cctvAutoHopBtn, 'click', () => { this.listen(this._cctvAutoHopBtn, 'click', () => {
const current = !!this._cctvState?.autoHop; const current = !!this._cctvState?.autoHop;
this.actions.setParams({ autoHop: !current }, { origin: 'user' }); this.actions.setParams({ autoHop: !current }, { origin: 'user' });
}); });
this.listen(this._cctvProjectionBtn, 'click', () => { this.listen(this._cctvProjectionBtn, 'click', () => {
const current = this._cctvState?.showProjection !== false; const current = this._cctvState?.showProjection !== false;
this.actions.setParams({ showProjection: !current }, { origin: 'user' }); this.actions.setParams({ showProjection: !current }, { origin: 'user' });
}); });
this.listen(this._cctvAdjustBtn, 'click', () => { this.listen(this._cctvAdjustBtn, 'click', () => {
const current = !!this._cctvState?.calibrationMode; const current = !!this._cctvState?.calibrationMode;
this.actions.setParams({ calibrationMode: !current }, { origin: 'user' }); this.actions.setParams({ calibrationMode: !current }, { origin: 'user' });
}); });
// Click-to-edit pose readout: each chip swaps to a number input; Enter or // Click-to-edit pose readout: each chip swaps to a number input; Enter or
// blur commits (converted to a calibration offset against basePose), // blur commits (converted to a calibration offset against basePose),
// Escape cancels. Delegated so re-renders never re-bind. // Escape cancels. Delegated so re-renders never re-bind.
this.listen(this._cctvCalReadout, 'click', (event) => { this.listen(this._cctvCalReadout, 'click', (event) => {
const chip = event.target.closest?.('.cctv-cal-value'); const chip = event.target.closest?.('.cctv-cal-value');
if (!chip || chip.disabled || chip.querySelector('input')) return; if (!chip || chip.disabled || chip.querySelector('input')) return;
this._beginCctvCalValueEdit(chip); this._beginCctvCalValueEdit(chip);
}); });
this.listen(this._cctvCalibSaveBtn, 'click', () => { this.listen(this._cctvCalibSaveBtn, 'click', () => {
const cameraId = this._activeCctvCameraId(); const cameraId = this._activeCctvCameraId();
if (!cameraId || !this.actions.setParams) return; if (!cameraId || !this.actions.setParams) return;
this.actions.setParams({ this.actions.setParams(
{
selectedCameraId: cameraId, selectedCameraId: cameraId,
calibration: { cameraId, save: true }, 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.listen(this._cctvCalibResetBtn, 'click', () => {
this._resetCctvCalibration(); this._resetCctvCalibration();
}); });
this._renderCctvState(null); this._renderCctvState(null);
this.actions.syncViewport(); this.actions.syncViewport();
} }
+123 -78
View File
@@ -10,131 +10,176 @@ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
*/ */
const CCTV_CAL_FIELDS = { const CCTV_CAL_FIELDS = {
heading: { heading: {
label: 'HDG', unit: '°', decimals: 1, label: 'HDG',
unit: '°',
decimals: 1,
get: (cam) => cam.headingDeg, get: (cam) => cam.headingDeg,
toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), toPatch: (value, base) => ({
headingDeg: signedNormalizeDeg(value - base.headingDeg),
}),
}, },
pitch: { pitch: {
label: 'PITCH', unit: '°', decimals: 1, label: 'PITCH',
unit: '°',
decimals: 1,
get: (cam) => cam.pitchDeg, get: (cam) => cam.pitchDeg,
toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }),
}, },
fov: { fov: {
label: 'FOV', unit: '°', decimals: 0, label: 'FOV',
unit: '°',
decimals: 0,
get: (cam) => cam.fovDeg, get: (cam) => cam.fovDeg,
toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }),
}, },
range: { range: {
label: 'RANGE', unit: 'm', decimals: 0, label: 'RANGE',
unit: 'm',
decimals: 0,
get: (cam) => cam.rangeM, 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: { height: {
label: 'HGT', unit: 'm', decimals: 0, label: 'HGT',
unit: 'm',
decimals: 0,
get: (cam) => cam.mountHeightM, get: (cam) => cam.mountHeightM,
toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), toPatch: (value, base) => ({ heightM: value - base.mountHeightM }),
}, },
north: { north: {
label: 'ΔN', unit: 'm', decimals: 1, label: 'ΔN',
unit: 'm',
decimals: 1,
get: (cam) => cam.calibration?.offsetNorthM || 0, get: (cam) => cam.calibration?.offsetNorthM || 0,
toPatch: (value) => ({ offsetNorthM: value }), toPatch: (value) => ({ offsetNorthM: value }),
}, },
east: { east: {
label: 'ΔE', unit: 'm', decimals: 1, label: 'ΔE',
unit: 'm',
decimals: 1,
get: (cam) => cam.calibration?.offsetEastM || 0, get: (cam) => cam.calibration?.offsetEastM || 0,
toPatch: (value) => ({ offsetEastM: value }), toPatch: (value) => ({ offsetEastM: value }),
}, },
}; };
export function _activeCctvCameraId() { export function _activeCctvCameraId() {
return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; return this._cctvState?.activeCameraId || this._cctvSelect?.value || '';
} }
export function _resetCctvCalibration() { export function _resetCctvCalibration() {
const cameraId = this._activeCctvCameraId(); const cameraId = this._activeCctvCameraId();
if (!cameraId || !this.actions.setParams) return; if (!cameraId || !this.actions.setParams) return;
this.actions.setParams({ this.actions.setParams(
{
selectedCameraId: cameraId, selectedCameraId: cameraId,
calibration: { calibration: {
cameraId, cameraId,
reset: true, reset: true,
}, },
}, { origin: 'user' }); },
this.actions.showToast('CCTV calibration reset'); { origin: 'user' },
);
this.actions.showToast('CCTV calibration reset');
} }
export function _beginCctvCalValueEdit(chip) { export function _beginCctvCalValueEdit(chip) {
if (this.destroyed) return; if (this.destroyed) return;
this._calibrationEdit?.(false); this._calibrationEdit?.(false);
const field = CCTV_CAL_FIELDS[chip.dataset.calField]; const field = CCTV_CAL_FIELDS[chip.dataset.calField];
const activeCamera = this._cctvState?.activeCamera; const activeCamera = this._cctvState?.activeCamera;
if (!field || !activeCamera?.basePose) return; if (!field || !activeCamera?.basePose) return;
const cameraId = this._activeCctvCameraId(); const cameraId = this._activeCctvCameraId();
const basePose = { ...activeCamera.basePose }; const basePose = { ...activeCamera.basePose };
const editListeners = new AbortController(); const editListeners = new AbortController();
const startValue = field.get(activeCamera); const startValue = field.get(activeCamera);
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'number'; input.type = 'number';
input.step = field.decimals > 0 ? '0.1' : '1'; input.step = field.decimals > 0 ? '0.1' : '1';
input.value = Number(startValue).toFixed(field.decimals); input.value = Number(startValue).toFixed(field.decimals);
input.className = 'cctv-cal-input'; input.className = 'cctv-cal-input';
chip.textContent = `${field.label} `; chip.textContent = `${field.label} `;
chip.appendChild(input); chip.appendChild(input);
input.focus(); input.focus();
input.select(); input.select();
let finished = false; let finished = false;
const finish = (commit) => { const finish = (commit) => {
if (finished) return; if (finished) return;
finished = true; finished = true;
editListeners.abort(); editListeners.abort();
this._calibrationEdit = null; this._calibrationEdit = null;
const typed = parseFloat(input.value); const typed = parseFloat(input.value);
input.remove(); input.remove();
if (commit && !this.destroyed && Number.isFinite(typed) if (
&& this._cctvState?.enabled && this.actions.isEnabled() commit &&
&& cameraId === this._activeCctvCameraId()) { !this.destroyed &&
if (cameraId && this.actions.setParams) { Number.isFinite(typed) &&
this.actions.setParams({ this._cctvState?.enabled &&
this.actions.isEnabled() &&
cameraId === this._activeCctvCameraId()
) {
if (cameraId && this.actions.setParams) {
this.actions.setParams(
{
selectedCameraId: cameraId, selectedCameraId: cameraId,
calibration: { cameraId, patch: field.toPatch(typed, basePose) }, 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._syncCctvCalReadout(
this._calibrationEdit = finish; !!this._cctvState?.enabled,
input.addEventListener('keydown', (event) => { this._cctvState?.activeCamera || null,
);
};
this._calibrationEdit = finish;
input.addEventListener(
'keydown',
(event) => {
if (event.key === 'Enter') finish(true); 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(); event.stopPropagation();
}, { signal: editListeners.signal }); },
input.addEventListener('blur', () => finish(true), { signal: editListeners.signal }); { signal: editListeners.signal },
input.addEventListener('click', (event) => 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) { export function _syncCctvCalReadout(enabled, activeCamera) {
const canCalibrate = !!enabled && !!activeCamera; const canCalibrate = !!enabled && !!activeCamera;
if (this._cctvAdjustBtn) { if (this._cctvAdjustBtn) {
const adjustOn = !!this._cctvState?.calibrationMode; const adjustOn = !!this._cctvState?.calibrationMode;
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST';
this._cctvAdjustBtn.disabled = !canCalibrate; this._cctvAdjustBtn.disabled = !canCalibrate;
} }
if (this._cctvCalReadout) { if (this._cctvCalReadout) {
for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { for (const chip of this._cctvCalReadout.querySelectorAll(
if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber '.cctv-cal-value',
const field = CCTV_CAL_FIELDS[chip.dataset.calField]; )) {
if (!field) continue; if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber
const value = canCalibrate ? field.get(activeCamera) : null; const field = CCTV_CAL_FIELDS[chip.dataset.calField];
chip.textContent = Number.isFinite(value) if (!field) continue;
? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` const value = canCalibrate ? field.get(activeCamera) : null;
: `${field.label} --`; chip.textContent = Number.isFinite(value)
chip.disabled = !canCalibrate; ? `${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;
} }
}
for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) {
if (el) el.disabled = !canCalibrate;
}
} }
+67 -19
View File
@@ -1,6 +1,21 @@
import { _clearCctvFrame, _queueCctvFrame, _settleCctvFrame, _syncCctvSourceBadge } from './cctvFrames.js'; import {
import { _activeCctvCameraId, _resetCctvCalibration, _beginCctvCalValueEdit, _syncCctvCalReadout } from './cctvCalibration.js'; _clearCctvFrame,
import { _calBadgeLabel, _renderCctvState, _typeCctvSummary, _updateCctvSyncChip } from './cctvPresentation.js'; _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'; import { _initCctvPanel } from './cctvBindings.js';
/** Own camera-panel interaction and presentation; receive the camera port and application actions. */ /** Own camera-panel interaction and presentation; receive the camera port and application actions. */
@@ -25,29 +40,62 @@ export class CctvControls {
this._initCctvPanel(); this._initCctvPanel();
} }
listen(target, type, handler, options = {}) { 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() { connect() {
this._cctvUnsubscribe?.(); this._cctvUnsubscribe?.();
this._cctvUnsubscribe = null; this._cctvUnsubscribe = null;
if (this.destroyed) return; 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()); if (this.cctv.getUIState) this._renderCctvState(this.cctv.getUIState());
} }
_clearCctvFrame(...args) { return _clearCctvFrame.call(this, ...args); } _clearCctvFrame(...args) {
_queueCctvFrame(...args) { return _queueCctvFrame.call(this, ...args); } return _clearCctvFrame.call(this, ...args);
_settleCctvFrame(...args) { return _settleCctvFrame.call(this, ...args); } }
_syncCctvSourceBadge(...args) { return _syncCctvSourceBadge.call(this, ...args); } _queueCctvFrame(...args) {
_activeCctvCameraId(...args) { return _activeCctvCameraId.call(this, ...args); } return _queueCctvFrame.call(this, ...args);
_resetCctvCalibration(...args) { return _resetCctvCalibration.call(this, ...args); } }
_beginCctvCalValueEdit(...args) { return _beginCctvCalValueEdit.call(this, ...args); } _settleCctvFrame(...args) {
_syncCctvCalReadout(...args) { return _syncCctvCalReadout.call(this, ...args); } return _settleCctvFrame.call(this, ...args);
_calBadgeLabel(...args) { return _calBadgeLabel.call(this, ...args); } }
_renderCctvState(...args) { return _renderCctvState.call(this, ...args); } _syncCctvSourceBadge(...args) {
_typeCctvSummary(...args) { return _typeCctvSummary.call(this, ...args); } return _syncCctvSourceBadge.call(this, ...args);
_updateCctvSyncChip(...args) { return _updateCctvSyncChip.call(this, ...args); } }
_initCctvPanel(...args) { return _initCctvPanel.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() { destroy() {
if (this.destroyed) return; if (this.destroyed) return;
this.destroyed = true; this.destroyed = true;
+87 -29
View File
@@ -5,24 +5,43 @@ import { CctvControls } from './cctvControls.js';
function element() { function element() {
const classes = new Set(); const classes = new Set();
return { return {
dataset: {}, src: '', dataset: {},
removeAttribute(name) { if (name === 'src') this.src = ''; }, src: '',
removeAttribute(name) {
if (name === 'src') this.src = '';
},
classList: { classList: {
add(...values) { values.forEach(value => classes.add(value)); }, add(...values) {
remove(...values) { values.forEach(value => classes.delete(value)); }, values.forEach((value) => classes.add(value));
contains(value) { return classes.has(value); }, },
toggle(value, enabled) { if (enabled) classes.add(value); else classes.delete(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) { function fixture(t) {
const prior = globalThis.Image; const prior = globalThis.Image;
const requests = []; const requests = [];
globalThis.Image = class { constructor() { requests.push(this); } }; globalThis.Image = class {
t.after(() => { globalThis.Image = prior; }); constructor() {
requests.push(this);
}
};
t.after(() => {
globalThis.Image = prior;
});
const controls = new CctvControls({ const controls = new CctvControls({
elements: { _cctvFrame: element(), _cctvFrameWrap: element() }, elements: { _cctvFrame: element(), _cctvFrameWrap: element() },
cctv: {}, actions: { isEnabled: () => true }, cctv: {},
actions: { isEnabled: () => true },
}); });
t.after(() => controls.destroy()); t.after(() => controls.destroy());
return { controls, requests }; 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) => { test('destroy invalidates image callbacks and releases each subscription once', (t) => {
const { controls, requests } = fixture(t); const { controls, requests } = fixture(t);
let unsubscribed = 0; let unsubscribed = 0;
controls.cctv.subscribe = () => () => { unsubscribed++; }; controls.cctv.subscribe = () => () => {
unsubscribed++;
};
controls.connect(); controls.connect();
controls.connect(); controls.connect();
assert.equal(unsubscribed, 1); assert.equal(unsubscribed, 1);
@@ -78,27 +99,46 @@ function calibrationFixture(t) {
const { controls } = fixture(t); const { controls } = fixture(t);
const prior = globalThis.document; const prior = globalThis.document;
const inputs = []; const inputs = [];
globalThis.document = { createElement() { globalThis.document = {
const input = new EventTarget(); createElement() {
Object.assign(input, { focus() {}, select() {}, remove() { this.parent.input = null; } }); const input = new EventTarget();
inputs.push(input); Object.assign(input, {
return input; focus() {},
} }; select() {},
t.after(() => { globalThis.document = prior; }); remove() {
this.parent.input = null;
},
});
inputs.push(input);
return input;
},
};
t.after(() => {
globalThis.document = prior;
});
const chip = { const chip = {
dataset: { calField: 'heading' }, textContent: '', input: null, dataset: { calField: 'heading' },
appendChild(input) { this.input = input; input.parent = this; }, textContent: '',
querySelector() { return this.input; }, input: null,
appendChild(input) {
this.input = input;
input.parent = this;
},
querySelector() {
return this.input;
},
}; };
const patches = []; const patches = [];
controls.actions.setParams = (params) => patches.push(params); controls.actions.setParams = (params) => patches.push(params);
controls.actions.setPanelCollapsed = () => {}; controls.actions.setPanelCollapsed = () => {};
controls._cctvCalReadout = { querySelectorAll: () => [chip] }; controls._cctvCalReadout = { querySelectorAll: () => [chip] };
controls._cctvState = { controls._cctvState = {
enabled: true, activeCameraId: 'a', enabled: true,
activeCameraId: 'a',
activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } }, 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 }; 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; controls._cctvState.activeCamera.basePose.headingDeg = 60;
inputs[0].dispatchEvent(key('Enter')); inputs[0].dispatchEvent(key('Enter'));
inputs[0].dispatchEvent(new Event('blur')); 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); 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); const { controls, chip, inputs, patches } = calibrationFixture(t);
controls._beginCctvCalValueEdit(chip); controls._beginCctvCalValueEdit(chip);
inputs[0].value = '100'; 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')); inputs[0].dispatchEvent(new Event('blur'));
assert.deepEqual(patches, []); assert.deepEqual(patches, []);
assert.equal(chip.input, null); 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 enables = 0;
let focuses = 0; let focuses = 0;
const controls = new CctvControls({ const controls = new CctvControls({
elements: { _cctvPanel: {}, _cctvNextBtn: button }, cctv: {}, elements: { _cctvPanel: {}, _cctvNextBtn: button },
cctv: {},
actions: { actions: {
isEnabled: () => true, syncViewport() {}, isEnabled: () => true,
toggleEnabled() { enables++; return new Promise(resolve => { resolveEnable = resolve; }); }, syncViewport() {},
runExplicitFocus() { focuses++; }, toggleEnabled() {
enables++;
return new Promise((resolve) => {
resolveEnable = resolve;
});
},
runExplicitFocus() {
focuses++;
},
}, },
}); });
button.dispatchEvent(new Event('click')); button.dispatchEvent(new Event('click'));
controls.destroy(); controls.destroy();
resolveEnable(true); resolveEnable(true);
await new Promise(resolve => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
button.dispatchEvent(new Event('click')); button.dispatchEvent(new Event('click'));
assert.equal(enables, 1); assert.equal(enables, 1);
assert.equal(focuses, 0); assert.equal(focuses, 0);
+92 -83
View File
@@ -1,102 +1,111 @@
export function _clearCctvFrame() { export function _clearCctvFrame() {
this._cctvFrameRequestToken += 1; this._cctvFrameRequestToken += 1;
if (this._cctvFramePreloader) { if (this._cctvFramePreloader) {
this._cctvFramePreloader.onload = null; this._cctvFramePreloader.onload = null;
this._cctvFramePreloader.onerror = null; this._cctvFramePreloader.onerror = null;
} }
this._cctvFramePreloader = null; this._cctvFramePreloader = null;
if (this._cctvFrame) { if (this._cctvFrame) {
this._cctvFrame.classList.remove('active'); this._cctvFrame.classList.remove('active');
this._cctvFrame.removeAttribute('src'); this._cctvFrame.removeAttribute('src');
this._cctvFrame.dataset.cameraId = ''; this._cctvFrame.dataset.cameraId = '';
this._cctvFrame.dataset.currentSrc = ''; this._cctvFrame.dataset.currentSrc = '';
this._cctvFrame.dataset.loading = ''; this._cctvFrame.dataset.loading = '';
this._cctvFrame.dataset.error = ''; this._cctvFrame.dataset.error = '';
} }
this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); this._cctvFrameWrap?.classList.remove('loading', 'has-frame');
} }
export function _queueCctvFrame(src, cameraId, cameraChanged) { export function _queueCctvFrame(src, cameraId, cameraChanged) {
if (this.destroyed || !this._cctvFrame || !src) return; if (this.destroyed || !this._cctvFrame || !src) return;
if (cameraChanged) { if (cameraChanged) {
// A different camera gets an honest acquisition state. Never retain // A different camera gets an honest acquisition state. Never retain
// the prior camera's pixels under the newly selected metadata. // the prior camera's pixels under the newly selected metadata.
this._cctvFrame.classList.remove('active'); this._cctvFrame.classList.remove('active');
this._cctvFrame.removeAttribute('src'); this._cctvFrame.removeAttribute('src');
this._cctvFrameWrap?.classList.remove('has-frame'); this._cctvFrameWrap?.classList.remove('has-frame');
} }
if (this._cctvFramePreloader) { if (this._cctvFramePreloader) {
this._cctvFramePreloader.onload = null; this._cctvFramePreloader.onload = null;
this._cctvFramePreloader.onerror = null; this._cctvFramePreloader.onerror = null;
} }
const token = ++this._cctvFrameRequestToken; const token = ++this._cctvFrameRequestToken;
this._cctvFrame.dataset.cameraId = cameraId; this._cctvFrame.dataset.cameraId = cameraId;
this._cctvFrame.dataset.currentSrc = src; this._cctvFrame.dataset.currentSrc = src;
this._cctvFrame.dataset.loading = 'true'; this._cctvFrame.dataset.loading = 'true';
this._cctvFrame.dataset.error = ''; this._cctvFrame.dataset.error = '';
this._cctvFrameWrap?.classList.toggle( this._cctvFrameWrap?.classList.toggle(
'loading', 'loading',
!this._cctvFrameWrap?.classList.contains('has-frame') !this._cctvFrameWrap?.classList.contains('has-frame'),
); );
const preloader = new Image(); const preloader = new Image();
this._cctvFramePreloader = preloader; this._cctvFramePreloader = preloader;
preloader.onload = () => this._settleCctvFrame(token, src, true); preloader.onload = () => this._settleCctvFrame(token, src, true);
preloader.onerror = () => this._settleCctvFrame(token, src, false); preloader.onerror = () => this._settleCctvFrame(token, src, false);
preloader.src = src; preloader.src = src;
} }
export function _settleCctvFrame(token, src, ok) { export function _settleCctvFrame(token, src, ok) {
if (this.destroyed || !this._cctvFrame || token !== this._cctvFrameRequestToken) return; if (
if (this._cctvFramePreloader) { this.destroyed ||
this._cctvFramePreloader.onload = null; !this._cctvFrame ||
this._cctvFramePreloader.onerror = null; token !== this._cctvFrameRequestToken
} )
this._cctvFramePreloader = null; return;
this._cctvFrame.dataset.loading = ''; if (this._cctvFramePreloader) {
this._cctvFrameWrap?.classList.remove('loading'); 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?.activeCamera,
!!this._cctvState?.enabled && !!this.actions.isEnabled() !!this._cctvState?.enabled && !!this.actions.isEnabled(),
); );
if (!ok) { if (!ok) {
// Leave the element untouched — a settled frame stays on screen. // Leave the element untouched — a settled frame stays on screen.
this._cctvFrame.dataset.error = 'true'; 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(); 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) { export function _syncCctvSourceBadge(activeCamera, enabled) {
if (!this._cctvSourceBadge) return; if (!this._cctvSourceBadge) return;
if (!enabled || !activeCamera) { if (!enabled || !activeCamera) {
this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN';
this._cctvSourceBadge.dataset.frameState = 'idle'; this._cctvSourceBadge.dataset.frameState = 'idle';
return; return;
} }
const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); const hasDisplayedFrame =
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { this._cctvFrameWrap?.classList.contains('has-frame');
this._cctvSourceBadge.textContent = 'FRAME · LOADING'; if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.dataset.frameState = 'loading'; this._cctvSourceBadge.textContent = 'FRAME · LOADING';
return; this._cctvSourceBadge.dataset.frameState = 'loading';
} return;
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { }
this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.dataset.frameState = 'error'; this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE';
return; this._cctvSourceBadge.dataset.frameState = 'error';
} return;
const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); }
const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); const kind = String(
this._cctvSourceBadge.textContent = `${kind} · ${status}`; activeCamera.sourceKind || activeCamera.feedType || 'unknown',
this._cctvSourceBadge.dataset.frameState = 'ready'; ).toUpperCase();
const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
this._cctvSourceBadge.dataset.frameState = 'ready';
} }
+225 -183
View File
@@ -1,212 +1,254 @@
export function _calBadgeLabel(badge) { export function _calBadgeLabel(badge) {
switch (badge) { switch (badge) {
case 'calibrated': return 'CALIBRATED'; case 'calibrated':
case 'curated': return 'CURATED'; return 'CALIBRATED';
case 'raw-prior': return 'RAW PRIOR'; case 'curated':
default: return '--'; return 'CURATED';
} case 'raw-prior':
return 'RAW PRIOR';
default:
return '--';
}
} }
export function _renderCctvState(state) { export function _renderCctvState(state) {
if (this.destroyed) return; if (this.destroyed) return;
if (!state?.enabled || !this.actions.isEnabled() || state?.activeCameraId !== this._cctvState?.activeCameraId) { if (
this._calibrationEdit?.(false); !state?.enabled ||
} !this.actions.isEnabled() ||
this._cctvState = state || null; state?.activeCameraId !== this._cctvState?.activeCameraId
const cameras = state?.cameras || []; ) {
const enabled = !!state?.enabled && !!this.actions.isEnabled(); this._calibrationEdit?.(false);
const activeId = state?.activeCameraId || ''; }
const activeCamera = state?.activeCamera || null; 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 // 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, // id while the layer is enabled. Covers click-on-globe, panel controls,
// and voice (selectCamera/cycleCamera/focusNearest all notify through // and voice (selectCamera/cycleCamera/focusNearest all notify through
// this subscription). The last-seen guard keeps routine notifications // this subscription). The last-seen guard keeps routine notifications
// from re-expanding a panel the user deliberately collapsed, and timed // from re-expanding a panel the user deliberately collapsed, and timed
// auto-hop transitions only expand on the first activation so the panel // auto-hop transitions only expand on the first activation so the panel
// does not pop open on every hop. // does not pop open on every hop.
const effectiveActiveId = enabled ? (activeId || null) : null; const effectiveActiveId = enabled ? activeId || null : null;
const isFirstActivation = this._lastSeenCctvActiveId === null; const isFirstActivation = this._lastSeenCctvActiveId === null;
if (effectiveActiveId if (
&& effectiveActiveId !== this._lastSeenCctvActiveId effectiveActiveId &&
&& (!state?.autoHop || isFirstActivation)) { effectiveActiveId !== this._lastSeenCctvActiveId &&
this.actions.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); (!state?.autoHop || isFirstActivation)
} ) {
this._lastSeenCctvActiveId = effectiveActiveId; 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) { if (this._cctvEnableBtn) {
this._cctvEnableBtn.classList.toggle('active', enabled); this._cctvEnableBtn.classList.toggle('active', enabled);
this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF';
} }
if (this._cctvSelect) { if (this._cctvSelect) {
const shouldRebuild = this._cctvSelect.options.length !== cameras.length const shouldRebuild =
|| cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); this._cctvSelect.options.length !== cameras.length ||
if (shouldRebuild) { cameras.some(
this._cctvSelect.innerHTML = ''; (cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
for (const camera of cameras) { );
const option = document.createElement('option'); if (shouldRebuild) {
option.value = camera.id; this._cctvSelect.innerHTML = '';
option.textContent = `${camera.city} · ${camera.name}`; for (const camera of cameras) {
this._cctvSelect.appendChild(option); const option = document.createElement('option');
} option.value = camera.id;
} option.textContent = `${camera.city} · ${camera.name}`;
this._cctvSelect.disabled = !enabled || cameras.length === 0; this._cctvSelect.appendChild(option);
if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) {
this._cctvSelect.value = activeId;
} else if (!activeId) {
this._cctvSelect.selectedIndex = -1;
} }
} }
this._cctvSelect.disabled = !enabled || cameras.length === 0;
for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { if (
if (!btn) continue; activeId &&
btn.disabled = !enabled || cameras.length === 0; Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)
} ) {
if (this._cctvFocusBtn) { this._cctvSelect.value = activeId;
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; } else if (!activeId) {
this._cctvSelect.selectedIndex = -1;
} }
}
if (this._cctvCoverageBtn) { for (const btn of [
// Tri-state (viewshed design §3b): off → on (wireframes) → viewshed this._cctvNearestBtn,
// (color-coded volumes). The click handler cycles; this renders. this._cctvPrevBtn,
const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); this._cctvNextBtn,
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); ]) {
this._cctvCoverageBtn.textContent = mode === 'viewshed' 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' ? 'VIEWSHED ON'
: mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; : mode === 'on'
this._cctvCoverageBtn.disabled = !enabled; ? '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) { if (this._cctvFrame) {
const autoHop = !!state?.autoHop; const nextSrc = enabled ? activeCamera?.frameUrl : null;
this._cctvAutoHopBtn.classList.toggle('active', autoHop); const nextCameraId = enabled ? activeCamera?.id || '' : '';
this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId;
this._cctvAutoHopBtn.disabled = !enabled; 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) {
if (this._cctvProjectionBtn) { this._clearCctvFrame();
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) { this._syncCctvSourceBadge(activeCamera, enabled);
// CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, this._typeCctvSummary(
// no in-world tint): three states driven by cctv.js's deriveCalBadge, state?.summary ||
// no client-side scoring math. Casing is unified via _calBadgeLabel so 'Enable CCTV to start camera-linked intelligence summaries.',
// 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) { export function _typeCctvSummary(text) {
if (this.destroyed || !this._cctvSummary) return; if (this.destroyed || !this._cctvSummary) return;
const nextText = String(text || '').trim() || 'No summary available.'; const nextText = String(text || '').trim() || 'No summary available.';
if (nextText === this._lastCctvSummaryText) return; if (nextText === this._lastCctvSummaryText) return;
this._lastCctvSummaryText = nextText; this._lastCctvSummaryText = nextText;
clearInterval(this._cctvSummaryTypingTimer); clearInterval(this._cctvSummaryTypingTimer);
this._cctvSummary.textContent = ''; this._cctvSummary.textContent = '';
let idx = 0; let idx = 0;
this._cctvSummaryTypingTimer = setInterval(() => { this._cctvSummaryTypingTimer = setInterval(() => {
if (this.destroyed) return; if (this.destroyed) return;
idx += 3; idx += 3;
if (idx >= nextText.length) { if (idx >= nextText.length) {
this._cctvSummary.textContent = nextText; this._cctvSummary.textContent = nextText;
clearInterval(this._cctvSummaryTypingTimer); clearInterval(this._cctvSummaryTypingTimer);
this._cctvSummaryTypingTimer = null; this._cctvSummaryTypingTimer = null;
return; return;
} }
this._cctvSummary.textContent = nextText.slice(0, idx); this._cctvSummary.textContent = nextText.slice(0, idx);
}, 20); }, 20);
} }
export function _updateCctvSyncChip(loading, enabled) { export function _updateCctvSyncChip(loading, enabled) {
if (this.destroyed) return; if (this.destroyed) return;
if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress)
const total = Number(loading?.total) || 0; return;
const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); const total = Number(loading?.total) || 0;
const busy = !!enabled && !!loading?.active && total > 0; const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total));
const busy = !!enabled && !!loading?.active && total > 0;
if (busy) { if (busy) {
clearTimeout(this._cctvChipHideTimer); 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._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'); this._cctvSyncChip.classList.remove('visible');
} }, 1500);
return;
}
if (!this._cctvChipHideTimer) {
this._cctvChipWasBusy = false;
this._cctvSyncChip.classList.remove('visible');
}
} }