diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index b270c966e..ab0051cd2 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7991ec126..8f3d960ac 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-status-badges.mjs b/scripts/qa-status-badges.mjs index 961fdba7f..65b044891 100644 --- a/scripts/qa-status-badges.mjs +++ b/scripts/qa-status-badges.mjs @@ -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, diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index 0b7cee20f..e7bc7f72b 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -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(); diff --git a/src/tooling/panelStorageDocs.test.mjs b/src/tooling/panelStorageDocs.test.mjs index 474ba39f2..9c2c69b23 100644 --- a/src/tooling/panelStorageDocs.test.mjs +++ b/src/tooling/panelStorageDocs.test.mjs @@ -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( diff --git a/src/ui/cctvBadges.test.mjs b/src/ui/cctvBadges.test.mjs index 1703d5d54..0283da411 100644 --- a/src/ui/cctvBadges.test.mjs +++ b/src/ui/cctvBadges.test.mjs @@ -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', + }); }); diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 2626fb719..718287a10 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -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; diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs new file mode 100644 index 000000000..0d9dcd670 --- /dev/null +++ b/src/ui/cctvPanel.test.mjs @@ -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', + ); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 5cf311155..55273d2d4 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -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; diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 8dcf9ead3..05bb9ced1 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -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', diff --git a/src/ui/railScrollers.test.mjs b/src/ui/railScrollers.test.mjs index bb744090d..ef7ea591e 100644 --- a/src/ui/railScrollers.test.mjs +++ b/src/ui/railScrollers.test.mjs @@ -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( diff --git a/src/ui/stores/cctv.js b/src/ui/stores/cctv.js new file mode 100644 index 000000000..d4f4e1f41 --- /dev/null +++ b/src/ui/stores/cctv.js @@ -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 }; +}); diff --git a/src/ui/views/LayerPanelsView.vue b/src/ui/views/LayerPanelsView.vue index 94a115c51..79163cdd3 100644 --- a/src/ui/views/LayerPanelsView.vue +++ b/src/ui/views/LayerPanelsView.vue @@ -1,6 +1,7 @@ @@ -20,64 +21,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue'; -