Merge branch 'main' into panel-chrome-consistency

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-30 15:00:57 +00:00
commit 47f3377d2b
33 files changed
+1305 -71

No files matched your search

+9 -3
View File
@@ -59,6 +59,12 @@ export function _initCctvPanel() {
);
});
// The picker defers rebuilds while focused; catch up once it lets go.
this.listen(this._cctvSelect, 'blur', () => {
this._cctvSelectSource = null;
this._renderCctvState(this._cctvState);
});
this.listen(this._cctvSelect, 'change', async () => {
const generation = ++this._actionGeneration;
const activeId = this._cctvState?.activeCameraId;
@@ -72,9 +78,9 @@ export function _initCctvPanel() {
(activeId && activeId !== this._cctvState?.activeCameraId)
)
return;
// Picking a camera from the dropdown flies to it. The catalog spans
// three metros, so a bare selection used to leave the view in the old
// city with a camera active thousands of km away.
// Picking a camera from the dropdown flies to it. The dropdown lists the
// cameras in view (and the active camera, which may be far away), so a
// bare selection could leave the view somewhere else.
this.actions.runExplicitFocus(
() => (this.cctv.selectCamera(cameraId) ? cameraId : null),
(selectedId) => this.cctv.focusCamera(selectedId, 2.2),
+160
View File
@@ -0,0 +1,160 @@
// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and
// says how many more the view leaves out.
import test from 'node:test';
import assert from 'node:assert/strict';
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 };
}
function fixture(t) {
const { document, select } = pickerDocument();
const previous = globalThis.document;
globalThis.document = document;
const controls = new CctvControls({
elements: { _cctvSelect: select },
cctv: {},
actions: { isEnabled: () => true, setPanelCollapsed() {} },
});
t.after(() => {
controls.destroy();
globalThis.document = previous;
});
return { controls, select };
}
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({
enabled: true,
count: 40,
cameraIndex: [camera('a'), camera('b')],
});
assert.deepEqual(
select.options.map((o) => o.value),
['a', 'b', ''],
);
const note = select.options[2];
assert.equal(note.disabled, true);
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({
enabled: true,
count: 2,
cameraIndex: [camera('a'), camera('b')],
});
assert.deepEqual(
select.options.map((o) => o.value),
['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: [] });
assert.equal(select.options.length, 1);
assert.equal(
select.options[select.selectedIndex].textContent,
'1 more camera outside this view · pan or zoom out',
);
});
test('a disabled layer has no note', (t) => {
const { controls, select } = fixture(t);
controls._renderCctvState({ enabled: false, count: 40, cameraIndex: [] });
assert.equal(select.options.length, 0);
assert.equal(select.selectedIndex, -1);
});
test('a camera activated under a focused picker counts against the note', (t) => {
const { controls, select } = fixture(t);
const a = camera('a');
const b = camera('b');
controls._renderCctvState({
enabled: true,
count: 3,
cameraIndex: [a],
activeCameraId: 'a',
activeCamera: a,
});
assert.equal(
select.options.at(-1).textContent,
'2 more cameras outside this view · pan or zoom out',
);
globalThis.document.activeElement = select;
controls._renderCctvState({
enabled: true,
count: 3,
cameraIndex: [b, a],
activeCameraId: 'b',
activeCamera: b,
});
assert.deepEqual(
select.options.map((o) => o.value),
['b', 'a', ''],
);
assert.equal(
select.options.at(-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({
enabled: true,
count: 3,
cameraIndex: [camera('a')],
});
globalThis.document.activeElement = select;
controls._renderCctvState({ enabled: true, count: 3, cameraIndex: [] });
assert.equal(select.selectedIndex, -1);
});
+80 -14
View File
@@ -16,6 +16,15 @@ export function _calBadgeLabel(badge) {
}
}
/** The picker's closing line for the cameras the view leaves out. */
function outsideNoteText(count) {
return tn(
count,
'{count} more camera outside this view · pan or zoom out',
'{count} more cameras outside this view · pan or zoom out',
);
}
export function _renderCctvState(state) {
if (this.destroyed) return;
if (
@@ -26,7 +35,13 @@ export function _renderCctvState(state) {
this._calibrationEdit?.(false);
}
this._cctvState = state || null;
const cameras = state?.cameras || [];
// The light index, not `state.cameras`: reading that builds every camera's
// full state, which a large catalog cannot afford on each notification.
const cameras = state?.cameraIndex || state?.cameras || [];
// The picker lists what is in view; the catalog is every camera.
const catalogCount = Number.isFinite(state?.count)
? state.count
: cameras.length;
const enabled = !!state?.enabled && !!this.actions.isEnabled();
const activeId = state?.activeCameraId || '';
const activeCamera = state?.activeCamera || null;
@@ -59,28 +74,79 @@ export function _renderCctvState(state) {
}
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;
// 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 =
this._cctvSelect.options.length !== cameras.length ||
cameras.some(
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
);
!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) {
this._cctvSelect.innerHTML = '';
const options = document.createDocumentFragment();
for (const camera of cameras) {
const option = document.createElement('option');
option.value = camera.id;
option.textContent = `${camera.city} · ${camera.name}`;
this._cctvSelect.appendChild(option);
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 &&
activeId &&
Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)
activeCamera &&
![...this._cctvSelect.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) {
this._cctvSelect.selectedIndex = -1;
// 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;
}
}
@@ -90,10 +156,10 @@ export function _renderCctvState(state) {
this._cctvNextBtn,
]) {
if (!btn) continue;
btn.disabled = !enabled || cameras.length === 0;
btn.disabled = !enabled || catalogCount === 0;
}
if (this._cctvFocusBtn) {
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId;
this._cctvFocusBtn.disabled = !enabled || catalogCount === 0 || !activeId;
}
if (this._cctvCoverageBtn) {
@@ -180,15 +246,15 @@ export function _renderCctvState(state) {
status: `${credit}${statusMsg}`,
},
);
} else if (cameras.length > 0) {
} else if (catalogCount > 0) {
this._cctvMeta.textContent = enabled
? tn(
cameras.length,
catalogCount,
'{count} camera loaded · click a camera to activate',
'{count} cameras loaded · click a camera to activate',
)
: tn(
cameras.length,
catalogCount,
'{count} camera loaded · enable CCTV to activate',
'{count} cameras loaded · enable CCTV to activate',
);