mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into panel-chrome-consistency
This commit is contained in:
33 files changed
+1305
-71
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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',
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user