mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the CCTV camera picker from the store
The picker's options are data: CctvControls computes the cameras in view and the closing note for those left out, keeps a focused picker's list and prepends a camera activated under it, and publishes the options, disabled state and selection. CctvPicker renders them keyed by camera id and applies the selection once the options are in place; it updates only when the controls publish, as the old picker did on layer notifications. A picker about to be disabled no longer counts as focused. This completes the CCTV panel: everything it shows is rendered from the gev-cctv store.
This commit is contained in:
1 parent
eef3dec759
commit
ce343c1c6d
10 files changed
+257
-196
No files matched your search
@@ -86,10 +86,11 @@ They contain no application state.
|
||||
(done: `RecentImageryReadout` and its day cards, the same way),
|
||||
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 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,
|
||||
listeners, EXPAND, focus and resizing), CCTV (done: `CctvPanel` renders
|
||||
the whole panel from the `gev-cctv` store, including the camera picker,
|
||||
the inline pose editor, the frame preview, source badge and typed
|
||||
summary; `CctvControls` keeps its listeners, the frame requests and the
|
||||
live video surface), 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.
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"src/ui/views/panels/StreetLevelPanel.vue",
|
||||
"src/ui/views/panels/CctvPanel.vue",
|
||||
"src/ui/cctv/CctvCalValue.vue",
|
||||
"src/ui/cctv/CctvPicker.vue",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/rail/RailChips.vue"
|
||||
|
||||
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
|
||||
.replace(/\(\s+/g, '(');
|
||||
}
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||
@@ -83,16 +80,6 @@ import {
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'ui',
|
||||
'cctvPresentation.js',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const ASPECT = 16 / 9;
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const GESTURE_TYPES = {
|
||||
@@ -1302,16 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
|
||||
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
||||
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
|
||||
|
||||
const renderer = CCTV_PRESENTATION_SOURCE.match(
|
||||
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
|
||||
);
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
// Nothing active clears the pick; only the outside-view note may show.
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
_setCctvOverlayHostForTest();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useCctvStore } from '../stores/cctv.js';
|
||||
|
||||
// The camera picker: the cameras in view and a closing note for those the
|
||||
// view leaves out. It renders only when CctvControls publishes a picker,
|
||||
// which re-applies the active camera, as on every layer notification.
|
||||
const { picker } = storeToRefs(useCctvStore());
|
||||
|
||||
// The selection, applied once the options are in place.
|
||||
const pick = (el, { value: selection }) => {
|
||||
if (!selection) return;
|
||||
if ('value' in selection) {
|
||||
if (el.value !== selection.value) el.value = selection.value;
|
||||
} else if (el.selectedIndex !== selection.index)
|
||||
el.selectedIndex = selection.index;
|
||||
};
|
||||
const vPick = { mounted: pick, updated: pick };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select v-pick="picker?.selection" :disabled="picker?.disabled"
|
||||
><option
|
||||
v-for="option in picker?.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>{{ option.label }}</option
|
||||
><option v-if="picker?.note" value="" disabled data-cctv-outside>{{
|
||||
picker.note
|
||||
}}</option></select
|
||||
>
|
||||
</template>
|
||||
@@ -61,7 +61,7 @@ export function _initCctvPanel() {
|
||||
|
||||
// The picker defers rebuilds while focused; catch up once it lets go.
|
||||
this.listen(this._cctvSelect, 'blur', () => {
|
||||
this._cctvSelectSource = null;
|
||||
this._picker = null;
|
||||
this._renderCctvState(this._cctvState);
|
||||
});
|
||||
|
||||
|
||||
@@ -149,21 +149,6 @@ test('the buttons stay the elements the controls listen on', async (t) => {
|
||||
assert.equal(byId('cctv-nearest-btn').disabled, true);
|
||||
});
|
||||
|
||||
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);
|
||||
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');
|
||||
});
|
||||
|
||||
test('FOCUS waits for an active camera while the other buttons work', () => {
|
||||
const loaded = { enabled: true, count: 2, cameraIndex: [] };
|
||||
const view = presentCctvPanel(loaded, true);
|
||||
|
||||
+151
-83
@@ -1,123 +1,116 @@
|
||||
// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and
|
||||
// says how many more the view leaves out.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
|
||||
/** The slice of a <select> and its document the picker touches. */
|
||||
function pickerDocument() {
|
||||
const select = new EventTarget();
|
||||
const option = () => ({
|
||||
value: '',
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
dataset: {},
|
||||
remove() {
|
||||
select.options = select.options.filter((entry) => entry !== this);
|
||||
},
|
||||
});
|
||||
Object.assign(select, {
|
||||
options: [],
|
||||
disabled: false,
|
||||
selectedIndex: -1,
|
||||
get value() {
|
||||
return this.options[this.selectedIndex]?.value ?? '';
|
||||
},
|
||||
set value(id) {
|
||||
this.selectedIndex = this.options.findIndex((o) => o.value === id);
|
||||
},
|
||||
replaceChildren(fragment) {
|
||||
this.options = [...fragment.children];
|
||||
this.selectedIndex = this.options.length ? 0 : -1;
|
||||
},
|
||||
prepend(child) {
|
||||
this.options.unshift(child);
|
||||
},
|
||||
});
|
||||
const document = {
|
||||
activeElement: null,
|
||||
createElement: option,
|
||||
createDocumentFragment: () => ({
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { document, select };
|
||||
}
|
||||
import { useCctvStore } from './stores/cctv.js';
|
||||
import CctvPanel from './views/panels/CctvPanel.vue';
|
||||
|
||||
function fixture(t) {
|
||||
const { document, select } = pickerDocument();
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = document;
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CctvPanel).use(pinia);
|
||||
app.mount(host);
|
||||
const select = document.getElementById('cctv-camera-select');
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvSelect: select },
|
||||
elements: {
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_cctvSelect: select,
|
||||
},
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
actions: {
|
||||
isEnabled: () => true,
|
||||
setPanelCollapsed() {},
|
||||
syncViewport() {},
|
||||
},
|
||||
store: useCctvStore(pinia),
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
globalThis.document = previous;
|
||||
app.unmount();
|
||||
});
|
||||
return { controls, select };
|
||||
const render = async (state) => {
|
||||
controls._renderCctvState(state);
|
||||
await nextTick();
|
||||
};
|
||||
const values = () => [...select.options].map((option) => option.value);
|
||||
return { controls, select, render, values };
|
||||
}
|
||||
|
||||
const camera = (id) => ({ id, city: 'Austin', name: `Cam ${id}` });
|
||||
|
||||
test('the picker closes with the count of cameras outside the view', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
test('the picker closes with the count of cameras outside the view', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 40,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['a', 'b', ''],
|
||||
);
|
||||
assert.deepEqual(values(), ['a', 'b', '']);
|
||||
assert.equal(select.options[0].textContent, 'Austin · Cam a');
|
||||
const note = select.options[2];
|
||||
assert.equal(note.disabled, true);
|
||||
assert.equal(note.dataset.cctvOutside, '');
|
||||
assert.equal(
|
||||
note.textContent,
|
||||
'38 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.selectedIndex, -1, 'nothing active, nothing picked');
|
||||
|
||||
controls._renderCctvState({
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
values(),
|
||||
['a', 'b'],
|
||||
'a view holding every camera has no note',
|
||||
);
|
||||
});
|
||||
|
||||
test('with no camera in view, the closed picker shows the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
|
||||
test('with no camera in view, the closed picker shows the note', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
await render({ enabled: true, count: 1, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 1);
|
||||
assert.equal(
|
||||
select.options[select.selectedIndex].textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.value, '');
|
||||
});
|
||||
|
||||
test('a disabled layer has no note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: false, count: 40, cameraIndex: [] });
|
||||
test('a disabled layer has no note', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
await render({ enabled: false, count: 40, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 0);
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
assert.equal(select.disabled, true);
|
||||
});
|
||||
|
||||
test('a camera activated under a focused picker counts against the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
test('the active camera is the one picked', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [a, b],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.equal(select.value, 'b');
|
||||
assert.equal(select.disabled, false);
|
||||
});
|
||||
|
||||
test('a camera activated under a focused picker counts against the note', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
const a = camera('a');
|
||||
const b = camera('b');
|
||||
controls._renderCctvState({
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [a],
|
||||
@@ -125,36 +118,111 @@ test('a camera activated under a focused picker counts against the note', (t) =>
|
||||
activeCamera: a,
|
||||
});
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
select.options[select.options.length - 1].textContent,
|
||||
'2 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({
|
||||
select.focus();
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [b, a],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['b', 'a', ''],
|
||||
);
|
||||
assert.deepEqual(values(), ['b', 'a', '']);
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
select.options[select.options.length - 1].textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
|
||||
test('a focused picker does not pick a stale camera when the view empties', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
test('a focused picker that empties is disabled and shows the note', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
await render({ enabled: true, count: 3, cameraIndex: [camera('a')] });
|
||||
select.focus();
|
||||
await render({ enabled: true, count: 3, cameraIndex: [] });
|
||||
assert.equal(select.disabled, true);
|
||||
assert.deepEqual(values(), ['']);
|
||||
assert.equal(select.selectedIndex, 0, 'no stale camera is picked');
|
||||
});
|
||||
|
||||
test('a focused picker keeps its options until it lets go', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
await render({ enabled: true, count: 3, cameraIndex: [camera('a')] });
|
||||
select.focus();
|
||||
await render({ enabled: true, count: 3, cameraIndex: [camera('b')] });
|
||||
assert.deepEqual(values(), ['a', '']);
|
||||
select.blur();
|
||||
await nextTick();
|
||||
assert.deepEqual(values(), ['b', '']);
|
||||
});
|
||||
|
||||
test('losing the active camera clears the pick', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
const a = camera('a');
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [camera('a')],
|
||||
count: 1,
|
||||
cameraIndex: [a],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({ enabled: true, count: 3, cameraIndex: [] });
|
||||
assert.equal(select.value, 'a');
|
||||
await render({ enabled: true, count: 1, cameraIndex: [a] });
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
|
||||
test('the last camera outside the view, added under focus, drops the note', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({ enabled: true, count: 2, cameraIndex: [a] });
|
||||
assert.deepEqual(values(), ['a', '']);
|
||||
select.focus();
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [b],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.deepEqual(values(), ['b', 'a']);
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
|
||||
test('other panel updates leave the picker alone', async (t) => {
|
||||
const { controls, select, render } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [a, b],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
const before = [...select.options];
|
||||
// The user arrows to another camera while its activation is pending.
|
||||
select.value = 'b';
|
||||
controls.store.setSummary('Typing…');
|
||||
controls.store.setSourceBadge({ label: 'LIVE · OK', state: 'ready' });
|
||||
await nextTick();
|
||||
assert.equal(select.value, 'b');
|
||||
assert.ok(
|
||||
[...select.options].every((option, index) => option === before[index]),
|
||||
'the options are the same elements',
|
||||
);
|
||||
});
|
||||
|
||||
test('an active id without its camera record leaves the pick alone', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({ enabled: true, count: 2, cameraIndex: [a, b] });
|
||||
select.value = 'b';
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [a, b],
|
||||
activeCameraId: 'a',
|
||||
});
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
+49
-66
@@ -16,6 +16,11 @@ export function _calBadgeLabel(badge) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A picker option for a camera. */
|
||||
function pickerOption(camera, id = camera.id) {
|
||||
return { value: id, label: `${camera.city} · ${camera.name}` };
|
||||
}
|
||||
|
||||
/** The picker's closing line for the cameras the view leaves out. */
|
||||
function outsideNoteText(count) {
|
||||
return tn(
|
||||
@@ -170,80 +175,58 @@ export function _renderCctvState(state) {
|
||||
this._updateCctvSyncChip(state?.loading, enabled);
|
||||
|
||||
if (this._cctvSelect) {
|
||||
// The index keeps its identity until the view or the active camera
|
||||
// changes, so an unchanged list skips the per-option comparison. A
|
||||
// focused picker is not rebuilt under the user; blur re-renders it.
|
||||
const picking = document.activeElement === this._cctvSelect;
|
||||
// A focused picker keeps the options it shows; blur re-renders it. One
|
||||
// about to be disabled cannot hold focus, so it does not count.
|
||||
const disabled = !enabled || cameras.length === 0;
|
||||
const picking = document.activeElement === this._cctvSelect && !disabled;
|
||||
// Cameras the view leaves out get one closing line, so a short list
|
||||
// does not read as the whole catalog. A disabled layer has no view.
|
||||
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
||||
const shouldRebuild =
|
||||
!picking &&
|
||||
(this._cctvSelectSource !== cameras ||
|
||||
this._cctvSelectOutside !== outside) &&
|
||||
(this._cctvSelectOutside !== outside ||
|
||||
this._cctvSelect.options.length !==
|
||||
cameras.length + (outside ? 1 : 0) ||
|
||||
cameras.some(
|
||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||
));
|
||||
if (!picking) {
|
||||
this._cctvSelectSource = cameras;
|
||||
this._cctvSelectOutside = outside;
|
||||
}
|
||||
if (shouldRebuild) {
|
||||
const options = document.createDocumentFragment();
|
||||
for (const camera of cameras) {
|
||||
const option = document.createElement('option');
|
||||
option.value = camera.id;
|
||||
option.textContent = `${camera.city} · ${camera.name}`;
|
||||
options.appendChild(option);
|
||||
}
|
||||
if (outside) {
|
||||
const note = document.createElement('option');
|
||||
note.value = '';
|
||||
note.disabled = true;
|
||||
note.dataset.cctvOutside = '';
|
||||
note.textContent = outsideNoteText(outside);
|
||||
options.appendChild(note);
|
||||
}
|
||||
this._cctvSelect.replaceChildren(options);
|
||||
}
|
||||
this._cctvSelect.disabled = !enabled || cameras.length === 0;
|
||||
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
||||
// voice); add it so the picker does not go blank.
|
||||
if (
|
||||
let picker = this._picker;
|
||||
// The index keeps its identity until the view or the active camera
|
||||
// changes, so an unchanged list keeps its options.
|
||||
if (!picking && (picker?.source !== cameras || picker.outside !== outside))
|
||||
picker = {
|
||||
source: cameras,
|
||||
outside,
|
||||
options: cameras.map((camera) => pickerOption(camera)),
|
||||
note: outside ? outsideNoteText(outside) : '',
|
||||
};
|
||||
else if (
|
||||
picking &&
|
||||
activeId &&
|
||||
activeCamera &&
|
||||
![...this._cctvSelect.options].some((option) => option.value === activeId)
|
||||
!picker?.options.some((option) => option.value === activeId)
|
||||
) {
|
||||
const option = document.createElement('option');
|
||||
option.value = activeId;
|
||||
option.textContent = `${activeCamera.city} · ${activeCamera.name}`;
|
||||
this._cctvSelect.prepend(option);
|
||||
// The added camera is one fewer outside the view.
|
||||
const note = [...this._cctvSelect.options].find(
|
||||
(entry) => entry.dataset?.cctvOutside !== undefined,
|
||||
);
|
||||
if (note) {
|
||||
const left = Math.max(
|
||||
0,
|
||||
catalogCount - (this._cctvSelect.options.length - 1),
|
||||
);
|
||||
if (left) note.textContent = outsideNoteText(left);
|
||||
else note.remove();
|
||||
}
|
||||
}
|
||||
// The active camera is always one of the listed records.
|
||||
if (activeId && activeCamera) {
|
||||
this._cctvSelect.value = activeId;
|
||||
} else if (!activeId) {
|
||||
// With nothing in view, the closed picker says where the cameras are.
|
||||
// A focused picker keeps its old options, so it only clears.
|
||||
this._cctvSelect.selectedIndex =
|
||||
!picking && cameras.length === 0 && outside ? 0 : -1;
|
||||
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
||||
// voice); add it so the picker does not go blank. It is one fewer
|
||||
// outside the view.
|
||||
const options = [
|
||||
pickerOption(activeCamera, activeId),
|
||||
...(picker?.options || []),
|
||||
];
|
||||
const left = Math.max(0, catalogCount - options.length);
|
||||
picker = {
|
||||
...picker,
|
||||
options,
|
||||
note: picker?.note && left ? outsideNoteText(left) : '',
|
||||
};
|
||||
}
|
||||
this._picker = picker;
|
||||
this.store.setPicker({
|
||||
options: picker?.options || [],
|
||||
note: picker?.note || '',
|
||||
disabled,
|
||||
// The active camera is always one of the listed records. With nothing
|
||||
// active the closed picker shows the note when nothing is in view; a
|
||||
// focused picker keeps its old options, so it only clears.
|
||||
selection:
|
||||
activeId && activeCamera
|
||||
? { value: activeId }
|
||||
: !activeId
|
||||
? { index: !picking && cameras.length === 0 && outside ? 0 : -1 }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
this.store.publish(presentCctvPanel(state, enabled));
|
||||
|
||||
+14
-1
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
|
||||
* 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.
|
||||
* here, with the camera picker's options; it keeps its listeners on the
|
||||
* panel's controls.
|
||||
*/
|
||||
export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
/** The presented panel; null until the controls first render. */
|
||||
@@ -23,6 +24,11 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
const live = shallowRef(false);
|
||||
/** `{label, state}` for the source badge; null until first synced. */
|
||||
const sourceBadge = shallowRef(null);
|
||||
/**
|
||||
* The camera picker: `{options: [{value, label}], note, disabled,
|
||||
* selection: {value}|{index}|null}`; null until first rendered.
|
||||
*/
|
||||
const picker = shallowRef(null);
|
||||
/** The scene summary as typed so far; null until the first one. */
|
||||
const summary = shallowRef(null);
|
||||
|
||||
@@ -53,6 +59,11 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
sourceBadge.value = markRaw(next);
|
||||
}
|
||||
|
||||
/** @param {object} next */
|
||||
function setPicker(next) {
|
||||
picker.value = markRaw(next);
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function setSummary(text) {
|
||||
summary.value = text;
|
||||
@@ -65,11 +76,13 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
live,
|
||||
sourceBadge,
|
||||
summary,
|
||||
picker,
|
||||
publish,
|
||||
setCalEdit,
|
||||
setFrame,
|
||||
setLive,
|
||||
setSourceBadge,
|
||||
setSummary,
|
||||
setPicker,
|
||||
};
|
||||
});
|
||||
@@ -3,11 +3,12 @@ import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
||||
import CctvPicker from '../../cctv/CctvPicker.vue';
|
||||
import { useCctvStore } from '../../stores/cctv.js';
|
||||
|
||||
// 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.
|
||||
// until it renders, the panel shows its initial markup. The controls 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.
|
||||
@@ -47,7 +48,7 @@ const frameData = computed(() =>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>PREV</button>
|
||||
<select class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
|
||||
<CctvPicker class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label />
|
||||
<button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>NEXT</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
|
||||
Reference in new issue
Block a user