mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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),
|
(done: `RecentImageryReadout` and its day cards, the same way),
|
||||||
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
||||||
store that `StreetLevelControls` publishes to; the controls keep their
|
store that `StreetLevelControls` publishes to; the controls keep their
|
||||||
listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel`
|
listeners, EXPAND, focus and resizing), CCTV (done: `CctvPanel` renders
|
||||||
renders the whole panel from the `gev-cctv` store, including the inline
|
the whole panel from the `gev-cctv` store, including the camera picker,
|
||||||
pose editor, the frame preview, source badge and typed summary;
|
the inline pose editor, the frame preview, source badge and typed
|
||||||
`CctvControls` still fills the picker's options), Layers, Display, Context and Radio, the command dock,
|
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
|
the phone shell, then Cockpit. Layers that write into the right rail
|
||||||
directly gain a slot instead.
|
directly gain a slot instead.
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"src/ui/views/panels/StreetLevelPanel.vue",
|
"src/ui/views/panels/StreetLevelPanel.vue",
|
||||||
"src/ui/views/panels/CctvPanel.vue",
|
"src/ui/views/panels/CctvPanel.vue",
|
||||||
"src/ui/cctv/CctvCalValue.vue",
|
"src/ui/cctv/CctvCalValue.vue",
|
||||||
|
"src/ui/cctv/CctvPicker.vue",
|
||||||
"src/ui/stores/cctv.js",
|
"src/ui/stores/cctv.js",
|
||||||
"src/ui/stores/streetLevel.js",
|
"src/ui/stores/streetLevel.js",
|
||||||
"src/ui/rail/RailChips.vue"
|
"src/ui/rail/RailChips.vue"
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
|
|||||||
.replace(/\(\s+/g, '(');
|
.replace(/\(\s+/g, '(');
|
||||||
}
|
}
|
||||||
import assert from 'node:assert/strict';
|
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 * as Cesium from 'cesium';
|
||||||
import cctvLayer, {
|
import cctvLayer, {
|
||||||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||||||
@@ -83,16 +80,6 @@ import {
|
|||||||
LIVE_CAMERA_COLOR,
|
LIVE_CAMERA_COLOR,
|
||||||
} from '../layers/cctv/policy.js';
|
} 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 ASPECT = 16 / 9;
|
||||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||||
const GESTURE_TYPES = {
|
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(-1, -1, records.length), records.length - 1);
|
||||||
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
||||||
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
|
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 {
|
} finally {
|
||||||
cctvLayer.setParams({ autoHop: false });
|
cctvLayer.setParams({ autoHop: false });
|
||||||
_setCctvOverlayHostForTest();
|
_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.
|
// The picker defers rebuilds while focused; catch up once it lets go.
|
||||||
this.listen(this._cctvSelect, 'blur', () => {
|
this.listen(this._cctvSelect, 'blur', () => {
|
||||||
this._cctvSelectSource = null;
|
this._picker = null;
|
||||||
this._renderCctvState(this._cctvState);
|
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);
|
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', () => {
|
test('FOCUS waits for an active camera while the other buttons work', () => {
|
||||||
const loaded = { enabled: true, count: 2, cameraIndex: [] };
|
const loaded = { enabled: true, count: 2, cameraIndex: [] };
|
||||||
const view = presentCctvPanel(loaded, true);
|
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
|
// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and
|
||||||
// says how many more the view leaves out.
|
// says how many more the view leaves out.
|
||||||
|
import '../testSupport/registerDom.mjs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import { createApp, nextTick } from 'vue';
|
||||||
import { CctvControls } from './cctvControls.js';
|
import { CctvControls } from './cctvControls.js';
|
||||||
|
import { useCctvStore } from './stores/cctv.js';
|
||||||
/** The slice of a <select> and its document the picker touches. */
|
import CctvPanel from './views/panels/CctvPanel.vue';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixture(t) {
|
function fixture(t) {
|
||||||
const { document, select } = pickerDocument();
|
document.body.replaceChildren();
|
||||||
const previous = globalThis.document;
|
const host = document.body.appendChild(document.createElement('div'));
|
||||||
globalThis.document = document;
|
const pinia = createPinia();
|
||||||
|
const app = createApp(CctvPanel).use(pinia);
|
||||||
|
app.mount(host);
|
||||||
|
const select = document.getElementById('cctv-camera-select');
|
||||||
const controls = new CctvControls({
|
const controls = new CctvControls({
|
||||||
elements: { _cctvSelect: select },
|
elements: {
|
||||||
|
_cctvPanel: document.getElementById('cctv-panel'),
|
||||||
|
_cctvSelect: select,
|
||||||
|
},
|
||||||
cctv: {},
|
cctv: {},
|
||||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
actions: {
|
||||||
|
isEnabled: () => true,
|
||||||
|
setPanelCollapsed() {},
|
||||||
|
syncViewport() {},
|
||||||
|
},
|
||||||
|
store: useCctvStore(pinia),
|
||||||
});
|
});
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
controls.destroy();
|
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}` });
|
const camera = (id) => ({ id, city: 'Austin', name: `Cam ${id}` });
|
||||||
|
|
||||||
test('the picker closes with the count of cameras outside the view', (t) => {
|
test('the picker closes with the count of cameras outside the view', async (t) => {
|
||||||
const { controls, select } = fixture(t);
|
const { select, render, values } = fixture(t);
|
||||||
controls._renderCctvState({
|
await render({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
count: 40,
|
count: 40,
|
||||||
cameraIndex: [camera('a'), camera('b')],
|
cameraIndex: [camera('a'), camera('b')],
|
||||||
});
|
});
|
||||||
assert.deepEqual(
|
assert.deepEqual(values(), ['a', 'b', '']);
|
||||||
select.options.map((o) => o.value),
|
assert.equal(select.options[0].textContent, 'Austin · Cam a');
|
||||||
['a', 'b', ''],
|
|
||||||
);
|
|
||||||
const note = select.options[2];
|
const note = select.options[2];
|
||||||
assert.equal(note.disabled, true);
|
assert.equal(note.disabled, true);
|
||||||
|
assert.equal(note.dataset.cctvOutside, '');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
note.textContent,
|
note.textContent,
|
||||||
'38 more cameras outside this view · pan or zoom out',
|
'38 more cameras outside this view · pan or zoom out',
|
||||||
);
|
);
|
||||||
assert.equal(select.selectedIndex, -1, 'nothing active, nothing picked');
|
assert.equal(select.selectedIndex, -1, 'nothing active, nothing picked');
|
||||||
|
|
||||||
controls._renderCctvState({
|
await render({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
count: 2,
|
count: 2,
|
||||||
cameraIndex: [camera('a'), camera('b')],
|
cameraIndex: [camera('a'), camera('b')],
|
||||||
});
|
});
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
select.options.map((o) => o.value),
|
values(),
|
||||||
['a', 'b'],
|
['a', 'b'],
|
||||||
'a view holding every camera has no note',
|
'a view holding every camera has no note',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('with no camera in view, the closed picker shows the note', (t) => {
|
test('with no camera in view, the closed picker shows the note', async (t) => {
|
||||||
const { controls, select } = fixture(t);
|
const { select, render } = fixture(t);
|
||||||
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
|
await render({ enabled: true, count: 1, cameraIndex: [] });
|
||||||
assert.equal(select.options.length, 1);
|
assert.equal(select.options.length, 1);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
select.options[select.selectedIndex].textContent,
|
select.options[select.selectedIndex].textContent,
|
||||||
'1 more camera outside this view · pan or zoom out',
|
'1 more camera outside this view · pan or zoom out',
|
||||||
);
|
);
|
||||||
|
assert.equal(select.value, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a disabled layer has no note', (t) => {
|
test('a disabled layer has no note', async (t) => {
|
||||||
const { controls, select } = fixture(t);
|
const { select, render } = fixture(t);
|
||||||
controls._renderCctvState({ enabled: false, count: 40, cameraIndex: [] });
|
await render({ enabled: false, count: 40, cameraIndex: [] });
|
||||||
assert.equal(select.options.length, 0);
|
assert.equal(select.options.length, 0);
|
||||||
assert.equal(select.selectedIndex, -1);
|
assert.equal(select.selectedIndex, -1);
|
||||||
|
assert.equal(select.disabled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a camera activated under a focused picker counts against the note', (t) => {
|
test('the active camera is the one picked', async (t) => {
|
||||||
const { controls, select } = fixture(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 a = camera('a');
|
||||||
const b = camera('b');
|
const b = camera('b');
|
||||||
controls._renderCctvState({
|
await render({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
count: 3,
|
count: 3,
|
||||||
cameraIndex: [a],
|
cameraIndex: [a],
|
||||||
@@ -125,36 +118,111 @@ test('a camera activated under a focused picker counts against the note', (t) =>
|
|||||||
activeCamera: a,
|
activeCamera: a,
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
select.options.at(-1).textContent,
|
select.options[select.options.length - 1].textContent,
|
||||||
'2 more cameras outside this view · pan or zoom out',
|
'2 more cameras outside this view · pan or zoom out',
|
||||||
);
|
);
|
||||||
globalThis.document.activeElement = select;
|
select.focus();
|
||||||
controls._renderCctvState({
|
await render({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
count: 3,
|
count: 3,
|
||||||
cameraIndex: [b, a],
|
cameraIndex: [b, a],
|
||||||
activeCameraId: 'b',
|
activeCameraId: 'b',
|
||||||
activeCamera: b,
|
activeCamera: b,
|
||||||
});
|
});
|
||||||
assert.deepEqual(
|
assert.deepEqual(values(), ['b', 'a', '']);
|
||||||
select.options.map((o) => o.value),
|
|
||||||
['b', 'a', ''],
|
|
||||||
);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
select.options.at(-1).textContent,
|
select.options[select.options.length - 1].textContent,
|
||||||
'1 more camera outside this view · pan or zoom out',
|
'1 more camera outside this view · pan or zoom out',
|
||||||
);
|
);
|
||||||
assert.equal(select.value, 'b');
|
assert.equal(select.value, 'b');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a focused picker does not pick a stale camera when the view empties', (t) => {
|
test('a focused picker that empties is disabled and shows the note', async (t) => {
|
||||||
const { controls, select } = fixture(t);
|
const { select, render, values } = fixture(t);
|
||||||
controls._renderCctvState({
|
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,
|
enabled: true,
|
||||||
count: 3,
|
count: 1,
|
||||||
cameraIndex: [camera('a')],
|
cameraIndex: [a],
|
||||||
|
activeCameraId: 'a',
|
||||||
|
activeCamera: a,
|
||||||
});
|
});
|
||||||
globalThis.document.activeElement = select;
|
assert.equal(select.value, 'a');
|
||||||
controls._renderCctvState({ enabled: true, count: 3, cameraIndex: [] });
|
await render({ enabled: true, count: 1, cameraIndex: [a] });
|
||||||
assert.equal(select.selectedIndex, -1);
|
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. */
|
/** The picker's closing line for the cameras the view leaves out. */
|
||||||
function outsideNoteText(count) {
|
function outsideNoteText(count) {
|
||||||
return tn(
|
return tn(
|
||||||
@@ -170,80 +175,58 @@ export function _renderCctvState(state) {
|
|||||||
this._updateCctvSyncChip(state?.loading, enabled);
|
this._updateCctvSyncChip(state?.loading, enabled);
|
||||||
|
|
||||||
if (this._cctvSelect) {
|
if (this._cctvSelect) {
|
||||||
// The index keeps its identity until the view or the active camera
|
// A focused picker keeps the options it shows; blur re-renders it. One
|
||||||
// changes, so an unchanged list skips the per-option comparison. A
|
// about to be disabled cannot hold focus, so it does not count.
|
||||||
// focused picker is not rebuilt under the user; blur re-renders it.
|
const disabled = !enabled || cameras.length === 0;
|
||||||
const picking = document.activeElement === this._cctvSelect;
|
const picking = document.activeElement === this._cctvSelect && !disabled;
|
||||||
// Cameras the view leaves out get one closing line, so a short list
|
// 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.
|
// does not read as the whole catalog. A disabled layer has no view.
|
||||||
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
||||||
const shouldRebuild =
|
let picker = this._picker;
|
||||||
!picking &&
|
// The index keeps its identity until the view or the active camera
|
||||||
(this._cctvSelectSource !== cameras ||
|
// changes, so an unchanged list keeps its options.
|
||||||
this._cctvSelectOutside !== outside) &&
|
if (!picking && (picker?.source !== cameras || picker.outside !== outside))
|
||||||
(this._cctvSelectOutside !== outside ||
|
picker = {
|
||||||
this._cctvSelect.options.length !==
|
source: cameras,
|
||||||
cameras.length + (outside ? 1 : 0) ||
|
outside,
|
||||||
cameras.some(
|
options: cameras.map((camera) => pickerOption(camera)),
|
||||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
note: outside ? outsideNoteText(outside) : '',
|
||||||
));
|
};
|
||||||
if (!picking) {
|
else if (
|
||||||
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 (
|
|
||||||
picking &&
|
picking &&
|
||||||
activeId &&
|
activeId &&
|
||||||
activeCamera &&
|
activeCamera &&
|
||||||
![...this._cctvSelect.options].some((option) => option.value === activeId)
|
!picker?.options.some((option) => option.value === activeId)
|
||||||
) {
|
) {
|
||||||
const option = document.createElement('option');
|
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
||||||
option.value = activeId;
|
// voice); add it so the picker does not go blank. It is one fewer
|
||||||
option.textContent = `${activeCamera.city} · ${activeCamera.name}`;
|
// outside the view.
|
||||||
this._cctvSelect.prepend(option);
|
const options = [
|
||||||
// The added camera is one fewer outside the view.
|
pickerOption(activeCamera, activeId),
|
||||||
const note = [...this._cctvSelect.options].find(
|
...(picker?.options || []),
|
||||||
(entry) => entry.dataset?.cctvOutside !== undefined,
|
];
|
||||||
);
|
const left = Math.max(0, catalogCount - options.length);
|
||||||
if (note) {
|
picker = {
|
||||||
const left = Math.max(
|
...picker,
|
||||||
0,
|
options,
|
||||||
catalogCount - (this._cctvSelect.options.length - 1),
|
note: picker?.note && left ? outsideNoteText(left) : '',
|
||||||
);
|
};
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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));
|
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,
|
* The CCTV panel as its view renders it. CctvControls presents the buttons,
|
||||||
* CAL chip, meta line and calibration readout (`presentCctvPanel()`), runs
|
* CAL chip, meta line and calibration readout (`presentCctvPanel()`), runs
|
||||||
* the frame preview, source badge and typed summary, and publishes them
|
* 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', () => {
|
export const useCctvStore = defineStore('gev-cctv', () => {
|
||||||
/** The presented panel; null until the controls first render. */
|
/** The presented panel; null until the controls first render. */
|
||||||
@@ -23,6 +24,11 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
|||||||
const live = shallowRef(false);
|
const live = shallowRef(false);
|
||||||
/** `{label, state}` for the source badge; null until first synced. */
|
/** `{label, state}` for the source badge; null until first synced. */
|
||||||
const sourceBadge = shallowRef(null);
|
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. */
|
/** The scene summary as typed so far; null until the first one. */
|
||||||
const summary = shallowRef(null);
|
const summary = shallowRef(null);
|
||||||
|
|
||||||
@@ -53,6 +59,11 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
|||||||
sourceBadge.value = markRaw(next);
|
sourceBadge.value = markRaw(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {object} next */
|
||||||
|
function setPicker(next) {
|
||||||
|
picker.value = markRaw(next);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {string} text */
|
/** @param {string} text */
|
||||||
function setSummary(text) {
|
function setSummary(text) {
|
||||||
summary.value = text;
|
summary.value = text;
|
||||||
@@ -65,11 +76,13 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
|||||||
live,
|
live,
|
||||||
sourceBadge,
|
sourceBadge,
|
||||||
summary,
|
summary,
|
||||||
|
picker,
|
||||||
publish,
|
publish,
|
||||||
setCalEdit,
|
setCalEdit,
|
||||||
setFrame,
|
setFrame,
|
||||||
setLive,
|
setLive,
|
||||||
setSourceBadge,
|
setSourceBadge,
|
||||||
setSummary,
|
setSummary,
|
||||||
|
setPicker,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3,11 +3,12 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
||||||
|
import CctvPicker from '../../cctv/CctvPicker.vue';
|
||||||
import { useCctvStore } from '../../stores/cctv.js';
|
import { useCctvStore } from '../../stores/cctv.js';
|
||||||
|
|
||||||
// The CCTV panel, rendered from what CctvControls presents and publishes;
|
// The CCTV panel, rendered from what CctvControls presents and publishes;
|
||||||
// until it renders, the panel shows its initial markup. The controls fill
|
// until it renders, the panel shows its initial markup. The controls draw
|
||||||
// the picker's options, and draw the live video into its canvas.
|
// the live video into its canvas.
|
||||||
const { view, frame, live, sourceBadge, summary } = storeToRefs(useCctvStore());
|
const { view, frame, live, sourceBadge, summary } = storeToRefs(useCctvStore());
|
||||||
// The frame's request state, as data attributes that read "true" or are
|
// The frame's request state, as data attributes that read "true" or are
|
||||||
// empty; none until the controls first publish.
|
// empty; none until the controls first publish.
|
||||||
@@ -47,7 +48,7 @@ const frameData = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
<button id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>PREV</button>
|
<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>
|
<button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>NEXT</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
|
|||||||
Reference in new issue
Block a user