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
+356 -126

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 {
+50 -25
View File
@@ -9,10 +9,14 @@ export function _initCctvPanel() {
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.isEnabled() ||
(activeId && activeId !== this._cctvState?.activeCameraId)
)
return;
this.actions.runExplicitFocus( this.actions.runExplicitFocus(
() => this.cctv.focusNearest({ focus: false }), () => this.cctv.focusNearest({ focus: false }),
(cameraId) => this.cctv.focusCamera(cameraId, 1.8), (cameraId) => this.cctv.focusCamera(cameraId, 1.8),
@@ -22,10 +26,14 @@ export function _initCctvPanel() {
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.isEnabled() ||
(activeId && activeId !== this._cctvState?.activeCameraId)
)
return;
this.actions.runExplicitFocus( this.actions.runExplicitFocus(
() => this.cctv.cycleCamera(-1), () => this.cctv.cycleCamera(-1),
(cameraId) => this.cctv.focusCamera(cameraId, 1.4), (cameraId) => this.cctv.focusCamera(cameraId, 1.4),
@@ -35,10 +43,14 @@ export function _initCctvPanel() {
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.isEnabled() ||
(activeId && activeId !== this._cctvState?.activeCameraId)
)
return;
this.actions.runExplicitFocus( this.actions.runExplicitFocus(
() => this.cctv.cycleCamera(1), () => this.cctv.cycleCamera(1),
(cameraId) => this.cctv.focusCamera(cameraId, 1.4), (cameraId) => this.cctv.focusCamera(cameraId, 1.4),
@@ -50,10 +62,14 @@ export function _initCctvPanel() {
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 ||
!this.actions.isEnabled() ||
(activeId && activeId !== this._cctvState?.activeCameraId)
)
return;
// Picking a camera from the dropdown flies to it. The catalog spans // 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 // three metros, so a bare selection used to leave the view in the old
// city with a camera active thousands of km away. // city with a camera active thousands of km away.
@@ -69,10 +85,14 @@ export function _initCctvPanel() {
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.isEnabled() ||
(activeId && activeId !== this._cctvState?.activeCameraId)
)
return;
this.actions.runExplicitFocus( this.actions.runExplicitFocus(
() => selected, () => selected,
(cameraId) => this.cctv.focusCamera(cameraId, 1.9), (cameraId) => this.cctv.focusCamera(cameraId, 1.9),
@@ -81,9 +101,11 @@ export function _initCctvPanel() {
}); });
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');
const next =
current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off';
this.actions.setParams({ coverageMode: next }, { origin: 'user' }); this.actions.setParams({ coverageMode: next }, { origin: 'user' });
}); });
@@ -114,10 +136,13 @@ export function _initCctvPanel() {
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' }); },
{ origin: 'user' },
);
this.actions.showToast('CCTV calibration saved'); this.actions.showToast('CCTV calibration saved');
}); });
+68 -23
View File
@@ -10,37 +10,55 @@ 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 }),
}, },
@@ -53,13 +71,16 @@ export function _activeCctvCameraId() {
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' }); },
{ origin: 'user' },
);
this.actions.showToast('CCTV calibration reset'); this.actions.showToast('CCTV calibration reset');
} }
@@ -91,27 +112,49 @@ export function _beginCctvCalValueEdit(chip) {
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 &&
Number.isFinite(typed) &&
this._cctvState?.enabled &&
this.actions.isEnabled() &&
cameraId === this._activeCctvCameraId()
) {
if (cameraId && this.actions.setParams) { if (cameraId && this.actions.setParams) {
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' }); },
{ origin: 'user' },
);
return; // re-render restores the chip text from fresh state return; // re-render restores the chip text from fresh state
} }
} }
this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); this._syncCctvCalReadout(
!!this._cctvState?.enabled,
this._cctvState?.activeCamera || null,
);
}; };
this._calibrationEdit = finish; this._calibrationEdit = finish;
input.addEventListener('keydown', (event) => { 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) {
@@ -123,7 +166,9 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
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(
'.cctv-cal-value',
)) {
if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber
const field = CCTV_CAL_FIELDS[chip.dataset.calField]; const field = CCTV_CAL_FIELDS[chip.dataset.calField];
if (!field) continue; if (!field) continue;
+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;
+84 -26
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 = {
createElement() {
const input = new EventTarget(); const input = new EventTarget();
Object.assign(input, { focus() {}, select() {}, remove() { this.parent.input = null; } }); Object.assign(input, {
focus() {},
select() {},
remove() {
this.parent.input = null;
},
});
inputs.push(input); inputs.push(input);
return input; return input;
} }; },
t.after(() => { globalThis.document = prior; }); };
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);
+15 -6
View File
@@ -38,7 +38,7 @@ export function _queueCctvFrame(src, cameraId, cameraChanged) {
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();
@@ -49,7 +49,12 @@ export function _queueCctvFrame(src, cameraId, cameraChanged) {
} }
export function _settleCctvFrame(token, src, ok) { export function _settleCctvFrame(token, src, ok) {
if (this.destroyed || !this._cctvFrame || token !== this._cctvFrameRequestToken) return; if (
this.destroyed ||
!this._cctvFrame ||
token !== this._cctvFrameRequestToken
)
return;
if (this._cctvFramePreloader) { if (this._cctvFramePreloader) {
this._cctvFramePreloader.onload = null; this._cctvFramePreloader.onload = null;
this._cctvFramePreloader.onerror = null; this._cctvFramePreloader.onerror = null;
@@ -58,9 +63,10 @@ export function _settleCctvFrame(token, src, ok) {
this._cctvFrame.dataset.loading = ''; this._cctvFrame.dataset.loading = '';
this._cctvFrameWrap?.classList.remove('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) {
@@ -84,7 +90,8 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
this._cctvSourceBadge.dataset.frameState = 'idle'; this._cctvSourceBadge.dataset.frameState = 'idle';
return; return;
} }
const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); const hasDisplayedFrame =
this._cctvFrameWrap?.classList.contains('has-frame');
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.textContent = 'FRAME · LOADING'; this._cctvSourceBadge.textContent = 'FRAME · LOADING';
this._cctvSourceBadge.dataset.frameState = 'loading'; this._cctvSourceBadge.dataset.frameState = 'loading';
@@ -95,7 +102,9 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
this._cctvSourceBadge.dataset.frameState = 'error'; this._cctvSourceBadge.dataset.frameState = 'error';
return; return;
} }
const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); const kind = String(
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
).toUpperCase();
const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
this._cctvSourceBadge.textContent = `${kind} · ${status}`; this._cctvSourceBadge.textContent = `${kind} · ${status}`;
this._cctvSourceBadge.dataset.frameState = 'ready'; this._cctvSourceBadge.dataset.frameState = 'ready';
+67 -25
View File
@@ -1,15 +1,23 @@
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 (
!state?.enabled ||
!this.actions.isEnabled() ||
state?.activeCameraId !== this._cctvState?.activeCameraId
) {
this._calibrationEdit?.(false); this._calibrationEdit?.(false);
} }
this._cctvState = state || null; this._cctvState = state || null;
@@ -25,12 +33,16 @@ export function _renderCctvState(state) {
// 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.actions.setPanelCollapsed('cctv-panel', false, {
explicit: Boolean(state?.explicitSelection),
});
} }
this._lastSeenCctvActiveId = effectiveActiveId; this._lastSeenCctvActiveId = effectiveActiveId;
@@ -42,8 +54,11 @@ export function _renderCctvState(state) {
} }
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 ||
cameras.some(
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
);
if (shouldRebuild) { if (shouldRebuild) {
this._cctvSelect.innerHTML = ''; this._cctvSelect.innerHTML = '';
for (const camera of cameras) { for (const camera of cameras) {
@@ -54,14 +69,21 @@ export function _renderCctvState(state) {
} }
} }
this._cctvSelect.disabled = !enabled || cameras.length === 0; this._cctvSelect.disabled = !enabled || cameras.length === 0;
if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { if (
activeId &&
Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)
) {
this._cctvSelect.value = activeId; this._cctvSelect.value = activeId;
} else if (!activeId) { } else if (!activeId) {
this._cctvSelect.selectedIndex = -1; this._cctvSelect.selectedIndex = -1;
} }
} }
for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { for (const btn of [
this._cctvNearestBtn,
this._cctvPrevBtn,
this._cctvNextBtn,
]) {
if (!btn) continue; if (!btn) continue;
btn.disabled = !enabled || cameras.length === 0; btn.disabled = !enabled || cameras.length === 0;
} }
@@ -74,9 +96,12 @@ export function _renderCctvState(state) {
// (color-coded volumes). The click handler cycles; this renders. // (color-coded volumes). The click handler cycles; this renders.
const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off');
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
this._cctvCoverageBtn.textContent = mode === 'viewshed' this._cctvCoverageBtn.textContent =
mode === 'viewshed'
? 'VIEWSHED ON' ? 'VIEWSHED ON'
: mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; : mode === 'on'
? 'COVERAGE ON'
: 'COVERAGE OFF';
this._cctvCoverageBtn.disabled = !enabled; this._cctvCoverageBtn.disabled = !enabled;
} }
@@ -90,7 +115,9 @@ export function _renderCctvState(state) {
if (this._cctvProjectionBtn) { if (this._cctvProjectionBtn) {
const showProjection = state?.showProjection !== false; const showProjection = state?.showProjection !== false;
this._cctvProjectionBtn.classList.toggle('active', showProjection); this._cctvProjectionBtn.classList.toggle('active', showProjection);
this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; this._cctvProjectionBtn.textContent = showProjection
? 'PROJECTION ON'
: 'PROJECTION OFF';
this._cctvProjectionBtn.disabled = !enabled; this._cctvProjectionBtn.disabled = !enabled;
} }
@@ -107,16 +134,23 @@ export function _renderCctvState(state) {
this._cctvQualityChip.textContent = dirty this._cctvQualityChip.textContent = dirty
? 'CAL · EDITED (UNSAVED)' ? 'CAL · EDITED (UNSAVED)'
: `CAL · ${this._calBadgeLabel(badge)}`; : `CAL · ${this._calBadgeLabel(badge)}`;
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
} }
this._syncCctvCalReadout(enabled, activeCamera); this._syncCctvCalReadout(enabled, activeCamera);
if (this._cctvMeta) { if (this._cctvMeta) {
if (activeCamera) { if (activeCamera) {
const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; const provider =
const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; activeCamera.sourceLabel ||
const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; 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'; 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}`; 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) { } else if (cameras.length > 0) {
@@ -130,14 +164,18 @@ export function _renderCctvState(state) {
if (this._cctvFrame) { if (this._cctvFrame) {
const nextSrc = enabled ? activeCamera?.frameUrl : null; const nextSrc = enabled ? activeCamera?.frameUrl : null;
const nextCameraId = enabled ? (activeCamera?.id || '') : ''; const nextCameraId = enabled ? activeCamera?.id || '' : '';
const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId;
const frameLoading = this._cctvFrame.dataset.loading === 'true'; const frameLoading = this._cctvFrame.dataset.loading === 'true';
// A same-camera refresh waits for the current image to settle. Replacing // 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 // src every 10 seconds can cancel a slow but healthy decode forever and
// leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are
// immediate so navigation never waits on the prior camera's request. // immediate so navigation never waits on the prior camera's request.
if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { if (
nextSrc &&
(cameraChanged ||
(!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))
) {
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
} }
if (!nextSrc) { if (!nextSrc) {
@@ -146,7 +184,10 @@ export function _renderCctvState(state) {
} }
this._syncCctvSourceBadge(activeCamera, enabled); this._syncCctvSourceBadge(activeCamera, enabled);
this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); this._typeCctvSummary(
state?.summary ||
'Enable CCTV to start camera-linked intelligence summaries.',
);
} }
export function _typeCctvSummary(text) { export function _typeCctvSummary(text) {
@@ -173,7 +214,8 @@ export function _typeCctvSummary(text) {
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)
return;
const total = Number(loading?.total) || 0; const total = Number(loading?.total) || 0;
const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total));
const busy = !!enabled && !!loading?.active && total > 0; const busy = !!enabled && !!loading?.active && total > 0;