mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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]
|
## [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.
|
- 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.
|
- 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
|
importing the renderer or station providers. Pure tuner calculations retain
|
||||||
compatibility exports from the data layer. Disposal revokes DOM listeners and
|
compatibility exports from the data layer. Disposal revokes DOM listeners and
|
||||||
subscriptions before ending the active tuning interaction.
|
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
|
# 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
|
||||||
|
|
||||||
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
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": "./src/ui/layers.js",
|
||||||
"./ui/layers/feedback": "./src/loadingFeedback.js",
|
"./ui/layers/feedback": "./src/loadingFeedback.js",
|
||||||
"./ui/location": "./src/ui/location.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/radioPresentation.js",
|
||||||
"src/ui/radioTunerModel.js",
|
"src/ui/radioTunerModel.js",
|
||||||
"src/ui/radioControls.test.mjs",
|
"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"
|
"src/ui/radioTunerModel.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"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 getFlag = (name) => argv.includes(name);
|
||||||
|
|
||||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
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 HEADFUL = getFlag('--headful');
|
||||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ try {
|
|||||||
'_leftStackMutationObserver',
|
'_leftStackMutationObserver',
|
||||||
'_rightStackMutationObserver',
|
'_rightStackMutationObserver',
|
||||||
].filter(watchObserver);
|
].filter(watchObserver);
|
||||||
const cctvUnsubscribe = ui._cctvUnsubscribe;
|
const cctvUnsubscribe = ui._cctvControls._cctvUnsubscribe;
|
||||||
counts.cctv = 0;
|
counts.cctv = 0;
|
||||||
if (cctvUnsubscribe)
|
if (cctvUnsubscribe)
|
||||||
ui._cctvUnsubscribe = () => {
|
ui._cctvControls._cctvUnsubscribe = () => {
|
||||||
counts.cctv++;
|
counts.cctv++;
|
||||||
return cctvUnsubscribe();
|
return cctvUnsubscribe();
|
||||||
};
|
};
|
||||||
@@ -82,10 +82,10 @@ try {
|
|||||||
observersReleased: observed.every(
|
observersReleased: observed.every(
|
||||||
(name) => counts[name] === 1 && ui[name] === null,
|
(name) => counts[name] === 1 && ui[name] === null,
|
||||||
),
|
),
|
||||||
subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null,
|
subscriptionReleased: counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
||||||
resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null,
|
resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null,
|
||||||
controlsReleased:
|
controlsReleased:
|
||||||
ui._radioControls.destroyed && ui._locationControls.destroyed,
|
ui._radioControls.destroyed && ui._locationControls.destroyed && ui._cctvControls.destroyed,
|
||||||
idempotent: once === JSON.stringify(counts),
|
idempotent: once === JSON.stringify(counts),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -109,7 +109,7 @@ try {
|
|||||||
result.resizeReleased,
|
result.resizeReleased,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'Radio and Location controls are disposed with the UI',
|
'Radio, Location and CCTV controls are disposed with the UI',
|
||||||
result.controlsReleased,
|
result.controlsReleased,
|
||||||
);
|
);
|
||||||
check('repeated UI disposal is inert', result.idempotent);
|
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(transitions, [false]);
|
||||||
assert.deepEqual(diagnostics, []);
|
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 { RadioControls } from './ui/radio.js';
|
||||||
import { LocationControls, LocationSearch } from './ui/location.js';
|
import { LocationControls, LocationSearch } from './ui/location.js';
|
||||||
import { bindClearLayersControl } from './ui/layers.js';
|
import { bindClearLayersControl } from './ui/layers.js';
|
||||||
@@ -370,54 +371,6 @@ const STYLE_STATUS_LABELS = {
|
|||||||
* - Intel HUD lifecycle and variant switching.
|
* - 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) {
|
function formatCockpitBriefAge(value) {
|
||||||
const timestamp = Date.parse(value);
|
const timestamp = Date.parse(value);
|
||||||
if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN';
|
if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN';
|
||||||
@@ -2084,15 +2037,7 @@ export class StyleManager {
|
|||||||
this._clearSelectedLayersPromise = null;
|
this._clearSelectedLayersPromise = null;
|
||||||
this._clearSelectedLayersManagerPromise = null;
|
this._clearSelectedLayersManagerPromise = null;
|
||||||
this._dataManager = 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._leftStackLayoutFrame = null;
|
||||||
this._leftStackReconsiderAutoCollapse = false;
|
this._leftStackReconsiderAutoCollapse = false;
|
||||||
this._leftStackResizeObserver = null;
|
this._leftStackResizeObserver = null;
|
||||||
@@ -2261,8 +2206,6 @@ export class StyleManager {
|
|||||||
this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn');
|
this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn');
|
||||||
this._cctvFrame = document.getElementById('cctv-frame');
|
this._cctvFrame = document.getElementById('cctv-frame');
|
||||||
this._cctvFrameWrap = document.getElementById('cctv-frame-wrap');
|
this._cctvFrameWrap = document.getElementById('cctv-frame-wrap');
|
||||||
this._cctvFrameRequestToken = 0;
|
|
||||||
this._cctvFramePreloader = null;
|
|
||||||
this._cctvSourceBadge = document.getElementById('cctv-source-badge');
|
this._cctvSourceBadge = document.getElementById('cctv-source-badge');
|
||||||
this._cctvMeta = document.getElementById('cctv-meta');
|
this._cctvMeta = document.getElementById('cctv-meta');
|
||||||
this._cctvSummary = document.getElementById('cctv-summary');
|
this._cctvSummary = document.getElementById('cctv-summary');
|
||||||
@@ -3674,52 +3617,6 @@ export class StyleManager {
|
|||||||
this._trafficSyncChip.classList.toggle('visible', presentation.visible);
|
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.
|
* Initializes panel collapse buttons and restores persisted collapsed state.
|
||||||
@@ -4173,18 +4070,7 @@ export class StyleManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._syncContextModeButtons();
|
this._syncContextModeButtons();
|
||||||
if (this._cctvUnsubscribe) {
|
this._cctvControls.connect();
|
||||||
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._radioControls.connect();
|
this._radioControls.connect();
|
||||||
if (!this._awarenessSelectedHandler) {
|
if (!this._awarenessSelectedHandler) {
|
||||||
this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false);
|
this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false);
|
||||||
@@ -4999,6 +4885,7 @@ export class StyleManager {
|
|||||||
|
|
||||||
/** Wire the independent Radio companion controls. */
|
/** Wire the independent Radio companion controls. */
|
||||||
_initRadioPanel() {
|
_initRadioPanel() {
|
||||||
|
this._cctvControls?.destroy();
|
||||||
this._radioControls?.destroy();
|
this._radioControls?.destroy();
|
||||||
this._radioControls = new RadioControls({
|
this._radioControls = new RadioControls({
|
||||||
elements: {
|
elements: {
|
||||||
@@ -5091,351 +4978,50 @@ export class StyleManager {
|
|||||||
return this._runExplicitNavigation('camera', () => focus(cameraId));
|
return this._runExplicitNavigation('camera', () => focus(cameraId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Compose camera panel controls from the existing camera port and application actions. */
|
||||||
* 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}
|
|
||||||
*/
|
|
||||||
_initCctvPanel() {
|
_initCctvPanel() {
|
||||||
if (!this._cctvPanel) return;
|
this._cctvControls?.destroy();
|
||||||
|
this._cctvControls = new CctvControls({
|
||||||
this._cctvEnableBtn?.addEventListener('click', async () => {
|
elements: {
|
||||||
await this._toggleCctvEnabled();
|
_cctvAdjustBtn: this._cctvAdjustBtn,
|
||||||
});
|
_cctvAutoHopBtn: this._cctvAutoHopBtn,
|
||||||
|
_cctvCalReadout: this._cctvCalReadout,
|
||||||
this._cctvNearestBtn?.addEventListener('click', async () => {
|
_cctvCalibResetBtn: this._cctvCalibResetBtn,
|
||||||
if (!await this._toggleCctvEnabled(true)) return;
|
_cctvCalibSaveBtn: this._cctvCalibSaveBtn,
|
||||||
this._runExplicitCctvFocus(
|
_cctvCoverageBtn: this._cctvCoverageBtn,
|
||||||
() => cctvLayer.focusNearest({ focus: false }),
|
_cctvEnableBtn: this._cctvEnableBtn,
|
||||||
(cameraId) => cctvLayer.focusCamera(cameraId, 1.8),
|
_cctvFocusBtn: this._cctvFocusBtn,
|
||||||
);
|
_cctvFrame: this._cctvFrame,
|
||||||
});
|
_cctvFrameWrap: this._cctvFrameWrap,
|
||||||
|
_cctvMeta: this._cctvMeta,
|
||||||
this._cctvPrevBtn?.addEventListener('click', async () => {
|
_cctvNearestBtn: this._cctvNearestBtn,
|
||||||
if (!await this._toggleCctvEnabled(true)) return;
|
_cctvNextBtn: this._cctvNextBtn,
|
||||||
this._runExplicitCctvFocus(
|
_cctvPanel: this._cctvPanel,
|
||||||
() => cctvLayer.cycleCamera(-1),
|
_cctvPrevBtn: this._cctvPrevBtn,
|
||||||
(cameraId) => cctvLayer.focusCamera(cameraId, 1.4),
|
_cctvProjectionBtn: this._cctvProjectionBtn,
|
||||||
);
|
_cctvQualityChip: this._cctvQualityChip,
|
||||||
});
|
_cctvSelect: this._cctvSelect,
|
||||||
|
_cctvSourceBadge: this._cctvSourceBadge,
|
||||||
this._cctvNextBtn?.addEventListener('click', async () => {
|
_cctvSummary: this._cctvSummary,
|
||||||
if (!await this._toggleCctvEnabled(true)) return;
|
_cctvSyncChip: this._cctvSyncChip,
|
||||||
this._runExplicitCctvFocus(
|
_cctvSyncLabel: this._cctvSyncLabel,
|
||||||
() => cctvLayer.cycleCamera(1),
|
_cctvSyncProgress: this._cctvSyncProgress,
|
||||||
(cameraId) => cctvLayer.focusCamera(cameraId, 1.4),
|
},
|
||||||
);
|
cctv: cctvLayer,
|
||||||
});
|
actions: {
|
||||||
|
isEnabled: () => this._dataManager?.isEnabled('cctv'),
|
||||||
this._cctvSelect?.addEventListener('change', async () => {
|
setParams: (params, options) => this._dataManager?.setLayerParams('cctv', params, options),
|
||||||
const cameraId = this._cctvSelect.value;
|
toggleEnabled: (...args) => this._toggleCctvEnabled(...args),
|
||||||
if (!cameraId) return;
|
runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args),
|
||||||
if (!await this._toggleCctvEnabled(true)) return;
|
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||||
// Picking a camera from the dropdown flies to it. The catalog spans
|
showToast: (message) => this._showToast(message),
|
||||||
// three metros, so a bare selection used to leave the view in the old
|
syncViewport: () => this._syncCctvPanelViewport(),
|
||||||
// city with a camera active thousands of km away.
|
setSplitFlapText,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
}, { 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,
|
* 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.
|
* @returns {Promise<boolean>} True if the layer is now in the requested state.
|
||||||
*/
|
*/
|
||||||
async _toggleCctvEnabled(forceState) {
|
async _toggleCctvEnabled(forceState) {
|
||||||
|
if (this._disposed) return false;
|
||||||
if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
|
if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
|
||||||
this._showToast('CCTV layer unavailable');
|
this._showToast('CCTV layer unavailable');
|
||||||
return false;
|
return false;
|
||||||
@@ -5458,7 +5045,8 @@ export class StyleManager {
|
|||||||
trackedEntity: this.viewer?.trackedEntity,
|
trackedEntity: this.viewer?.trackedEntity,
|
||||||
cockpitActive: !!this.cockpitView?.active,
|
cockpitActive: !!this.cockpitView?.active,
|
||||||
}),
|
}),
|
||||||
shouldFocus: () => !this._cctvState?.activeCameraId,
|
shouldFocus: () => !this._disposed && this._dataManager.isEnabled('cctv')
|
||||||
|
&& !this._cctvControls?.getState()?.activeCameraId,
|
||||||
activate: () => cctvLayer.focusNearest({ focus: false }),
|
activate: () => cctvLayer.focusNearest({ focus: false }),
|
||||||
fly: (cameraId) => this._runExplicitCctvFocus(
|
fly: (cameraId) => this._runExplicitCctvFocus(
|
||||||
() => cameraId,
|
() => cameraId,
|
||||||
@@ -5468,195 +5056,6 @@ export class StyleManager {
|
|||||||
return true;
|
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.
|
* Returns the versioned localStorage key for a panel's saved position.
|
||||||
@@ -8508,6 +7907,7 @@ export class StyleManager {
|
|||||||
this._mapSourceControls?.destroy();
|
this._mapSourceControls?.destroy();
|
||||||
this._clearLayersControl?.destroy();
|
this._clearLayersControl?.destroy();
|
||||||
this._locationControls?.destroy();
|
this._locationControls?.destroy();
|
||||||
|
this._cctvControls?.destroy();
|
||||||
this._radioControls?.destroy();
|
this._radioControls?.destroy();
|
||||||
this._visualEffects.stop();
|
this._visualEffects.stop();
|
||||||
this._styleParameters?.destroy();
|
this._styleParameters?.destroy();
|
||||||
@@ -8594,8 +7994,6 @@ export class StyleManager {
|
|||||||
this._dataManagerUnsubscribe?.();
|
this._dataManagerUnsubscribe?.();
|
||||||
this._dataManagerUnsubscribe = null;
|
this._dataManagerUnsubscribe = null;
|
||||||
|
|
||||||
this._cctvUnsubscribe?.();
|
|
||||||
this._cctvUnsubscribe = null;
|
|
||||||
this._commandDockTrayObserver?.disconnect?.();
|
this._commandDockTrayObserver?.disconnect?.();
|
||||||
this._commandDockTrayObserver = null;
|
this._commandDockTrayObserver = null;
|
||||||
this._draggableResizeObserver?.disconnect();
|
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