feat(ui): render the CCTV panel's buttons, CAL chip and meta line from a store

CctvControls presents the switch, navigation and toggle buttons, the CAL
chip and the meta line with a pure presentCctvPanel() and publishes them
to the gev-cctv store; CctvPanel renders them. The controls keep their
listeners on the same elements, and still write the picker, frame, source
badge, calibration readout and summary, which carry no bindings.

A page that imports ui/cctv with its own markup no longer has these
elements painted for it, as with Street Level.
This commit is contained in:
Sameh Khamis committed 2026-10-30 20:57:03 -07:00
1 parent e6aee52ea0
commit 5b8cb40fce
14 files changed
+469 -215

No files matched your search

+4 -1
View File
@@ -86,7 +86,10 @@ 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, Layers, Display, Context and Radio, the command dock,
listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel`
renders the buttons, CAL chip and meta line from the `gev-cctv` store;
the picker, frame, source badge, calibration readout and summary are
still written by `CctvControls`), 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.
+10 -4
View File
@@ -46,6 +46,8 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/views/panels/StreetLevelPanel.vue",
"src/ui/views/panels/CctvPanel.vue",
"src/ui/stores/cctv.js",
"src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue"
],
@@ -654,9 +656,11 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/ui/statusBadge.js"
"src/ui/statusBadge.js",
"src/ui/vueRuntime.js",
"src/ui/stores/cctv.js"
],
"external": []
"external": ["vue", "pinia"]
},
"context-controls": {
"exports": ["./ui/context", "./ui/context/policy"],
@@ -948,7 +952,8 @@
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue",
"src/ui/stores/streetLevel.js"
"src/ui/stores/streetLevel.js",
"src/ui/stores/cctv.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -2914,7 +2919,8 @@
"src/ui/weather/WeatherReadout.vue",
"src/ui/recentImagery/RecentImageryCard.vue",
"src/ui/recentImagery/RecentImageryReadout.vue",
"src/ui/stores/streetLevel.js"
"src/ui/stores/streetLevel.js",
"src/ui/stores/cctv.js"
],
"external": [
"hls.js",
+3 -1
View File
@@ -164,7 +164,7 @@ try {
['curated', true, 'rgb(255, 208, 138)'],
]) {
const result = await page.evaluate(
({ calBadge, dirty }) => {
async ({ calBadge, dirty }) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
document.getElementById('cctv-next-btn').focus();
owner._renderCctvState({
@@ -175,6 +175,8 @@ try {
calDirty: dirty,
},
});
// The panel view renders the chip on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
const badge = document.getElementById('cctv-quality-chip');
return {
state: badge.dataset.calBadge,
-4
View File
@@ -1312,10 +1312,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
// Nothing active clears the pick; only the outside-view note may show.
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
);
assert.match(
renderer[0],
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
);
} finally {
cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest();
+1 -1
View File
@@ -78,7 +78,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
const { PanelPositionControls } =
await import('../ui/panelPositionControls.js');
const html = await readFile(
path.join(root, 'src/ui/views/LayerPanelsView.vue'),
path.join(root, 'src/ui/views/panels/CctvPanel.vue'),
'utf8',
);
assert.match(
+24 -24
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CctvControls } from './cctvControls.js';
import { presentCctvPanel } from './cctvPresentation.js';
import { railFixture } from './railTestFixture.mjs';
import { setLocale, t } from '../i18n/index.js';
@@ -8,7 +9,6 @@ function fixture(t) {
const { document } = railFixture();
const controls = new CctvControls({
elements: {
_cctvQualityChip: document.createElement('span'),
_cctvSourceBadge: document.createElement('div'),
},
cctv: {},
@@ -56,8 +56,7 @@ test('source and frame states retain their distinct meanings, including retained
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
});
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
const { controls } = fixture(testContext);
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', () => {
const cases = [
['raw-prior', false, 'CAL · RAW PRIOR'],
['curated', false, 'CAL · CURATED'],
@@ -69,20 +68,19 @@ test('calibration labels follow supplied provenance and dirty/save/reset transit
];
for (const [calBadge, calDirty, label] of cases) {
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
controls._renderCctvState({
enabled: true,
activeCameraId: 'a',
activeCamera,
});
assert.equal(controls._cctvQualityChip.textContent, label);
assert.equal(
controls._cctvQualityChip.dataset.calBadge,
calDirty ? 'edited' : calBadge || '',
const { quality } = presentCctvPanel(
{ enabled: true, activeCameraId: 'a', activeCamera },
true,
);
assert.deepEqual(quality, {
label,
calBadge: calDirty ? 'edited' : calBadge || '',
});
}
controls._renderCctvState(null);
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
assert.deepEqual(presentCctvPanel(null, false).quality, {
label: 'CAL · --',
calBadge: '',
});
});
test('localization changes badge copy while preserving machine state ids', (testContext) => {
@@ -99,14 +97,16 @@ test('localization changes badge copy while preserving machine state ids', (test
);
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
controls._renderCctvState({
enabled: true,
activeCameraId: 'a',
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
});
assert.equal(
controls._cctvQualityChip.textContent,
t('CAL · EDITED (UNSAVED)'),
const { quality } = presentCctvPanel(
{
enabled: true,
activeCameraId: 'a',
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
},
true,
);
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
assert.deepEqual(quality, {
label: t('CAL · EDITED (UNSAVED)'),
calBadge: 'edited',
});
});
+14 -2
View File
@@ -17,11 +17,23 @@ import {
_updateCctvSyncChip,
} from './cctvPresentation.js';
import { _initCctvPanel } from './cctvBindings.js';
import { useCctvStore } from './stores/cctv.js';
import { applicationPinia } from './vueRuntime.js';
/** Own camera-panel interaction and presentation; receive the camera port and application actions. */
/**
* Own camera-panel interaction and presentation; receive the camera port and
* application actions. The panel view renders the buttons, CAL chip and meta
* line from what this publishes (`gev-cctv` store).
*/
export class CctvControls {
constructor({ elements, cctv, actions }) {
constructor({
elements,
cctv,
actions,
store = useCctvStore(applicationPinia()),
}) {
Object.assign(this, elements);
this.store = store;
this.cctv = cctv;
this.actions = actions;
this.destroyed = false;
+215
View File
@@ -0,0 +1,215 @@
import '../testSupport/registerDom.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { CctvControls } from './cctvControls.js';
import { presentCctvPanel } from './cctvPresentation.js';
import { useCctvStore } from './stores/cctv.js';
import CctvPanel from './views/panels/CctvPanel.vue';
const BUTTONS = {
_cctvEnableBtn: 'cctv-enable-btn',
_cctvNearestBtn: 'cctv-nearest-btn',
_cctvPrevBtn: 'cctv-prev-btn',
_cctvNextBtn: 'cctv-next-btn',
_cctvFocusBtn: 'cctv-focus-btn',
_cctvCoverageBtn: 'cctv-coverage-btn',
_cctvAutoHopBtn: 'cctv-auto-hop-btn',
_cctvProjectionBtn: 'cctv-projection-btn',
};
function panel(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CctvPanel).use(pinia);
app.mount(host);
const elements = Object.fromEntries(
Object.entries(BUTTONS).map(([key, id]) => [
key,
document.getElementById(id),
]),
);
const controls = new CctvControls({
elements: {
...elements,
_cctvPanel: document.getElementById('cctv-panel'),
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
},
cctv: {},
actions: {
isEnabled: () => true,
setPanelCollapsed() {},
syncViewport() {},
},
store: useCctvStore(pinia),
});
t.after(() => {
controls.destroy();
app.unmount();
});
const byId = (id) => document.getElementById(id);
return { controls, byId };
}
const camera = {
id: 'a',
city: 'Austin',
name: 'Congress Ave',
headingDeg: 91.6,
fovDeg: 60,
rangeM: 120.4,
sourceLabel: 'Configured Source',
calBadge: 'curated',
};
test('the panel keeps its initial markup until the controls render', (t) => {
const { byId } = panel(t);
assert.equal(byId('cctv-enable-btn').textContent, 'CCTV OFF');
assert.equal(byId('cctv-enable-btn').classList.contains('active'), false);
assert.equal(byId('cctv-coverage-btn').textContent, 'COVERAGE OFF');
assert.equal(byId('cctv-auto-hop-btn').textContent, 'AUTO HOP OFF');
assert.equal(byId('cctv-projection-btn').textContent, 'PROJECTION ON');
assert.equal(byId('cctv-quality-chip').textContent, 'CAL · --');
assert.equal(byId('cctv-quality-chip').hasAttribute('data-cal-badge'), false);
assert.equal(
byId('cctv-meta').textContent,
'Enable CCTV to load camera intersections',
);
for (const id of Object.values(BUTTONS))
assert.equal(byId(id).disabled, false, id);
});
test('a disabled layer disables every button but the switch', async (t) => {
const { controls, byId } = panel(t);
controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] });
await nextTick();
assert.equal(byId('cctv-enable-btn').disabled, false);
for (const [key, id] of Object.entries(BUTTONS)) {
if (key === '_cctvEnableBtn') continue;
assert.equal(byId(id).disabled, true, id);
}
assert.equal(
byId('cctv-meta').textContent,
'3 cameras loaded · enable CCTV to activate',
);
});
test('an active camera renders the switch, toggles, CAL chip and meta line', async (t) => {
const { controls, byId } = panel(t);
controls._renderCctvState({
enabled: true,
count: 1,
cameraIndex: [camera],
activeCameraId: 'a',
activeCamera: camera,
coverageMode: 'viewshed',
autoHop: true,
showProjection: false,
});
await nextTick();
assert.equal(byId('cctv-enable-btn').textContent, 'CCTV ON');
assert.equal(byId('cctv-enable-btn').classList.contains('active'), true);
for (const id of Object.values(BUTTONS))
assert.equal(byId(id).disabled, false, id);
assert.equal(byId('cctv-coverage-btn').textContent, 'VIEWSHED ON');
assert.equal(byId('cctv-coverage-btn').classList.contains('active'), true);
assert.equal(byId('cctv-auto-hop-btn').textContent, 'AUTO HOP ON');
assert.equal(byId('cctv-projection-btn').textContent, 'PROJECTION OFF');
assert.equal(byId('cctv-projection-btn').classList.contains('active'), false);
assert.equal(byId('cctv-quality-chip').textContent, 'CAL · CURATED');
assert.equal(byId('cctv-quality-chip').dataset.calBadge, 'curated');
assert.equal(
byId('cctv-meta').textContent,
'Austin · HDG 92° · FOV 60° · RANGE 120m · OFF · CURATED · Configured Source',
);
});
test('the buttons stay the elements the controls listen on', async (t) => {
const { controls, byId } = panel(t);
const before = Object.values(BUTTONS).map(byId);
controls._renderCctvState({ enabled: true, count: 0, cameraIndex: [] });
await nextTick();
controls._renderCctvState({ enabled: false, count: 0, cameraIndex: [] });
await nextTick();
const after = Object.values(BUTTONS).map(byId);
assert.ok(
before.every((node, index) => node === after[index]),
'a button was replaced',
);
assert.equal(byId('cctv-nearest-btn').disabled, true);
});
test('re-renders leave what the controls write 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);
byId('cctv-frame').setAttribute('src', 'https://example.test/a.jpg');
byId('cctv-summary').textContent = 'Typed summary';
const heading = document.querySelector('[data-cal-field="heading"]');
heading.textContent = 'HDG 92°';
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');
assert.equal(
byId('cctv-frame').getAttribute('src'),
'https://example.test/a.jpg',
);
assert.equal(byId('cctv-summary').textContent, 'Typed summary');
assert.equal(heading.textContent, 'HDG 92°');
});
test('FOCUS waits for an active camera while the other buttons work', () => {
const loaded = { enabled: true, count: 2, cameraIndex: [] };
const view = presentCctvPanel(loaded, true);
assert.equal(view.navDisabled, false);
assert.equal(view.focusDisabled, true);
assert.equal(view.toggleDisabled, false);
const active = presentCctvPanel(
{ ...loaded, activeCameraId: 'a', activeCamera: camera },
true,
);
assert.equal(active.focusDisabled, false);
assert.equal(presentCctvPanel(loaded, false).toggleDisabled, true);
});
test('coverage reads its mode, or the older on/off flag', () => {
const text = (state) => presentCctvPanel(state, true).coverage;
assert.deepEqual(text({ coverageMode: 'on' }), {
active: true,
text: 'COVERAGE ON',
});
assert.deepEqual(text({ showCoverage: true }), {
active: true,
text: 'COVERAGE ON',
});
assert.deepEqual(text({}), { active: false, text: 'COVERAGE OFF' });
});
test('losing the active camera resets the CAL chip', async (t) => {
const { controls, byId } = panel(t);
controls._renderCctvState({
enabled: true,
count: 1,
cameraIndex: [camera],
activeCameraId: 'a',
activeCamera: camera,
});
await nextTick();
assert.equal(byId('cctv-quality-chip').dataset.calBadge, 'curated');
controls._renderCctvState(null);
await nextTick();
assert.equal(byId('cctv-quality-chip').textContent, 'CAL · --');
assert.equal(byId('cctv-quality-chip').getAttribute('data-cal-badge'), '');
assert.equal(
byId('cctv-meta').textContent,
'Enable CCTV to load camera intersections',
);
});
+101 -119
View File
@@ -1,4 +1,3 @@
import { updateStatusBadge } from './statusBadge.js';
import { t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
@@ -25,6 +24,106 @@ function outsideNoteText(count) {
);
}
/**
* The panel's buttons, CAL chip and meta line for a camera state, as the CCTV
* panel view renders them. Pure: the picker, frame, source badge, calibration
* readout and summary stay with the controls.
* @param {object|null} state Camera layer UI state.
* @param {boolean} enabled Whether the layer is on.
*/
export function presentCctvPanel(state, enabled) {
const cameras = state?.cameraIndex || state?.cameras || [];
const catalogCount = Number.isFinite(state?.count)
? state.count
: cameras.length;
const activeId = state?.activeCameraId || '';
const activeCamera = state?.activeCamera || null;
// Tri-state (viewshed design §3b): off → on (wireframes) → viewshed
// (color-coded volumes). The click handler cycles; this renders.
const coverage = state?.coverageMode || (state?.showCoverage ? 'on' : 'off');
const autoHop = !!state?.autoHop;
const showProjection = state?.showProjection !== false;
// CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, no
// in-world tint): three states from cctv.js's deriveCalBadge. Unsaved live
// edits show EDITED over the persisted state (viewshed design §3e): SAVE
// CAL promotes to CALIBRATED, RESET CAL clears.
const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty;
return {
enabled,
navDisabled: !enabled || catalogCount === 0,
focusDisabled: !enabled || catalogCount === 0 || !activeId,
toggleDisabled: !enabled,
coverage: {
active: coverage !== 'off',
text:
coverage === 'viewshed'
? t('VIEWSHED ON')
: coverage === 'on'
? t('COVERAGE ON')
: t('COVERAGE OFF'),
},
autoHop: {
active: autoHop,
text: autoHop ? t('AUTO HOP ON') : t('AUTO HOP OFF'),
},
projection: {
active: showProjection,
text: showProjection ? t('PROJECTION ON') : t('PROJECTION OFF'),
},
quality: {
label: dirty
? t('CAL · EDITED (UNSAVED)')
: t('CAL · {badge}', { badge: _calBadgeLabel(badge) }),
calBadge: dirty ? 'edited' : badge || '',
},
meta: metaText(state, enabled, activeCamera, catalogCount),
};
}
function metaText(state, enabled, activeCamera, catalogCount) {
if (activeCamera) {
const provider = cctvSourceLabelText(
activeCamera.sourceLabel || activeCamera.provider || 'Configured Source',
);
const statusMsg = activeCamera.sourceMessage
? ` · ${cctvSourceMessageText(activeCamera.sourceMessage)}`
: '';
// A partner-supplied feed inside a pack names its owner here.
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
const calBadge = activeCamera.calBadge
? _calBadgeLabel(activeCamera.calBadge)
: '';
const projLabel = state?.showProjection !== false ? t('MONITOR') : t('OFF');
return t(
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '',
provider,
status: `${credit}${statusMsg}`,
},
);
}
if (catalogCount > 0)
return enabled
? tn(
catalogCount,
'{count} camera loaded · click a camera to activate',
'{count} cameras loaded · click a camera to activate',
)
: tn(
catalogCount,
'{count} camera loaded · enable CCTV to activate',
'{count} cameras loaded · enable CCTV to activate',
);
return t('Enable CCTV to load camera intersections');
}
export function _renderCctvState(state) {
if (this.destroyed) return;
if (
@@ -68,11 +167,6 @@ export function _renderCctvState(state) {
this._updateCctvSyncChip(state?.loading, enabled);
if (this._cctvEnableBtn) {
this._cctvEnableBtn.classList.toggle('active', enabled);
this._cctvEnableBtn.textContent = enabled ? t('CCTV ON') : t('CCTV OFF');
}
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
@@ -150,121 +244,9 @@ export function _renderCctvState(state) {
}
}
for (const btn of [
this._cctvNearestBtn,
this._cctvPrevBtn,
this._cctvNextBtn,
]) {
if (!btn) continue;
btn.disabled = !enabled || catalogCount === 0;
}
if (this._cctvFocusBtn) {
this._cctvFocusBtn.disabled = !enabled || catalogCount === 0 || !activeId;
}
if (this._cctvCoverageBtn) {
// Tri-state (viewshed design §3b): off → on (wireframes) → viewshed
// (color-coded volumes). The click handler cycles; this renders.
const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off');
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
this._cctvCoverageBtn.textContent =
mode === 'viewshed'
? t('VIEWSHED ON')
: mode === 'on'
? t('COVERAGE ON')
: t('COVERAGE OFF');
this._cctvCoverageBtn.disabled = !enabled;
}
if (this._cctvAutoHopBtn) {
const autoHop = !!state?.autoHop;
this._cctvAutoHopBtn.classList.toggle('active', autoHop);
this._cctvAutoHopBtn.textContent = autoHop
? t('AUTO HOP ON')
: t('AUTO HOP OFF');
this._cctvAutoHopBtn.disabled = !enabled;
}
if (this._cctvProjectionBtn) {
const showProjection = state?.showProjection !== false;
this._cctvProjectionBtn.classList.toggle('active', showProjection);
this._cctvProjectionBtn.textContent = showProjection
? t('PROJECTION ON')
: t('PROJECTION OFF');
this._cctvProjectionBtn.disabled = !enabled;
}
if (this._cctvQualityChip) {
// CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only,
// no in-world tint): three states driven by cctv.js's deriveCalBadge,
// no client-side scoring math. Casing is unified via _calBadgeLabel so
// the chip and the meta line never drift onto different conventions.
// Save-gated persistence (viewshed design §3e): unsaved live edits show
// EDITED on top of whatever the persisted badge state is — SAVE CAL
// promotes to CALIBRATED, RESET CAL clears.
const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty;
updateStatusBadge(this._cctvQualityChip, {
label: dirty
? t('CAL · EDITED (UNSAVED)')
: t('CAL · {badge}', {
badge: this._calBadgeLabel(badge),
}),
});
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
}
this.store.publish(presentCctvPanel(state, enabled));
this._syncCctvCalReadout(enabled, activeCamera);
if (this._cctvMeta) {
if (activeCamera) {
const provider = cctvSourceLabelText(
activeCamera.sourceLabel ||
activeCamera.provider ||
'Configured Source',
);
const statusMsg = activeCamera.sourceMessage
? ` · ${cctvSourceMessageText(activeCamera.sourceMessage)}`
: '';
// A partner-supplied feed inside a pack names its owner here.
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
const calBadge = activeCamera.calBadge
? this._calBadgeLabel(activeCamera.calBadge)
: '';
const projLabel =
state?.showProjection !== false ? t('MONITOR') : t('OFF');
this._cctvMeta.textContent = t(
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '',
provider,
status: `${credit}${statusMsg}`,
},
);
} else if (catalogCount > 0) {
this._cctvMeta.textContent = enabled
? tn(
catalogCount,
'{count} camera loaded · click a camera to activate',
'{count} cameras loaded · click a camera to activate',
)
: tn(
catalogCount,
'{count} camera loaded · enable CCTV to activate',
'{count} cameras loaded · enable CCTV to activate',
);
} else {
this._cctvMeta.textContent = t(
'Enable CCTV to load camera intersections',
);
}
}
const liveIntent = enabled && !!activeCamera?.isVideo;
if (this._cctvVideo) {
this._cctvVideo.hidden = !liveIntent;
+1 -1
View File
@@ -17,7 +17,7 @@ import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js';
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => {
for (const file of ['LayerPanelsView.vue', 'ContextView.vue']) {
for (const file of ['panels/CctvPanel.vue', 'ContextView.vue']) {
const markup = readFileSync(
new URL(`./views/${file}`, import.meta.url),
'utf8',
+2
View File
@@ -13,6 +13,8 @@ test('every scroller the rail measuring pass lifts restores its scroll offset',
const templates = [
read('./views/ContextView.vue'),
read('./views/LayerPanelsView.vue'),
read('./views/panels/CctvPanel.vue'),
read('./views/panels/StreetLevelPanel.vue'),
].join('\n');
const lifted = [
...css.matchAll(
+20
View File
@@ -0,0 +1,20 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The CCTV panel's buttons, CAL chip and meta line as its view renders them.
* CctvControls presents them (`presentCctvPanel()`) and publishes here; it
* keeps its listeners on the buttons and paints the picker, frame, source
* badge, calibration readout and summary itself.
*/
export const useCctvStore = defineStore('gev-cctv', () => {
/** The presented panel; null until the controls first render. */
const view = shallowRef(null);
/** @param {object} next A fresh presentation; every publish re-renders. */
function publish(next) {
view.value = markRaw(next);
}
return { view, publish };
});
+2 -58
View File
@@ -1,6 +1,7 @@
<script setup>
import SceneOptions from './SceneOptions.vue';
import SceneShotList from './SceneShotList.vue';
import CctvPanel from './panels/CctvPanel.vue';
import StreetLevelPanel from './panels/StreetLevelPanel.vue';
</script>
@@ -20,64 +21,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
</div>
<!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div>
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
<div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
<button id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEAREST</button>
</div>
<div class="cctv-controls">
<button id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" data-i18n>PREV</button>
<select class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div>
<div class="cctv-controls">
<button id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" data-i18n>FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
<span id="cctv-quality-chip" class="gev-status-badge gev-status-badge--box" data-i18n-runtime>CAL · --</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label" data-i18n>CALIBRATION</div>
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title data-i18n>ADJUST</button>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title data-i18n-runtime>HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title data-i18n-runtime>PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title data-i18n-runtime>FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title data-i18n-runtime>RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title data-i18n-runtime>HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title>ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
<div id="cctv-summary" data-i18n>Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
</div>
<CctvPanel />
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
<StreetLevelPanel />
+72
View File
@@ -0,0 +1,72 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import { useCctvStore } from '../../stores/cctv.js';
// The CCTV panel, its buttons, CAL chip and meta line rendered from what
// CctvControls presents; until it renders, the panel shows its initial
// markup. The picker, frame, source badge, calibration readout and summary
// are written by the controls, so their elements carry no bindings.
const { view } = storeToRefs(useCctvStore());
</script>
<template>
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div>
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
<div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div>
<div class="cctv-controls">
<button id="cctv-enable-btn" :class="['scene-btn', { active: view?.enabled }]">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</button>
<button id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>NEAREST</button>
</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>
<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">
<button id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.focusDisabled" data-i18n>FOCUS</button>
<button id="cctv-coverage-btn" :class="['scene-btn', { active: view?.coverage.active }]" :disabled="view?.toggleDisabled">{{ view?.coverage.text ?? t('COVERAGE OFF') }}</button>
<button id="cctv-auto-hop-btn" :class="['scene-btn', { active: view?.autoHop.active }]" :disabled="view?.toggleDisabled">{{ view?.autoHop.text ?? t('AUTO HOP OFF') }}</button>
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" :class="['scene-btn', { active: view?.projection.active }]" :disabled="view?.toggleDisabled">{{ view?.projection.text ?? t('PROJECTION ON') }}</button>
<span id="cctv-quality-chip" class="gev-status-badge gev-status-badge--box" :data-cal-badge="view?.quality.calBadge">{{ view?.quality.label ?? t('CAL · {badge}', { badge: '--' }) }}</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label" data-i18n>CALIBRATION</div>
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title data-i18n>ADJUST</button>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title data-i18n-runtime>HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title data-i18n-runtime>PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title data-i18n-runtime>FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title data-i18n-runtime>RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title data-i18n-runtime>HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title>ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
<div id="cctv-summary" data-i18n>Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
</div>
</template>