feat(bhote-koshi): render the reconstruction panel from a store

The Bhote Koshi layer keeps its panel controls as plain records and
presents them to a store; the context rail renders the panel as its last
child and marks itself while it is on. Controls are bound to the enable
that published them, and an unchanged frame is not sent again.
This commit is contained in:
Sameh Khamis committed 2026-10-31 21:12:08 -07:00
1 parent 9ea6d7e8aa
commit fd0d51c450
10 files changed
+580 -234

No files matched your search

+3 -3
View File
@@ -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, 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 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 indicator, the FPS readout, the Scene actions panel, the data-pack attribution
cards, the wind inspection marker and the Space Missions replay vehicle, and cards, the wind inspection marker and the Space Missions replay vehicle;
leaving out `command-dock` the voice control and its pointer leaving out `command-dock` the voice control and its pointer reticle; and
reticle. An unknown 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 view name is an error. A host that does not initialize Core's locale passes
`localePicker: false` to `createApplicationControls` instead of editing the `localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component markup. Node tests that import application modules register the component
+6 -1
View File
@@ -167,7 +167,12 @@ a feature supplying only its accent.
(`MissionReplayOverlay` teleports into the globe container what the (`MissionReplayOverlay` teleports into the globe container what the
launches layer presents through `subscribeReplayOverlay()`, which the launches layer presents through `subscribeReplayOverlay()`, which the
application shell publishes to the `gev-mission-replay` store, so 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 Cockpit (in progress: `CockpitBriefing` renders the briefing card's
pages, cycle toggle, regional news and local conditions, and pages, cycle toggle, regional news and local conditions, and
`CockpitContext` the Contact card's scope, navigation, counts, nearest `CockpitContext` the Contact card's scope, navigation, counts, nearest
+3 -1
View File
@@ -1144,5 +1144,7 @@
"src/layers/wind/inspectionMarker.test.mjs", "src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js", "src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.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"
] ]
+7 -3
View File
@@ -106,7 +106,9 @@
"src/ui/stores/windInspection.js", "src/ui/stores/windInspection.js",
"src/ui/views/panels/WindInspectionMarker.vue", "src/ui/views/panels/WindInspectionMarker.vue",
"src/ui/stores/missionReplay.js", "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"] "external": ["vue", "pinia"]
}, },
@@ -3023,7 +3025,8 @@
"src/ui/stores/pointerReticle.js", "src/ui/stores/pointerReticle.js",
"src/ui/stores/windInspection.js", "src/ui/stores/windInspection.js",
"src/layers/wind/inspectionMarker.js", "src/layers/wind/inspectionMarker.js",
"src/ui/stores/missionReplay.js" "src/ui/stores/missionReplay.js",
"src/ui/stores/bhoteKoshiPanel.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3613,7 +3616,8 @@
"src/ui/stores/imagerySplit.js", "src/ui/stores/imagerySplit.js",
"src/ui/vueRuntime.js", "src/ui/vueRuntime.js",
"src/ui/stores/windInspection.js", "src/ui/stores/windInspection.js",
"src/layers/wind/inspectionMarker.js" "src/layers/wind/inspectionMarker.js",
"src/ui/stores/bhoteKoshiPanel.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+135 -197
View File
@@ -26,6 +26,8 @@ import {
import { acquireImageryComparison } from '../maps/imageryComparison.js'; import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js'; import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.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_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses'; export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -449,18 +451,6 @@ function imageryEndpointText(entry) {
return [name, date].filter(Boolean).join(' · '); 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. */ /** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) { export function evidenceCorroborationLabel(observation) {
const count = Math.max( const count = Math.max(
@@ -929,185 +919,70 @@ function setElementProperty(element, property, value) {
} }
function setElementAttribute(element, attribute, value) { function setElementAttribute(element, attribute, value) {
if (!element || element.getAttribute?.(attribute) === value) return false; if (!element || element.attributes[attribute] === value) return false;
element.setAttribute(attribute, value); element.attributes[attribute] = value;
return true; 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({ return Object.freeze({
imagerySection: panel.querySelector('[data-role="imagery-comparison"]'), root: { sceneBeat: false },
observedStatus: panel.querySelector('.bhote-event-status.observed'), imagerySection: panelRecord(),
beforeDate: panel.querySelector('[data-role="before-date"]'), observedStatus: panelRecord(),
afterDate: panel.querySelector('[data-role="after-date"]'), time: panelRecord({
time: panel.querySelector('[data-role="time"]'), textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`,
caveat: panel.querySelector('[data-role="caveat"]'), }),
imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'), caveat: panelRecord({
reportList: panel.querySelector('.bhote-event-report-list'), textContent: bhoteEventText(event.reconstruction.caveat),
playButton: panel.querySelector('[data-action="play"]'), }),
playSceneButton: panel.querySelector('[data-action="play-scene"]'), imageryCloudNote: panelRecord(),
cinematicButton: panel.querySelector('[data-action="cinematic"]'), playButton: panelRecord({ textContent: t('▶ PLAY') }),
storyReplayButton: panel.querySelector('[data-action="story-replay"]'), playSceneButton: panelRecord({
corridorButton: panel.querySelector('[data-action="corridor"]'), textContent: t('▶ PLAY SCENE'),
previousButton: panel.querySelector('[data-action="previous-beat"]'), hidden: true,
nextButton: panel.querySelector('[data-action="next-beat"]'), }),
openSourceButton: panel.querySelector('[data-action="open-source"]'), cinematicButton: panelRecord({
progressInput: panel.querySelector('[data-role="progress"]'), textContent: t('◉ CINEMATIC'),
splitInput: panel.querySelector('[data-role="split"]'), attributes: { 'aria-pressed': 'false' },
splitReadout: panel.querySelector('[data-role="split-readout"]'), }),
timelineLabel: panel.querySelector('[data-role="timeline-label"]'), storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }),
beatIndex: panel.querySelector('[data-role="beat-index"]'), corridorButton: panelRecord(),
beatTitle: panel.querySelector('[data-role="beat-title"]'), previousButton: panelRecord({
beatMeta: panel.querySelector('[data-role="beat-meta"]'), 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
</div>
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
`;
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({ export function createBhoteKoshiEventLayer({
overlayHost = DEFAULT_OVERLAY_HOST, overlayHost = DEFAULT_OVERLAY_HOST,
renderHost = DEFAULT_RENDER_HOST, renderHost = DEFAULT_RENDER_HOST,
@@ -1119,14 +994,20 @@ export function createBhoteKoshiEventLayer({
evidenceFrameUpdater = updateEvidenceFrame, evidenceFrameUpdater = updateEvidenceFrame,
videoFactory = DEFAULT_VIDEO_FACTORY, videoFactory = DEFAULT_VIDEO_FACTORY,
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia, embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
} = {}) { } = {}) {
let _viewer = null; let _viewer = null;
let _dataManager = null; let _dataManager = null;
let _mapStackController = null; let _mapStackController = null;
let _event = null; let _event = null;
let _enabled = false; 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 _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 _splitControl = null;
let _beforeLayer = null; let _beforeLayer = null;
let _afterLayer = null; let _afterLayer = null;
@@ -2756,15 +2637,50 @@ export function createBhoteKoshiEventLayer({
_lastCinematicProgress = _progress; _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() { function syncCinematicButton() {
if (writeCinematicButton()) publishPanel();
}
/** Write the cinematic button's label and state; false without a panel. */
function writeCinematicButton() {
const button = _panelRefs?.cinematicButton; const button = _panelRefs?.cinematicButton;
if (!button) return; if (!button) return false;
setElementProperty( setElementProperty(
button, button,
'textContent', 'textContent',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'), _cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
); );
setElementAttribute(button, 'aria-pressed', String(_cinematicActive)); setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
return true;
} }
function claimCinematicCamera() { function claimCinematicCamera() {
@@ -3029,6 +2945,7 @@ export function createBhoteKoshiEventLayer({
'textContent', 'textContent',
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`, `~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
); );
publishPanel();
if (_sceneSeekFrame != null) return; if (_sceneSeekFrame != null) return;
const schedule = const schedule =
globalThis.requestAnimationFrame || globalThis.requestAnimationFrame ||
@@ -3302,7 +3219,7 @@ export function createBhoteKoshiEventLayer({
'disabled', 'disabled',
!observation?.media?.sourceUrl, !observation?.media?.sourceUrl,
); );
syncCinematicButton(); writeCinematicButton();
setElementProperty( setElementProperty(
_panelRefs.splitInput, _panelRefs.splitInput,
'value', 'value',
@@ -3314,6 +3231,7 @@ export function createBhoteKoshiEventLayer({
'textContent', 'textContent',
`${beforePercent} / ${100 - beforePercent}`, `${beforePercent} / ${100 - beforePercent}`,
); );
publishPanel();
} }
function syncPresentationMode() { function syncPresentationMode() {
@@ -3330,7 +3248,7 @@ export function createBhoteKoshiEventLayer({
const evidenceAvailable = const evidenceAvailable =
!sceneDirected || !sceneDirected ||
(_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat); (_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected); if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected;
if (_panelRefs?.playSceneButton) if (_panelRefs?.playSceneButton)
_panelRefs.playSceneButton.hidden = !sceneDirected; _panelRefs.playSceneButton.hidden = !sceneDirected;
if (_panelRefs?.imagerySection) if (_panelRefs?.imagerySection)
@@ -3364,6 +3282,7 @@ export function createBhoteKoshiEventLayer({
} }
if (_panelRefs?.openSourceButton) if (_panelRefs?.openSourceButton)
_panelRefs.openSourceButton.hidden = !evidenceAvailable; _panelRefs.openSourceButton.hidden = !evidenceAvailable;
publishPanel();
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable); overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
} }
@@ -3561,7 +3480,12 @@ export function createBhoteKoshiEventLayer({
throwIfEnableCancelled(signal, _enabled); throwIfEnableCancelled(signal, _enabled);
createFloodDataSource(); createFloodDataSource();
createSplitControl(event); createSplitControl(event);
const panelView = createPanel(event, { _panelRefs = createPanelRecords(event);
_panelOwner = {};
_panelPublished = '';
_panelFixed = null;
const owner = _panelOwner;
const controls = {
close: () => close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, { _dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
origin: 'user', origin: 'user',
@@ -3578,9 +3502,21 @@ export function createBhoteKoshiEventLayer({
commitProgress, commitProgress,
setSplit, setSplit,
focusCorridor, focusCorridor,
}); openReport: (index) => {
_panel = panelView.panel; const url = event.fieldReports[index]?.url;
_panelRefs = panelView.refs; 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(); syncPresentationMode();
overlayHost.setVisible( overlayHost.setVisible(
BHOTE_KOSHI_OVERLAY_SOURCE_ID, BHOTE_KOSHI_OVERLAY_SOURCE_ID,
@@ -3673,12 +3609,14 @@ export function createBhoteKoshiEventLayer({
_surgePoint = null; _surgePoint = null;
_lastSurgePosition = null; _lastSurgePosition = null;
_lastSurgeVisible = null; _lastSurgeVisible = null;
_panel?.remove(); if (_panelOwner && panelStore.panel?.owner === _panelOwner) {
document panelStore.connect(null);
.getElementById?.('right-context-rail') panelStore.publish(null);
?.classList.remove('bhote-event-active'); }
_panel = null; _panelOwner = null;
_panelRefs = null; _panelRefs = null;
_panelPublished = '';
_panelFixed = null;
_splitControl?.destroy(); _splitControl?.destroy();
_splitControl = null; _splitControl = null;
_progress = 0; _progress = 0;
+101 -28
View File
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
import test from 'node:test'; import test from 'node:test';
import { claimPointer, releasePointer } from './inputOwnership.js'; import { claimPointer, releasePointer } from './inputOwnership.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js'; import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
import { applicationPinia } from '../ui/vueRuntime.js'; import { applicationPinia } from '../ui/vueRuntime.js';
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js'; import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
@@ -44,6 +45,89 @@ const eventSplit = () =>
(split) => split.view.id === 'bhote-koshi-split-line', (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 { class FakeElement {
constructor(tagName = 'div') { constructor(tagName = 'div') {
this.tagName = tagName; 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(await layer.enable(viewer, { origin: 'scene' }), true);
assert.equal(viewer._test.imagery.length, 2); assert.equal(viewer._test.imagery.length, 2);
assert.equal(viewer._test.dataSources.length, 1); assert.equal(viewer._test.dataSources.length, 1);
assert.equal(body.children.length, 1); assert.ok(eventPanel());
assert.ok(eventSplit()); assert.ok(eventSplit());
activeMap = 'operator-choice'; 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.deepEqual(mapCalls, ['esri-imagery']);
assert.equal(viewer._test.removedImagery.length, 2); assert.equal(viewer._test.removedImagery.length, 2);
assert.equal(viewer._test.removedDataSources.length, 1); 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(eventSplit(), undefined);
assert.equal(viewer.scene.splitPosition, 0.37); assert.equal(viewer.scene.splitPosition, 0.37);
assert.deepEqual(clearedTimeouts, [71]); assert.deepEqual(clearedTimeouts, [71]);
@@ -945,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h
try { try {
await layer.enable(viewer, { origin: 'scene' }); 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(panel.querySelector('[data-action="play-scene"]').hidden, true);
assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false); assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false);
panel.querySelector('[data-action="play"]').dispatch('click'); 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.deepEqual(timers, []);
assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair'); 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)); 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(panel);
assert.ok(eventSplit()); assert.ok(eventSplit());
assert.equal(eventSplit().view.hidden, true); 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="cinematic"]').disabled, true);
assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false); assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false);
assert.equal(panel.querySelector('[data-action="corridor"]').disabled, 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"]'); const fullStory = panel.querySelector('[data-action="story-replay"]');
assert.equal(fullStory.textContent, '↺ FULL STORY'); assert.equal(fullStory.textContent, '↺ FULL STORY');
assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback'); 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(layer.getCinematicState().active, false);
assert.equal(viewer._test.cameraLooks.length, 0); assert.equal(viewer._test.cameraLooks.length, 0);
assert.deepEqual(mapCalls, []); assert.deepEqual(mapCalls, []);
assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1); assert.ok(eventPanel());
assert.equal(viewer.scene.splitPosition, 0.62); assert.equal(viewer.scene.splitPosition, 0.62);
for (const beatId of [ for (const beatId of [
'debris-dammed-lake', '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.equal(breadcrumbs.filter((entry) => entry.position()).length, 0);
assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1)); 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"]'); const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285'; progress.value = '285';
progress.dispatch('input'); 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'); videos[0].dispatch('loadeddata');
assert.equal(layer.getCinematicState().mediaReady, true); 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"]'); const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285'; progress.value = '285';
progress.dispatch('input'); 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.pauseCalls >= 1);
assert.ok(video.cancelledFrameHandles.length >= 1); assert.ok(video.cancelledFrameHandles.length >= 1);
assert.equal(layer.getCinematicState().mediaEvidenceId, null); assert.equal(layer.getCinematicState().mediaEvidenceId, null);
assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false); assert.ok(eventPanel());
await layer.disable(); await layer.disable();
} finally { } finally {
globalThis.document = originalDocument; globalThis.document = originalDocument;
@@ -2129,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
try { try {
await layer.enable(viewer, { origin: 'local-restore' }); await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside'); const panel = eventPanel();
assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/); assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK');
assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/);
assert.match(panel.innerHTML, /OPEN PUBLIC MAP/);
const entries = publications[0].entries; const entries = publications[0].entries;
const cards = entries.filter((entry) => entry.variant === 'thumbnail'); const cards = entries.filter((entry) => entry.variant === 'thumbnail');
const breadcrumbs = entries.filter((entry) => entry.variant === 'label'); 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 event = JSON.parse(await readFile(eventUrl, 'utf8'));
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
@@ -2246,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c
try { try {
await layer.enable(viewer, { origin: 'local-restore' }); 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 cinematic = panel.querySelector('[data-action="cinematic"]');
const progress = panel.querySelector('[data-role="progress"]'); const progress = panel.querySelector('[data-role="progress"]');
cinematic.dispatch('click'); cinematic.dispatch('click');
progress.value = '500'; progress.value = '500';
progress.dispatch('input'); progress.dispatch('input');
const queryCount = panel._queryCount; const panelStore = useBhoteKoshiPanelStore(applicationPinia());
const writeCount = [...panel._selectors.values()].reduce((sum, element) => ( const published = panelStore.panel;
sum + element._writeCount
), 0);
const cameraLookCount = viewer._test.cameraLooks.length; const cameraLookCount = viewer._test.cameraLooks.length;
for (let index = 0; index < 20; index += 1) progress.dispatch('input'); for (let index = 0; index < 20; index += 1) progress.dispatch('input');
assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references'); assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel');
assert.equal(
[...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0),
writeCount,
'unchanged frames do not rewrite panel state',
);
assert.equal( assert.equal(
viewer._test.cameraLooks.length, viewer._test.cameraLooks.length,
cameraLookCount, cameraLookCount,
@@ -2336,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin
try { try {
await layer.enable(viewer, { origin: 'local-restore' }); 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'); panel.querySelector('[data-action="cinematic"]').dispatch('click');
assert.equal(layer.getCinematicState().active, true); assert.equal(layer.getCinematicState().active, true);
assert.equal(layer.getPlaybackState().playing, true); assert.equal(layer.getPlaybackState().playing, true);
@@ -2421,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas
try { try {
await layer.enable(viewer, { origin: 'local-restore' }); 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'); panel.querySelector('[data-action="cinematic"]').dispatch('click');
animationCallbacks[0](91001); animationCallbacks[0](91001);
assert.equal(layer.getPlaybackState().progress, 1); assert.equal(layer.getPlaybackState().progress, 1);
+212
View File
@@ -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],
]);
});
+35
View File
@@ -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 };
},
);
+6 -1
View File
@@ -3,8 +3,10 @@ import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js'; import { useCockpitStore } from '../stores/cockpit.js';
import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js';
import { useContextStore } from '../stores/context.js'; import { useContextStore } from '../stores/context.js';
import AwarenessPanel from './panels/AwarenessPanel.vue'; import AwarenessPanel from './panels/AwarenessPanel.vue';
import BhoteKoshiPanel from './panels/BhoteKoshiPanel.vue';
import MissionDetail from './panels/MissionDetail.vue'; import MissionDetail from './panels/MissionDetail.vue';
import MissionRoster from './panels/MissionRoster.vue'; import MissionRoster from './panels/MissionRoster.vue';
import RadioPanel from './panels/RadioPanel.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. // publishes them; until it does, the panel shows its initial markup.
const { view: ctx } = storeToRefs(useContextStore()); const { view: ctx } = storeToRefs(useContextStore());
const { presence: cockpit } = storeToRefs(useCockpitStore()); 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());
</script> </script>
<template> <template>
<!-- Global Context is deliberately independent from the left layer accordion. --> <!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail" class="ui-scroll"> <aside id="right-context-rail" class="ui-scroll" v-class-toggle="{ 'bhote-event-active': !!bhotePanel }">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden> <div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller> <div class="weather-panel-inner" data-rail-scroller>
@@ -86,6 +90,7 @@ const { presence: cockpit } = storeToRefs(useCockpitStore());
</div> </div>
</div> </div>
</div> </div>
<BhoteKoshiPanel />
</aside> </aside>
<div id="scene-runtime"></div> <div id="scene-runtime"></div>
+72
View File
@@ -0,0 +1,72 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import { useBhoteKoshiPanelStore } from '../../stores/bhoteKoshiPanel.js';
// The Bhote Koshi reconstruction controls, at the end of the context rail,
// as the event layer (src/data/bhoteKoshiEvent.js) presents them. Clicks and
// slider moves go back to the layer.
const { panel, controls } = storeToRefs(useBhoteKoshiPanelStore());
const call = (name, ...args) => controls.value?.[name]?.(...args);
const sliderValue = (event, scale) => Number(event.target.value) / scale;
</script>
<template>
<aside v-if="panel" id="bhote-koshi-event-panel" :class="{ 'bhote-event-scene-beat': panel.sceneBeat }" :aria-label="t('Bhote Koshi flood reconstruction controls')">
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">{{ t('EVENT RECONSTRUCTION · 26 AUG 2026') }}</div>
<div class="bhote-event-title">{{ t('BHOTE KOSHI OUTBURST FLOOD') }}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" :title="t('Close event layer')" :aria-label="t('Close event layer')" @click="call('close')">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed" :hidden="panel.refs.observedStatus.hidden">{{ t('OBSERVED IMAGERY') }}</span>
<span class="bhote-event-status reconstructed">{{ t('SCHEMATIC CORRIDOR') }}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison" :hidden="panel.refs.imagerySection.hidden">
<div class="bhote-event-section-head">
<span>{{ t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT') }}</span>
<span data-role="split-readout">{{ panel.refs.splitReadout.textContent }}</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" :value="panel.refs.splitInput.value" :aria-label="t('Historical reference and post-event image split')" @input="call('setSplit', sliderValue($event, 100))" />
<div class="bhote-event-dates">
<span data-role="before-date">{{ panel.beforeDate.date ? `${panel.beforeDate.name} · ` : panel.beforeDate.name }}<span v-if="panel.beforeDate.date" class="bhote-event-date">{{ panel.beforeDate.date }}</span></span>
<span data-role="after-date">{{ panel.afterDate.date ? `${panel.afterDate.name} · ` : panel.afterDate.name }}<span v-if="panel.afterDate.date" class="bhote-event-date">{{ panel.afterDate.date }}</span></span>
</div>
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">{{ panel.refs.timelineLabel.textContent }}</span>
<span data-role="time">{{ panel.refs.time.textContent }}</span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" :value="panel.refs.progressInput.value" :hidden="panel.refs.progressInput.hidden" :disabled="panel.refs.progressInput.disabled" :aria-label="panel.refs.progressInput.attributes['aria-label']" @input="call('previewProgress', sliderValue($event, 1000))" @change="call('commitProgress', sliderValue($event, 1000), { final: true })" />
<div class="bhote-event-story-nav" :aria-label="t('Story beat navigation')">
<button type="button" data-action="previous-beat" :title="t('Previous story beat')" :aria-label="panel.refs.previousButton.attributes['aria-label']" :disabled="panel.refs.previousButton.disabled" @click="call('previousBeat')">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">{{ panel.refs.beatIndex.textContent }}</span>
<strong data-role="beat-title" aria-live="polite">{{ panel.refs.beatTitle.textContent }}</strong>
<small data-role="beat-meta">{{ panel.refs.beatMeta.textContent }}</small>
</div>
<button type="button" data-action="next-beat" :title="t('Next story beat')" :aria-label="panel.refs.nextButton.attributes['aria-label']" :disabled="panel.refs.nextButton.disabled" @click="call('nextBeat')">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play" :aria-label="panel.refs.playButton.attributes['aria-label']" :disabled="panel.refs.playButton.disabled" @click="call('play')">{{ panel.refs.playButton.textContent }}</button>
<button type="button" data-action="play-scene" :hidden="panel.refs.playSceneButton.hidden" :title="t('Play the next shot and continue through this scene')" :aria-label="panel.refs.playSceneButton.attributes['aria-label']" :disabled="panel.refs.playSceneButton.disabled" @click="call('playScene')">{{ panel.refs.playSceneButton.textContent }}</button>
<button type="button" data-action="cinematic" :aria-pressed="panel.refs.cinematicButton.attributes['aria-pressed']" :hidden="panel.refs.cinematicButton.hidden" :disabled="panel.refs.cinematicButton.disabled" @click="call('toggleCinematic')">{{ panel.refs.cinematicButton.textContent }}</button>
<button type="button" data-action="story-replay" :aria-label="panel.refs.storyReplayButton.attributes['aria-label']" :hidden="panel.refs.storyReplayButton.hidden" :disabled="panel.refs.storyReplayButton.disabled" @click="call('replayCinematic')">{{ panel.refs.storyReplayButton.textContent }}</button>
<button type="button" data-action="open-source" :hidden="panel.refs.openSourceButton.hidden" :disabled="panel.refs.openSourceButton.disabled" @click="call('openActiveSource')">{{ t('↗ OPEN ORIGINAL') }}</button>
<button type="button" data-action="corridor" :hidden="panel.refs.corridorButton.hidden" :disabled="panel.refs.corridorButton.disabled" @click="call('focusCorridor')">{{ t('⌖ LOWER GORGE') }}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>{{ t('FIELD REPORTS · {count}', { count: panel.reports.length }) }}</summary>
<div class="bhote-event-report-list"><button v-for="(report, index) in panel.reports" :key="index" type="button" class="bhote-event-report" @click="call('openReport', index)"><span>{{ report.label }}</span><small>{{ report.status }}</small></button></div>
</details>
<div class="bhote-event-credit">
<span>{{ t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }) }}</span>
<button type="button" data-action="geolocation-map" @click="call('openGeolocationMap')">{{ t('↗ OPEN PUBLIC MAP') }}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat">{{ panel.refs.caveat.textContent }}</span><span data-role="imagery-cloud-note" :hidden="panel.refs.imageryCloudNote.hidden">{{ t(' Clouds are preserved from the source imagery.') }}</span></p>
</aside>
</template>