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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 20:53:54 -07:00
1 parent e0bfa471ac
commit 9ea6d7e8aa
12 files changed
+253 -84

No files matched your search

+2 -1
View File
@@ -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
+5 -1
View File
@@ -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
+3 -1
View File
@@ -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"
]
+7 -3
View File
@@ -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",
+1
View File
@@ -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:
+62 -77
View File
@@ -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 = `
<div class="mission-replay-flight-symbol">
<svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true">
<path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path>
<circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle>
</svg>
<svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true">
<ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse>
<ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse>
<ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse>
<ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse>
<ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse>
<ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse>
</svg>
</div>
<div class="mission-replay-orbit-dot" aria-hidden="true"></div>
<div class="mission-replay-overlay-callout">
<strong data-replay-overlay-title></strong>
<span data-replay-overlay-detail></span>
</div>`;
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,
+121
View File
@@ -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);
});
+2 -1
View File
@@ -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();
+12
View File
@@ -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);
+22
View File
@@ -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 };
});
+2
View File
@@ -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 = {
<DataPacks />
<SceneInteractions />
<WindInspectionMarker />
<MissionReplayOverlay />
</template>
@@ -0,0 +1,14 @@
<script setup>
import { storeToRefs } from 'pinia';
import { useMissionReplayStore } from '../../stores/missionReplay.js';
// The replay vehicle, its orbit dot and callout over the globe, where the
// launches layer places them after each rendered frame.
const { overlay } = storeToRefs(useMissionReplayStore());
</script>
<template>
<Teleport v-if="overlay" defer to="#cesiumContainer">
<div class="mission-replay-vehicle-overlay" :class="{ 'is-thrusting': overlay.thrusting, 'is-paused': overlay.paused }" :hidden="!overlay.visible" aria-hidden="true" :data-mode="overlay.mode ?? undefined" :style="{ transform: overlay.transform ?? undefined, '--mission-replay-rotation': overlay.rotation ?? undefined }"><div class="mission-replay-flight-symbol" :style="{ opacity: overlay.iconOpacity ?? undefined }"><svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true"><path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path><circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle></svg><svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true"><ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse><ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse><ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse><ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse><ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse><ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse></svg></div><div class="mission-replay-orbit-dot" aria-hidden="true" :style="{ opacity: overlay.iconOpacity ?? undefined }"></div><div class="mission-replay-overlay-callout" :style="{ opacity: overlay.labelOpacity ?? undefined }"><strong data-replay-overlay-title="">{{ overlay.title }}</strong><span data-replay-overlay-detail="">{{ overlay.detail }}</span></div></div>
</Teleport>
</template>