import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { MISSION_CLOSE_VIEW_RANGE_M, MISSION_GLOBE_VIEW_RANGE_M, } from './policy.js'; import { UNKNOWN_LAUNCH_STATUS } from './model.js'; export function createPanel({ state: layerState, services, parts, source }) { /** * Preserve the user's globe scale for roster previews while avoiding an * accidental surface-level fly-to when the list is opened from a close view. * @param {number} cameraHeight Current camera height above the ellipsoid. * @returns {number} Preview range in metres. */ function missionHoverPreviewRange(cameraHeight) { const height = Number(cameraHeight); return Math.max( MISSION_CLOSE_VIEW_RANGE_M, Number.isFinite(height) ? height : MISSION_GLOBE_VIEW_RANGE_M, ); } /** * Coordinate pointer and keyboard ownership of the temporary roster preview. * The most recent input owns the preview until it leaves, then any remaining * input resumes ownership. This lets keyboard focus take over from a pointer * resting on another row without losing that pointer preview on blur. * @param {{preview: (index: number) => void, clear: () => void}} handlers * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void, * focus: (index: number) => void, blur: (nextIndex?: number|null) => void, * reset: () => void}} */ function createMissionRosterPreviewOwnership({ preview, clear }) { let pointerIndex = null; let focusIndex = null; let latestOwner = null; let activeIndex = null; const sync = () => { const nextIndex = latestOwner === 'pointer' ? (pointerIndex ?? focusIndex) : (focusIndex ?? pointerIndex); if (nextIndex === activeIndex) return; activeIndex = nextIndex; if (Number.isInteger(nextIndex)) preview(nextIndex); else clear(); }; return { pointerEnter(index) { pointerIndex = index; latestOwner = 'pointer'; sync(); }, pointerLeave() { pointerIndex = null; if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus'; sync(); }, focus(index) { focusIndex = index; latestOwner = 'focus'; sync(); }, blur(nextIndex = null) { focusIndex = Number.isInteger(nextIndex) ? nextIndex : null; if (latestOwner === 'focus') latestOwner = focusIndex === null ? pointerIndex === null ? null : 'pointer' : 'focus'; sync(); }, reset() { pointerIndex = null; focusIndex = null; latestOwner = null; activeIndex = null; clear(); }, }; } /** * 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) { return ( (launches || []).find((candidate) => candidate.id === launchId) || null ); } function missionTableRows(items, columns, emptyText) { if (!items.length) return `${emptyText}`; return items.map((item) => item).join(''); } 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'); } function renderMissionPanel() { if (!layerState._missionPanel) return; 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); 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' ? 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, 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; } 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 .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]', ), ); } function escapeMissionText(value) { return String(value ?? '').replace( /[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[character], ); } function updateMissionTelemetry(force = false) { if ( !layerState._missionPanel || !layerState._selectedLaunchId || !layerState._dataSource ) return; const now = performance.now(); if (!force && now - layerState._lastPanelTelemetryMs < 250) return; layerState._lastPanelTelemetryMs = now; const satellite = layerState._dataSource.entities.getById( `rocket-satellite:${layerState._selectedLaunchId}`, ); const position = satellite?.position?.getValue( Cesium.JulianDate.now(layerState._declutterTime), ); const altitudeM = position ? Cesium.Cartographic.fromCartesian(position)?.height : null; setMissionPanelField( '[data-mission-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) ? t('{value} KM/S', { value: (speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, }), }) : null, Number.isFinite(speedMps) ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` : '', ); } function selectMissionAt(index) { const launch = layerState._launches[index]; if (!launch) return; parts.selection.setSelectedMission(launch.id, true); parts.selection.focusMission(launch); } function clearMissionRosterPreviewState() { if (layerState._missionRosterHoverTimer) clearTimeout(layerState._missionRosterHoverTimer); layerState._missionRosterHoverTimer = null; const changed = layerState._hoveredRosterLaunchId !== null; layerState._hoveredRosterLaunchId = null; if (changed) parts.overlays.syncMissionOverlayEntries(); } function clearMissionRosterHover() { if (layerState._missionRosterPreviewOwnership) layerState._missionRosterPreviewOwnership.reset(); else clearMissionRosterPreviewState(); } function scheduleMissionRosterPreview(index) { const launch = layerState._launches[index]; if (!launch || layerState._selectedLaunchId) return; if (layerState._missionRosterHoverTimer) clearTimeout(layerState._missionRosterHoverTimer); layerState._hoveredRosterLaunchId = launch.id; parts.overlays.syncMissionOverlayEntries(); layerState._missionRosterHoverTimer = setTimeout(() => { layerState._missionRosterHoverTimer = null; if (layerState._hoveredRosterLaunchId !== launch.id) return; const currentLaunch = resolveMissionRosterPreviewLaunch( layerState._launches, launch.id, ); if (currentLaunch) parts.policyHelpers.previewMissionFromRoster(currentLaunch); }, 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'), ); 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(); } return { missionHoverPreviewRange, createMissionRosterPreviewOwnership, bindMissionRosterItemKeyboardPreview, captureMissionRosterFocus, restoreMissionRosterFocus, resolveMissionRosterPreviewLaunch, missionTableRows, setMissionPanelField, renderMissionPanel, renderMissionRoster, escapeMissionText, updateMissionTelemetry, selectMissionAt, clearMissionRosterPreviewState, clearMissionRosterHover, scheduleMissionRosterPreview, createMissionPanel, }; }