diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 2e615e71c..ff20e213d 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -210,9 +210,9 @@ such as `provider-settings`, and their hosts are removed. Leaving out out `scene-chrome` the before/after imagery dividers (the imagery still splits, with no line or handle to move it), the status chips, the toast, the ORBIT indicator, the FPS readout, the Scene actions panel, the data-pack attribution -cards, the wind inspection marker and the Space Missions replay vehicle, and -leaving out `command-dock` the voice control and its pointer -reticle. An unknown +cards, the wind inspection marker and the Space Missions replay vehicle; +leaving out `command-dock` the voice control and its pointer reticle; and +leaving out `context` the Bhote Koshi reconstruction panel. An unknown view name is an error. A host that does not initialize Core's locale passes `localePicker: false` to `createApplicationControls` instead of editing the markup. Node tests that import application modules register the component diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 461fb8e82..93a3cc254 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -167,7 +167,12 @@ a feature supplying only its accent. (`MissionReplayOverlay` teleports into the globe container what the launches layer presents through `subscribeReplayOverlay()`, which the application shell publishes to the `gev-mission-replay` store, so the - portable `./layers/launches` entry needs no Vue), then + portable `./layers/launches` entry needs no Vue), the Bhote Koshi + reconstruction panel (`BhoteKoshiPanel`, last in the context rail, + renders what the event layer publishes to the `gev-bhote-koshi-panel` + store: each control's text, hidden, disabled, value and attributes as + the layer writes them, unchanged frames sending nothing; the rail takes + `bhote-event-active` while it shows), then Cockpit (in progress: `CockpitBriefing` renders the briefing card's pages, cycle toggle, regional news and local conditions, and `CockpitContext` the Contact card's scope, navigation, counts, nearest diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8fba97f75..8eda84e3c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1144,5 +1144,7 @@ "src/layers/wind/inspectionMarker.test.mjs", "src/ui/stores/windInspection.js", "src/ui/stores/missionReplay.js", - "src/layers/launches/replayOverlay.test.mjs" + "src/layers/launches/replayOverlay.test.mjs", + "src/ui/stores/bhoteKoshiPanel.js", + "src/data/bhoteKoshiPanelView.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8ee8f0387..12d9a537f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -106,7 +106,9 @@ "src/ui/stores/windInspection.js", "src/ui/views/panels/WindInspectionMarker.vue", "src/ui/stores/missionReplay.js", - "src/ui/views/panels/MissionReplayOverlay.vue" + "src/ui/views/panels/MissionReplayOverlay.vue", + "src/ui/stores/bhoteKoshiPanel.js", + "src/ui/views/panels/BhoteKoshiPanel.vue" ], "external": ["vue", "pinia"] }, @@ -3023,7 +3025,8 @@ "src/ui/stores/pointerReticle.js", "src/ui/stores/windInspection.js", "src/layers/wind/inspectionMarker.js", - "src/ui/stores/missionReplay.js" + "src/ui/stores/missionReplay.js", + "src/ui/stores/bhoteKoshiPanel.js" ], "external": [ "hls.js", @@ -3613,7 +3616,8 @@ "src/ui/stores/imagerySplit.js", "src/ui/vueRuntime.js", "src/ui/stores/windInspection.js", - "src/layers/wind/inspectionMarker.js" + "src/layers/wind/inspectionMarker.js", + "src/ui/stores/bhoteKoshiPanel.js" ], "external": [ "hls.js", diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 434aff6d0..e007d9eb2 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -26,6 +26,8 @@ import { import { acquireImageryComparison } from '../maps/imageryComparison.js'; import { createImagerySplit } from '../ui/imagerySplit.js'; import { formatDate, t, tn, msg } from '../i18n/index.js'; +import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js'; +import { applicationPinia } from '../ui/vueRuntime.js'; export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026'; export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses'; @@ -449,18 +451,6 @@ function imageryEndpointText(entry) { return [name, date].filter(Boolean).join(' · '); } -/** Name, then the date kept on one line so the year never wraps alone. */ -function renderImageryEndpoint(element, entry) { - if (!element) return; - const { name, date } = imageryEndpointLabel(entry); - element.textContent = date ? `${name} · ` : name; - if (!date) return; - const dateElement = document.createElement('span'); - dateElement.className = 'bhote-event-date'; - dateElement.textContent = date; - element.appendChild(dateElement); -} - /** Compact, source-bounded disclosure for multi-source geolocation beats. */ export function evidenceCorroborationLabel(observation) { const count = Math.max( @@ -929,185 +919,70 @@ function setElementProperty(element, property, value) { } function setElementAttribute(element, attribute, value) { - if (!element || element.getAttribute?.(attribute) === value) return false; - element.setAttribute(attribute, value); + if (!element || element.attributes[attribute] === value) return false; + element.attributes[attribute] = value; return true; } -function collectPanelReferences(panel) { +/** One panel control's state, as the panel view renders it. */ +function panelRecord(initial = {}) { + return { + textContent: '', + hidden: false, + disabled: false, + value: '', + ...initial, + attributes: { ...initial.attributes }, + }; +} + +/** + * The panel's controls as the layer writes them, starting from the panel's + * markup. The panel view renders what the layer publishes from them. + */ +function createPanelRecords(event) { return Object.freeze({ - imagerySection: panel.querySelector('[data-role="imagery-comparison"]'), - observedStatus: panel.querySelector('.bhote-event-status.observed'), - beforeDate: panel.querySelector('[data-role="before-date"]'), - afterDate: panel.querySelector('[data-role="after-date"]'), - time: panel.querySelector('[data-role="time"]'), - caveat: panel.querySelector('[data-role="caveat"]'), - imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'), - reportList: panel.querySelector('.bhote-event-report-list'), - playButton: panel.querySelector('[data-action="play"]'), - playSceneButton: panel.querySelector('[data-action="play-scene"]'), - cinematicButton: panel.querySelector('[data-action="cinematic"]'), - storyReplayButton: panel.querySelector('[data-action="story-replay"]'), - corridorButton: panel.querySelector('[data-action="corridor"]'), - previousButton: panel.querySelector('[data-action="previous-beat"]'), - nextButton: panel.querySelector('[data-action="next-beat"]'), - openSourceButton: panel.querySelector('[data-action="open-source"]'), - progressInput: panel.querySelector('[data-role="progress"]'), - splitInput: panel.querySelector('[data-role="split"]'), - splitReadout: panel.querySelector('[data-role="split-readout"]'), - timelineLabel: panel.querySelector('[data-role="timeline-label"]'), - beatIndex: panel.querySelector('[data-role="beat-index"]'), - beatTitle: panel.querySelector('[data-role="beat-title"]'), - beatMeta: panel.querySelector('[data-role="beat-meta"]'), + root: { sceneBeat: false }, + imagerySection: panelRecord(), + observedStatus: panelRecord(), + time: panelRecord({ + textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`, + }), + caveat: panelRecord({ + textContent: bhoteEventText(event.reconstruction.caveat), + }), + imageryCloudNote: panelRecord(), + playButton: panelRecord({ textContent: t('▶ PLAY') }), + playSceneButton: panelRecord({ + textContent: t('▶ PLAY SCENE'), + hidden: true, + }), + cinematicButton: panelRecord({ + textContent: t('◉ CINEMATIC'), + attributes: { 'aria-pressed': 'false' }, + }), + storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }), + corridorButton: panelRecord(), + previousButton: panelRecord({ + attributes: { 'aria-label': t('Previous story beat') }, + }), + nextButton: panelRecord({ + attributes: { 'aria-label': t('Next story beat') }, + }), + openSourceButton: panelRecord(), + progressInput: panelRecord({ + value: '0', + attributes: { 'aria-label': t('Schematic downstream progression') }, + }), + splitInput: panelRecord({ value: '50' }), + splitReadout: panelRecord({ textContent: '50 / 50' }), + timelineLabel: panelRecord({ textContent: t('RECONSTRUCTION CLOCK') }), + beatIndex: panelRecord({ textContent: '01 / 06' }), + beatTitle: panelRecord({ textContent: t('CAUSE') }), + beatMeta: panelRecord({ textContent: t('CAPTURE TIME UNVERIFIED') }), }); } -/** Escape translated text for the panel markup template. */ -function escapeMarkup(text) { - return String(text) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function createPanel(event, handlers) { - const panel = document.createElement('aside'); - panel.id = 'bhote-koshi-event-panel'; - panel.setAttribute( - 'aria-label', - t('Bhote Koshi flood reconstruction controls'), - ); - panel.innerHTML = ` -
-
-
${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}
-
${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}
-
- -
-
- ${escapeMarkup(t('OBSERVED IMAGERY'))} - ${escapeMarkup(t('SCHEMATIC CORRIDOR'))} -
-
-
- ${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))} - 50 / 50 -
- -
- - -
-
-
-
- ${escapeMarkup(t('RECONSTRUCTION CLOCK'))} - -
- -
- -
- 01 / 06 - ${escapeMarkup(t('CAUSE'))} - ${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))} -
- -
-
- - - - - - -
-
-
- ${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))} -
-
-
- ${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))} - -
-

${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}

- `; - - const refs = collectPanelReferences(panel); - renderImageryEndpoint(refs.beforeDate, event.imagery.before); - renderImageryEndpoint(refs.afterDate, event.imagery.after); - refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`; - refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat); - - for (const report of event.fieldReports) { - const button = document.createElement('button'); - const label = document.createElement('span'); - const status = document.createElement('small'); - button.type = 'button'; - button.className = 'bhote-event-report'; - label.textContent = bhoteEventText(report.label); - status.textContent = bhoteEventText(report.status); - button.append(label, status); - button.addEventListener('click', () => openExternal(report.url)); - refs.reportList.appendChild(button); - } - - panel - .querySelector('[data-action="close"]') - .addEventListener('click', handlers.close); - panel - .querySelector('[data-action="play"]') - .addEventListener('click', handlers.play); - panel - .querySelector('[data-action="play-scene"]') - .addEventListener('click', handlers.playScene); - panel - .querySelector('[data-action="cinematic"]') - .addEventListener('click', handlers.toggleCinematic); - panel - .querySelector('[data-action="previous-beat"]') - .addEventListener('click', handlers.previousBeat); - panel - .querySelector('[data-action="next-beat"]') - .addEventListener('click', handlers.nextBeat); - panel - .querySelector('[data-action="story-replay"]') - .addEventListener('click', handlers.replayCinematic); - panel - .querySelector('[data-action="open-source"]') - .addEventListener('click', handlers.openActiveSource); - panel - .querySelector('[data-action="geolocation-map"]') - .addEventListener('click', handlers.openGeolocationMap); - panel - .querySelector('[data-action="corridor"]') - .addEventListener('click', handlers.focusCorridor); - panel - .querySelector('[data-role="split"]') - .addEventListener('input', (inputEvent) => { - handlers.setSplit(Number(inputEvent.target.value) / 100); - }); - panel - .querySelector('[data-role="progress"]') - .addEventListener('input', (inputEvent) => { - handlers.previewProgress(Number(inputEvent.target.value) / 1000); - }); - panel - .querySelector('[data-role="progress"]') - .addEventListener('change', (inputEvent) => { - handlers.commitProgress(Number(inputEvent.target.value) / 1000, { - final: true, - }); - }); - const rightRail = document.getElementById?.('right-context-rail'); - rightRail?.classList.add('bhote-event-active'); - (rightRail || document.body).appendChild(panel); - return { panel, refs }; -} - export function createBhoteKoshiEventLayer({ overlayHost = DEFAULT_OVERLAY_HOST, renderHost = DEFAULT_RENDER_HOST, @@ -1119,14 +994,20 @@ export function createBhoteKoshiEventLayer({ evidenceFrameUpdater = updateEvidenceFrame, videoFactory = DEFAULT_VIDEO_FACTORY, embeddedMediaFactory = createBhoteKoshiEmbeddedMedia, + panelStore = useBhoteKoshiPanelStore(applicationPinia()), } = {}) { let _viewer = null; let _dataManager = null; let _mapStackController = null; let _event = null; let _enabled = false; - let _panel = null; + // The panel's controls; null while the layer is off. Their state is + // published to the panel store, which the context rail renders. let _panelRefs = null; + let _panelOwner = null; + let _panelPublished = ''; + // The dates and field reports, which stay as they were when the panel opened. + let _panelFixed = null; let _splitControl = null; let _beforeLayer = null; let _afterLayer = null; @@ -2756,15 +2637,50 @@ export function createBhoteKoshiEventLayer({ _lastCinematicProgress = _progress; } + /** Present the panel's controls as they stand; an unchanged panel is not sent again. */ + function publishPanel() { + if (!_panelRefs) return; + const { root, ...controls } = _panelRefs; + const refs = Object.fromEntries( + Object.entries(controls).map(([key, record]) => [ + key, + { ...record, attributes: { ...record.attributes } }, + ]), + ); + const signature = JSON.stringify([root.sceneBeat, refs]); + if (signature === _panelPublished) return; + _panelPublished = signature; + _panelFixed ??= { + beforeDate: imageryEndpointLabel(_event.imagery.before), + afterDate: imageryEndpointLabel(_event.imagery.after), + reports: _event.fieldReports.map((report) => ({ + label: bhoteEventText(report.label), + status: bhoteEventText(report.status), + })), + }; + panelStore.publish({ + owner: _panelOwner, + sceneBeat: root.sceneBeat, + refs, + ..._panelFixed, + }); + } + function syncCinematicButton() { + if (writeCinematicButton()) publishPanel(); + } + + /** Write the cinematic button's label and state; false without a panel. */ + function writeCinematicButton() { const button = _panelRefs?.cinematicButton; - if (!button) return; + if (!button) return false; setElementProperty( button, 'textContent', _cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'), ); setElementAttribute(button, 'aria-pressed', String(_cinematicActive)); + return true; } function claimCinematicCamera() { @@ -3029,6 +2945,7 @@ export function createBhoteKoshiEventLayer({ 'textContent', `~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`, ); + publishPanel(); if (_sceneSeekFrame != null) return; const schedule = globalThis.requestAnimationFrame || @@ -3302,7 +3219,7 @@ export function createBhoteKoshiEventLayer({ 'disabled', !observation?.media?.sourceUrl, ); - syncCinematicButton(); + writeCinematicButton(); setElementProperty( _panelRefs.splitInput, 'value', @@ -3314,6 +3231,7 @@ export function createBhoteKoshiEventLayer({ 'textContent', `${beforePercent} / ${100 - beforePercent}`, ); + publishPanel(); } function syncPresentationMode() { @@ -3330,7 +3248,7 @@ export function createBhoteKoshiEventLayer({ const evidenceAvailable = !sceneDirected || (_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat); - _panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected); + if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected; if (_panelRefs?.playSceneButton) _panelRefs.playSceneButton.hidden = !sceneDirected; if (_panelRefs?.imagerySection) @@ -3364,6 +3282,7 @@ export function createBhoteKoshiEventLayer({ } if (_panelRefs?.openSourceButton) _panelRefs.openSourceButton.hidden = !evidenceAvailable; + publishPanel(); overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable); } @@ -3561,7 +3480,12 @@ export function createBhoteKoshiEventLayer({ throwIfEnableCancelled(signal, _enabled); createFloodDataSource(); createSplitControl(event); - const panelView = createPanel(event, { + _panelRefs = createPanelRecords(event); + _panelOwner = {}; + _panelPublished = ''; + _panelFixed = null; + const owner = _panelOwner; + const controls = { close: () => _dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, { origin: 'user', @@ -3578,9 +3502,21 @@ export function createBhoteKoshiEventLayer({ commitProgress, setSplit, focusCorridor, - }); - _panel = panelView.panel; - _panelRefs = panelView.refs; + openReport: (index) => { + const url = event.fieldReports[index]?.url; + if (url) openExternal(url); + }, + }; + // Only the panel this enable published may act. + panelStore.connect( + Object.fromEntries( + Object.entries(controls).map(([name, control]) => [ + name, + (...args) => (_panelOwner === owner ? control(...args) : undefined), + ]), + ), + ); + publishPanel(); syncPresentationMode(); overlayHost.setVisible( BHOTE_KOSHI_OVERLAY_SOURCE_ID, @@ -3673,12 +3609,14 @@ export function createBhoteKoshiEventLayer({ _surgePoint = null; _lastSurgePosition = null; _lastSurgeVisible = null; - _panel?.remove(); - document - .getElementById?.('right-context-rail') - ?.classList.remove('bhote-event-active'); - _panel = null; + if (_panelOwner && panelStore.panel?.owner === _panelOwner) { + panelStore.connect(null); + panelStore.publish(null); + } + _panelOwner = null; _panelRefs = null; + _panelPublished = ''; + _panelFixed = null; _splitControl?.destroy(); _splitControl = null; _progress = 0; diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 61cdfc3c3..51e5f11f5 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { claimPointer, releasePointer } from './inputOwnership.js'; import * as Cesium from 'cesium'; +import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js'; import { useImagerySplitStore } from '../ui/stores/imagerySplit.js'; import { applicationPinia } from '../ui/vueRuntime.js'; import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js'; @@ -44,6 +45,89 @@ const eventSplit = () => (split) => split.view.id === 'bhote-koshi-split-line', ); +// The panel controls the layer presents, by the selector the panel view gives each. +const PANEL_REFS = { + '[data-role="imagery-comparison"]': 'imagerySection', + '[data-role="time"]': 'time', + '[data-role="caveat"]': 'caveat', + '[data-role="imagery-cloud-note"]': 'imageryCloudNote', + '[data-role="progress"]': 'progressInput', + '[data-role="split"]': 'splitInput', + '[data-role="timeline-label"]': 'timelineLabel', + '[data-role="beat-index"]': 'beatIndex', + '[data-role="beat-title"]': 'beatTitle', + '[data-role="beat-meta"]': 'beatMeta', + '[data-action="play"]': 'playButton', + '[data-action="play-scene"]': 'playSceneButton', + '[data-action="cinematic"]': 'cinematicButton', + '[data-action="story-replay"]': 'storyReplayButton', + '[data-action="corridor"]': 'corridorButton', + '[data-action="previous-beat"]': 'previousButton', + '[data-action="next-beat"]': 'nextButton', + '[data-action="open-source"]': 'openSourceButton', +}; +// What each control calls, as the panel view wires it. +const PANEL_EVENTS = { + playButton: { click: 'play' }, + playSceneButton: { click: 'playScene' }, + cinematicButton: { click: 'toggleCinematic' }, + storyReplayButton: { click: 'replayCinematic' }, + corridorButton: { click: 'focusCorridor' }, + previousButton: { click: 'previousBeat' }, + nextButton: { click: 'nextBeat' }, + openSourceButton: { click: 'openActiveSource' }, + progressInput: { + input: (controls, value) => controls.previewProgress(Number(value) / 1000), + change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }), + }, + splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) }, +}; + +/** + * The event panel as the layer presents it to the panel store, or undefined + * while it is off. Controls read the latest presentation, and dispatching an + * event calls the connected controls as the panel view does. + */ +function eventPanel() { + const store = useBhoteKoshiPanelStore(applicationPinia()); + if (!store.panel) return undefined; + const control = (key) => { + let typed = null; + const record = () => store.panel.refs[key]; + return { + get textContent() { return record().textContent; }, + get hidden() { return record().hidden; }, + get disabled() { return record().disabled; }, + get value() { return typed ?? record().value; }, + set value(value) { typed = value; }, + getAttribute: (name) => record().attributes[name] ?? null, + dispatch(type) { + const value = typed ?? record().value; + typed = null; + const handler = PANEL_EVENTS[key]?.[type]; + return typeof handler === 'function' + ? handler(store.controls, value) + : store.controls[handler]?.(); + }, + }; + }; + const named = (name) => ({ dispatch: () => store.controls[name]() }); + return { + querySelector(selector) { + if (selector === '.bhote-event-report-list') { + return { + children: store.panel.reports.map((_, index) => ({ + dispatch: () => store.controls.openReport(index), + })), + }; + } + if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap'); + if (selector === '[data-action="close"]') return named('close'); + return control(PANEL_REFS[selector]); + }, + }; +} + class FakeElement { constructor(tagName = 'div') { this.tagName = tagName; @@ -888,7 +972,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever assert.equal(await layer.enable(viewer, { origin: 'scene' }), true); assert.equal(viewer._test.imagery.length, 2); assert.equal(viewer._test.dataSources.length, 1); - assert.equal(body.children.length, 1); + assert.ok(eventPanel()); assert.ok(eventSplit()); activeMap = 'operator-choice'; @@ -898,7 +982,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever assert.deepEqual(mapCalls, ['esri-imagery']); assert.equal(viewer._test.removedImagery.length, 2); assert.equal(viewer._test.removedDataSources.length, 1); - assert.ok(body.children.every((element) => element.removed)); + assert.equal(eventPanel(), undefined); assert.equal(eventSplit(), undefined); assert.equal(viewer.scene.splitPosition, 0.37); assert.deepEqual(clearedTimeouts, [71]); @@ -945,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h try { await layer.enable(viewer, { origin: 'scene' }); - const panel = body.children.find((element) => element.tagName === 'aside'); + const panel = eventPanel(); assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true); assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false); panel.querySelector('[data-action="play"]').dispatch('click'); @@ -1193,7 +1277,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership assert.deepEqual(timers, []); assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair'); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); - const panel = body.children.find((element) => element.tagName === 'aside'); + const panel = eventPanel(); assert.ok(panel); assert.ok(eventSplit()); assert.equal(eventSplit().view.hidden, true); @@ -1231,8 +1315,6 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true); assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false); assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false); - assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/); - assert.match(panel.innerHTML, /data-action="open-source"/); const fullStory = panel.querySelector('[data-action="story-replay"]'); assert.equal(fullStory.textContent, '↺ FULL STORY'); assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback'); @@ -1355,7 +1437,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership assert.equal(layer.getCinematicState().active, false); assert.equal(viewer._test.cameraLooks.length, 0); assert.deepEqual(mapCalls, []); - assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1); + assert.ok(eventPanel()); assert.equal(viewer.scene.splitPosition, 0.62); for (const beatId of [ 'debris-dammed-lake', @@ -1602,7 +1684,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0); assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1)); - const panel = body.children.find((element) => element.tagName === 'aside'); + const panel = eventPanel(); const progress = panel.querySelector('[data-role="progress"]'); progress.value = '285'; progress.dispatch('input'); @@ -1693,7 +1775,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c videos[0].dispatch('loadeddata'); assert.equal(layer.getCinematicState().mediaReady, true); - const panel = body.children.find((element) => element.tagName === 'aside'); + const panel = eventPanel(); const progress = panel.querySelector('[data-role="progress"]'); progress.value = '285'; progress.dispatch('input'); @@ -1816,7 +1898,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting assert.ok(video.pauseCalls >= 1); assert.ok(video.cancelledFrameHandles.length >= 1); assert.equal(layer.getCinematicState().mediaEvidenceId, null); - assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false); + assert.ok(eventPanel()); await layer.disable(); } finally { globalThis.document = originalDocument; @@ -2129,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one try { await layer.enable(viewer, { origin: 'local-restore' }); - const panel = body.children.find((element) => element.tagName === 'aside'); - assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/); - assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/); - assert.match(panel.innerHTML, /OPEN PUBLIC MAP/); + const panel = eventPanel(); + assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK'); const entries = publications[0].entries; const cards = entries.filter((entry) => entry.variant === 'thumbnail'); const breadcrumbs = entries.filter((entry) => entry.variant === 'label'); @@ -2211,7 +2291,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one } }); -test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => { +test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; @@ -2246,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c try { await layer.enable(viewer, { origin: 'local-restore' }); - const panel = body.children.find((element) => element.tagName === 'aside'); + const panel = eventPanel(); const cinematic = panel.querySelector('[data-action="cinematic"]'); const progress = panel.querySelector('[data-role="progress"]'); cinematic.dispatch('click'); progress.value = '500'; progress.dispatch('input'); - const queryCount = panel._queryCount; - const writeCount = [...panel._selectors.values()].reduce((sum, element) => ( - sum + element._writeCount - ), 0); + const panelStore = useBhoteKoshiPanelStore(applicationPinia()); + const published = panelStore.panel; const cameraLookCount = viewer._test.cameraLooks.length; for (let index = 0; index < 20; index += 1) progress.dispatch('input'); - assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references'); - assert.equal( - [...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0), - writeCount, - 'unchanged frames do not rewrite panel state', - ); + assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel'); assert.equal( viewer._test.cameraLooks.length, cameraLookCount, @@ -2336,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin try { await layer.enable(viewer, { origin: 'local-restore' }); - const panel = body.children.find((element) => element.tagName === 'aside'); + const panel = eventPanel(); panel.querySelector('[data-action="cinematic"]').dispatch('click'); assert.equal(layer.getCinematicState().active, true); assert.equal(layer.getPlaybackState().playing, true); @@ -2421,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas try { await layer.enable(viewer, { origin: 'local-restore' }); - const panel = body.children.find((element) => element.tagName === 'aside'); + const panel = eventPanel(); panel.querySelector('[data-action="cinematic"]').dispatch('click'); animationCallbacks[0](91001); assert.equal(layer.getPlaybackState().progress, 1); diff --git a/src/data/bhoteKoshiPanelView.test.mjs b/src/data/bhoteKoshiPanelView.test.mjs new file mode 100644 index 000000000..3e6c2da10 --- /dev/null +++ b/src/data/bhoteKoshiPanelView.test.mjs @@ -0,0 +1,212 @@ +// src/data/bhoteKoshiPanelView.test.mjs — the context rail renders the Bhote +// Koshi reconstruction panel the event layer presents and sends its clicks +// and slider moves back. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js'; +import ContextView from '../ui/views/ContextView.vue'; + +const record = (initial = {}) => ({ + textContent: '', + hidden: false, + disabled: false, + value: '', + attributes: {}, + ...initial, +}); + +function presentation(patch = {}) { + return { + owner: {}, + sceneBeat: false, + beforeDate: { name: 'Historical reference', date: '2021-05-01' }, + afterDate: { name: 'Post-event', date: '' }, + reports: [ + { label: 'Report A', status: 'Verified' }, + { label: 'Report B', status: 'Unverified' }, + ], + refs: { + imagerySection: record(), + observedStatus: record(), + time: record({ textContent: '~0:00 / 2:00' }), + caveat: record({ textContent: 'Schematic only.' }), + imageryCloudNote: record(), + playButton: record({ textContent: '▶ PLAY' }), + playSceneButton: record({ textContent: '▶ PLAY SCENE', hidden: true }), + cinematicButton: record({ + textContent: '◉ CINEMATIC', + attributes: { 'aria-pressed': 'false' }, + }), + storyReplayButton: record({ textContent: '↺ FULL STORY' }), + corridorButton: record(), + previousButton: record({ + disabled: true, + attributes: { 'aria-label': 'Previous story beat' }, + }), + nextButton: record({ attributes: { 'aria-label': 'Next story beat' } }), + openSourceButton: record(), + progressInput: record({ + value: '0', + attributes: { 'aria-label': 'Schematic downstream progression' }, + }), + splitInput: record({ value: '50' }), + splitReadout: record({ textContent: '50 / 50' }), + timelineLabel: record({ textContent: 'RECONSTRUCTION CLOCK' }), + beatIndex: record({ textContent: '01 / 06' }), + beatTitle: record({ textContent: 'CAUSE' }), + beatMeta: record({ textContent: 'CAPTURE TIME UNVERIFIED' }), + }, + ...patch, + }; +} + +function fixture(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(ContextView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + const store = useBhoteKoshiPanelStore(pinia); + const calls = []; + store.connect( + new Proxy( + {}, + { + get: + (_, name) => + (...args) => + calls.push([name, ...args]), + }, + ), + ); + const rail = document.getElementById('right-context-rail'); + const $ = (selector) => rail.querySelector(selector); + return { store, calls, rail, $ }; +} + +test('the panel renders last in the context rail and marks the rail while on', async (t) => { + const { store, rail, $ } = fixture(t); + assert.equal($('#bhote-koshi-event-panel'), null); + assert.equal(rail.classList.contains('bhote-event-active'), false); + store.publish(presentation()); + await nextTick(); + const panel = $('#bhote-koshi-event-panel'); + assert.equal(panel.tagName, 'ASIDE'); + assert.ok(rail.lastElementChild === panel); + assert.ok(rail.classList.contains('ui-scroll')); + assert.ok(rail.classList.contains('bhote-event-active')); + assert.equal( + panel.getAttribute('aria-label'), + 'Bhote Koshi flood reconstruction controls', + ); + assert.match( + panel.textContent, + /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/, + ); + assert.match(panel.textContent, /RECONSTRUCTION CLOCK/); + assert.match(panel.textContent, /OPEN PUBLIC MAP/); + assert.equal( + $('[data-role="before-date"]').textContent, + 'Historical reference · 2021-05-01', + ); + assert.equal( + $('[data-role="before-date"] .bhote-event-date').textContent, + '2021-05-01', + ); + assert.equal($('[data-role="after-date"]').textContent, 'Post-event'); + assert.equal($('[data-role="after-date"] .bhote-event-date'), null); + assert.equal($('[data-action="play-scene"]').hidden, true); + assert.ok($('[data-action="open-source"]')); + assert.equal($('[data-action="witness"]'), null); + assert.doesNotMatch(panel.innerHTML, /witness/i); + assert.equal($('[data-action="previous-beat"]').disabled, true); + assert.equal($('[data-action="previous-beat"]').title, 'Previous story beat'); + assert.equal( + $('[data-action="cinematic"]').getAttribute('aria-pressed'), + 'false', + ); + assert.equal($('[data-action="play"]').hasAttribute('aria-label'), false); + assert.equal($('[data-role="progress"]').value, '0'); + assert.equal($('[data-role="split"]').value, '50'); + assert.equal($('summary').textContent, 'FIELD REPORTS · 2'); + assert.deepEqual( + [...$('.bhote-event-report-list').children].map( + (button) => button.textContent, + ), + ['Report AVerified', 'Report BUnverified'], + ); + store.publish(null); + await nextTick(); + assert.equal($('#bhote-koshi-event-panel'), null); + assert.equal(rail.classList.contains('bhote-event-active'), false); + assert.ok(rail.classList.contains('ui-scroll')); +}); + +test('a later presentation updates the controls in place', async (t) => { + const { store, $ } = fixture(t); + store.publish(presentation()); + await nextTick(); + const play = $('[data-action="play"]'); + const next = presentation({ sceneBeat: true }); + next.refs.playButton = record({ + textContent: '▶ PLAY SHOT', + disabled: true, + attributes: { 'aria-label': 'Play Global context' }, + }); + next.refs.progressInput = record({ value: '250', hidden: true }); + next.refs.imagerySection = record({ hidden: true }); + store.publish(next); + await nextTick(); + assert.ok($('[data-action="play"]') === play); + assert.equal(play.textContent, '▶ PLAY SHOT'); + assert.equal(play.disabled, true); + assert.equal(play.getAttribute('aria-label'), 'Play Global context'); + assert.ok( + $('#bhote-koshi-event-panel').classList.contains('bhote-event-scene-beat'), + ); + assert.equal($('[data-role="progress"]').value, '250'); + assert.equal($('[data-role="progress"]').hidden, true); + assert.equal($('[data-role="imagery-comparison"]').hidden, true); +}); + +test('buttons, sliders and reports call the connected controls', async (t) => { + const { store, calls, $ } = fixture(t); + store.publish(presentation()); + await nextTick(); + for (const action of [ + 'close', + 'play', + 'cinematic', + 'story-replay', + 'open-source', + 'corridor', + 'next-beat', + 'geolocation-map', + ]) + $(`[data-action="${action}"]`).click(); + $('.bhote-event-report-list').children[1].click(); + const progress = $('[data-role="progress"]'); + progress.value = '500'; + progress.dispatchEvent(new Event('input')); + progress.dispatchEvent(new Event('change')); + const split = $('[data-role="split"]'); + split.value = '25'; + split.dispatchEvent(new Event('input')); + assert.deepEqual(calls, [ + ['close'], + ['play'], + ['toggleCinematic'], + ['replayCinematic'], + ['openActiveSource'], + ['focusCorridor'], + ['nextBeat'], + ['openGeolocationMap'], + ['openReport', 1], + ['previewProgress', 0.5], + ['commitProgress', 0.5, { final: true }], + ['setSplit', 0.25], + ]); +}); diff --git a/src/ui/stores/bhoteKoshiPanel.js b/src/ui/stores/bhoteKoshiPanel.js new file mode 100644 index 000000000..c3d1e4fd0 --- /dev/null +++ b/src/ui/stores/bhoteKoshiPanel.js @@ -0,0 +1,35 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * The Bhote Koshi event's reconstruction panel as its view renders it. The + * event layer (src/data/bhoteKoshiEvent.js) presents each control's state + * and the field reports; the view sends clicks and slider moves back to the + * connected controls. + */ +export const useBhoteKoshiPanelStore = defineStore( + 'gev-bhote-koshi-panel', + () => { + /** + * `{owner, sceneBeat, refs, beforeDate, afterDate, reports}`, or null while + * the event layer is off. Each entry of `refs` holds one control's + * `textContent`, `hidden`, `disabled`, `value` and `attributes`. + */ + const panel = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + panel.value = next && markRaw(next); + } + + /** The controls the panel's buttons, sliders and reports call. */ + const controls = shallowRef(null); + + /** @param {?object} next The controls, or null to disconnect. */ + function connect(next) { + controls.value = next && markRaw(next); + } + + return { panel, publish, controls, connect }; + }, +); diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index 8bea4e2ef..91e32d06a 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -3,8 +3,10 @@ import { storeToRefs } from 'pinia'; import { t } from '../../i18n/index.js'; import { vClassToggle } from '../rail/classToggle.js'; import { useCockpitStore } from '../stores/cockpit.js'; +import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js'; import { useContextStore } from '../stores/context.js'; import AwarenessPanel from './panels/AwarenessPanel.vue'; +import BhoteKoshiPanel from './panels/BhoteKoshiPanel.vue'; import MissionDetail from './panels/MissionDetail.vue'; import MissionRoster from './panels/MissionRoster.vue'; import RadioPanel from './panels/RadioPanel.vue'; @@ -13,11 +15,13 @@ import RadioPanel from './panels/RadioPanel.vue'; // publishes them; until it does, the panel shows its initial markup. const { view: ctx } = storeToRefs(useContextStore()); const { presence: cockpit } = storeToRefs(useCockpitStore()); +// While the Bhote Koshi event is on, its panel is the rail's only content. +const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());