From 9ea6d7e8aa3c722531b9f16566a3ffc2545fe1bf Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 20:53:54 -0700 Subject: [PATCH] feat(launches): render the replay vehicle overlay from a store The launches layer keeps the replay state, projection and wording and presents the replay vehicle overlay through subscribeReplayOverlay(), sending nothing for an unchanged frame, so the portable launches entry stays free of Vue. The application shell publishes each presentation to the gev-mission-replay store and the scene chrome teleports the vehicle, orbit dot and callout into the globe container with the same markup, classes, mode, transform, rotation and sonar opacities. Hiding keeps the mode, place and text, as before. --- docs/APPLICATION.md | 3 +- docs/VUE-MIGRATION.md | 6 +- scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 10 +- src/layers/launches/index.js | 1 + src/layers/launches/overlays.js | 139 +++++++++---------- src/layers/launches/replayOverlay.test.mjs | 121 ++++++++++++++++ src/layers/launches/state.js | 3 +- src/ui/applicationShell.js | 12 ++ src/ui/stores/missionReplay.js | 22 +++ src/ui/views/SceneChromeView.vue | 2 + src/ui/views/panels/MissionReplayOverlay.vue | 14 ++ 12 files changed, 253 insertions(+), 84 deletions(-) create mode 100644 src/layers/launches/replayOverlay.test.mjs create mode 100644 src/ui/stores/missionReplay.js create mode 100644 src/ui/views/panels/MissionReplayOverlay.vue diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index d4ba5f2bd..2e615e71c 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -210,7 +210,8 @@ 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 and the wind inspection marker, and leaving out `command-dock` the voice control and its pointer +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 view name is an error. A host that does not initialize Core's locale passes `localePicker: false` to `createApplicationControls` instead of editing the diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 115191a94..461fb8e82 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -163,7 +163,11 @@ a feature supplying only its accent. container where `createWindInspectionMarker()`, now in `src/layers/wind/inspectionMarker.js` so the portable `./tools` entry needs no Vue, places it after each rendered frame; an unchanged frame - publishes nothing), then + publishes nothing), the Space Missions replay vehicle + (`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 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 ee6894de1..8fba97f75 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1142,5 +1142,7 @@ "src/scenes/interactionsView.test.mjs", "src/layers/wind/inspectionMarker.js", "src/layers/wind/inspectionMarker.test.mjs", - "src/ui/stores/windInspection.js" + "src/ui/stores/windInspection.js", + "src/ui/stores/missionReplay.js", + "src/layers/launches/replayOverlay.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index daeabac91..8ee8f0387 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -104,7 +104,9 @@ "src/ui/views/panels/DataPacks.vue", "src/ui/stores/pointerReticle.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/views/panels/MissionReplayOverlay.vue" ], "external": ["vue", "pinia"] }, @@ -1024,7 +1026,8 @@ "src/ui/stores/location.js", "src/ui/stores/display.js", "src/ui/stores/status.js", - "src/ui/toast.js" + "src/ui/toast.js", + "src/ui/stores/missionReplay.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3019,7 +3022,8 @@ "src/ui/stores/dataPacks.js", "src/ui/stores/pointerReticle.js", "src/ui/stores/windInspection.js", - "src/layers/wind/inspectionMarker.js" + "src/layers/wind/inspectionMarker.js", + "src/ui/stores/missionReplay.js" ], "external": [ "hls.js", diff --git a/src/layers/launches/index.js b/src/layers/launches/index.js index 320dbab1b..766db3447 100644 --- a/src/layers/launches/index.js +++ b/src/layers/launches/index.js @@ -45,6 +45,7 @@ export function createRocketLaunchesLayer({ services, source }) { parts.panel.commands, { subscribePanel: parts.panel.subscribePanel, + subscribeReplayOverlay: parts.overlays.subscribeReplayOverlay, satelliteParamsForSpaceMissions: parts.policyHelpers.satelliteParamsForSpaceMissions, satelliteParamsAfterSpaceMissions: diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index afa11091c..4b246e702 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -280,60 +280,64 @@ export function createOverlays({ state: layerState, services, parts, source }) { return ascending ? 'ascent' : 'orbit'; } - function createReplayVehicleOverlay() { - if (layerState._replayVehicleOverlay || typeof document === 'undefined') + /** + * Present the replay vehicle overlay to its subscribers, or null to remove + * it. An unchanged presentation is not sent again. + */ + function presentReplayVehicleOverlay(patch) { + const current = layerState._replayVehicleOverlay; + const next = patch && { ...current, ...patch }; + if ( + next === current || + (next && + current && + Object.keys(next).every((k) => next[k] === current[k])) + ) return; - const host = document.getElementById('cesiumContainer') || document.body; - layerState._replayVehicleOverlay = document.createElement('div'); - layerState._replayVehicleOverlay.className = - 'mission-replay-vehicle-overlay'; - layerState._replayVehicleOverlay.hidden = true; - layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true'); - layerState._replayVehicleOverlay.innerHTML = ` -
- - -
- -
- - -
`; - host.appendChild(layerState._replayVehicleOverlay); - layerState._replayVehicleOverlay._sonarIcons = [ - ...layerState._replayVehicleOverlay.querySelectorAll( - '.mission-replay-flight-symbol, .mission-replay-orbit-dot', - ), - ]; - layerState._replayVehicleOverlay._sonarLabel = - layerState._replayVehicleOverlay.querySelector( - '.mission-replay-overlay-callout', - ); + layerState._replayVehicleOverlay = next; + for (const listener of [...layerState._replayVehicleOverlayListeners]) + listener(next); + } + + /** + * Receive every presentation of the replay vehicle overlay, starting with + * the current one. + * @param {(view: ?object) => void} listener + * @returns {() => void} Unsubscribe. + */ + function subscribeReplayOverlay(listener) { + layerState._replayVehicleOverlayListeners.add(listener); + listener(layerState._replayVehicleOverlay); + return () => layerState._replayVehicleOverlayListeners.delete(listener); + } + + function createReplayVehicleOverlay() { + if (layerState._replayVehicleOverlay) return; + presentReplayVehicleOverlay({ + visible: false, + mode: null, + thrusting: false, + paused: false, + transform: null, + rotation: null, + iconOpacity: null, + labelOpacity: null, + title: '', + detail: '', + }); } function hideReplayVehicleOverlay() { if (!layerState._replayVehicleOverlay) return; - layerState._replayVehicleOverlay.hidden = true; - layerState._replayVehicleOverlay.classList.remove( - 'is-thrusting', - 'is-paused', - ); + presentReplayVehicleOverlay({ + visible: false, + thrusting: false, + paused: false, + }); } function destroyReplayVehicleOverlay() { - layerState._replayVehicleOverlay?.remove(); - layerState._replayVehicleOverlay = null; - layerState._replayVehicleOverlayText = ''; + presentReplayVehicleOverlay(null); } function clearMissionOverlaySources() { @@ -523,23 +527,15 @@ export function createOverlays({ state: layerState, services, parts, source }) { : null; const factor = sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1; - for (const icon of layerState._replayVehicleOverlay._sonarIcons || []) - icon.style.opacity = String(factor); - if (layerState._replayVehicleOverlay._sonarLabel) - layerState._replayVehicleOverlay._sonarLabel.style.opacity = String( - sonar ? sonar.label(factor) : 1, - ); - layerState._replayVehicleOverlay.hidden = false; - layerState._replayVehicleOverlay.dataset.mode = mode; - layerState._replayVehicleOverlay.classList.toggle( - 'is-thrusting', - replayActive && state.ascending && !state.countdownActive, - ); - layerState._replayVehicleOverlay.classList.toggle( - 'is-paused', - replayActive && layerState._replayPaused, - ); - layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`; + const frame = { + visible: true, + mode, + thrusting: replayActive && state.ascending && !state.countdownActive, + paused: replayActive && layerState._replayPaused, + transform: `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`, + iconOpacity: String(factor), + labelOpacity: String(sonar ? sonar.label(factor) : 1), + }; let vehicleRotation = 0; if (replayActive && !state.countdownActive) { const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); @@ -581,10 +577,7 @@ export function createOverlays({ state: layerState, services, parts, source }) { } } } - layerState._replayVehicleOverlay.style.setProperty( - '--mission-replay-rotation', - `${Cesium.Math.toDegrees(vehicleRotation)}deg`, - ); + frame.rotation = `${Cesium.Math.toDegrees(vehicleRotation)}deg`; const mission = shortMissionLabel(launch.name, 22).toUpperCase(); const siteName = compactLaunchSiteName(launch.launchSite); @@ -620,16 +613,7 @@ export function createOverlays({ state: layerState, services, parts, source }) { detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); } if (layerState._replayPaused) title = t('PAUSED · {title}', { title }); - const nextText = `${title}\n${detail}`; - if (nextText !== layerState._replayVehicleOverlayText) { - layerState._replayVehicleOverlayText = nextText; - layerState._replayVehicleOverlay.querySelector( - '[data-replay-overlay-title]', - ).textContent = title; - layerState._replayVehicleOverlay.querySelector( - '[data-replay-overlay-detail]', - ).textContent = detail; - } + presentReplayVehicleOverlay({ ...frame, title, detail }); return mode; } return { @@ -643,6 +627,7 @@ export function createOverlays({ state: layerState, services, parts, source }) { missionHoverReticleImage, replayOverlayMode, createReplayVehicleOverlay, + subscribeReplayOverlay, hideReplayVehicleOverlay, destroyReplayVehicleOverlay, clearMissionOverlaySources, diff --git a/src/layers/launches/replayOverlay.test.mjs b/src/layers/launches/replayOverlay.test.mjs new file mode 100644 index 000000000..76d85c0e1 --- /dev/null +++ b/src/layers/launches/replayOverlay.test.mjs @@ -0,0 +1,121 @@ +// src/layers/launches/replayOverlay.test.mjs — the launches layer presents +// its replay vehicle overlay to subscribers, and the scene chrome renders it +// inside the globe container. +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 { useMissionReplayStore } from '../../ui/stores/missionReplay.js'; +import SceneChromeView from '../../ui/views/SceneChromeView.vue'; +import { createOverlays } from './overlays.js'; + +function overlays() { + const state = { + _replayVehicleOverlay: null, + _replayVehicleOverlayListeners: new Set(), + }; + const parts = createOverlays({ state, services: {}, parts: {}, source: {} }); + return Object.assign(parts, { state }); +} + +test('subscribers get the overlay as it is created, hidden and removed, never twice the same', () => { + const parts = overlays(); + const views = []; + const unsubscribe = parts.subscribeReplayOverlay((view) => views.push(view)); + assert.deepEqual(views, [null], 'a subscriber starts from the current one'); + parts.createReplayVehicleOverlay(); + parts.createReplayVehicleOverlay(); + assert.equal(views.length, 2); + assert.equal(views[1].visible, false); + parts.hideReplayVehicleOverlay(); + assert.equal(views.length, 2, 'an unchanged overlay is not sent again'); + parts.destroyReplayVehicleOverlay(); + assert.equal(views.at(-1), null); + parts.hideReplayVehicleOverlay(); + unsubscribe(); + parts.createReplayVehicleOverlay(); + assert.equal(views.length, 3); +}); + +test('the scene chrome renders the overlay in the globe container', async (t) => { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneChromeView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + const store = useMissionReplayStore(pinia); + const node = () => document.querySelector('.mission-replay-vehicle-overlay'); + const parts = overlays(); + parts.subscribeReplayOverlay((view) => store.publish(view)); + await nextTick(); + assert.equal(node(), null); + parts.createReplayVehicleOverlay(); + await nextTick(); + const element = node(); + assert.equal(element.parentElement.id, 'cesiumContainer'); + assert.equal(element.hidden, true); + assert.equal(element.getAttribute('aria-hidden'), 'true'); + assert.equal(element.hasAttribute('data-mode'), false); + assert.equal( + element.querySelectorAll('.mission-replay-thrust ellipse').length, + 6, + ); + // A frame as updateReplayVehicleOverlay presents it. + parts.state._replayVehicleOverlay = { + ...parts.state._replayVehicleOverlay, + visible: true, + mode: 'ascent', + thrusting: true, + paused: false, + transform: 'translate3d(10px, 20px, 0) translate(-50%, -50%)', + rotation: '12deg', + iconOpacity: '0.5', + labelOpacity: '1', + title: 'ASCENT REPLAY · TEST', + detail: 'T+00:10', + }; + store.publish(parts.state._replayVehicleOverlay); + await nextTick(); + assert.equal(element.hidden, false); + assert.equal(element.dataset.mode, 'ascent'); + assert.ok(element.classList.contains('is-thrusting')); + assert.equal(element.classList.contains('is-paused'), false); + assert.equal( + element.style.getPropertyValue('--mission-replay-rotation'), + '12deg', + ); + assert.match(element.style.transform, /translate3d\(10px, 20px, 0(px)?\)/); + assert.equal( + element.querySelector('.mission-replay-flight-symbol').style.opacity, + '0.5', + ); + assert.equal( + element.querySelector('.mission-replay-orbit-dot').style.opacity, + '0.5', + ); + assert.equal( + element.querySelector('.mission-replay-overlay-callout').style.opacity, + '1', + ); + assert.equal( + element.querySelector('[data-replay-overlay-title]').textContent, + 'ASCENT REPLAY · TEST', + ); + assert.equal( + element.querySelector('[data-replay-overlay-detail]').textContent, + 'T+00:10', + ); + parts.hideReplayVehicleOverlay(); + await nextTick(); + assert.equal(element.hidden, true); + assert.equal(element.classList.contains('is-thrusting'), false); + assert.equal( + element.dataset.mode, + 'ascent', + 'hiding keeps the mode, as before', + ); + parts.destroyReplayVehicleOverlay(); + await nextTick(); + assert.equal(node(), null); +}); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 98ebebc7f..b2c4d266b 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -77,9 +77,10 @@ export function createState({ services }) { state._missionHoverReticleImage = null; + // The replay vehicle overlay as its view renders it; null without one. state._replayVehicleOverlay = null; - state._replayVehicleOverlayText = ''; + state._replayVehicleOverlayListeners = new Set(); state._animationStarts = new Map(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index d0b1b3897..2af763e67 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -54,6 +54,7 @@ import { applyLocaleChoice } from './localePicker.js'; import { PanelNavigation } from './panelNavigation.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { useContextStore } from './stores/context.js'; +import { useMissionReplayStore } from './stores/missionReplay.js'; import { useDisplayStore } from './stores/display.js'; import { useLocaleStore } from './stores/locale.js'; import { useLocationStore } from './stores/location.js'; @@ -1036,6 +1037,14 @@ export class StyleManager extends ShellFacade { this._missionsPanelUnsubscribe = rocketLaunchesLayer?.subscribePanel?.( (view) => contextStore.publishMissions(view), ); + // And its replay vehicle overlay, which the scene chrome renders. + const missionReplayStore = useMissionReplayStore(applicationPinia()); + this._missionReplayUnsubscribe?.(); + missionReplayStore.publish(null); + this._missionReplayUnsubscribe = + rocketLaunchesLayer?.subscribeReplayOverlay?.((view) => + missionReplayStore.publish(view), + ); } /** Wire the independent Radio companion controls. */ @@ -1786,6 +1795,9 @@ export class StyleManager extends ShellFacade { this._awarenessPanelUnsubscribe = null; this._missionsPanelUnsubscribe?.(); this._missionsPanelUnsubscribe = null; + this._missionReplayUnsubscribe?.(); + this._missionReplayUnsubscribe = null; + useMissionReplayStore(applicationPinia()).publish(null); const contextStore = useContextStore(applicationPinia()); contextStore.connectAwareness(null); contextStore.publishAwareness(null); diff --git a/src/ui/stores/missionReplay.js b/src/ui/stores/missionReplay.js new file mode 100644 index 000000000..f8cfb771e --- /dev/null +++ b/src/ui/stores/missionReplay.js @@ -0,0 +1,22 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * The Space Missions replay vehicle overlay as its view renders it. The + * launches layer presents it through `subscribeReplayOverlay()` and the + * application shell publishes each presentation here. + */ +export const useMissionReplayStore = defineStore('gev-mission-replay', () => { + /** + * `{visible, mode, thrusting, paused, transform, rotation, iconOpacity, + * labelOpacity, title, detail}`, or null while the layer has no overlay. + */ + const overlay = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + overlay.value = next && markRaw(next); + } + + return { overlay, publish }; +}); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 750f3a089..2b22e0f05 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -6,6 +6,7 @@ import { useLocationStore } from '../stores/location.js'; import { useStatusStore } from '../stores/status.js'; import { vClassToggle } from '../rail/classToggle.js'; import ImagerySplitView from './panels/ImagerySplitView.vue'; +import MissionReplayOverlay from './panels/MissionReplayOverlay.vue'; import DataPacks from './panels/DataPacks.vue'; import SceneInteractions from './panels/SceneInteractions.vue'; import SplitFlapText from './panels/SplitFlapText.vue'; @@ -102,4 +103,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/MissionReplayOverlay.vue b/src/ui/views/panels/MissionReplayOverlay.vue new file mode 100644 index 000000000..ece041f72 --- /dev/null +++ b/src/ui/views/panels/MissionReplayOverlay.vue @@ -0,0 +1,14 @@ + + +