diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a4b64f4f..05e90cb05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,17 @@
`restoreAwarenessPanelFocus` helpers are gone; the view keeps focus itself
(samehkhamis).
+- `./layers/launches` no longer writes the Space Missions roster or the
+ selected mission panel into `#space-mission-panel-host`: the layer
+ publishes both through `subscribePanel(listener)` and Core's Space Missions
+ views render them, calling the layer's panel commands (`selectMissionAt`,
+ `selectPreviousMission`, `selectNextMission`, `deselectMission`,
+ `focusSelectedLaunchSite`, `startSelectedReplay`, `toggleReplayPause`,
+ `cancelReplay`, `setReplaySpeed` and the `roster*` preview inputs). The
+ `bindMissionRosterItemKeyboardPreview`, `captureMissionRosterFocus` and
+ `restoreMissionRosterFocus` helpers are gone; the roster keeps focus itself
+ (samehkhamis).
+
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index 89b1fcdd5..8a61e0cb4 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -113,7 +113,9 @@ They contain no application state.
through its `publish` option, so `./ui/context` needs no Vue;
`AwarenessPanel` renders the Contacts panel that the awareness layer
publishes through `subscribePanel()`, the first layer to hand its rail
- content to a view), the command dock,
+ content to a view, and `MissionRoster` and `MissionDetail` the Space
+ Missions roster and selected mission that the launches layer publishes
+ the same way), the command dock,
the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead.
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 013f6e0b8..207a45157 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -26,6 +26,8 @@
"src/ui/views/ContextView.vue",
"src/ui/views/panels/RadioPanel.vue",
"src/ui/views/panels/SdrCard.vue",
+ "src/ui/views/panels/MissionDetail.vue",
+ "src/ui/views/panels/MissionRoster.vue",
"src/ui/views/panels/AwarenessPanel.vue",
"src/ui/views/panels/AwarenessNames.vue",
"src/ui/views/DisplayControlsView.vue",
diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js
index 14002ecaf..bfcbe06cf 100644
--- a/src/data/rocketLaunches.js
+++ b/src/data/rocketLaunches.js
@@ -39,10 +39,6 @@ export const missionRosterEntries = layer.missionRosterEntries;
export const missionHoverPreviewRange = layer.missionHoverPreviewRange;
export const createMissionRosterPreviewOwnership =
layer.createMissionRosterPreviewOwnership;
-export const bindMissionRosterItemKeyboardPreview =
- layer.bindMissionRosterItemKeyboardPreview;
-export const captureMissionRosterFocus = layer.captureMissionRosterFocus;
-export const restoreMissionRosterFocus = layer.restoreMissionRosterFocus;
export const resolveMissionRosterPreviewLaunch =
layer.resolveMissionRosterPreviewLaunch;
export const formatMissionEventTime = layer.formatMissionEventTime;
diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs
index b68a11db3..f94273d4e 100644
--- a/src/data/rocketLaunches.test.mjs
+++ b/src/data/rocketLaunches.test.mjs
@@ -7,8 +7,6 @@ import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
approximateOrbitPath,
- bindMissionRosterItemKeyboardPreview,
- captureMissionRosterFocus,
buildMissionPaths,
cameraHeadingForPath,
compactLaunchSiteName,
@@ -46,7 +44,6 @@ import rocketLaunchesLayer, {
replayStartAfterPause,
replayVehicleScreenRotation,
resolveMissionRosterPreviewLaunch,
- restoreMissionRosterFocus,
releaseAircraftTracking,
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
@@ -514,42 +511,6 @@ test('keyboard focus previews roster missions and clears without selecting them'
]);
});
-test('rendered mission row focus and blur drive the preview ownership controller', () => {
- const listeners = new Map();
- const calls = [];
- const button = {
- addEventListener(type, listener) {
- listeners.set(type, listener);
- },
- };
- const nextButton = {
- dataset: { missionRosterIndex: '4' },
- closest: () => nextButton,
- };
- const roster = { contains: (candidate) => candidate === nextButton };
- const ownership = {
- pointerEnter: (index) => calls.push(['pointer-enter', index]),
- pointerLeave: () => calls.push(['pointer-leave']),
- focus: (index) => calls.push(['focus', index]),
- blur: (index) => calls.push(['blur', index]),
- };
-
- bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
- listeners.get('mouseenter')();
- listeners.get('mouseleave')();
- listeners.get('focus')();
- listeners.get('blur')({ relatedTarget: nextButton });
- listeners.get('blur')({ relatedTarget: null });
-
- assert.deepEqual(calls, [
- ['pointer-enter', 2],
- ['pointer-leave'],
- ['focus', 2],
- ['blur', 4],
- ['blur', null],
- ]);
-});
-
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
const calls = [];
const ownership = createMissionRosterPreviewOwnership({
@@ -568,50 +529,6 @@ test('leaving a hovered row for an internal roster gap restores keyboard preview
]);
});
-test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
- const focused = {
- dataset: { missionRosterId: 'mission-b' },
- closest: () => focused,
- };
- let restored = false;
- const replacement = {
- dataset: { missionRosterId: 'mission-b' },
- focus: ({ preventScroll }) => { restored = preventScroll; },
- };
- const list = {
- contains: (candidate) => candidate === focused,
- querySelectorAll: () => [
- { dataset: { missionRosterId: 'mission-c' } },
- replacement,
- { dataset: { missionRosterId: 'mission-a' } },
- ],
- };
-
- const snapshot = captureMissionRosterFocus(list, focused);
- assert.deepEqual(snapshot, { launchId: 'mission-b' });
- assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
- assert.equal(restored, true);
-});
-
-test('mission roster refresh continues after the list when the focused mission departed', () => {
- const focused = {
- dataset: { missionRosterId: 'departed' },
- closest: () => focused,
- };
- let continuationFocused = false;
- const list = {
- contains: (candidate) => candidate === focused,
- querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
- };
- const continuation = {
- focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
- };
-
- const snapshot = captureMissionRosterFocus(list, focused);
- assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
- assert.equal(continuationFocused, true);
-});
-
test('pending mission preview resolves the current refreshed record by identity', () => {
const stale = { id: 'mission-a', name: 'Stale' };
const current = { id: 'mission-a', name: 'Current' };
diff --git a/src/layers/launches/index.js b/src/layers/launches/index.js
index 8df45903e..320dbab1b 100644
--- a/src/layers/launches/index.js
+++ b/src/layers/launches/index.js
@@ -42,7 +42,9 @@ export function createRocketLaunchesLayer({ services, source }) {
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
+ parts.panel.commands,
{
+ subscribePanel: parts.panel.subscribePanel,
satelliteParamsForSpaceMissions:
parts.policyHelpers.satelliteParamsForSpaceMissions,
satelliteParamsAfterSpaceMissions:
@@ -69,10 +71,6 @@ export function createRocketLaunchesLayer({ services, source }) {
missionHoverPreviewRange: parts.panel.missionHoverPreviewRange,
createMissionRosterPreviewOwnership:
parts.panel.createMissionRosterPreviewOwnership,
- bindMissionRosterItemKeyboardPreview:
- parts.panel.bindMissionRosterItemKeyboardPreview,
- captureMissionRosterFocus: parts.panel.captureMissionRosterFocus,
- restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch:
parts.panel.resolveMissionRosterPreviewLaunch,
formatMissionEventTime: parts.policyHelpers.formatMissionEventTime,
diff --git a/src/layers/launches/ingestion.test.mjs b/src/layers/launches/ingestion.test.mjs
index c19cb40e4..bc1a526d6 100644
--- a/src/layers/launches/ingestion.test.mjs
+++ b/src/layers/launches/ingestion.test.mjs
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
renderMissionRoster: noop,
renderMissionPanel: noop,
clearMissionRosterHover: noop,
+ clearMissionPanel: noop,
},
replay: { stopMissionReplay: noop },
selection: {
diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js
index 6ac777da1..53401e08e 100644
--- a/src/layers/launches/lifecycle.js
+++ b/src/layers/launches/lifecycle.js
@@ -262,13 +262,7 @@ export function createLifecycle({
layerState._animationStarts.clear();
layerState._satelliteTelemetry.clear();
layerState._replayTracks.clear();
- layerState._missionPanel?.remove();
- layerState._missionPanel = null;
- if (layerState._missionRoster) {
- layerState._missionRoster.onclick = null;
- }
- layerState._missionRosterPreviewOwnership = null;
- layerState._missionRoster = null;
+ parts.panel.clearMissionPanel();
layerState._viewer = null;
if (layerState._dataSource)
viewer.dataSources.remove(layerState._dataSource, true);
diff --git a/src/layers/launches/missionStatus.i18n.test.mjs b/src/layers/launches/missionStatus.i18n.test.mjs
index 45b03ec4a..8826d0d36 100644
--- a/src/layers/launches/missionStatus.i18n.test.mjs
+++ b/src/layers/launches/missionStatus.i18n.test.mjs
@@ -13,20 +13,7 @@ import { createPanel } from './panel.js';
const NOW = new Date('2026-07-27T00:00:00Z');
function shownStatus(status) {
- const outputs = new Map();
const state = {
- _missionPanel: {
- querySelector(selector) {
- if (!outputs.has(selector))
- outputs.set(selector, {
- textContent: '',
- closest: () => null,
- removeAttribute() {},
- });
- return outputs.get(selector);
- },
- closest: () => null,
- },
_selectedLaunchId: 'mission-1',
_launches: [
{
@@ -39,15 +26,14 @@ function shownStatus(status) {
],
_replayTracks: new Map(),
};
- createPanel({
+ return createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
- replay: { syncReplayButton() {} },
+ replay: { presentReplay: () => ({}) },
},
- }).renderMissionPanel();
- return outputs.get('[data-mission-status]').textContent;
+ }).presentMission().status;
}
test('a launch without a status keeps the English fallback in its record', () => {
diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js
index f2e237b72..88f4dfdbb 100644
--- a/src/layers/launches/panel.js
+++ b/src/layers/launches/panel.js
@@ -88,68 +88,6 @@ export function createPanel({ state: layerState, services, parts, source }) {
};
}
- /**
- * Bind native keyboard focus events for one rendered roster row.
- * @param {Element} button Mission roster button.
- * @param {number} index Source-array mission index.
- * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
- * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
- * @param {Element} roster Roster root used to reject focus targets outside the list.
- */
-
- function bindMissionRosterItemKeyboardPreview(
- button,
- index,
- ownership,
- roster,
- ) {
- button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
- button.addEventListener('mouseleave', () => ownership.pointerLeave());
- button.addEventListener('focus', () => ownership.focus(index));
- button.addEventListener('blur', (event) => {
- const nextButton = event.relatedTarget?.closest?.(
- '[data-mission-roster-index]',
- );
- const nextIndex =
- nextButton && roster.contains(nextButton)
- ? Number(nextButton.dataset.missionRosterIndex)
- : null;
- ownership.blur(nextIndex);
- });
- }
-
- /** Capture the exact mission identity owned by keyboard focus before a refresh. */
-
- function captureMissionRosterFocus(
- list,
- activeElement = globalThis.document?.activeElement,
- ) {
- const button = activeElement?.closest?.('[data-mission-roster-id]');
- if (!button || !list?.contains(button)) return null;
- const launchId = button.dataset.missionRosterId;
- return launchId ? { launchId } : null;
- }
-
- /** Restore focus by mission identity, or continue after the list if it departed. */
-
- function restoreMissionRosterFocus(list, snapshot, continuation) {
- if (!snapshot?.launchId) return 'none';
- const button = Array.from(
- list?.querySelectorAll?.('[data-mission-roster-id]') || [],
- ).find(
- (candidate) => candidate.dataset.missionRosterId === snapshot.launchId,
- );
- if (button) {
- button.focus({ preventScroll: true });
- return 'restored';
- }
- if (continuation?.focus) {
- continuation.focus({ preventScroll: true });
- return 'continued';
- }
- return 'none';
- }
-
/** Resolve a pending preview against the current refresh, never a stale object. */
function resolveMissionRosterPreviewLaunch(launches, launchId) {
@@ -158,213 +96,213 @@ export function createPanel({ state: layerState, services, parts, source }) {
);
}
- function missionTableRows(items, columns, emptyText) {
- if (!items.length)
- return `
| ${emptyText} |
`;
- return items.map((item) => item).join('');
+ /**
+ * Publish a fresh view unless it is the one already shown. The roster
+ * carries its own signature, so a telemetry update compares the mission.
+ */
+ function publish(view) {
+ const signature = `${layerState._missionRosterSignature}|${JSON.stringify(view.mission)}`;
+ if (layerState._missionPanelSignature === signature) return;
+ layerState._missionPanelSignature = signature;
+ layerState._missionPanelView = view;
+ for (const listener of [...layerState._missionPanelListeners])
+ listener(view);
}
- function setMissionPanelField(selector, value, title = '') {
- const output = layerState._missionPanel?.querySelector(selector);
- if (!output) return;
- const row = output.closest('[data-mission-field]');
- const available =
- value !== null && value !== undefined && String(value).trim() !== '';
- if (row) row.hidden = !available;
- if (!available) {
- output.textContent = '';
- output.removeAttribute('title');
- return;
- }
- output.textContent = value;
- if (title) output.title = title;
- else output.removeAttribute('title');
+ /**
+ * Receive every published view, starting with the current one.
+ * @param {(view: {roster: ?object, mission: ?object}) => void} listener
+ * @returns {() => void} Unsubscribe.
+ */
+ function subscribePanel(listener) {
+ layerState._missionPanelListeners.add(listener);
+ if (layerState._missionPanelView) listener(layerState._missionPanelView);
+ return () => layerState._missionPanelListeners.delete(listener);
}
- function renderMissionPanel() {
- if (!layerState._missionPanel) return;
+ /** Publish the roster and the selected mission as they stand. */
+ function publishPanel() {
+ if (!layerState._missionPanelReady) return;
+ publish({
+ roster: layerState._missionRosterView,
+ mission: presentMission(),
+ });
+ }
+
+ /** A detail value, or null when the panel hides its row. */
+ function presentField(value) {
+ return value !== null && value !== undefined && String(value).trim() !== ''
+ ? String(value)
+ : null;
+ }
+
+ function presentPayload(payload, launch) {
+ const detail = [
+ payload.manufacturer,
+ payload.operator && payload.operator !== payload.manufacturer
+ ? payload.operator
+ : null,
+ Number.isFinite(payload.massKg)
+ ? `${payload.massKg.toLocaleString()} KG`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+ return {
+ name: `${payload.name ?? ''}${payload.amount > 1 ? ` ×${payload.amount}` : ''}`,
+ detail,
+ type: payload.type || t('UNSPECIFIED'),
+ destination:
+ payload.destination || launch.orbit?.name || t('UNAVAILABLE'),
+ };
+ }
+
+ function presentStage(stage, launch) {
+ const endpoint = stage.endpoint;
+ const destination =
+ stage.destination ||
+ (endpoint?.accuracy === 'PAD / RTLS'
+ ? launch.launchSite
+ : t('UNAVAILABLE'));
+ const position = endpoint
+ ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
+ : stage.downrangeKm > 0
+ ? t('{value} KM DOWNRANGE', {
+ value: stage.downrangeKm.toLocaleString(),
+ })
+ : t('POSITION UNAVAILABLE');
+ const detail = [
+ Number.isFinite(stage.flightNumber)
+ ? t('FLIGHT {count}', {
+ count: stage.flightNumber,
+ })
+ : null,
+ stage.reused ? t('REUSED') : null,
+ stage.recoveryType,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+ return {
+ name: String(stage.name ?? ''),
+ detail,
+ status: String(stage.status ?? ''),
+ destination: String(destination ?? ''),
+ position,
+ };
+ }
+
+ /**
+ * The selected mission as the panel shows it, or null when none is
+ * selected and the panel hides. Text arrives in the UI language.
+ */
+ function presentMission() {
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
- const index = launch ? layerState._launches.indexOf(launch) : -1;
- layerState._missionPanel.hidden = !launch;
- if (!launch) return;
- layerState._missionPanel.querySelector('[data-mission-title]').textContent =
- parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
- setMissionPanelField('[data-mission-provider]', launch.provider);
- // Launch Library status names are provider data, shown as sent; only the
- // fallback for a launch without one is ours to translate.
- setMissionPanelField(
- '[data-mission-status]',
- launch.status === UNKNOWN_LAUNCH_STATUS
- ? t(UNKNOWN_LAUNCH_STATUS)
- : launch.status,
- );
- setMissionPanelField(
- '[data-mission-site]',
- launch.launchSite && launch.launchSite !== 'Unknown launch site'
- ? launch.launchSite
- : null,
- );
- setMissionPanelField('[data-mission-time]', launch.launchTime);
+ if (!launch) return null;
+ const index = layerState._launches.indexOf(launch);
const pathPresentation = parts.policyHelpers.missionPathPresentation(
launch,
layerState._replayTracks.has(launch.id),
);
- setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit);
- layerState._missionPanel.querySelector(
- '[data-mission-ascent-source]',
- ).textContent = pathPresentation.ascent;
- const payloadRows = launch.payloads.length
- ? launch.payloads.slice(0, 5).map((payload) => {
- const detail = [
- payload.manufacturer,
- payload.operator && payload.operator !== payload.manufacturer
- ? payload.operator
- : null,
- Number.isFinite(payload.massKg)
- ? `${payload.massKg.toLocaleString()} KG`
- : null,
- ]
- .filter(Boolean)
- .join(' · ');
- return `| ${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `${escapeMissionText(detail)}` : ''} | ${escapeMissionText(payload.type || t('UNSPECIFIED'))} | ${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))} |
`;
- })
- : [];
- if (launch.payloads.length > 5) {
- payloadRows.push(
- `| ${t('+{count} additional payload records', { count: launch.payloads.length - 5 })} |
`,
- );
- }
- layerState._missionPanel.querySelector(
- '[data-mission-payloads]',
- ).innerHTML = missionTableRows(
- payloadRows,
- 3,
- t('PAYLOAD DATA UNAVAILABLE'),
- );
- const stageRows = launch.recoveryStages.map((stage) => {
- const endpoint = stage.endpoint;
- const destination =
- stage.destination ||
- (endpoint?.accuracy === 'PAD / RTLS'
+ const telemetry = layerState._missionTelemetry;
+ return {
+ scrollRequest: layerState._missionPanelScrollRequest,
+ title: parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(),
+ provider: presentField(launch.provider),
+ // Launch Library status names are provider data, shown as sent; only
+ // the fallback for a launch without one is ours to translate.
+ status: presentField(
+ launch.status === UNKNOWN_LAUNCH_STATUS
+ ? t(UNKNOWN_LAUNCH_STATUS)
+ : launch.status,
+ ),
+ site: presentField(
+ launch.launchSite && launch.launchSite !== 'Unknown launch site'
? launch.launchSite
- : t('UNAVAILABLE'));
- const position = endpoint
- ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
- : stage.downrangeKm > 0
- ? t('{value} KM DOWNRANGE', {
- value: stage.downrangeKm.toLocaleString(),
- })
- : t('POSITION UNAVAILABLE');
- const stageDetail = [
- Number.isFinite(stage.flightNumber)
- ? t('FLIGHT {count}', {
- count: stage.flightNumber,
+ : null,
+ ),
+ time: presentField(launch.launchTime),
+ orbit: presentField(pathPresentation.orbit),
+ ascent: pathPresentation.ascent ?? '',
+ distance: telemetry?.distance ?? null,
+ speed: telemetry?.speed ?? null,
+ speedTitle: telemetry?.speedTitle ?? null,
+ payloads: launch.payloads
+ .slice(0, 5)
+ .map((payload) => presentPayload(payload, launch)),
+ payloadsMore:
+ launch.payloads.length > 5
+ ? t('+{count} additional payload records', {
+ count: launch.payloads.length - 5,
})
: null,
- stage.reused ? t('REUSED') : null,
- stage.recoveryType,
- ]
- .filter(Boolean)
- .join(' · ');
- return `| ${escapeMissionText(stage.name)}${stageDetail ? `${escapeMissionText(stageDetail)}` : ''} | ${escapeMissionText(stage.status)} | ${escapeMissionText(destination)}${escapeMissionText(position)} |
`;
- });
- layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
- missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
- const stageSection = layerState._missionPanel.querySelector(
- '[data-mission-stages-section]',
- );
- if (stageSection) stageSection.hidden = stageRows.length === 0;
- updateMissionTelemetry(true);
- layerState._missionPanel.querySelector('[data-mission-index]').textContent =
- `${index + 1} / ${layerState._launches.length}`;
- layerState._missionPanel.querySelector('[data-mission-prev]').disabled =
- index <= 0;
- layerState._missionPanel.querySelector('[data-mission-next]').disabled =
- index < 0 || index >= layerState._launches.length - 1;
- parts.replay.syncReplayButton();
- const panelScroller = layerState._missionPanel.closest(
- ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
- );
- if (panelScroller) panelScroller.scrollTop = 0;
+ stages: launch.recoveryStages.map((stage) => presentStage(stage, launch)),
+ index: `${index + 1} / ${layerState._launches.length}`,
+ previousDisabled: index <= 0,
+ nextDisabled: index < 0 || index >= layerState._launches.length - 1,
+ replay: parts.replay.presentReplay(),
+ };
}
- function renderMissionRoster() {
- if (!layerState._missionRoster) return;
- const list = layerState._missionRoster.querySelector(
- '[data-mission-roster-list]',
- );
- const count = layerState._missionRoster.querySelector(
- '[data-mission-roster-count]',
- );
- if (count)
- count.textContent = t('{count} / 30D', {
- count: layerState._launches.length,
- });
- if (!list) return;
- const focusSnapshot = captureMissionRosterFocus(list);
- layerState._missionRosterPreviewOwnership?.reset();
- const entries = parts.policyHelpers.missionRosterEntries(
- layerState._launches,
- );
- if (!entries.length) {
- list.innerHTML = `${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}
`;
- restoreMissionRosterFocus(
- list,
- focusSnapshot,
- layerState._missionRoster.querySelector(
- '[data-mission-roster-focus-continuation]',
- ),
- );
- return;
- }
- list.innerHTML = entries
+ /** Present the newly selected mission and return its panel to the top. */
+ function renderMissionPanel() {
+ if (!layerState._missionPanelReady) return;
+ layerState._missionTelemetry = null;
+ updateMissionTelemetry(true, false);
+ layerState._missionPanelScrollRequest++;
+ publishPanel();
+ }
+
+ /** The roster entries as the list shows them. */
+ function presentRoster() {
+ const entries = parts.policyHelpers
+ .missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
- const color = parts.model.missionMarkerColor(launch).toCssColorString();
- const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
- const provider = launch.provider || t('UNSPECIFIED OPERATOR');
const label = parts.overlays
.shortMissionLabel(launch.name, 27)
.toUpperCase();
- return ``;
- })
- .join('');
- list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
- const index = Number(button.dataset.missionRosterIndex);
- bindMissionRosterItemKeyboardPreview(
- button,
- index,
- layerState._missionRosterPreviewOwnership,
- layerState._missionRoster,
- );
- });
- restoreMissionRosterFocus(
- list,
- focusSnapshot,
- layerState._missionRoster.querySelector(
- '[data-mission-roster-focus-continuation]',
- ),
- );
+ return {
+ index,
+ id: String(launch.id ?? ''),
+ color: parts.model.missionMarkerColor(launch).toCssColorString(),
+ label,
+ ariaLabel: t('Select {mission}', { mission: label }),
+ provider: launch.provider || t('UNSPECIFIED OPERATOR'),
+ date: launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE'),
+ };
+ });
+ return {
+ count: t('{count} / 30D', { count: layerState._launches.length }),
+ entries,
+ empty: t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'),
+ };
}
- function escapeMissionText(value) {
- return String(value ?? '').replace(
- /[&<>"']/g,
- (character) =>
- ({
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": ''',
- })[character],
- );
+ /**
+ * Present the roster. A changed roster drops the hover and keyboard
+ * preview; the list restores keyboard focus by mission identity, which
+ * resumes the preview.
+ */
+ function renderMissionRoster() {
+ if (!layerState._missionPanelReady) return;
+ const roster = presentRoster();
+ const signature = JSON.stringify(roster);
+ if (signature !== layerState._missionRosterSignature) {
+ layerState._missionRosterSignature = signature;
+ layerState._missionRosterView = roster;
+ layerState._missionRosterPreviewOwnership?.reset();
+ }
+ publishPanel();
}
- function updateMissionTelemetry(force = false) {
+ /**
+ * Read the selected mission's altitude and speed, at most four times a
+ * second unless forced, and republish when they change.
+ */
+ function updateMissionTelemetry(force = false, republish = true) {
if (
- !layerState._missionPanel ||
+ !layerState._missionPanelReady ||
!layerState._selectedLaunchId ||
!layerState._dataSource
)
@@ -381,22 +319,18 @@ export function createPanel({ state: layerState, services, parts, source }) {
const altitudeM = position
? Cesium.Cartographic.fromCartesian(position)?.height
: null;
- setMissionPanelField(
- '[data-mission-distance]',
- Number.isFinite(altitudeM)
+ const speedMps = layerState._satelliteTelemetry.get(
+ layerState._selectedLaunchId,
+ )?.speedMps;
+ layerState._missionTelemetry = {
+ distance: Number.isFinite(altitudeM)
? t('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
maximumFractionDigits: 0,
}),
})
: null,
- );
- const speedMps = layerState._satelliteTelemetry.get(
- layerState._selectedLaunchId,
- )?.speedMps;
- setMissionPanelField(
- '[data-mission-speed]',
- Number.isFinite(speedMps)
+ speed: Number.isFinite(speedMps)
? t('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, {
minimumFractionDigits: 2,
@@ -404,10 +338,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
}),
})
: null,
- Number.isFinite(speedMps)
+ speedTitle: Number.isFinite(speedMps)
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
- : '',
- );
+ : null,
+ };
+ if (republish) publishPanel();
}
function selectMissionAt(index) {
@@ -451,121 +386,86 @@ export function createPanel({ state: layerState, services, parts, source }) {
}, 140);
}
- function createMissionPanel() {
- if (layerState._missionPanel || typeof document === 'undefined') return;
- const host =
- document.getElementById('space-mission-panel-host') ||
- document.getElementById('right-context-rail');
- if (!host) return;
- layerState._missionRoster = document.getElementById('space-mission-roster');
- if (layerState._missionRoster) {
- layerState._missionRosterPreviewOwnership =
- createMissionRosterPreviewOwnership({
- preview: scheduleMissionRosterPreview,
- clear: clearMissionRosterPreviewState,
- });
- layerState._missionRoster.onclick = (event) => {
- const button =
- event.target instanceof Element
- ? event.target.closest('[data-mission-roster-index]')
- : null;
- if (!button) return;
- selectMissionAt(Number(button.dataset.missionRosterIndex));
- };
- }
- layerState._missionPanel = document.createElement('aside');
- layerState._missionPanel.id = 'space-mission-panel';
- layerState._missionPanel.className = 'context-space-mission-detail';
- layerState._missionPanel.setAttribute(
- 'aria-label',
- t('Selected Space Mission'),
+ /** Select the mission before or after the selected one. */
+ function selectAdjacentMission(step) {
+ selectMissionAt(
+ layerState._launches.findIndex(
+ (item) => item.id === layerState._selectedLaunchId,
+ ) + step,
);
- layerState._missionPanel.innerHTML = `${t('MISSION')}${t('STATUS · ')}${t('LAUNCH SITE · ')}${t('LAUNCH TIME · ')}${t('ORBIT · ')}${t('ASCENT PATH · ')}${t('CURRENT DISTANCE FROM EARTH · ')}${t('SATELLITE SPEED · ')}
${t('STAGE / RE-ENTRY / RECOVERY')}
—
`;
- layerState._missionPanel
- .querySelector('.mission-action-row')
- .insertAdjacentHTML(
- 'beforeend',
- `
-
-
-
`,
- );
- host.appendChild(layerState._missionPanel);
- layerState._missionPanel
- .querySelector('[data-mission-prev]')
- .addEventListener('click', () =>
- selectMissionAt(
- layerState._launches.findIndex(
- (item) => item.id === layerState._selectedLaunchId,
- ) - 1,
- ),
- );
- layerState._missionPanel
- .querySelector('[data-mission-next]')
- .addEventListener('click', () =>
- selectMissionAt(
- layerState._launches.findIndex(
- (item) => item.id === layerState._selectedLaunchId,
- ) + 1,
- ),
- );
- layerState._missionPanel
- .querySelector('[data-mission-close]')
- .addEventListener('click', () =>
- parts.selection.setSelectedMission(null),
- );
- layerState._missionPanel
- .querySelector('[data-mission-show-all]')
- .addEventListener('click', () =>
- parts.selection.setSelectedMission(null),
- );
- layerState._missionPanel
- .querySelector('[data-mission-focus]')
- .addEventListener('click', () => {
- const launch = layerState._launches.find(
- (item) => item.id === layerState._selectedLaunchId,
- );
- if (launch) parts.selection.focusLaunchSite(launch);
- });
- layerState._missionPanel
- .querySelector('[data-mission-replay]')
- .addEventListener('click', () => {
- if (layerState._selectedLaunchId)
- parts.replay.startMissionReplay(layerState._selectedLaunchId);
- });
- layerState._missionPanel
- .querySelector('[data-mission-replay-toggle]')
- .addEventListener('click', () => {
- if (layerState._replayPaused) parts.replay.resumeMissionReplay();
- else parts.replay.pauseMissionReplay();
- });
- layerState._missionPanel
- .querySelector('[data-mission-replay-cancel]')
- .addEventListener('click', parts.replay.stopMissionReplay);
- layerState._missionPanel
- .querySelector('[data-mission-replay-speed]')
- .addEventListener('input', (event) => {
- parts.replay.setReplaySpeed(event.currentTarget.value);
- });
- parts.replay.syncReplaySpeedControl();
}
+
+ /** The panel's commands, called by Core's mission components. */
+ const commands = {
+ selectMissionAt,
+ selectPreviousMission: () => selectAdjacentMission(-1),
+ selectNextMission: () => selectAdjacentMission(1),
+ deselectMission: () => parts.selection.setSelectedMission(null),
+ focusSelectedLaunchSite() {
+ const launch = layerState._launches.find(
+ (item) => item.id === layerState._selectedLaunchId,
+ );
+ if (launch) parts.selection.focusLaunchSite(launch);
+ },
+ startSelectedReplay() {
+ if (layerState._selectedLaunchId)
+ parts.replay.startMissionReplay(layerState._selectedLaunchId);
+ },
+ toggleReplayPause() {
+ if (layerState._replayPaused) parts.replay.resumeMissionReplay();
+ else parts.replay.pauseMissionReplay();
+ },
+ cancelReplay: () => parts.replay.stopMissionReplay(),
+ setReplaySpeed: (value) => parts.replay.setReplaySpeed(value),
+ rosterPointerEnter: (index) =>
+ layerState._missionRosterPreviewOwnership?.pointerEnter(index),
+ rosterPointerLeave: () =>
+ layerState._missionRosterPreviewOwnership?.pointerLeave(),
+ rosterFocus: (index) =>
+ layerState._missionRosterPreviewOwnership?.focus(index),
+ rosterBlur: (nextIndex) =>
+ layerState._missionRosterPreviewOwnership?.blur(nextIndex),
+ };
+
+ /** Start presenting the roster and the selected mission. */
+ function createMissionPanel() {
+ if (layerState._missionPanelReady) return;
+ layerState._missionPanelReady = true;
+ layerState._missionRosterPreviewOwnership =
+ createMissionRosterPreviewOwnership({
+ preview: scheduleMissionRosterPreview,
+ clear: clearMissionRosterPreviewState,
+ });
+ publishPanel();
+ }
+
+ /** Stop presenting; the components return to their initial markup. */
+ function clearMissionPanel() {
+ layerState._missionPanelReady = false;
+ layerState._missionRosterPreviewOwnership = null;
+ layerState._missionRosterView = null;
+ layerState._missionRosterSignature = null;
+ layerState._missionTelemetry = null;
+ layerState._replayTransportLabel = null;
+ publish({ roster: null, mission: null });
+ }
+
return {
missionHoverPreviewRange,
createMissionRosterPreviewOwnership,
- bindMissionRosterItemKeyboardPreview,
- captureMissionRosterFocus,
- restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch,
- missionTableRows,
- setMissionPanelField,
+ subscribePanel,
+ publishPanel,
+ presentMission,
renderMissionPanel,
renderMissionRoster,
- escapeMissionText,
updateMissionTelemetry,
selectMissionAt,
clearMissionRosterPreviewState,
clearMissionRosterHover,
scheduleMissionRosterPreview,
createMissionPanel,
+ clearMissionPanel,
+ commands,
};
}
diff --git a/src/layers/launches/panel.test.mjs b/src/layers/launches/panel.test.mjs
new file mode 100644
index 000000000..1842a6c17
--- /dev/null
+++ b/src/layers/launches/panel.test.mjs
@@ -0,0 +1,196 @@
+// The launches layer presents its roster and selected mission as plain views
+// and publishes them to subscribers; Core's mission components render them.
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createPanel } from './panel.js';
+import { createState } from './state.js';
+
+function fixture() {
+ const state = createState({
+ services: { overlays: {}, render: {} },
+ });
+ const calls = [];
+ const parts = {
+ overlays: {
+ shortMissionLabel: (name) => name,
+ syncMissionOverlayEntries: () => calls.push(['overlays']),
+ },
+ policyHelpers: {
+ missionRosterEntries: (launches) =>
+ launches.map((launch, index) => ({ launch, index })),
+ missionPathPresentation: () => ({ orbit: 'LEO', ascent: 'ESTIMATED' }),
+ previewMissionFromRoster: (launch) => calls.push(['preview', launch.id]),
+ },
+ model: {
+ missionMarkerColor: () => ({ toCssColorString: () => 'rgb(1, 2, 3)' }),
+ },
+ selection: {
+ setSelectedMission: (id, isolate) => calls.push(['select', id, isolate]),
+ focusMission: (launch) => calls.push(['focus-mission', launch.id]),
+ focusLaunchSite: (launch) => calls.push(['focus-site', launch.id]),
+ },
+ replay: {
+ presentReplay: () => ({ available: false }),
+ startMissionReplay: (id) => calls.push(['replay', id]),
+ stopMissionReplay: () => calls.push(['cancel']),
+ pauseMissionReplay: () => calls.push(['pause']),
+ resumeMissionReplay: () => calls.push(['resume']),
+ setReplaySpeed: (value) => calls.push(['speed', value]),
+ },
+ };
+ const panel = createPanel({ state, parts });
+ const views = [];
+ panel.subscribePanel((view) => views.push(view));
+ return { state, panel, views, calls };
+}
+
+const launch = (id, extra = {}) => ({
+ id,
+ name: `Mission ${id}`,
+ provider: 'SpaceX',
+ status: 'Go',
+ launchSite: 'Unknown launch site',
+ launchTime: '2026-11-01T10:00:00Z',
+ payloads: [],
+ recoveryStages: [],
+ ...extra,
+});
+
+test('the panel publishes only once the layer starts presenting', () => {
+ const { state, panel, views } = fixture();
+ state._launches = [launch('a')];
+ panel.renderMissionRoster();
+ assert.equal(views.length, 0);
+ panel.createMissionPanel();
+ assert.deepEqual(views, [{ roster: null, mission: null }]);
+ panel.renderMissionRoster();
+ const { roster } = views.at(-1);
+ assert.equal(roster.count, '1 / 30D');
+ assert.deepEqual(roster.entries, [
+ {
+ index: 0,
+ id: 'a',
+ color: 'rgb(1, 2, 3)',
+ label: 'MISSION A',
+ ariaLabel: 'Select MISSION A',
+ provider: 'SpaceX',
+ date: '2026-11-01',
+ },
+ ]);
+ const late = [];
+ panel.subscribePanel((view) => late.push(view));
+ assert.equal(late[0], views.at(-1));
+ panel.clearMissionPanel();
+ assert.deepEqual(views.at(-1), { roster: null, mission: null });
+});
+
+test('an unchanged roster keeps its view and the preview; a changed one drops it', async () => {
+ const { state, panel, views, calls } = fixture();
+ state._launches = [launch('a'), launch('b')];
+ panel.createMissionPanel();
+ panel.renderMissionRoster();
+ const published = views.length;
+ const roster = views.at(-1).roster;
+ panel.commands.rosterFocus(1);
+ assert.equal(state._hoveredRosterLaunchId, 'b');
+ panel.renderMissionRoster();
+ assert.equal(views.length, published);
+ assert.equal(state._hoveredRosterLaunchId, 'b');
+ await new Promise((resolve) => setTimeout(resolve, 160));
+ assert.deepEqual(calls.at(-1), ['preview', 'b']);
+ state._launches = [launch('b'), launch('a')];
+ panel.renderMissionRoster();
+ assert.equal(state._hoveredRosterLaunchId, null);
+ assert.notEqual(views.at(-1).roster, roster);
+ // The list restores focus on the mission, which takes the preview back.
+ panel.commands.rosterFocus(0);
+ assert.equal(state._hoveredRosterLaunchId, 'b');
+ panel.clearMissionPanel();
+});
+
+test('the selected mission is presented with its rows and returns to the top', () => {
+ const { state, panel, views } = fixture();
+ state._launches = [
+ launch('a'),
+ launch('b', {
+ payloads: Array.from({ length: 6 }, (_, index) => ({
+ name: `Sat ${index}`,
+ amount: index === 0 ? 2 : 1,
+ manufacturer: 'Maker',
+ massKg: 500,
+ })),
+ recoveryStages: [
+ {
+ name: 'B1080',
+ status: 'Landed',
+ flightNumber: 9,
+ reused: true,
+ endpoint: { lat: 28.4, lon: -77.1, accuracy: 'DRONESHIP' },
+ },
+ ],
+ }),
+ ];
+ panel.createMissionPanel();
+ state._selectedLaunchId = 'b';
+ panel.renderMissionPanel();
+ const first = views.at(-1).mission;
+ assert.equal(first.title, 'MISSION B');
+ assert.equal(first.site, null);
+ assert.equal(first.orbit, 'LEO');
+ assert.equal(first.index, '2 / 2');
+ assert.equal(first.previousDisabled, false);
+ assert.equal(first.nextDisabled, true);
+ assert.equal(first.payloads.length, 5);
+ assert.deepEqual(first.payloads[0], {
+ name: 'Sat 0 ×2',
+ detail: 'Maker · 500 KG',
+ type: 'UNSPECIFIED',
+ destination: 'UNAVAILABLE',
+ });
+ assert.equal(first.payloadsMore, '+1 additional payload records');
+ assert.deepEqual(first.stages, [
+ {
+ name: 'B1080',
+ detail: 'FLIGHT 9 · REUSED',
+ status: 'Landed',
+ destination: 'UNAVAILABLE',
+ position: '28.40, -77.10 · DRONESHIP',
+ },
+ ]);
+ panel.renderMissionPanel();
+ assert.equal(views.at(-1).mission.scrollRequest, first.scrollRequest + 1);
+ state._selectedLaunchId = null;
+ panel.renderMissionPanel();
+ assert.equal(views.at(-1).mission, null);
+});
+
+test('the panel commands act on the selected mission', () => {
+ const { state, panel, calls } = fixture();
+ state._launches = [launch('a'), launch('b'), launch('c')];
+ state._selectedLaunchId = 'b';
+ const { commands } = panel;
+ commands.selectPreviousMission();
+ commands.selectNextMission();
+ commands.selectMissionAt(9);
+ commands.focusSelectedLaunchSite();
+ commands.startSelectedReplay();
+ commands.toggleReplayPause();
+ state._replayPaused = true;
+ commands.toggleReplayPause();
+ commands.cancelReplay();
+ commands.setReplaySpeed('2');
+ commands.deselectMission();
+ assert.deepEqual(calls, [
+ ['select', 'a', true],
+ ['focus-mission', 'a'],
+ ['select', 'c', true],
+ ['focus-mission', 'c'],
+ ['focus-site', 'b'],
+ ['replay', 'b'],
+ ['pause'],
+ ['resume'],
+ ['cancel'],
+ ['speed', '2'],
+ ['select', null, undefined],
+ ]);
+});
diff --git a/src/layers/launches/panelScroll.test.mjs b/src/layers/launches/panelScroll.test.mjs
deleted file mode 100644
index b43e634e1..000000000
--- a/src/layers/launches/panelScroll.test.mjs
+++ /dev/null
@@ -1,34 +0,0 @@
-import assert from 'node:assert/strict';
-import test from 'node:test';
-import { createPanel } from './panel.js';
-
-test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
- for (const cyber of [true, false]) {
- const body = { scrollTop: 180 };
- const inner = { scrollTop: 90 };
- const output = { closest: () => null, removeAttribute() {} };
- const state = {
- _missionPanel: {
- querySelector: () => output,
- closest(selector) {
- assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
- return cyber ? body : inner;
- },
- },
- _selectedLaunchId: 'mission-1',
- _launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
- _replayTracks: new Map(),
- };
- const panel = createPanel({
- state,
- parts: {
- overlays: { shortMissionLabel: (name) => name },
- policyHelpers: { missionPathPresentation: () => ({}) },
- replay: { syncReplayButton() {} },
- },
- });
- panel.renderMissionPanel();
- assert.equal(body.scrollTop, cyber ? 0 : 180);
- assert.equal(inner.scrollTop, cyber ? 90 : 0);
- }
-});
diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js
index 5354996bd..037eff225 100644
--- a/src/layers/launches/replay.js
+++ b/src/layers/launches/replay.js
@@ -179,60 +179,41 @@ export function createReplay({ state: layerState, services, parts, source }) {
};
}
+ /** Republish the mission panel after a replay state change. */
function syncReplayButton() {
- const button = layerState._missionPanel?.querySelector(
- '[data-mission-replay]',
- );
- const transport = layerState._missionPanel?.querySelector(
- '[data-mission-replay-transport]',
- );
- const speedControl = layerState._missionPanel?.querySelector(
- '.mission-replay-speed-control',
- );
- if (!button) return;
+ parts.panel.publishPanel();
+ }
+
+ /**
+ * The replay controls as the mission panel shows them: the start button,
+ * the transport with its pause toggle, and the speed slider.
+ */
+ function presentReplay() {
const active = Boolean(
layerState._replayCameraLaunchId &&
layerState._replayCameraLaunchId === layerState._selectedLaunchId,
);
- const replayAvailable = Boolean(
+ const available = Boolean(
layerState._selectedLaunchId &&
layerState._replayTracks.has(layerState._selectedLaunchId),
);
- if (speedControl) speedControl.hidden = !replayAvailable;
- button.hidden = active || !replayAvailable;
- button.disabled = !replayAvailable;
- button.textContent = t('REPLAY ASCENT');
- button.classList.remove('active');
- button.setAttribute('aria-pressed', String(active));
- button.title = t('Replay the estimated ascent with a following camera');
- if (transport) {
- transport.hidden = !active;
- transport.classList.toggle(
- 'is-paused',
- active && layerState._replayPaused,
- );
- const toggleButton = transport.querySelector(
- '[data-mission-replay-toggle]',
- );
- if (toggleButton) {
- toggleButton.disabled = !active;
- toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
- toggleButton.setAttribute(
- 'aria-label',
- layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
- );
- toggleButton.title = layerState._replayPaused
- ? t('Resume replay')
- : t('Pause replay');
- }
- }
+ const paused = layerState._replayPaused;
+ const speed = layerState._replaySpeed;
+ return {
+ available,
+ active,
+ paused: active && paused,
+ toggleText: paused ? '▶' : 'Ⅱ',
+ toggleLabel: paused ? t('Resume replay') : t('Pause replay'),
+ transportLabel: layerState._replayTransportLabel,
+ speed: String(speed),
+ speedText: `${speed.toFixed(speed % 1 ? 2 : 0)}×`,
+ speedProgress: `${((speed - REPLAY_SPEED_MIN) / (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * 100}%`,
+ };
}
function syncReplayCountdownButton(state) {
- const transport = layerState._missionPanel?.querySelector(
- '[data-mission-replay-transport]',
- );
- if (!transport || !layerState._replayCameraLaunchId) return;
+ if (!layerState._replayCameraLaunchId) return;
const phase = state.countdownActive
? t('T minus {seconds}', {
seconds: state.countdownSeconds,
@@ -244,29 +225,14 @@ export function createReplay({ state: layerState, services, parts, source }) {
: state.ascending
? t('Ascent replay')
: t('Orbit replay');
- transport.setAttribute(
- 'aria-label',
- `${phase}${layerState._replayPaused ? t(', paused') : ''}`,
- );
+ const label = `${phase}${layerState._replayPaused ? t(', paused') : ''}`;
+ if (label === layerState._replayTransportLabel) return;
+ layerState._replayTransportLabel = label;
+ parts.panel.publishPanel();
}
function syncReplaySpeedControl() {
- const input = layerState._missionPanel?.querySelector(
- '[data-mission-replay-speed]',
- );
- const output = layerState._missionPanel?.querySelector(
- '[data-mission-replay-speed-output]',
- );
- if (input) {
- input.value = String(layerState._replaySpeed);
- const progress =
- ((layerState._replaySpeed - REPLAY_SPEED_MIN) /
- (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) *
- 100;
- input.style.setProperty('--replay-speed-progress', `${progress}%`);
- }
- if (output)
- output.textContent = `${layerState._replaySpeed.toFixed(layerState._replaySpeed % 1 ? 2 : 0)}×`;
+ parts.panel.publishPanel();
}
function setReplaySpeed(value) {
@@ -548,6 +514,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
replayStartAfterPause,
replayState,
syncReplayButton,
+ presentReplay,
syncReplayCountdownButton,
syncReplaySpeedControl,
setReplaySpeed,
diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js
index d70be5b54..98ebebc7f 100644
--- a/src/layers/launches/state.js
+++ b/src/layers/launches/state.js
@@ -53,9 +53,21 @@ export function createState({ services }) {
state._launches = [];
- state._missionPanel = null;
+ state._missionPanelReady = false;
- state._missionRoster = null;
+ state._missionPanelView = null;
+
+ state._missionPanelSignature = null;
+
+ state._missionPanelListeners = new Set();
+
+ state._missionRosterView = null;
+
+ state._missionRosterSignature = null;
+
+ state._missionTelemetry = null;
+
+ state._missionPanelScrollRequest = 0;
state._missionRosterHoverTimer = null;
@@ -99,6 +111,8 @@ export function createState({ services }) {
state._replayPaused = false;
+ state._replayTransportLabel = null;
+
state._replayPausedAtMs = null;
state._replaySpeed = 1;
diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs
index 1c76be59f..e51b2d44d 100644
--- a/src/radioMarkup.test.mjs
+++ b/src/radioMarkup.test.mjs
@@ -3,7 +3,6 @@ import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import { realtimeInstructions } from '../server/providers/openai/instructions.js';
import { readShellSource } from './testSupport/readShellSource.mjs';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
-import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
@@ -19,7 +18,7 @@ const ui = readShellSource();
const radio = ['playback', 'interaction'].map(name =>
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
-const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
+const missionDetail = readFileSync(new URL('./ui/views/panels/MissionDetail.vue', import.meta.url), 'utf8');
const realtime = readRealtimeSource();
const voice = ['./voice/actionSchemas.js', '../server/providers/openai/toolDescriptions.js', '../server/providers/openai/instructions.js'].map(file => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n');
const css = readStylesheet(new URL('../style.css', import.meta.url));
@@ -270,10 +269,10 @@ test('Radio volume and mission speed share the Sharpen slider visual language',
);
}
assert.match(
- rocketLaunches,
+ missionDetail,
/id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0\.25" max="4" step="0\.25" value="1"/,
);
- assert.match(rocketLaunches, /class="gev-slider-value"[^>]*data-mission-replay-speed-output/);
+ assert.match(missionDetail, /class="gev-slider-value"[^>]*data-mission-replay-speed-output/);
assert.match(css, /\.gev-quantitative-slider\s*\{[\s\S]*?min-width: 0;[\s\S]*?height: 18px;/);
assert.match(css, /\.gev-quantitative-slider::-webkit-slider-runnable-track\s*\{[\s\S]*?height: 3px;[\s\S]*?background: rgba\(255, 255, 255, 0\.08\);/);
assert.match(css, /\.gev-quantitative-slider::-webkit-slider-thumb\s*\{[\s\S]*?width: 10px;[\s\S]*?height: 10px;[\s\S]*?border-radius: 50%;[\s\S]*?background: var\(--accent\);/);
@@ -286,12 +285,8 @@ test('Radio volume and mission speed share the Sharpen slider visual language',
test('Radio is nested inside Context with its own disclosure and power controls', () => {
const contextStart = html.indexOf('id="global-context-panel"');
const radioStart = html.indexOf('id="radio-panel"');
- // The Context panel ends with the right rail's own closing tag, after the
- // military awareness aside nested inside it.
- const contextEnd = html.indexOf(
- '',
- html.indexOf('', html.indexOf('id="military-awareness-panel"')) + 1,
- );
+ // The Context view ends with the right rail, before the scene runtime.
+ const contextEnd = html.indexOf('id="scene-runtime"');
assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd);
assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/);
assert.match(html, /aria-label="Radio playback"/);
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index f366e61fc..c79ed3834 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -973,8 +973,12 @@ export class StyleManager extends ShellFacade {
}
_initGlobalContextPanel() {
- const { radioLayer, militaryInstallationsLayer, militaryAwarenessLayer } =
- this.services;
+ const {
+ radioLayer,
+ militaryInstallationsLayer,
+ militaryAwarenessLayer,
+ rocketLaunchesLayer,
+ } = this.services;
const contextStore = useContextStore(applicationPinia());
this._contextControls = new ContextControls({
elements: {
@@ -1008,6 +1012,13 @@ export class StyleManager extends ShellFacade {
this._awarenessPanelUnsubscribe = militaryAwarenessLayer?.subscribePanel?.(
(view) => contextStore.publishAwareness(view),
);
+ // The Space Missions layer publishes its roster and selected mission.
+ contextStore.connectMissions(rocketLaunchesLayer);
+ this._missionsPanelUnsubscribe?.();
+ contextStore.publishMissions(null);
+ this._missionsPanelUnsubscribe = rocketLaunchesLayer?.subscribePanel?.(
+ (view) => contextStore.publishMissions(view),
+ );
}
/** Wire the independent Radio companion controls. */
@@ -1768,7 +1779,13 @@ export class StyleManager extends ShellFacade {
this._localSdrControls?.destroy();
this._awarenessPanelUnsubscribe?.();
this._awarenessPanelUnsubscribe = null;
- useContextStore(applicationPinia()).connectAwareness(null);
+ this._missionsPanelUnsubscribe?.();
+ this._missionsPanelUnsubscribe = null;
+ const contextStore = useContextStore(applicationPinia());
+ contextStore.connectAwareness(null);
+ contextStore.publishAwareness(null);
+ contextStore.connectMissions(null);
+ contextStore.publishMissions(null);
this._cockpitCoordinator.stop();
this._visualSettings.stop();
this.shareLinkManager?.destroy();
diff --git a/src/ui/awarenessPanel.test.mjs b/src/ui/awarenessPanel.test.mjs
index f1de3dd70..101711c66 100644
--- a/src/ui/awarenessPanel.test.mjs
+++ b/src/ui/awarenessPanel.test.mjs
@@ -77,7 +77,11 @@ test('before the layer publishes, the panel shows its initial markup', (t) => {
standby.querySelector('strong').textContent,
'CONTACTS CONTEXT OFF',
);
- assert.equal(standby.querySelector('strong').hasAttribute('data-i18n'), true);
+ // Rendered through t(), so a later null view is translated too.
+ assert.equal(
+ standby.querySelector('strong').hasAttribute('data-i18n'),
+ false,
+ );
});
test('a standby view replaces the initial copy', async (t) => {
diff --git a/src/ui/missionPanels.test.mjs b/src/ui/missionPanels.test.mjs
new file mode 100644
index 000000000..ed1bcb594
--- /dev/null
+++ b/src/ui/missionPanels.test.mjs
@@ -0,0 +1,378 @@
+// src/ui/missionPanels.test.mjs — the Space Missions roster and selected
+// mission render what the launches layer publishes (`subscribePanel()`),
+// over happy-dom.
+import '../testSupport/registerDom.mjs';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createPinia } from 'pinia';
+import { createApp, h, nextTick } from 'vue';
+import { useContextStore } from './stores/context.js';
+import MissionDetail from './views/panels/MissionDetail.vue';
+import MissionRoster from './views/panels/MissionRoster.vue';
+
+function fixture(t) {
+ document.body.replaceChildren();
+ const scroller = document.body.appendChild(document.createElement('div'));
+ scroller.className = 'global-context-panel-inner';
+ const host = scroller.appendChild(document.createElement('div'));
+ const pinia = createPinia();
+ const app = createApp({
+ render: () => [h(MissionRoster), h(MissionDetail)],
+ }).use(pinia);
+ app.mount(host);
+ t.after(() => app.unmount());
+ const store = useContextStore(pinia);
+ const calls = [];
+ const record =
+ (name) =>
+ (...args) =>
+ calls.push([name, ...args]);
+ store.connectMissions({
+ selectMissionAt: record('select'),
+ selectPreviousMission: record('previous'),
+ selectNextMission: record('next'),
+ deselectMission: record('deselect'),
+ focusSelectedLaunchSite: record('focus'),
+ startSelectedReplay: record('replay'),
+ toggleReplayPause: record('toggle'),
+ cancelReplay: record('cancel'),
+ setReplaySpeed: record('speed'),
+ rosterPointerEnter: record('pointer-enter'),
+ rosterPointerLeave: record('pointer-leave'),
+ rosterFocus: record('focus-row'),
+ rosterBlur: record('blur-row'),
+ });
+ const $ = (selector) => document.querySelector(selector);
+ const publish = async (view) => {
+ store.publishMissions(view);
+ await nextTick();
+ await nextTick();
+ };
+ return { $, publish, calls, scroller };
+}
+
+const entry = (id, index, label = id.toUpperCase()) => ({
+ index,
+ id,
+ color: 'rgb(1, 2, 3)',
+ label,
+ ariaLabel: `Select ${label}`,
+ provider: 'SpaceX',
+ date: '2026-11-01',
+});
+
+const roster = (entries) => ({
+ count: `${entries.length} / 30D`,
+ entries,
+ empty: 'NONE IN WINDOW',
+});
+
+const replay = (extra = {}) => ({
+ available: true,
+ active: false,
+ paused: false,
+ toggleText: 'Ⅱ',
+ toggleLabel: 'Pause replay',
+ transportLabel: null,
+ speed: '1',
+ speedText: '1×',
+ speedProgress: '20%',
+ ...extra,
+});
+
+const mission = (extra = {}) => ({
+ scrollRequest: 1,
+ title: 'STARLINK 12-3',
+ provider: 'SpaceX',
+ status: 'Go for Launch',
+ site: null,
+ time: '2026-11-01T10:00:00Z',
+ orbit: 'LEO',
+ ascent: 'ESTIMATED',
+ distance: '410 KM',
+ speed: '7.66 KM/S',
+ speedTitle: '27,576 km/h',
+ payloads: [],
+ payloadsMore: null,
+ stages: [],
+ index: '2 / 5',
+ previousDisabled: false,
+ nextDisabled: true,
+ replay: replay(),
+ ...extra,
+});
+
+test('before the layer presents, the roster loads and no mission shows', (t) => {
+ const { $ } = fixture(t);
+ assert.equal($('[data-mission-roster-count]').textContent, '—');
+ assert.equal(
+ $('.space-mission-roster-empty').textContent,
+ 'LOADING 30-DAY MISSION INDEX',
+ );
+ assert.equal($('#space-mission-panel').hidden, true);
+ assert.equal($('[data-mission-replay-speed-output]').textContent, '1×');
+ assert.equal($('[data-mission-replay-speed]').getAttribute('value'), '1');
+});
+
+test('the roster lists missions and drives selection and preview', async (t) => {
+ const { $, publish, calls } = fixture(t);
+ await publish({
+ roster: roster([entry('a', 0), entry('b', 3)]),
+ mission: null,
+ });
+ assert.equal($('[data-mission-roster-count]').textContent, '2 / 30D');
+ const [a, b] = document.querySelectorAll('.space-mission-roster-item');
+ assert.equal(b.dataset.missionRosterIndex, '3');
+ assert.equal(b.dataset.missionRosterId, 'b');
+ assert.equal(b.getAttribute('aria-label'), 'Select B');
+ assert.equal(b.querySelector('small').textContent, 'SpaceX · 2026-11-01');
+ assert.equal(
+ b
+ .querySelector('.space-mission-roster-marker')
+ .style.getPropertyValue('--mission-roster-color'),
+ 'rgb(1, 2, 3)',
+ );
+ b.click();
+ b.dispatchEvent(new MouseEvent('mouseenter'));
+ b.dispatchEvent(new MouseEvent('mouseleave'));
+ a.dispatchEvent(new FocusEvent('blur', { relatedTarget: b }));
+ a.dispatchEvent(new FocusEvent('blur', { relatedTarget: null }));
+ assert.deepEqual(calls, [
+ ['select', 3],
+ ['pointer-enter', 3],
+ ['pointer-leave'],
+ ['blur-row', 3],
+ ['blur-row', null],
+ ]);
+ await publish({ roster: roster([]), mission: null });
+ assert.equal($('.space-mission-roster-empty').textContent, 'NONE IN WINDOW');
+ assert.equal(
+ document.querySelectorAll('.space-mission-roster-item').length,
+ 0,
+ );
+});
+
+test('a roster refresh keeps keyboard focus and its preview on the same mission', async (t) => {
+ const { publish, calls } = fixture(t);
+ await publish({
+ roster: roster([entry('a', 0), entry('b', 1), entry('c', 2)]),
+ mission: null,
+ });
+ const list = document.querySelector('[data-mission-roster-list]');
+ // Browsers drop focus from a node that is moved; happy-dom keeps it, so
+ // any move drops it here.
+ const insertBefore = list.insertBefore;
+ list.insertBefore = function (node, ref) {
+ if (list.contains(document.activeElement)) document.activeElement.blur();
+ return insertBefore.call(this, node, ref);
+ };
+ document.querySelector('[data-mission-roster-id="b"]').focus();
+ calls.length = 0;
+ await publish({
+ roster: roster([entry('b', 0), entry('c', 1), entry('a', 2)]),
+ mission: null,
+ });
+ const focused = document.activeElement;
+ assert.equal(focused?.dataset?.missionRosterId, 'b');
+ assert.deepEqual(calls.at(-1), ['focus-row', 0]);
+});
+
+test('a departed focused mission continues focus beyond the list', async (t) => {
+ const { publish } = fixture(t);
+ await publish({
+ roster: roster([entry('a', 0), entry('b', 1)]),
+ mission: null,
+ });
+ document.querySelector('[data-mission-roster-id="b"]').focus();
+ await publish({ roster: roster([entry('a', 0)]), mission: null });
+ assert.equal(
+ document.activeElement ===
+ document.querySelector('[data-mission-roster-focus-continuation]'),
+ true,
+ );
+});
+
+test('the selected mission shows its details and hides absent rows', async (t) => {
+ const { $, publish } = fixture(t);
+ await publish({ roster: null, mission: mission() });
+ const panel = $('#space-mission-panel');
+ assert.equal(panel.hidden, false);
+ assert.equal($('[data-mission-title]').textContent, 'STARLINK 12-3');
+ assert.equal($('[data-mission-status]').textContent, 'Go for Launch');
+ assert.equal(
+ $('[data-mission-site]').closest('[data-mission-field]').hidden,
+ true,
+ );
+ assert.equal(
+ $('[data-mission-orbit]').closest('[data-mission-field]').hidden,
+ false,
+ );
+ assert.equal($('[data-mission-speed]').title, '27,576 km/h');
+ assert.equal($('[data-mission-index]').textContent, '2 / 5');
+ assert.equal($('[data-mission-prev]').disabled, false);
+ assert.equal($('[data-mission-next]').disabled, true);
+ assert.equal(
+ $('[data-mission-payloads]').textContent,
+ 'PAYLOAD DATA UNAVAILABLE',
+ );
+ assert.equal($('[data-mission-stages-section]').hidden, true);
+ await publish({
+ roster: null,
+ mission: mission({
+ distance: null,
+ payloads: [
+ {
+ name: 'Sat ×2',
+ detail: 'Maker · 500 KG',
+ type: 'Comms',
+ destination: 'GTO',
+ },
+ ],
+ payloadsMore: '+1 additional payload records',
+ stages: [
+ {
+ name: 'B1080',
+ detail: 'FLIGHT 9',
+ status: 'Landed',
+ destination: 'ASOG',
+ position: '28.40, -77.10 · DRONESHIP',
+ },
+ ],
+ }),
+ });
+ assert.equal(
+ $('[data-mission-distance]').closest('[data-mission-field]').hidden,
+ true,
+ );
+ const rows = [...$('[data-mission-payloads]').querySelectorAll('tr')].map(
+ (row) => row.textContent,
+ );
+ assert.deepEqual(rows, [
+ 'Sat ×2Maker · 500 KGCommsGTO',
+ '+1 additional payload records',
+ ]);
+ assert.equal($('[data-mission-stages-section]').hidden, false);
+ assert.equal(
+ $('[data-mission-stages]').textContent,
+ 'B1080FLIGHT 9LandedASOG28.40, -77.10 · DRONESHIP',
+ );
+ await publish({ roster: null, mission: null });
+ assert.equal(panel.hidden, true);
+});
+
+test('the replay controls follow the layer and call its commands', async (t) => {
+ const { $, publish, calls } = fixture(t);
+ await publish({
+ roster: null,
+ mission: mission({ replay: replay({ available: false }) }),
+ });
+ const start = $('[data-mission-replay]');
+ assert.equal(start.hidden, true);
+ assert.equal(start.disabled, true);
+ assert.equal($('.mission-replay-speed-control').hidden, true);
+ await publish({ roster: null, mission: mission() });
+ assert.equal(start.hidden, false);
+ assert.equal(start.getAttribute('aria-pressed'), 'false');
+ assert.equal($('[data-mission-replay-transport]').hidden, true);
+ start.click();
+ await publish({
+ roster: null,
+ mission: mission({
+ nextDisabled: false,
+ replay: replay({
+ active: true,
+ paused: true,
+ toggleText: '▶',
+ toggleLabel: 'Resume replay',
+ transportLabel: 'Liftoff, paused',
+ speed: '2.5',
+ speedText: '2.50×',
+ speedProgress: '60%',
+ }),
+ }),
+ });
+ const transport = $('[data-mission-replay-transport]');
+ assert.equal(start.hidden, true);
+ assert.equal(transport.hidden, false);
+ assert.equal(transport.classList.contains('is-paused'), true);
+ assert.equal(transport.getAttribute('aria-label'), 'Liftoff, paused');
+ const toggle = $('[data-mission-replay-toggle]');
+ assert.equal(toggle.textContent, '▶');
+ assert.equal(toggle.getAttribute('aria-label'), 'Resume replay');
+ const speed = $('[data-mission-replay-speed]');
+ assert.equal(speed.value, '2.5');
+ assert.equal(speed.style.getPropertyValue('--replay-speed-progress'), '60%');
+ assert.equal($('[data-mission-replay-speed-output]').textContent, '2.50×');
+ toggle.click();
+ $('[data-mission-replay-cancel]').click();
+ speed.value = '3';
+ speed.dispatchEvent(new Event('input'));
+ $('[data-mission-focus]').click();
+ $('[data-mission-prev]').click();
+ $('[data-mission-next]').click();
+ $('[data-mission-close]').click();
+ $('[data-mission-show-all]').click();
+ assert.deepEqual(calls, [
+ ['replay'],
+ ['toggle'],
+ ['cancel'],
+ ['speed', '3'],
+ ['focus'],
+ ['previous'],
+ ['next'],
+ ['deselect'],
+ ['deselect'],
+ ]);
+});
+
+test('each newly presented selection returns the Context panel to the top', async (t) => {
+ const { publish, scroller } = fixture(t);
+ scroller.scrollTop = 120;
+ await publish({ roster: null, mission: mission({ scrollRequest: 4 }) });
+ assert.equal(scroller.scrollTop, 0);
+ scroller.scrollTop = 80;
+ await publish({
+ roster: null,
+ mission: mission({ scrollRequest: 4, distance: '412 KM' }),
+ });
+ assert.equal(scroller.scrollTop, 80);
+ await publish({ roster: null, mission: mission({ scrollRequest: 5 }) });
+ assert.equal(scroller.scrollTop, 0);
+});
+
+test('a roster refresh lets the row under the pointer take the preview back on its next movement', async (t) => {
+ const { publish, calls } = fixture(t);
+ await publish({
+ roster: roster([entry('a', 0), entry('b', 1)]),
+ mission: null,
+ });
+ const row = document.querySelector('[data-mission-roster-id="a"]');
+ row.dispatchEvent(new MouseEvent('mouseenter'));
+ row.dispatchEvent(new MouseEvent('mousemove'));
+ await publish({
+ roster: roster([entry('a', 0, 'RENAMED'), entry('b', 1)]),
+ mission: null,
+ });
+ assert.equal(
+ document.querySelector('[data-mission-roster-id="a"]') === row,
+ true,
+ );
+ row.dispatchEvent(new MouseEvent('mousemove'));
+ row.dispatchEvent(new MouseEvent('mousemove'));
+ assert.deepEqual(calls, [
+ ['pointer-enter', 0],
+ ['pointer-enter', 0],
+ ]);
+});
+
+test('a layer that stops presenting returns the roster to loading and hides the mission', async (t) => {
+ const { $, publish } = fixture(t);
+ await publish({ roster: roster([entry('a', 0)]), mission: mission() });
+ await publish({ roster: null, mission: null });
+ assert.equal($('[data-mission-roster-count]').textContent, '—');
+ assert.equal(
+ $('.space-mission-roster-empty').textContent,
+ 'LOADING 30-DAY MISSION INDEX',
+ );
+ assert.equal($('#space-mission-panel').hidden, true);
+});
diff --git a/src/ui/stores/context.js b/src/ui/stores/context.js
index 74162a367..e04827852 100644
--- a/src/ui/stores/context.js
+++ b/src/ui/stores/context.js
@@ -5,8 +5,9 @@ import { markRaw, shallowRef } from 'vue';
* The Context panel's mode switch as its view renders it. ContextControls
* presents the selected mode, the transition busy state and the nearby
* sites search (`_presentContext()`) and publishes them here; it
- * keeps its listeners and transactions. The Contacts layer publishes its
- * panel beside it, and the app connects that layer's navigation.
+ * keeps its listeners and transactions. The Contacts and Space Missions
+ * layers publish their panels beside it, and the app connects each layer's
+ * commands.
*/
export const useContextStore = defineStore('gev-context', () => {
/** The presented mode switch; null until the controls first render. */
@@ -36,6 +37,29 @@ export const useContextStore = defineStore('gev-context', () => {
awarenessLayer.value = layer && markRaw(layer);
}
+ /** The Space Missions roster; null until the layer first presents it. */
+ const missionRoster = shallowRef(null);
+
+ /** The selected space mission; null while none is selected. */
+ const mission = shallowRef(null);
+
+ /**
+ * @param {?{roster: ?object, mission: ?object}} next The layer's view. The
+ * layer keeps an unchanged roster's object, which re-renders nothing.
+ */
+ function publishMissions(next) {
+ missionRoster.value = next?.roster ? markRaw(next.roster) : null;
+ mission.value = next?.mission ? markRaw(next.mission) : null;
+ }
+
+ /** The Space Missions layer whose panel commands the components call. */
+ const missionsLayer = shallowRef(null);
+
+ /** @param {?object} layer The Space Missions layer, or null to disconnect. */
+ function connectMissions(layer) {
+ missionsLayer.value = layer && markRaw(layer);
+ }
+
return {
view,
publish,
@@ -43,5 +67,10 @@ export const useContextStore = defineStore('gev-context', () => {
publishAwareness,
awarenessLayer,
connectAwareness,
+ missionRoster,
+ mission,
+ publishMissions,
+ missionsLayer,
+ connectMissions,
};
});
diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue
index 38efa30fe..e6213e73e 100644
--- a/src/ui/views/ContextView.vue
+++ b/src/ui/views/ContextView.vue
@@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia';
import { vClassToggle } from '../rail/classToggle.js';
import { useContextStore } from '../stores/context.js';
import AwarenessPanel from './panels/AwarenessPanel.vue';
+import MissionDetail from './panels/MissionDetail.vue';
+import MissionRoster from './panels/MissionRoster.vue';
import RadioPanel from './panels/RadioPanel.vue';
// ContextControls presents the mode switch and the nearby sites search and
@@ -73,19 +75,8 @@ const { view: ctx } = storeToRefs(useContextStore());
-
-
-
-
LOADING 30-DAY MISSION INDEX
-
-
TAB PREVIEWS · ENTER / SPACE SELECTS
-
+
+
diff --git a/src/ui/views/panels/AwarenessPanel.vue b/src/ui/views/panels/AwarenessPanel.vue
index 1408481c5..bf431dc12 100644
--- a/src/ui/views/panels/AwarenessPanel.vue
+++ b/src/ui/views/panels/AwarenessPanel.vue
@@ -1,6 +1,7 @@
+
+
+
+
diff --git a/src/ui/views/panels/MissionRoster.vue b/src/ui/views/panels/MissionRoster.vue
new file mode 100644
index 000000000..5b28f160c
--- /dev/null
+++ b/src/ui/views/panels/MissionRoster.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
{{ t('LOADING 30-DAY MISSION INDEX') }}
+
{{ roster.empty }}
+
+
+
TAB PREVIEWS · ENTER / SPACE SELECTS
+
+