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 @@ + + +