feat(ui): render the Space Missions roster and selected mission from the launches layer's published view

The launches layer no longer builds the roster or the selected mission
panel as HTML strings in #space-mission-panel-host. It presents both as a
plain view and publishes it through subscribePanel(); MissionRoster and
MissionDetail render it from the gev-context store and call the layer's
panel commands. Replay controls are presented the same way.

The roster keeps keyboard focus on the same mission across refreshes, or
continues beyond the list when the mission departs, and the row under the
pointer takes the preview back on its next movement. Lines a null view
rebuilds are translated through t(). Disposing the shell clears the
Contacts and Space Missions views.
This commit is contained in:
Sameh Khamis committed 2026-10-31 10:48:20 -07:00
1 parent 1e4df63efe
commit 66a60ff67e
23 files changed
+1131 -605

No files matched your search

+2 -4
View File
@@ -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,
+1
View File
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
renderMissionRoster: noop,
renderMissionPanel: noop,
clearMissionRosterHover: noop,
clearMissionPanel: noop,
},
replay: { stopMissionReplay: noop },
selection: {
+1 -7
View File
@@ -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);
@@ -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', () => {
+259 -359
View File
@@ -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 `<tr><td colspan="${columns}" class="mission-table-empty">${emptyText}</td></tr>`;
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 `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
})
: [];
if (launch.payloads.length > 5) {
payloadRows.push(
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
);
}
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 `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
});
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 = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
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 `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
})
.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) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[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 = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
layerState._missionPanel
.querySelector('.mission-action-row')
.insertAdjacentHTML(
'beforeend',
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
</div>`,
);
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,
};
}
+196
View File
@@ -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],
]);
});
-34
View File
@@ -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);
}
});
+30 -63
View File
@@ -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,
+16 -2
View File
@@ -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;