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:
Sameh Khamis committed 2026-10-30 22:05:41 -07:00
1 parent eef3dec759
commit ce343c1c6d
10 files changed
+257 -196

No files matched your search

-23
View File
@@ -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();
+32
View File
@@ -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>
+1 -1
View File
@@ -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);
});
-15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
});
+4 -3
View File
@@ -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">