mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(ui): render the CCTV frame preview, source badge and summary from the store
The frame lifecycle keeps its request state in an object instead of the image's dataset and publishes it; the panel binds the image's src, class and data attributes and the wrapper's loading and has-frame classes from it, so preload tokens, kept pixels on a failed refresh and clearing on a camera change are unchanged. The source badge is the pure presentCctvSourceBadge(), the live switch and the typed summary are published too. The video surface still draws into the panel's canvas.
This commit is contained in:
1 parent
6c7035f617
commit
eef3dec759
10 files changed
+306
-177
No files matched your search
@@ -87,9 +87,9 @@ They contain no application state.
|
||||
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
||||
store that `StreetLevelControls` publishes to; the controls keep their
|
||||
listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel`
|
||||
renders the buttons, CAL chip, meta line and calibration readout, with
|
||||
its inline pose editor, from the `gev-cctv` store; the picker, frame,
|
||||
source badge and summary are still written by `CctvControls`), Layers, Display, Context and Radio, the command dock,
|
||||
renders the whole panel from the `gev-cctv` store, including the inline
|
||||
pose editor, the frame preview, source badge and typed summary;
|
||||
`CctvControls` still fills the picker's options), Layers, Display, Context and Radio, the command dock,
|
||||
the phone shell, then Cockpit. Layers that write into the right rail
|
||||
directly gain a slot instead.
|
||||
|
||||
|
||||
@@ -69,8 +69,6 @@ try {
|
||||
calDirty: false,
|
||||
},
|
||||
});
|
||||
window.__qaBadgeText =
|
||||
document.getElementById('cctv-source-badge').textContent;
|
||||
});
|
||||
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||
const inventory = await page.evaluate(() => {
|
||||
@@ -120,20 +118,22 @@ try {
|
||||
);
|
||||
|
||||
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||
const result = await page.evaluate((state) => {
|
||||
const result = await page.evaluate(async (state) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
|
||||
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
|
||||
? 'true'
|
||||
: '';
|
||||
owner._cctvFrameWrap.classList.toggle(
|
||||
'has-frame',
|
||||
state === 'retained',
|
||||
);
|
||||
const frame = owner._frame;
|
||||
owner._frame = {
|
||||
...frame,
|
||||
loading: state === 'loading',
|
||||
error: ['error', 'retained'].includes(state),
|
||||
hasFrame: state === 'retained',
|
||||
};
|
||||
owner._syncCctvSourceBadge(
|
||||
owner._cctvState.activeCamera,
|
||||
state !== 'idle',
|
||||
);
|
||||
owner._frame = frame;
|
||||
// The panel view renders the badge on Vue's next flush.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const badge = document.getElementById('cctv-source-badge');
|
||||
return {
|
||||
state: badge.dataset.frameState,
|
||||
|
||||
+22
-51
@@ -1,59 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
import { presentCctvSourceBadge } from './cctvFrames.js';
|
||||
import { presentCctvPanel } from './cctvPresentation.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
|
||||
function fixture(t) {
|
||||
const { document } = railFixture();
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
_cctvSourceBadge: document.createElement('div'),
|
||||
},
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
return { controls, document };
|
||||
}
|
||||
const IDLE = Object.freeze({ loading: false, error: false, hasFrame: false });
|
||||
|
||||
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
|
||||
const { controls, document } = fixture(testContext);
|
||||
test('source and frame states retain their distinct meanings, including retained pixels', () => {
|
||||
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||
controls._cctvFrame = document.createElement('img');
|
||||
controls._cctvFrameWrap = document.createElement('div');
|
||||
controls._cctvFrame.removeAttribute = () => {};
|
||||
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
|
||||
node.classList.remove = (...names) =>
|
||||
names.forEach((name) => node.classList.toggle(name, false));
|
||||
}
|
||||
const expect = (label, state, active = camera, enabled = true) => {
|
||||
controls._syncCctvSourceBadge(active, enabled);
|
||||
assert.equal(controls._cctvSourceBadge.textContent, label);
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
|
||||
};
|
||||
expect('SOURCE · UNKNOWN', 'idle', camera, false);
|
||||
expect('SOURCE · UNKNOWN', 'idle', null);
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('FRAME · LOADING', 'loading');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = 'true';
|
||||
expect('FRAME · UNAVAILABLE', 'error');
|
||||
const expect = (label, state, frame, active = camera, enabled = true) =>
|
||||
assert.deepEqual(presentCctvSourceBadge(active, enabled, frame), {
|
||||
label,
|
||||
state,
|
||||
});
|
||||
expect('SOURCE · UNKNOWN', 'idle', IDLE, camera, false);
|
||||
expect('SOURCE · UNKNOWN', 'idle', IDLE, null);
|
||||
expect('FRAME · LOADING', 'loading', { ...IDLE, loading: true });
|
||||
expect('FRAME · UNAVAILABLE', 'error', { ...IDLE, error: true });
|
||||
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||
controls._cctvFrameWrap.classList.toggle('has-frame', true);
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = '';
|
||||
const retained = { ...IDLE, error: true, hasFrame: true };
|
||||
expect('SNAPSHOT · DEGRADED', 'ready', retained);
|
||||
expect('SNAPSHOT · DEGRADED', 'ready', { ...retained, loading: true });
|
||||
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
|
||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', IDLE, {
|
||||
...camera,
|
||||
sourceStatus: status,
|
||||
});
|
||||
}
|
||||
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
|
||||
expect('LIVE · OK', 'ready', IDLE, { feedType: 'live', sourceStatus: 'ok' });
|
||||
});
|
||||
|
||||
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', () => {
|
||||
@@ -84,19 +58,16 @@ test('calibration labels follow supplied provenance and dirty/save/reset transit
|
||||
});
|
||||
|
||||
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
testContext.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
controls._syncCctvSourceBadge(
|
||||
const badge = presentCctvSourceBadge(
|
||||
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||
true,
|
||||
IDLE,
|
||||
);
|
||||
assert.equal(
|
||||
controls._cctvSourceBadge.textContent,
|
||||
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
|
||||
);
|
||||
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
|
||||
assert.equal(badge.label, `${t('SNAPSHOT')} · ${t('DEGRADED')}`);
|
||||
assert.notEqual(badge.label, 'SNAPSHOT · DEGRADED');
|
||||
assert.equal(badge.state, 'ready');
|
||||
const { quality } = presentCctvPanel(
|
||||
{
|
||||
enabled: true,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
EMPTY_CCTV_FRAME,
|
||||
_clearCctvFrame,
|
||||
_queueCctvFrame,
|
||||
_setCctvFrame,
|
||||
_settleCctvFrame,
|
||||
_syncCctvSourceBadge,
|
||||
} from './cctvFrames.js';
|
||||
@@ -46,6 +48,7 @@ export class CctvControls {
|
||||
this._cctvChipWasBusy = false;
|
||||
this._cctvFrameRequestToken = 0;
|
||||
this._cctvFramePreloader = null;
|
||||
this._frame = EMPTY_CCTV_FRAME;
|
||||
this._calibrationEdit = null;
|
||||
this._actionGeneration = 0;
|
||||
this._initCctvPanel();
|
||||
@@ -84,6 +87,9 @@ export class CctvControls {
|
||||
_clearCctvFrame(...args) {
|
||||
return _clearCctvFrame.call(this, ...args);
|
||||
}
|
||||
_setCctvFrame(...args) {
|
||||
return _setCctvFrame.call(this, ...args);
|
||||
}
|
||||
_queueCctvFrame(...args) {
|
||||
return _queueCctvFrame.call(this, ...args);
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ test('a late image completion cannot replace a newer camera preview', (t) => {
|
||||
assert.equal(requests[0].onload, null);
|
||||
requests[1].onload();
|
||||
stale();
|
||||
assert.equal(controls._cctvFrame.src, 'second.jpg');
|
||||
assert.equal(controls._cctvFrame.dataset.cameraId, 'b');
|
||||
assert.equal(controls._frame.src, 'second.jpg');
|
||||
assert.equal(controls._frame.cameraId, 'b');
|
||||
});
|
||||
|
||||
test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => {
|
||||
@@ -65,11 +65,11 @@ test('failed refresh preserves settled pixels, but changing cameras clears them'
|
||||
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);
|
||||
assert.equal(controls._frame.src, 'first.jpg');
|
||||
assert.equal(controls._frame.hasFrame, true);
|
||||
controls._queueCctvFrame('other.jpg', 'b', true);
|
||||
assert.equal(controls._cctvFrame.src, '');
|
||||
assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), false);
|
||||
assert.equal(controls._frame.src, '');
|
||||
assert.equal(controls._frame.hasFrame, false);
|
||||
});
|
||||
|
||||
test('destroy invalidates image callbacks and releases each subscription once', (t) => {
|
||||
@@ -90,7 +90,7 @@ test('destroy invalidates image callbacks and releases each subscription once',
|
||||
assert.equal(unsubscribed, 2);
|
||||
assert.equal(requests[0].onload, null);
|
||||
assert.equal(requests[0].onerror, null);
|
||||
assert.equal(controls._cctvFrame.src, '');
|
||||
assert.equal(controls._frame.src, '');
|
||||
controls._queueCctvFrame('late.jpg', 'b', true);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
+61
-70
@@ -1,4 +1,3 @@
|
||||
import { updateStatusBadge } from './statusBadge.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
@@ -78,6 +77,23 @@ export function cctvSourceLabelText(label) {
|
||||
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
|
||||
}
|
||||
|
||||
/** The preview before any frame: nothing requested, nothing shown. */
|
||||
export const EMPTY_CCTV_FRAME = Object.freeze({
|
||||
cameraId: '',
|
||||
currentSrc: '',
|
||||
src: '',
|
||||
loading: false,
|
||||
error: false,
|
||||
hasFrame: false,
|
||||
wrapLoading: false,
|
||||
});
|
||||
|
||||
/** Apply a change to the preview and publish it to the panel view. */
|
||||
export function _setCctvFrame(change) {
|
||||
this._frame = { ...this._frame, ...change };
|
||||
this.store.setFrame(this._frame);
|
||||
}
|
||||
|
||||
export function _clearCctvFrame() {
|
||||
this._cctvFrameRequestToken += 1;
|
||||
if (this._cctvFramePreloader) {
|
||||
@@ -85,41 +101,29 @@ export function _clearCctvFrame() {
|
||||
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');
|
||||
if (this._cctvFrame) this._setCctvFrame(EMPTY_CCTV_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'),
|
||||
);
|
||||
// A different camera gets an honest acquisition state. Never retain the
|
||||
// prior camera's pixels under the newly selected metadata.
|
||||
const hasFrame = cameraChanged ? false : this._frame.hasFrame;
|
||||
this._setCctvFrame({
|
||||
...(cameraChanged ? { src: '' } : {}),
|
||||
hasFrame,
|
||||
cameraId,
|
||||
currentSrc: src,
|
||||
loading: true,
|
||||
error: false,
|
||||
wrapLoading: !hasFrame,
|
||||
});
|
||||
|
||||
const preloader = new Image();
|
||||
this._cctvFramePreloader = preloader;
|
||||
@@ -140,54 +144,41 @@ export function _settleCctvFrame(token, src, ok) {
|
||||
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();
|
||||
// A failure leaves a settled frame on screen.
|
||||
this._setCctvFrame(
|
||||
ok
|
||||
? {
|
||||
loading: false,
|
||||
wrapLoading: false,
|
||||
error: false,
|
||||
src,
|
||||
hasFrame: true,
|
||||
}
|
||||
: { loading: false, wrapLoading: false, error: true },
|
||||
);
|
||||
this._syncCctvSourceBadge(
|
||||
this._cctvState?.activeCamera,
|
||||
!!this._cctvState?.enabled && !!this.actions.isEnabled(),
|
||||
);
|
||||
}
|
||||
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
if (!this._cctvSourceBadge) return;
|
||||
if (!enabled || !activeCamera) {
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||
return;
|
||||
}
|
||||
const hasDisplayedFrame =
|
||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||
return;
|
||||
}
|
||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||
updateStatusBadge(this._cctvSourceBadge, {
|
||||
label: t('FRAME · UNAVAILABLE'),
|
||||
});
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
/** The source badge's label and frame state for the camera and preview. */
|
||||
export function presentCctvSourceBadge(activeCamera, enabled, frame) {
|
||||
if (!enabled || !activeCamera)
|
||||
return { label: t('SOURCE · UNKNOWN'), state: 'idle' };
|
||||
if (frame.loading && !frame.hasFrame)
|
||||
return { label: t('FRAME · LOADING'), state: 'loading' };
|
||||
if (frame.error && !frame.hasFrame)
|
||||
return { label: t('FRAME · UNAVAILABLE'), state: 'error' };
|
||||
const kind = cctvSourceKindLabel(
|
||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||
);
|
||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
|
||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
||||
return { label: `${kind} · ${status}`, state: 'ready' };
|
||||
}
|
||||
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
this.store.setSourceBadge(
|
||||
presentCctvSourceBadge(activeCamera, enabled, this._frame),
|
||||
);
|
||||
}
|
||||
+119
-11
@@ -37,6 +37,9 @@ function panel(t) {
|
||||
...elements,
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
|
||||
_cctvFrame: document.getElementById('cctv-frame'),
|
||||
_cctvFrameWrap: document.getElementById('cctv-frame-wrap'),
|
||||
_cctvSummary: document.getElementById('cctv-summary'),
|
||||
_cctvCalReadout: document.getElementById('cctv-cal-readout'),
|
||||
_cctvAdjustBtn: document.getElementById('cctv-adjust-btn'),
|
||||
_cctvCalibSaveBtn: document.getElementById('cctv-calib-save-btn'),
|
||||
@@ -146,29 +149,19 @@ test('the buttons stay the elements the controls listen on', async (t) => {
|
||||
assert.equal(byId('cctv-nearest-btn').disabled, true);
|
||||
});
|
||||
|
||||
test('re-renders leave what the controls write alone', async (t) => {
|
||||
test('re-renders leave the picker options the controls fill alone', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
const select = byId('cctv-camera-select');
|
||||
const option = document.createElement('option');
|
||||
option.value = 'a';
|
||||
option.textContent = 'Austin · Congress Ave';
|
||||
select.append(option);
|
||||
byId('cctv-frame').setAttribute('src', 'https://example.test/a.jpg');
|
||||
byId('cctv-summary').textContent = 'Typed summary';
|
||||
const heading = document.querySelector('[data-cal-field="heading"]');
|
||||
heading.textContent = 'HDG 92°';
|
||||
for (const enabled of [true, false, true]) {
|
||||
controls._renderCctvState({ enabled, count: 1, cameraIndex: [] });
|
||||
await nextTick();
|
||||
}
|
||||
assert.equal(select.options.length, 1);
|
||||
assert.equal(select.options[0].value, 'a');
|
||||
assert.equal(
|
||||
byId('cctv-frame').getAttribute('src'),
|
||||
'https://example.test/a.jpg',
|
||||
);
|
||||
assert.equal(byId('cctv-summary').textContent, 'Typed summary');
|
||||
assert.equal(heading.textContent, 'HDG 92°');
|
||||
});
|
||||
|
||||
test('FOCUS waits for an active camera while the other buttons work', () => {
|
||||
@@ -390,3 +383,118 @@ test('a second edit of the same chip gets a fresh input', async (t) => {
|
||||
assert.ok(second && second !== first, 'a new input');
|
||||
assert.equal(second.value, '30.0');
|
||||
});
|
||||
|
||||
test('the frame preview renders each step of a camera frame request', async (t) => {
|
||||
const prior = globalThis.Image;
|
||||
const requests = [];
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
requests.push(this);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.Image = prior;
|
||||
});
|
||||
const { controls, byId } = panel(t);
|
||||
const frame = byId('cctv-frame');
|
||||
const wrap = byId('cctv-frame-wrap');
|
||||
const badge = byId('cctv-source-badge');
|
||||
const state = {
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: {
|
||||
...camera,
|
||||
frameUrl: 'a.jpg',
|
||||
sourceKind: 'snapshot',
|
||||
sourceStatus: 'ok',
|
||||
},
|
||||
};
|
||||
controls._renderCctvState(state);
|
||||
await nextTick();
|
||||
assert.equal(frame.dataset.cameraId, 'a');
|
||||
assert.equal(frame.dataset.loading, 'true');
|
||||
assert.equal(frame.hasAttribute('src'), false);
|
||||
assert.equal(wrap.classList.contains('loading'), true);
|
||||
assert.equal(badge.textContent, 'FRAME · LOADING');
|
||||
assert.equal(badge.dataset.frameState, 'loading');
|
||||
requests[0].onload();
|
||||
await nextTick();
|
||||
assert.equal(frame.getAttribute('src'), 'a.jpg');
|
||||
assert.equal(frame.dataset.loading, '');
|
||||
assert.equal(frame.classList.contains('active'), true);
|
||||
assert.equal(wrap.classList.contains('has-frame'), true);
|
||||
assert.equal(wrap.classList.contains('loading'), false);
|
||||
assert.equal(badge.textContent, 'SNAPSHOT · OK');
|
||||
// A failed refresh keeps the settled pixels.
|
||||
const refreshed = {
|
||||
...state,
|
||||
activeCamera: { ...state.activeCamera, frameUrl: 'a2.jpg' },
|
||||
};
|
||||
controls._renderCctvState(refreshed);
|
||||
requests[1].onerror();
|
||||
await nextTick();
|
||||
assert.equal(frame.getAttribute('src'), 'a.jpg');
|
||||
assert.equal(frame.dataset.error, 'true');
|
||||
assert.equal(wrap.classList.contains('has-frame'), true);
|
||||
assert.equal(badge.textContent, 'SNAPSHOT · OK');
|
||||
// Another camera clears them while its frame loads.
|
||||
controls._renderCctvState({
|
||||
...state,
|
||||
activeCameraId: 'b',
|
||||
activeCamera: { ...state.activeCamera, id: 'b', frameUrl: 'b.jpg' },
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(frame.hasAttribute('src'), false);
|
||||
assert.equal(frame.dataset.cameraId, 'b');
|
||||
assert.equal(wrap.classList.contains('has-frame'), false);
|
||||
assert.equal(badge.textContent, 'FRAME · LOADING');
|
||||
controls._renderCctvState({ ...state, enabled: false });
|
||||
await nextTick();
|
||||
assert.equal(frame.hasAttribute('src'), false);
|
||||
assert.equal(frame.dataset.cameraId, '');
|
||||
assert.equal(wrap.classList.contains('has-frame'), false);
|
||||
assert.equal(badge.textContent, 'SOURCE · UNKNOWN');
|
||||
assert.equal(badge.dataset.frameState, 'idle');
|
||||
});
|
||||
|
||||
test('a live camera swaps the still frame for the video canvas', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
assert.equal(byId('cctv-video').hidden, true);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { ...camera, isVideo: true },
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-video').hidden, false);
|
||||
assert.equal(byId('cctv-frame').hidden, true);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: camera,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-video').hidden, true);
|
||||
assert.equal(byId('cctv-frame').hidden, false);
|
||||
});
|
||||
|
||||
test('the scene summary types in, then holds the whole text', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
const text = 'Austin CCTV · 5th St / Congress Ave';
|
||||
controls._typeCctvSummary(text);
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-summary').textContent, '');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await nextTick();
|
||||
const partial = byId('cctv-summary').textContent;
|
||||
assert.ok(partial.length > 0 && partial.length < text.length, partial);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-summary').textContent, text);
|
||||
});
|
||||
@@ -249,9 +249,8 @@ export function _renderCctvState(state) {
|
||||
this.store.publish(presentCctvPanel(state, enabled));
|
||||
|
||||
const liveIntent = enabled && !!activeCamera?.isVideo;
|
||||
this.store.setLive(liveIntent);
|
||||
if (this._cctvVideo) {
|
||||
this._cctvVideo.hidden = !liveIntent;
|
||||
if (this._cctvFrame) this._cctvFrame.hidden = liveIntent;
|
||||
const visible =
|
||||
liveIntent &&
|
||||
!document.hidden &&
|
||||
@@ -271,16 +270,15 @@ export function _renderCctvState(state) {
|
||||
if (this._cctvFrame && !liveIntent) {
|
||||
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';
|
||||
const cameraChanged = this._frame.cameraId !== nextCameraId;
|
||||
const frameLoading = this._frame.loading;
|
||||
// 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))
|
||||
(cameraChanged || (!frameLoading && this._frame.currentSrc !== nextSrc))
|
||||
) {
|
||||
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
|
||||
}
|
||||
@@ -305,18 +303,18 @@ export function _typeCctvSummary(text) {
|
||||
this._lastCctvSummaryText = nextText;
|
||||
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
this._cctvSummary.textContent = '';
|
||||
this.store.setSummary('');
|
||||
let idx = 0;
|
||||
this._cctvSummaryTypingTimer = setInterval(() => {
|
||||
if (this.destroyed) return;
|
||||
idx += 3;
|
||||
if (idx >= nextText.length) {
|
||||
this._cctvSummary.textContent = nextText;
|
||||
this.store.setSummary(nextText);
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
return;
|
||||
}
|
||||
this._cctvSummary.textContent = nextText.slice(0, idx);
|
||||
this.store.setSummary(nextText.slice(0, idx));
|
||||
}, 20);
|
||||
}
|
||||
|
||||
|
||||
+47
-5
@@ -2,10 +2,10 @@ import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The CCTV panel's buttons, CAL chip, meta line and calibration readout as
|
||||
* its view renders them. CctvControls presents them (`presentCctvPanel()`)
|
||||
* and publishes here; it keeps its listeners on the buttons and paints the
|
||||
* picker, frame, source badge and summary itself.
|
||||
* The CCTV panel as its view renders it. CctvControls presents the buttons,
|
||||
* CAL chip, meta line and calibration readout (`presentCctvPanel()`), runs
|
||||
* the frame preview, source badge and typed summary, and publishes them
|
||||
* here; it keeps its listeners on the buttons and fills the picker itself.
|
||||
*/
|
||||
export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
/** The presented panel; null until the controls first render. */
|
||||
@@ -17,6 +17,15 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
*/
|
||||
const calEdit = shallowRef(null);
|
||||
|
||||
/** The preview: requested and shown frame, loading and error state. */
|
||||
const frame = shallowRef(null);
|
||||
/** Whether the live video surface replaces the still frame. */
|
||||
const live = shallowRef(false);
|
||||
/** `{label, state}` for the source badge; null until first synced. */
|
||||
const sourceBadge = shallowRef(null);
|
||||
/** The scene summary as typed so far; null until the first one. */
|
||||
const summary = shallowRef(null);
|
||||
|
||||
/** @param {object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
view.value = markRaw(next);
|
||||
@@ -29,5 +38,38 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
calEdit.value = edit ? markRaw({ ...edit, id: ++editCount }) : null;
|
||||
}
|
||||
|
||||
return { view, calEdit, publish, setCalEdit };
|
||||
/** @param {object} next */
|
||||
function setFrame(next) {
|
||||
frame.value = markRaw(next);
|
||||
}
|
||||
|
||||
/** @param {boolean} on */
|
||||
function setLive(on) {
|
||||
live.value = Boolean(on);
|
||||
}
|
||||
|
||||
/** @param {{label: string, state: string}} next */
|
||||
function setSourceBadge(next) {
|
||||
sourceBadge.value = markRaw(next);
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function setSummary(text) {
|
||||
summary.value = text;
|
||||
}
|
||||
|
||||
return {
|
||||
view,
|
||||
calEdit,
|
||||
frame,
|
||||
live,
|
||||
sourceBadge,
|
||||
summary,
|
||||
publish,
|
||||
setCalEdit,
|
||||
setFrame,
|
||||
setLive,
|
||||
setSourceBadge,
|
||||
setSummary,
|
||||
};
|
||||
});
|
||||
@@ -1,14 +1,27 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
||||
import { useCctvStore } from '../../stores/cctv.js';
|
||||
|
||||
// The CCTV panel, its buttons, CAL chip and meta line rendered from what
|
||||
// CctvControls presents; until it renders, the panel shows its initial
|
||||
// markup. The picker, frame, source badge and summary are written by the
|
||||
// controls, so their elements carry no bindings.
|
||||
const { view } = storeToRefs(useCctvStore());
|
||||
// The CCTV panel, rendered from what CctvControls presents and publishes;
|
||||
// until it renders, the panel shows its initial markup. The controls fill
|
||||
// the picker's options, and draw the live video into its canvas.
|
||||
const { view, frame, live, sourceBadge, summary } = storeToRefs(useCctvStore());
|
||||
// The frame's request state, as data attributes that read "true" or are
|
||||
// empty; none until the controls first publish.
|
||||
const flag = (on) => (on ? 'true' : '');
|
||||
const frameData = computed(() =>
|
||||
frame.value
|
||||
? {
|
||||
'data-camera-id': frame.value.cameraId,
|
||||
'data-current-src': frame.value.currentSrc,
|
||||
'data-loading': flag(frame.value.loading),
|
||||
'data-error': flag(frame.value.error),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,12 +34,12 @@ const { view } = storeToRefs(useCctvStore());
|
||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
|
||||
<div id="cctv-frame-wrap" :class="['cctv-frame-wrap', { loading: frame?.wrapLoading, 'has-frame': frame?.hasFrame }]">
|
||||
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
|
||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
||||
<img id="cctv-frame" alt="CCTV feed frame" :class="{ active: frame?.hasFrame }" :src="frame?.src || undefined" :hidden="live" v-bind="frameData" />
|
||||
<canvas id="cctv-video" class="cctv-frame" :hidden="!live"></canvas>
|
||||
</div>
|
||||
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
|
||||
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" :data-frame-state="sourceBadge?.state">{{ sourceBadge?.label ?? t('SOURCE · UNKNOWN') }}</div>
|
||||
<div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-enable-btn" :class="['scene-btn', { active: view?.enabled }]">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</button>
|
||||
@@ -66,7 +79,7 @@ const { view } = storeToRefs(useCctvStore());
|
||||
</div>
|
||||
</div>
|
||||
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
||||
<div id="cctv-summary" data-i18n>Enable CCTV to start camera-linked intelligence summaries.</div>
|
||||
<div id="cctv-summary">{{ summary ?? t('Enable CCTV to start camera-linked intelligence summaries.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user