From 66a60ff67e042b11a105e5f53cd8085459eff431 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 10:48:20 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 11 + docs/VUE-MIGRATION.md | 4 +- scripts/package-boundaries.json | 2 + src/data/rocketLaunches.js | 4 - src/data/rocketLaunches.test.mjs | 83 --- src/layers/launches/index.js | 6 +- src/layers/launches/ingestion.test.mjs | 1 + src/layers/launches/lifecycle.js | 8 +- .../launches/missionStatus.i18n.test.mjs | 20 +- src/layers/launches/panel.js | 618 ++++++++---------- src/layers/launches/panel.test.mjs | 196 ++++++ src/layers/launches/panelScroll.test.mjs | 34 - src/layers/launches/replay.js | 93 +-- src/layers/launches/state.js | 18 +- src/radioMarkup.test.mjs | 15 +- src/ui/applicationShell.js | 23 +- src/ui/awarenessPanel.test.mjs | 6 +- src/ui/missionPanels.test.mjs | 378 +++++++++++ src/ui/stores/context.js | 33 +- src/ui/views/ContextView.vue | 17 +- src/ui/views/panels/AwarenessPanel.vue | 5 +- src/ui/views/panels/MissionDetail.vue | 83 +++ src/ui/views/panels/MissionRoster.vue | 78 +++ 23 files changed, 1131 insertions(+), 605 deletions(-) create mode 100644 src/layers/launches/panel.test.mjs delete mode 100644 src/layers/launches/panelScroll.test.mjs create mode 100644 src/ui/missionPanels.test.mjs create mode 100644 src/ui/views/panels/MissionDetail.vue create mode 100644 src/ui/views/panels/MissionRoster.vue 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('SELECTED SPACE MISSION')}
${t('MISSION')}${t('STATUS · ')}${t('LAUNCH SITE · ')}${t('LAUNCH TIME · ')}${t('ORBIT · ')}${t('ASCENT PATH · ')}${t('CURRENT DISTANCE FROM EARTH · ')}${t('SATELLITE SPEED · ')}

${t('PAYLOAD')}

${t('NAME')}${t('TYPE')}${t('DESTINATION')}

${t('STAGE / RE-ENTRY / RECOVERY')}

${t('STAGE')}${t('STATUS')}${t('FINAL POSITION')}
1×
—
`; - 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());
-
-
-
- AVAILABLE MISSIONS - SELECT A MISSION TO INSPECT -
- — -
-
-
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 @@ + + +