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

+5 -4
View File
@@ -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.
+1
View File
@@ -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"
-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">