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