mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
e6aee52ea0
commit
5b8cb40fce
14 files changed
+469
-215
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user