mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: isolate camera panel controls and presentation
This commit is contained in:
1 parent
bd14d6c301
commit
f45f399c31
17 files changed
+932
-657
No files matched your search
@@ -0,0 +1 @@
|
||||
export { CctvControls } from './cctvControls.js';
|
||||
@@ -0,0 +1,130 @@
|
||||
export function _initCctvPanel() {
|
||||
if (!this._cctvPanel) return;
|
||||
|
||||
this.listen(this._cctvEnableBtn, 'click', async () => {
|
||||
this._actionGeneration++;
|
||||
await this.actions.toggleEnabled();
|
||||
});
|
||||
|
||||
this.listen(this._cctvNearestBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
if (!await this.actions.toggleEnabled(true)) return;
|
||||
if (this.destroyed || generation !== this._actionGeneration
|
||||
|| !this.actions.isEnabled()
|
||||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => this.cctv.focusNearest({ focus: false }),
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.8),
|
||||
);
|
||||
});
|
||||
|
||||
this.listen(this._cctvPrevBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
if (!await this.actions.toggleEnabled(true)) return;
|
||||
if (this.destroyed || generation !== this._actionGeneration
|
||||
|| !this.actions.isEnabled()
|
||||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => this.cctv.cycleCamera(-1),
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.4),
|
||||
);
|
||||
});
|
||||
|
||||
this.listen(this._cctvNextBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
if (!await this.actions.toggleEnabled(true)) return;
|
||||
if (this.destroyed || generation !== this._actionGeneration
|
||||
|| !this.actions.isEnabled()
|
||||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => this.cctv.cycleCamera(1),
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.4),
|
||||
);
|
||||
});
|
||||
|
||||
this.listen(this._cctvSelect, 'change', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
const cameraId = this._cctvSelect.value;
|
||||
if (!cameraId) return;
|
||||
if (!await this.actions.toggleEnabled(true)) return;
|
||||
if (this.destroyed || generation !== this._actionGeneration
|
||||
|| !this.actions.isEnabled()
|
||||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return;
|
||||
// Picking a camera from the dropdown flies to it. The catalog spans
|
||||
// three metros, so a bare selection used to leave the view in the old
|
||||
// city with a camera active thousands of km away.
|
||||
this.actions.runExplicitFocus(
|
||||
() => (this.cctv.selectCamera(cameraId) ? cameraId : null),
|
||||
(selectedId) => this.cctv.focusCamera(selectedId, 2.2),
|
||||
);
|
||||
this.actions.setParams({ selectedCameraId: cameraId }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvFocusBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value;
|
||||
if (!selected) return;
|
||||
if (!await this.actions.toggleEnabled(true)) return;
|
||||
if (this.destroyed || generation !== this._actionGeneration
|
||||
|| !this.actions.isEnabled()
|
||||
|| (activeId && activeId !== this._cctvState?.activeCameraId)) return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => selected,
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.9),
|
||||
);
|
||||
this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvCoverageBtn, 'click', () => {
|
||||
const current = this._cctvState?.coverageMode
|
||||
|| (this._cctvState?.showCoverage ? 'on' : 'off');
|
||||
const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off';
|
||||
this.actions.setParams({ coverageMode: next }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvAutoHopBtn, 'click', () => {
|
||||
const current = !!this._cctvState?.autoHop;
|
||||
this.actions.setParams({ autoHop: !current }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvProjectionBtn, 'click', () => {
|
||||
const current = this._cctvState?.showProjection !== false;
|
||||
this.actions.setParams({ showProjection: !current }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvAdjustBtn, 'click', () => {
|
||||
const current = !!this._cctvState?.calibrationMode;
|
||||
this.actions.setParams({ calibrationMode: !current }, { origin: 'user' });
|
||||
});
|
||||
|
||||
// Click-to-edit pose readout: each chip swaps to a number input; Enter or
|
||||
// blur commits (converted to a calibration offset against basePose),
|
||||
// Escape cancels. Delegated so re-renders never re-bind.
|
||||
this.listen(this._cctvCalReadout, 'click', (event) => {
|
||||
const chip = event.target.closest?.('.cctv-cal-value');
|
||||
if (!chip || chip.disabled || chip.querySelector('input')) return;
|
||||
this._beginCctvCalValueEdit(chip);
|
||||
});
|
||||
|
||||
this.listen(this._cctvCalibSaveBtn, 'click', () => {
|
||||
const cameraId = this._activeCctvCameraId();
|
||||
if (!cameraId || !this.actions.setParams) return;
|
||||
this.actions.setParams({
|
||||
selectedCameraId: cameraId,
|
||||
calibration: { cameraId, save: true },
|
||||
}, { origin: 'user' });
|
||||
this.actions.showToast('CCTV calibration saved');
|
||||
});
|
||||
|
||||
this.listen(this._cctvCalibResetBtn, 'click', () => {
|
||||
this._resetCctvCalibration();
|
||||
});
|
||||
|
||||
this._renderCctvState(null);
|
||||
this.actions.syncViewport();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
|
||||
/**
|
||||
* Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the
|
||||
* camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old
|
||||
* "HEADING 0°" nonsense); typed values convert back to calibration offsets
|
||||
* against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon
|
||||
* typing is user-hostile).
|
||||
*/
|
||||
const CCTV_CAL_FIELDS = {
|
||||
heading: {
|
||||
label: 'HDG', unit: '°', decimals: 1,
|
||||
get: (cam) => cam.headingDeg,
|
||||
toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }),
|
||||
},
|
||||
pitch: {
|
||||
label: 'PITCH', unit: '°', decimals: 1,
|
||||
get: (cam) => cam.pitchDeg,
|
||||
toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }),
|
||||
},
|
||||
fov: {
|
||||
label: 'FOV', unit: '°', decimals: 0,
|
||||
get: (cam) => cam.fovDeg,
|
||||
toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }),
|
||||
},
|
||||
range: {
|
||||
label: 'RANGE', unit: 'm', decimals: 0,
|
||||
get: (cam) => cam.rangeM,
|
||||
toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }),
|
||||
},
|
||||
height: {
|
||||
label: 'HGT', unit: 'm', decimals: 0,
|
||||
get: (cam) => cam.mountHeightM,
|
||||
toPatch: (value, base) => ({ heightM: value - base.mountHeightM }),
|
||||
},
|
||||
north: {
|
||||
label: 'ΔN', unit: 'm', decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetNorthM || 0,
|
||||
toPatch: (value) => ({ offsetNorthM: value }),
|
||||
},
|
||||
east: {
|
||||
label: 'ΔE', unit: 'm', decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetEastM || 0,
|
||||
toPatch: (value) => ({ offsetEastM: value }),
|
||||
},
|
||||
};
|
||||
|
||||
export function _activeCctvCameraId() {
|
||||
return this._cctvState?.activeCameraId || this._cctvSelect?.value || '';
|
||||
}
|
||||
|
||||
export function _resetCctvCalibration() {
|
||||
const cameraId = this._activeCctvCameraId();
|
||||
if (!cameraId || !this.actions.setParams) return;
|
||||
this.actions.setParams({
|
||||
selectedCameraId: cameraId,
|
||||
calibration: {
|
||||
cameraId,
|
||||
reset: true,
|
||||
},
|
||||
}, { origin: 'user' });
|
||||
this.actions.showToast('CCTV calibration reset');
|
||||
}
|
||||
|
||||
export function _beginCctvCalValueEdit(chip) {
|
||||
if (this.destroyed) return;
|
||||
this._calibrationEdit?.(false);
|
||||
const field = CCTV_CAL_FIELDS[chip.dataset.calField];
|
||||
const activeCamera = this._cctvState?.activeCamera;
|
||||
if (!field || !activeCamera?.basePose) return;
|
||||
const cameraId = this._activeCctvCameraId();
|
||||
const basePose = { ...activeCamera.basePose };
|
||||
const editListeners = new AbortController();
|
||||
const startValue = field.get(activeCamera);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.step = field.decimals > 0 ? '0.1' : '1';
|
||||
input.value = Number(startValue).toFixed(field.decimals);
|
||||
input.className = 'cctv-cal-input';
|
||||
chip.textContent = `${field.label} `;
|
||||
chip.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
let finished = false;
|
||||
const finish = (commit) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
editListeners.abort();
|
||||
this._calibrationEdit = null;
|
||||
const typed = parseFloat(input.value);
|
||||
input.remove();
|
||||
if (commit && !this.destroyed && Number.isFinite(typed)
|
||||
&& this._cctvState?.enabled && this.actions.isEnabled()
|
||||
&& cameraId === this._activeCctvCameraId()) {
|
||||
if (cameraId && this.actions.setParams) {
|
||||
this.actions.setParams({
|
||||
selectedCameraId: cameraId,
|
||||
calibration: { cameraId, patch: field.toPatch(typed, basePose) },
|
||||
}, { origin: 'user' });
|
||||
return; // re-render restores the chip text from fresh state
|
||||
}
|
||||
}
|
||||
this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null);
|
||||
};
|
||||
this._calibrationEdit = finish;
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') finish(true);
|
||||
else if (event.key === 'Escape') { event.preventDefault(); finish(false); }
|
||||
event.stopPropagation();
|
||||
}, { signal: editListeners.signal });
|
||||
input.addEventListener('blur', () => finish(true), { signal: editListeners.signal });
|
||||
input.addEventListener('click', (event) => event.stopPropagation(), { signal: editListeners.signal });
|
||||
}
|
||||
|
||||
export function _syncCctvCalReadout(enabled, activeCamera) {
|
||||
const canCalibrate = !!enabled && !!activeCamera;
|
||||
if (this._cctvAdjustBtn) {
|
||||
const adjustOn = !!this._cctvState?.calibrationMode;
|
||||
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
|
||||
this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST';
|
||||
this._cctvAdjustBtn.disabled = !canCalibrate;
|
||||
}
|
||||
if (this._cctvCalReadout) {
|
||||
for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) {
|
||||
if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber
|
||||
const field = CCTV_CAL_FIELDS[chip.dataset.calField];
|
||||
if (!field) continue;
|
||||
const value = canCalibrate ? field.get(activeCamera) : null;
|
||||
chip.textContent = Number.isFinite(value)
|
||||
? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}`
|
||||
: `${field.label} --`;
|
||||
chip.disabled = !canCalibrate;
|
||||
}
|
||||
}
|
||||
for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) {
|
||||
if (el) el.disabled = !canCalibrate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { _clearCctvFrame, _queueCctvFrame, _settleCctvFrame, _syncCctvSourceBadge } from './cctvFrames.js';
|
||||
import { _activeCctvCameraId, _resetCctvCalibration, _beginCctvCalValueEdit, _syncCctvCalReadout } from './cctvCalibration.js';
|
||||
import { _calBadgeLabel, _renderCctvState, _typeCctvSummary, _updateCctvSyncChip } from './cctvPresentation.js';
|
||||
import { _initCctvPanel } from './cctvBindings.js';
|
||||
|
||||
/** Own camera-panel interaction and presentation; receive the camera port and application actions. */
|
||||
export class CctvControls {
|
||||
constructor({ elements, cctv, actions }) {
|
||||
Object.assign(this, elements);
|
||||
this.cctv = cctv;
|
||||
this.actions = actions;
|
||||
this.destroyed = false;
|
||||
this.listeners = new AbortController();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._cctvState = null;
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._lastCctvSummaryText = '';
|
||||
this._lastSeenCctvActiveId = null;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = false;
|
||||
this._cctvFrameRequestToken = 0;
|
||||
this._cctvFramePreloader = null;
|
||||
this._calibrationEdit = null;
|
||||
this._actionGeneration = 0;
|
||||
this._initCctvPanel();
|
||||
}
|
||||
listen(target, type, handler, options = {}) {
|
||||
target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal });
|
||||
}
|
||||
getState() { return this._cctvState; }
|
||||
connect() {
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
if (this.destroyed) return;
|
||||
this._cctvUnsubscribe = this.cctv.subscribe?.((state) => this._renderCctvState(state));
|
||||
if (this.cctv.getUIState) this._renderCctvState(this.cctv.getUIState());
|
||||
}
|
||||
_clearCctvFrame(...args) { return _clearCctvFrame.call(this, ...args); }
|
||||
_queueCctvFrame(...args) { return _queueCctvFrame.call(this, ...args); }
|
||||
_settleCctvFrame(...args) { return _settleCctvFrame.call(this, ...args); }
|
||||
_syncCctvSourceBadge(...args) { return _syncCctvSourceBadge.call(this, ...args); }
|
||||
_activeCctvCameraId(...args) { return _activeCctvCameraId.call(this, ...args); }
|
||||
_resetCctvCalibration(...args) { return _resetCctvCalibration.call(this, ...args); }
|
||||
_beginCctvCalValueEdit(...args) { return _beginCctvCalValueEdit.call(this, ...args); }
|
||||
_syncCctvCalReadout(...args) { return _syncCctvCalReadout.call(this, ...args); }
|
||||
_calBadgeLabel(...args) { return _calBadgeLabel.call(this, ...args); }
|
||||
_renderCctvState(...args) { return _renderCctvState.call(this, ...args); }
|
||||
_typeCctvSummary(...args) { return _typeCctvSummary.call(this, ...args); }
|
||||
_updateCctvSyncChip(...args) { return _updateCctvSyncChip.call(this, ...args); }
|
||||
_initCctvPanel(...args) { return _initCctvPanel.call(this, ...args); }
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this._actionGeneration++;
|
||||
this.listeners.abort();
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._calibrationEdit?.(false);
|
||||
this._clearCctvFrame();
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvSyncChip?.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
|
||||
function element() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
dataset: {}, src: '',
|
||||
removeAttribute(name) { if (name === 'src') this.src = ''; },
|
||||
classList: {
|
||||
add(...values) { values.forEach(value => classes.add(value)); },
|
||||
remove(...values) { values.forEach(value => classes.delete(value)); },
|
||||
contains(value) { return classes.has(value); },
|
||||
toggle(value, enabled) { if (enabled) classes.add(value); else classes.delete(value); },
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(t) {
|
||||
const prior = globalThis.Image;
|
||||
const requests = [];
|
||||
globalThis.Image = class { constructor() { requests.push(this); } };
|
||||
t.after(() => { globalThis.Image = prior; });
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvFrame: element(), _cctvFrameWrap: element() },
|
||||
cctv: {}, actions: { isEnabled: () => true },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
return { controls, requests };
|
||||
}
|
||||
|
||||
test('a late image completion cannot replace a newer camera preview', (t) => {
|
||||
const { controls, requests } = fixture(t);
|
||||
controls._queueCctvFrame('first.jpg', 'a', true);
|
||||
const stale = requests[0].onload;
|
||||
controls._queueCctvFrame('second.jpg', 'b', true);
|
||||
assert.equal(requests[0].onload, null);
|
||||
requests[1].onload();
|
||||
stale();
|
||||
assert.equal(controls._cctvFrame.src, 'second.jpg');
|
||||
assert.equal(controls._cctvFrame.dataset.cameraId, 'b');
|
||||
});
|
||||
|
||||
test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => {
|
||||
const { controls, requests } = fixture(t);
|
||||
controls._queueCctvFrame('first.jpg', 'a', true);
|
||||
requests[0].onload();
|
||||
controls._queueCctvFrame('refresh.jpg', 'a', false);
|
||||
requests[1].onerror();
|
||||
assert.equal(controls._cctvFrame.src, 'first.jpg');
|
||||
assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), true);
|
||||
controls._queueCctvFrame('other.jpg', 'b', true);
|
||||
assert.equal(controls._cctvFrame.src, '');
|
||||
assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), false);
|
||||
});
|
||||
|
||||
test('destroy invalidates image callbacks and releases each subscription once', (t) => {
|
||||
const { controls, requests } = fixture(t);
|
||||
let unsubscribed = 0;
|
||||
controls.cctv.subscribe = () => () => { unsubscribed++; };
|
||||
controls.connect();
|
||||
controls.connect();
|
||||
assert.equal(unsubscribed, 1);
|
||||
controls._queueCctvFrame('first.jpg', 'a', true);
|
||||
const late = requests[0].onload;
|
||||
controls.destroy();
|
||||
controls.destroy();
|
||||
controls.connect();
|
||||
late();
|
||||
assert.equal(unsubscribed, 2);
|
||||
assert.equal(requests[0].onload, null);
|
||||
assert.equal(requests[0].onerror, null);
|
||||
assert.equal(controls._cctvFrame.src, '');
|
||||
controls._queueCctvFrame('late.jpg', 'b', true);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
function calibrationFixture(t) {
|
||||
const { controls } = fixture(t);
|
||||
const prior = globalThis.document;
|
||||
const inputs = [];
|
||||
globalThis.document = { createElement() {
|
||||
const input = new EventTarget();
|
||||
Object.assign(input, { focus() {}, select() {}, remove() { this.parent.input = null; } });
|
||||
inputs.push(input);
|
||||
return input;
|
||||
} };
|
||||
t.after(() => { globalThis.document = prior; });
|
||||
const chip = {
|
||||
dataset: { calField: 'heading' }, textContent: '', input: null,
|
||||
appendChild(input) { this.input = input; input.parent = this; },
|
||||
querySelector() { return this.input; },
|
||||
};
|
||||
const patches = [];
|
||||
controls.actions.setParams = (params) => patches.push(params);
|
||||
controls.actions.setPanelCollapsed = () => {};
|
||||
controls._cctvCalReadout = { querySelectorAll: () => [chip] };
|
||||
controls._cctvState = {
|
||||
enabled: true, activeCameraId: 'a',
|
||||
activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } },
|
||||
};
|
||||
const key = (name) => Object.assign(new Event('keydown', { cancelable: true }), { key: name });
|
||||
return { controls, chip, inputs, patches, key };
|
||||
}
|
||||
|
||||
test('calibration commits against the captured camera base and releases its editor', (t) => {
|
||||
const { controls, chip, inputs, patches, key } = calibrationFixture(t);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[0].value = '100';
|
||||
controls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||
inputs[0].dispatchEvent(key('Enter'));
|
||||
inputs[0].dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, [{ selectedCameraId: 'a', calibration: { cameraId: 'a', patch: { headingDeg: 80 } } }]);
|
||||
assert.equal(controls._calibrationEdit, null);
|
||||
});
|
||||
|
||||
test('a camera switch cancels calibration before old blur can change the new camera', (t) => {
|
||||
const { controls, chip, inputs, patches } = calibrationFixture(t);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[0].value = '100';
|
||||
controls._renderCctvState({ enabled: true, activeCameraId: 'b', activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } } });
|
||||
inputs[0].dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
assert.equal(chip.input, null);
|
||||
assert.equal(chip.textContent, 'HDG 200.0°');
|
||||
});
|
||||
|
||||
test('Escape claims calibration cancellation and disposal prevents late commits', (t) => {
|
||||
const { controls, chip, inputs, patches, key } = calibrationFixture(t);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[0].value = '100';
|
||||
const escape = key('Escape');
|
||||
inputs[0].dispatchEvent(escape);
|
||||
inputs[0].dispatchEvent(new Event('blur'));
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[1].value = '120';
|
||||
controls.destroy();
|
||||
inputs[1].dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
});
|
||||
|
||||
test('disposing during camera enable prevents the delayed focus and future clicks', async () => {
|
||||
const button = new EventTarget();
|
||||
let resolveEnable;
|
||||
let enables = 0;
|
||||
let focuses = 0;
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvPanel: {}, _cctvNextBtn: button }, cctv: {},
|
||||
actions: {
|
||||
isEnabled: () => true, syncViewport() {},
|
||||
toggleEnabled() { enables++; return new Promise(resolve => { resolveEnable = resolve; }); },
|
||||
runExplicitFocus() { focuses++; },
|
||||
},
|
||||
});
|
||||
button.dispatchEvent(new Event('click'));
|
||||
controls.destroy();
|
||||
resolveEnable(true);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
button.dispatchEvent(new Event('click'));
|
||||
assert.equal(enables, 1);
|
||||
assert.equal(focuses, 0);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
export function _clearCctvFrame() {
|
||||
this._cctvFrameRequestToken += 1;
|
||||
if (this._cctvFramePreloader) {
|
||||
this._cctvFramePreloader.onload = null;
|
||||
this._cctvFramePreloader.onerror = null;
|
||||
}
|
||||
this._cctvFramePreloader = null;
|
||||
if (this._cctvFrame) {
|
||||
this._cctvFrame.classList.remove('active');
|
||||
this._cctvFrame.removeAttribute('src');
|
||||
this._cctvFrame.dataset.cameraId = '';
|
||||
this._cctvFrame.dataset.currentSrc = '';
|
||||
this._cctvFrame.dataset.loading = '';
|
||||
this._cctvFrame.dataset.error = '';
|
||||
}
|
||||
this._cctvFrameWrap?.classList.remove('loading', 'has-frame');
|
||||
}
|
||||
|
||||
export function _queueCctvFrame(src, cameraId, cameraChanged) {
|
||||
if (this.destroyed || !this._cctvFrame || !src) return;
|
||||
|
||||
if (cameraChanged) {
|
||||
// A different camera gets an honest acquisition state. Never retain
|
||||
// the prior camera's pixels under the newly selected metadata.
|
||||
this._cctvFrame.classList.remove('active');
|
||||
this._cctvFrame.removeAttribute('src');
|
||||
this._cctvFrameWrap?.classList.remove('has-frame');
|
||||
}
|
||||
|
||||
if (this._cctvFramePreloader) {
|
||||
this._cctvFramePreloader.onload = null;
|
||||
this._cctvFramePreloader.onerror = null;
|
||||
}
|
||||
const token = ++this._cctvFrameRequestToken;
|
||||
this._cctvFrame.dataset.cameraId = cameraId;
|
||||
this._cctvFrame.dataset.currentSrc = src;
|
||||
this._cctvFrame.dataset.loading = 'true';
|
||||
this._cctvFrame.dataset.error = '';
|
||||
this._cctvFrameWrap?.classList.toggle(
|
||||
'loading',
|
||||
!this._cctvFrameWrap?.classList.contains('has-frame')
|
||||
);
|
||||
|
||||
const preloader = new Image();
|
||||
this._cctvFramePreloader = preloader;
|
||||
preloader.onload = () => this._settleCctvFrame(token, src, true);
|
||||
preloader.onerror = () => this._settleCctvFrame(token, src, false);
|
||||
preloader.src = src;
|
||||
}
|
||||
|
||||
export function _settleCctvFrame(token, src, ok) {
|
||||
if (this.destroyed || !this._cctvFrame || token !== this._cctvFrameRequestToken) return;
|
||||
if (this._cctvFramePreloader) {
|
||||
this._cctvFramePreloader.onload = null;
|
||||
this._cctvFramePreloader.onerror = null;
|
||||
}
|
||||
this._cctvFramePreloader = null;
|
||||
this._cctvFrame.dataset.loading = '';
|
||||
this._cctvFrameWrap?.classList.remove('loading');
|
||||
|
||||
const syncBadge = () => this._syncCctvSourceBadge(
|
||||
this._cctvState?.activeCamera,
|
||||
!!this._cctvState?.enabled && !!this.actions.isEnabled()
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
// Leave the element untouched — a settled frame stays on screen.
|
||||
this._cctvFrame.dataset.error = 'true';
|
||||
syncBadge();
|
||||
return;
|
||||
}
|
||||
|
||||
this._cctvFrame.dataset.error = '';
|
||||
this._cctvFrame.src = src;
|
||||
this._cctvFrame.classList.add('active');
|
||||
this._cctvFrameWrap?.classList.add('has-frame');
|
||||
syncBadge();
|
||||
}
|
||||
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
if (!this._cctvSourceBadge) return;
|
||||
if (!enabled || !activeCamera) {
|
||||
this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN';
|
||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||
return;
|
||||
}
|
||||
const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame');
|
||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = 'FRAME · LOADING';
|
||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||
return;
|
||||
}
|
||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE';
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase();
|
||||
const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
|
||||
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
|
||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
export function _calBadgeLabel(badge) {
|
||||
switch (badge) {
|
||||
case 'calibrated': return 'CALIBRATED';
|
||||
case 'curated': return 'CURATED';
|
||||
case 'raw-prior': return 'RAW PRIOR';
|
||||
default: return '--';
|
||||
}
|
||||
}
|
||||
|
||||
export function _renderCctvState(state) {
|
||||
if (this.destroyed) return;
|
||||
if (!state?.enabled || !this.actions.isEnabled() || state?.activeCameraId !== this._cctvState?.activeCameraId) {
|
||||
this._calibrationEdit?.(false);
|
||||
}
|
||||
this._cctvState = state || null;
|
||||
const cameras = state?.cameras || [];
|
||||
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
|
||||
// Auto-expand the panel when the active camera CHANGES to a new non-null
|
||||
// id while the layer is enabled. Covers click-on-globe, panel controls,
|
||||
// and voice (selectCamera/cycleCamera/focusNearest all notify through
|
||||
// this subscription). The last-seen guard keeps routine notifications
|
||||
// from re-expanding a panel the user deliberately collapsed, and timed
|
||||
// auto-hop transitions only expand on the first activation so the panel
|
||||
// does not pop open on every hop.
|
||||
const effectiveActiveId = enabled ? (activeId || null) : null;
|
||||
const isFirstActivation = this._lastSeenCctvActiveId === null;
|
||||
if (effectiveActiveId
|
||||
&& effectiveActiveId !== this._lastSeenCctvActiveId
|
||||
&& (!state?.autoHop || isFirstActivation)) {
|
||||
this.actions.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) });
|
||||
}
|
||||
this._lastSeenCctvActiveId = effectiveActiveId;
|
||||
|
||||
this._updateCctvSyncChip(state?.loading, enabled);
|
||||
|
||||
if (this._cctvEnableBtn) {
|
||||
this._cctvEnableBtn.classList.toggle('active', enabled);
|
||||
this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF';
|
||||
}
|
||||
|
||||
if (this._cctvSelect) {
|
||||
const shouldRebuild = this._cctvSelect.options.length !== cameras.length
|
||||
|| cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id);
|
||||
if (shouldRebuild) {
|
||||
this._cctvSelect.innerHTML = '';
|
||||
for (const camera of cameras) {
|
||||
const option = document.createElement('option');
|
||||
option.value = camera.id;
|
||||
option.textContent = `${camera.city} · ${camera.name}`;
|
||||
this._cctvSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
this._cctvSelect.disabled = !enabled || cameras.length === 0;
|
||||
if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) {
|
||||
this._cctvSelect.value = activeId;
|
||||
} else if (!activeId) {
|
||||
this._cctvSelect.selectedIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) {
|
||||
if (!btn) continue;
|
||||
btn.disabled = !enabled || cameras.length === 0;
|
||||
}
|
||||
if (this._cctvFocusBtn) {
|
||||
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId;
|
||||
}
|
||||
|
||||
if (this._cctvCoverageBtn) {
|
||||
// Tri-state (viewshed design §3b): off → on (wireframes) → viewshed
|
||||
// (color-coded volumes). The click handler cycles; this renders.
|
||||
const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off');
|
||||
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
|
||||
this._cctvCoverageBtn.textContent = mode === 'viewshed'
|
||||
? 'VIEWSHED ON'
|
||||
: mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF';
|
||||
this._cctvCoverageBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this._cctvAutoHopBtn) {
|
||||
const autoHop = !!state?.autoHop;
|
||||
this._cctvAutoHopBtn.classList.toggle('active', autoHop);
|
||||
this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF';
|
||||
this._cctvAutoHopBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this._cctvProjectionBtn) {
|
||||
const showProjection = state?.showProjection !== false;
|
||||
this._cctvProjectionBtn.classList.toggle('active', showProjection);
|
||||
this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF';
|
||||
this._cctvProjectionBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this._cctvQualityChip) {
|
||||
// CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only,
|
||||
// no in-world tint): three states driven by cctv.js's deriveCalBadge,
|
||||
// no client-side scoring math. Casing is unified via _calBadgeLabel so
|
||||
// the chip and the meta line never drift onto different conventions.
|
||||
// Save-gated persistence (viewshed design §3e): unsaved live edits show
|
||||
// EDITED on top of whatever the persisted badge state is — SAVE CAL
|
||||
// promotes to CALIBRATED, RESET CAL clears.
|
||||
const badge = activeCamera?.calBadge || null;
|
||||
const dirty = !!activeCamera?.calDirty;
|
||||
this._cctvQualityChip.textContent = dirty
|
||||
? 'CAL · EDITED (UNSAVED)'
|
||||
: `CAL · ${this._calBadgeLabel(badge)}`;
|
||||
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || '');
|
||||
}
|
||||
|
||||
this._syncCctvCalReadout(enabled, activeCamera);
|
||||
|
||||
if (this._cctvMeta) {
|
||||
if (activeCamera) {
|
||||
const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source';
|
||||
const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : '';
|
||||
const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : '';
|
||||
const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF';
|
||||
this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`;
|
||||
} else if (cameras.length > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? `${cameras.length} cameras loaded · click a camera to activate`
|
||||
: `${cameras.length} cameras loaded · enable CCTV to activate`;
|
||||
} else {
|
||||
this._cctvMeta.textContent = 'Enable CCTV to load camera intersections';
|
||||
}
|
||||
}
|
||||
|
||||
if (this._cctvFrame) {
|
||||
const nextSrc = enabled ? activeCamera?.frameUrl : null;
|
||||
const nextCameraId = enabled ? (activeCamera?.id || '') : '';
|
||||
const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId;
|
||||
const frameLoading = this._cctvFrame.dataset.loading === 'true';
|
||||
// A same-camera refresh waits for the current image to settle. Replacing
|
||||
// src every 10 seconds can cancel a slow but healthy decode forever and
|
||||
// leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are
|
||||
// immediate so navigation never waits on the prior camera's request.
|
||||
if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) {
|
||||
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
|
||||
}
|
||||
if (!nextSrc) {
|
||||
this._clearCctvFrame();
|
||||
}
|
||||
}
|
||||
|
||||
this._syncCctvSourceBadge(activeCamera, enabled);
|
||||
this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.');
|
||||
}
|
||||
|
||||
export function _typeCctvSummary(text) {
|
||||
if (this.destroyed || !this._cctvSummary) return;
|
||||
const nextText = String(text || '').trim() || 'No summary available.';
|
||||
if (nextText === this._lastCctvSummaryText) return;
|
||||
this._lastCctvSummaryText = nextText;
|
||||
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
this._cctvSummary.textContent = '';
|
||||
let idx = 0;
|
||||
this._cctvSummaryTypingTimer = setInterval(() => {
|
||||
if (this.destroyed) return;
|
||||
idx += 3;
|
||||
if (idx >= nextText.length) {
|
||||
this._cctvSummary.textContent = nextText;
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
return;
|
||||
}
|
||||
this._cctvSummary.textContent = nextText.slice(0, idx);
|
||||
}, 20);
|
||||
}
|
||||
|
||||
export function _updateCctvSyncChip(loading, enabled) {
|
||||
if (this.destroyed) return;
|
||||
if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return;
|
||||
const total = Number(loading?.total) || 0;
|
||||
const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total));
|
||||
const busy = !!enabled && !!loading?.active && total > 0;
|
||||
|
||||
if (busy) {
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = true;
|
||||
this.actions.setSplitFlapText(this._cctvSyncLabel, 'loading frames');
|
||||
// The counter is left plain on purpose: it ticks every few frames
|
||||
// during a grid load, and flapping it would read as a slot machine.
|
||||
this._cctvSyncProgress.textContent = `${loaded}/${total}`;
|
||||
this._cctvSyncChip.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._cctvChipWasBusy && enabled && total > 0) {
|
||||
// Load just completed — flash the final count, then auto-hide.
|
||||
this._cctvChipWasBusy = false;
|
||||
this.actions.setSplitFlapText(this._cctvSyncLabel, 'camera grid ready');
|
||||
this._cctvSyncProgress.textContent = `${total}/${total}`;
|
||||
this._cctvSyncChip.classList.add('visible');
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvChipHideTimer = window.setTimeout(() => {
|
||||
if (this.destroyed) return;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvSyncChip.classList.remove('visible');
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._cctvChipHideTimer) {
|
||||
this._cctvChipWasBusy = false;
|
||||
this._cctvSyncChip.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user