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