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), (done: `RecentImageryReadout` and its day cards, the same way),
Street level (done: `StreetLevelPanel` renders the `gev-street-level` Street level (done: `StreetLevelPanel` renders the `gev-street-level`
store that `StreetLevelControls` publishes to; the controls keep their 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 the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+10 -4
View File
@@ -46,6 +46,8 @@
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/views/panels/StreetLevelPanel.vue", "src/ui/views/panels/StreetLevelPanel.vue",
"src/ui/views/panels/CctvPanel.vue",
"src/ui/stores/cctv.js",
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue" "src/ui/rail/RailChips.vue"
], ],
@@ -654,9 +656,11 @@
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "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": { "context-controls": {
"exports": ["./ui/context", "./ui/context/policy"], "exports": ["./ui/context", "./ui/context/policy"],
@@ -948,7 +952,8 @@
"src/ui/rail/RailTimeline.vue", "src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/stores/streetLevel.js" "src/ui/stores/streetLevel.js",
"src/ui/stores/cctv.js"
], ],
"external": ["cesium", "vue", "pinia"] "external": ["cesium", "vue", "pinia"]
}, },
@@ -2914,7 +2919,8 @@
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/recentImagery/RecentImageryCard.vue", "src/ui/recentImagery/RecentImageryCard.vue",
"src/ui/recentImagery/RecentImageryReadout.vue", "src/ui/recentImagery/RecentImageryReadout.vue",
"src/ui/stores/streetLevel.js" "src/ui/stores/streetLevel.js",
"src/ui/stores/cctv.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+3 -1
View File
@@ -164,7 +164,7 @@ try {
['curated', true, 'rgb(255, 208, 138)'], ['curated', true, 'rgb(255, 208, 138)'],
]) { ]) {
const result = await page.evaluate( const result = await page.evaluate(
({ calBadge, dirty }) => { async ({ calBadge, dirty }) => {
const owner = window.__godsEyeView.styleManager._cctvControls; const owner = window.__godsEyeView.styleManager._cctvControls;
document.getElementById('cctv-next-btn').focus(); document.getElementById('cctv-next-btn').focus();
owner._renderCctvState({ owner._renderCctvState({
@@ -175,6 +175,8 @@ try {
calDirty: dirty, 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'); const badge = document.getElementById('cctv-quality-chip');
return { return {
state: badge.dataset.calBadge, 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. // Nothing active clears the pick; only the outside-view note may show.
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/, /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 { } finally {
cctvLayer.setParams({ autoHop: false }); cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest(); _setCctvOverlayHostForTest();
+1 -1
View File
@@ -78,7 +78,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
const { PanelPositionControls } = const { PanelPositionControls } =
await import('../ui/panelPositionControls.js'); await import('../ui/panelPositionControls.js');
const html = await readFile( const html = await readFile(
path.join(root, 'src/ui/views/LayerPanelsView.vue'), path.join(root, 'src/ui/views/panels/CctvPanel.vue'),
'utf8', 'utf8',
); );
assert.match( assert.match(
+24 -24
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { CctvControls } from './cctvControls.js'; import { CctvControls } from './cctvControls.js';
import { presentCctvPanel } from './cctvPresentation.js';
import { railFixture } from './railTestFixture.mjs'; import { railFixture } from './railTestFixture.mjs';
import { setLocale, t } from '../i18n/index.js'; import { setLocale, t } from '../i18n/index.js';
@@ -8,7 +9,6 @@ function fixture(t) {
const { document } = railFixture(); const { document } = railFixture();
const controls = new CctvControls({ const controls = new CctvControls({
elements: { elements: {
_cctvQualityChip: document.createElement('span'),
_cctvSourceBadge: document.createElement('div'), _cctvSourceBadge: document.createElement('div'),
}, },
cctv: {}, cctv: {},
@@ -56,8 +56,7 @@ test('source and frame states retain their distinct meanings, including retained
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' }); 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) => { test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', () => {
const { controls } = fixture(testContext);
const cases = [ const cases = [
['raw-prior', false, 'CAL · RAW PRIOR'], ['raw-prior', false, 'CAL · RAW PRIOR'],
['curated', false, 'CAL · CURATED'], ['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) { for (const [calBadge, calDirty, label] of cases) {
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty }); const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
controls._renderCctvState({ const { quality } = presentCctvPanel(
enabled: true, { enabled: true, activeCameraId: 'a', activeCamera },
activeCameraId: 'a', true,
activeCamera,
});
assert.equal(controls._cctvQualityChip.textContent, label);
assert.equal(
controls._cctvQualityChip.dataset.calBadge,
calDirty ? 'edited' : calBadge || '',
); );
assert.deepEqual(quality, {
label,
calBadge: calDirty ? 'edited' : calBadge || '',
});
} }
controls._renderCctvState(null); assert.deepEqual(presentCctvPanel(null, false).quality, {
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --'); label: 'CAL · --',
assert.equal(controls._cctvQualityChip.dataset.calBadge, ''); calBadge: '',
});
}); });
test('localization changes badge copy while preserving machine state ids', (testContext) => { 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.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready'); assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
controls._renderCctvState({ const { quality } = presentCctvPanel(
enabled: true, {
activeCameraId: 'a', enabled: true,
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true }, activeCameraId: 'a',
}); activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
assert.equal( },
controls._cctvQualityChip.textContent, true,
t('CAL · EDITED (UNSAVED)'),
); );
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, _updateCctvSyncChip,
} from './cctvPresentation.js'; } from './cctvPresentation.js';
import { _initCctvPanel } from './cctvBindings.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 { export class CctvControls {
constructor({ elements, cctv, actions }) { constructor({
elements,
cctv,
actions,
store = useCctvStore(applicationPinia()),
}) {
Object.assign(this, elements); Object.assign(this, elements);
this.store = store;
this.cctv = cctv; this.cctv = cctv;
this.actions = actions; this.actions = actions;
this.destroyed = false; 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 { t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.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) { export function _renderCctvState(state) {
if (this.destroyed) return; if (this.destroyed) return;
if ( if (
@@ -68,11 +167,6 @@ export function _renderCctvState(state) {
this._updateCctvSyncChip(state?.loading, enabled); 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) { if (this._cctvSelect) {
// The index keeps its identity until the view or the active camera // The index keeps its identity until the view or the active camera
// changes, so an unchanged list skips the per-option comparison. A // changes, so an unchanged list skips the per-option comparison. A
@@ -150,121 +244,9 @@ export function _renderCctvState(state) {
} }
} }
for (const btn of [ this.store.publish(presentCctvPanel(state, enabled));
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._syncCctvCalReadout(enabled, activeCamera); 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; const liveIntent = enabled && !!activeCamera?.isVideo;
if (this._cctvVideo) { if (this._cctvVideo) {
this._cctvVideo.hidden = !liveIntent; this._cctvVideo.hidden = !liveIntent;
+1 -1
View File
@@ -17,7 +17,7 @@ import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js'; import { STYLES } from './visualPresets.js';
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => { 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( const markup = readFileSync(
new URL(`./views/${file}`, import.meta.url), new URL(`./views/${file}`, import.meta.url),
'utf8', 'utf8',
+2
View File
@@ -13,6 +13,8 @@ test('every scroller the rail measuring pass lifts restores its scroll offset',
const templates = [ const templates = [
read('./views/ContextView.vue'), read('./views/ContextView.vue'),
read('./views/LayerPanelsView.vue'), read('./views/LayerPanelsView.vue'),
read('./views/panels/CctvPanel.vue'),
read('./views/panels/StreetLevelPanel.vue'),
].join('\n'); ].join('\n');
const lifted = [ const lifted = [
...css.matchAll( ...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> <script setup>
import SceneOptions from './SceneOptions.vue'; import SceneOptions from './SceneOptions.vue';
import SceneShotList from './SceneShotList.vue'; import SceneShotList from './SceneShotList.vue';
import CctvPanel from './panels/CctvPanel.vue';
import StreetLevelPanel from './panels/StreetLevelPanel.vue'; import StreetLevelPanel from './panels/StreetLevelPanel.vue';
</script> </script>
@@ -20,64 +21,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
</div> </div>
<!-- CCTV Coverage Panel --> <!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel"> <CctvPanel />
<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>
<!-- Street Level Panel (moved into #right-context-rail at runtime) --> <!-- Street Level Panel (moved into #right-context-rail at runtime) -->
<StreetLevelPanel /> <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>