mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the Space Missions roster and selected mission from the launches layer's published view
The launches layer no longer builds the roster or the selected mission panel as HTML strings in #space-mission-panel-host. It presents both as a plain view and publishes it through subscribePanel(); MissionRoster and MissionDetail render it from the gev-context store and call the layer's panel commands. Replay controls are presented the same way. The roster keeps keyboard focus on the same mission across refreshes, or continues beyond the list when the mission departs, and the row under the pointer takes the preview back on its next movement. Lines a null view rebuilds are translated through t(). Disposing the shell clears the Contacts and Space Missions views.
This commit is contained in:
1 parent
1e4df63efe
commit
66a60ff67e
23 files changed
+1131
-605
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
clearMissionPanel: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
|
||||
@@ -262,13 +262,7 @@ export function createLifecycle({
|
||||
layerState._animationStarts.clear();
|
||||
layerState._satelliteTelemetry.clear();
|
||||
layerState._replayTracks.clear();
|
||||
layerState._missionPanel?.remove();
|
||||
layerState._missionPanel = null;
|
||||
if (layerState._missionRoster) {
|
||||
layerState._missionRoster.onclick = null;
|
||||
}
|
||||
layerState._missionRosterPreviewOwnership = null;
|
||||
layerState._missionRoster = null;
|
||||
parts.panel.clearMissionPanel();
|
||||
layerState._viewer = null;
|
||||
if (layerState._dataSource)
|
||||
viewer.dataSources.remove(layerState._dataSource, true);
|
||||
|
||||
@@ -13,20 +13,7 @@ import { createPanel } from './panel.js';
|
||||
const NOW = new Date('2026-07-27T00:00:00Z');
|
||||
|
||||
function shownStatus(status) {
|
||||
const outputs = new Map();
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector(selector) {
|
||||
if (!outputs.has(selector))
|
||||
outputs.set(selector, {
|
||||
textContent: '',
|
||||
closest: () => null,
|
||||
removeAttribute() {},
|
||||
});
|
||||
return outputs.get(selector);
|
||||
},
|
||||
closest: () => null,
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [
|
||||
{
|
||||
@@ -39,15 +26,14 @@ function shownStatus(status) {
|
||||
],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
createPanel({
|
||||
return createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
replay: { presentReplay: () => ({}) },
|
||||
},
|
||||
}).renderMissionPanel();
|
||||
return outputs.get('[data-mission-status]').textContent;
|
||||
}).presentMission().status;
|
||||
}
|
||||
|
||||
test('a launch without a status keeps the English fallback in its record', () => {
|
||||
|
||||
+259
-359
@@ -88,68 +88,6 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind native keyboard focus events for one rendered roster row.
|
||||
* @param {Element} button Mission roster button.
|
||||
* @param {number} index Source-array mission index.
|
||||
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
|
||||
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
|
||||
* @param {Element} roster Roster root used to reject focus targets outside the list.
|
||||
*/
|
||||
|
||||
function bindMissionRosterItemKeyboardPreview(
|
||||
button,
|
||||
index,
|
||||
ownership,
|
||||
roster,
|
||||
) {
|
||||
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
|
||||
button.addEventListener('mouseleave', () => ownership.pointerLeave());
|
||||
button.addEventListener('focus', () => ownership.focus(index));
|
||||
button.addEventListener('blur', (event) => {
|
||||
const nextButton = event.relatedTarget?.closest?.(
|
||||
'[data-mission-roster-index]',
|
||||
);
|
||||
const nextIndex =
|
||||
nextButton && roster.contains(nextButton)
|
||||
? Number(nextButton.dataset.missionRosterIndex)
|
||||
: null;
|
||||
ownership.blur(nextIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
|
||||
|
||||
function captureMissionRosterFocus(
|
||||
list,
|
||||
activeElement = globalThis.document?.activeElement,
|
||||
) {
|
||||
const button = activeElement?.closest?.('[data-mission-roster-id]');
|
||||
if (!button || !list?.contains(button)) return null;
|
||||
const launchId = button.dataset.missionRosterId;
|
||||
return launchId ? { launchId } : null;
|
||||
}
|
||||
|
||||
/** Restore focus by mission identity, or continue after the list if it departed. */
|
||||
|
||||
function restoreMissionRosterFocus(list, snapshot, continuation) {
|
||||
if (!snapshot?.launchId) return 'none';
|
||||
const button = Array.from(
|
||||
list?.querySelectorAll?.('[data-mission-roster-id]') || [],
|
||||
).find(
|
||||
(candidate) => candidate.dataset.missionRosterId === snapshot.launchId,
|
||||
);
|
||||
if (button) {
|
||||
button.focus({ preventScroll: true });
|
||||
return 'restored';
|
||||
}
|
||||
if (continuation?.focus) {
|
||||
continuation.focus({ preventScroll: true });
|
||||
return 'continued';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Resolve a pending preview against the current refresh, never a stale object. */
|
||||
|
||||
function resolveMissionRosterPreviewLaunch(launches, launchId) {
|
||||
@@ -158,213 +96,213 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
}
|
||||
|
||||
function missionTableRows(items, columns, emptyText) {
|
||||
if (!items.length)
|
||||
return `<tr><td colspan="${columns}" class="mission-table-empty">${emptyText}</td></tr>`;
|
||||
return items.map((item) => item).join('');
|
||||
/**
|
||||
* Publish a fresh view unless it is the one already shown. The roster
|
||||
* carries its own signature, so a telemetry update compares the mission.
|
||||
*/
|
||||
function publish(view) {
|
||||
const signature = `${layerState._missionRosterSignature}|${JSON.stringify(view.mission)}`;
|
||||
if (layerState._missionPanelSignature === signature) return;
|
||||
layerState._missionPanelSignature = signature;
|
||||
layerState._missionPanelView = view;
|
||||
for (const listener of [...layerState._missionPanelListeners])
|
||||
listener(view);
|
||||
}
|
||||
|
||||
function setMissionPanelField(selector, value, title = '') {
|
||||
const output = layerState._missionPanel?.querySelector(selector);
|
||||
if (!output) return;
|
||||
const row = output.closest('[data-mission-field]');
|
||||
const available =
|
||||
value !== null && value !== undefined && String(value).trim() !== '';
|
||||
if (row) row.hidden = !available;
|
||||
if (!available) {
|
||||
output.textContent = '';
|
||||
output.removeAttribute('title');
|
||||
return;
|
||||
}
|
||||
output.textContent = value;
|
||||
if (title) output.title = title;
|
||||
else output.removeAttribute('title');
|
||||
/**
|
||||
* Receive every published view, starting with the current one.
|
||||
* @param {(view: {roster: ?object, mission: ?object}) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribePanel(listener) {
|
||||
layerState._missionPanelListeners.add(listener);
|
||||
if (layerState._missionPanelView) listener(layerState._missionPanelView);
|
||||
return () => layerState._missionPanelListeners.delete(listener);
|
||||
}
|
||||
|
||||
function renderMissionPanel() {
|
||||
if (!layerState._missionPanel) return;
|
||||
/** Publish the roster and the selected mission as they stand. */
|
||||
function publishPanel() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
publish({
|
||||
roster: layerState._missionRosterView,
|
||||
mission: presentMission(),
|
||||
});
|
||||
}
|
||||
|
||||
/** A detail value, or null when the panel hides its row. */
|
||||
function presentField(value) {
|
||||
return value !== null && value !== undefined && String(value).trim() !== ''
|
||||
? String(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
function presentPayload(payload, launch) {
|
||||
const detail = [
|
||||
payload.manufacturer,
|
||||
payload.operator && payload.operator !== payload.manufacturer
|
||||
? payload.operator
|
||||
: null,
|
||||
Number.isFinite(payload.massKg)
|
||||
? `${payload.massKg.toLocaleString()} KG`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return {
|
||||
name: `${payload.name ?? ''}${payload.amount > 1 ? ` ×${payload.amount}` : ''}`,
|
||||
detail,
|
||||
type: payload.type || t('UNSPECIFIED'),
|
||||
destination:
|
||||
payload.destination || launch.orbit?.name || t('UNAVAILABLE'),
|
||||
};
|
||||
}
|
||||
|
||||
function presentStage(stage, launch) {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
? launch.launchSite
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
|
||||
: stage.downrangeKm > 0
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: stage.downrangeKm.toLocaleString(),
|
||||
})
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const detail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return {
|
||||
name: String(stage.name ?? ''),
|
||||
detail,
|
||||
status: String(stage.status ?? ''),
|
||||
destination: String(destination ?? ''),
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected mission as the panel shows it, or null when none is
|
||||
* selected and the panel hides. Text arrives in the UI language.
|
||||
*/
|
||||
function presentMission() {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
const index = launch ? layerState._launches.indexOf(launch) : -1;
|
||||
layerState._missionPanel.hidden = !launch;
|
||||
if (!launch) return;
|
||||
layerState._missionPanel.querySelector('[data-mission-title]').textContent =
|
||||
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
|
||||
setMissionPanelField('[data-mission-provider]', launch.provider);
|
||||
// Launch Library status names are provider data, shown as sent; only the
|
||||
// fallback for a launch without one is ours to translate.
|
||||
setMissionPanelField(
|
||||
'[data-mission-status]',
|
||||
launch.status === UNKNOWN_LAUNCH_STATUS
|
||||
? t(UNKNOWN_LAUNCH_STATUS)
|
||||
: launch.status,
|
||||
);
|
||||
setMissionPanelField(
|
||||
'[data-mission-site]',
|
||||
launch.launchSite && launch.launchSite !== 'Unknown launch site'
|
||||
? launch.launchSite
|
||||
: null,
|
||||
);
|
||||
setMissionPanelField('[data-mission-time]', launch.launchTime);
|
||||
if (!launch) return null;
|
||||
const index = layerState._launches.indexOf(launch);
|
||||
const pathPresentation = parts.policyHelpers.missionPathPresentation(
|
||||
launch,
|
||||
layerState._replayTracks.has(launch.id),
|
||||
);
|
||||
setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit);
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-ascent-source]',
|
||||
).textContent = pathPresentation.ascent;
|
||||
const payloadRows = launch.payloads.length
|
||||
? launch.payloads.slice(0, 5).map((payload) => {
|
||||
const detail = [
|
||||
payload.manufacturer,
|
||||
payload.operator && payload.operator !== payload.manufacturer
|
||||
? payload.operator
|
||||
: null,
|
||||
Number.isFinite(payload.massKg)
|
||||
? `${payload.massKg.toLocaleString()} KG`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
|
||||
})
|
||||
: [];
|
||||
if (launch.payloads.length > 5) {
|
||||
payloadRows.push(
|
||||
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
|
||||
);
|
||||
}
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-payloads]',
|
||||
).innerHTML = missionTableRows(
|
||||
payloadRows,
|
||||
3,
|
||||
t('PAYLOAD DATA UNAVAILABLE'),
|
||||
);
|
||||
const stageRows = launch.recoveryStages.map((stage) => {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
const telemetry = layerState._missionTelemetry;
|
||||
return {
|
||||
scrollRequest: layerState._missionPanelScrollRequest,
|
||||
title: parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(),
|
||||
provider: presentField(launch.provider),
|
||||
// Launch Library status names are provider data, shown as sent; only
|
||||
// the fallback for a launch without one is ours to translate.
|
||||
status: presentField(
|
||||
launch.status === UNKNOWN_LAUNCH_STATUS
|
||||
? t(UNKNOWN_LAUNCH_STATUS)
|
||||
: launch.status,
|
||||
),
|
||||
site: presentField(
|
||||
launch.launchSite && launch.launchSite !== 'Unknown launch site'
|
||||
? launch.launchSite
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
|
||||
: stage.downrangeKm > 0
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: stage.downrangeKm.toLocaleString(),
|
||||
})
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const stageDetail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
: null,
|
||||
),
|
||||
time: presentField(launch.launchTime),
|
||||
orbit: presentField(pathPresentation.orbit),
|
||||
ascent: pathPresentation.ascent ?? '',
|
||||
distance: telemetry?.distance ?? null,
|
||||
speed: telemetry?.speed ?? null,
|
||||
speedTitle: telemetry?.speedTitle ?? null,
|
||||
payloads: launch.payloads
|
||||
.slice(0, 5)
|
||||
.map((payload) => presentPayload(payload, launch)),
|
||||
payloadsMore:
|
||||
launch.payloads.length > 5
|
||||
? t('+{count} additional payload records', {
|
||||
count: launch.payloads.length - 5,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
|
||||
});
|
||||
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
|
||||
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
|
||||
const stageSection = layerState._missionPanel.querySelector(
|
||||
'[data-mission-stages-section]',
|
||||
);
|
||||
if (stageSection) stageSection.hidden = stageRows.length === 0;
|
||||
updateMissionTelemetry(true);
|
||||
layerState._missionPanel.querySelector('[data-mission-index]').textContent =
|
||||
`${index + 1} / ${layerState._launches.length}`;
|
||||
layerState._missionPanel.querySelector('[data-mission-prev]').disabled =
|
||||
index <= 0;
|
||||
layerState._missionPanel.querySelector('[data-mission-next]').disabled =
|
||||
index < 0 || index >= layerState._launches.length - 1;
|
||||
parts.replay.syncReplayButton();
|
||||
const panelScroller = layerState._missionPanel.closest(
|
||||
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
|
||||
);
|
||||
if (panelScroller) panelScroller.scrollTop = 0;
|
||||
stages: launch.recoveryStages.map((stage) => presentStage(stage, launch)),
|
||||
index: `${index + 1} / ${layerState._launches.length}`,
|
||||
previousDisabled: index <= 0,
|
||||
nextDisabled: index < 0 || index >= layerState._launches.length - 1,
|
||||
replay: parts.replay.presentReplay(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderMissionRoster() {
|
||||
if (!layerState._missionRoster) return;
|
||||
const list = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-list]',
|
||||
);
|
||||
const count = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-count]',
|
||||
);
|
||||
if (count)
|
||||
count.textContent = t('{count} / 30D', {
|
||||
count: layerState._launches.length,
|
||||
});
|
||||
if (!list) return;
|
||||
const focusSnapshot = captureMissionRosterFocus(list);
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
const entries = parts.policyHelpers.missionRosterEntries(
|
||||
layerState._launches,
|
||||
);
|
||||
if (!entries.length) {
|
||||
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-focus-continuation]',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.innerHTML = entries
|
||||
/** Present the newly selected mission and return its panel to the top. */
|
||||
function renderMissionPanel() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
layerState._missionTelemetry = null;
|
||||
updateMissionTelemetry(true, false);
|
||||
layerState._missionPanelScrollRequest++;
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** The roster entries as the list shows them. */
|
||||
function presentRoster() {
|
||||
const entries = parts.policyHelpers
|
||||
.missionRosterEntries(layerState._launches)
|
||||
.map(({ launch, index }) => {
|
||||
const color = parts.model.missionMarkerColor(launch).toCssColorString();
|
||||
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
|
||||
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
|
||||
const label = parts.overlays
|
||||
.shortMissionLabel(launch.name, 27)
|
||||
.toUpperCase();
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
})
|
||||
.join('');
|
||||
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
|
||||
const index = Number(button.dataset.missionRosterIndex);
|
||||
bindMissionRosterItemKeyboardPreview(
|
||||
button,
|
||||
index,
|
||||
layerState._missionRosterPreviewOwnership,
|
||||
layerState._missionRoster,
|
||||
);
|
||||
});
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-focus-continuation]',
|
||||
),
|
||||
);
|
||||
return {
|
||||
index,
|
||||
id: String(launch.id ?? ''),
|
||||
color: parts.model.missionMarkerColor(launch).toCssColorString(),
|
||||
label,
|
||||
ariaLabel: t('Select {mission}', { mission: label }),
|
||||
provider: launch.provider || t('UNSPECIFIED OPERATOR'),
|
||||
date: launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE'),
|
||||
};
|
||||
});
|
||||
return {
|
||||
count: t('{count} / 30D', { count: layerState._launches.length }),
|
||||
entries,
|
||||
empty: t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeMissionText(value) {
|
||||
return String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character],
|
||||
);
|
||||
/**
|
||||
* Present the roster. A changed roster drops the hover and keyboard
|
||||
* preview; the list restores keyboard focus by mission identity, which
|
||||
* resumes the preview.
|
||||
*/
|
||||
function renderMissionRoster() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
const roster = presentRoster();
|
||||
const signature = JSON.stringify(roster);
|
||||
if (signature !== layerState._missionRosterSignature) {
|
||||
layerState._missionRosterSignature = signature;
|
||||
layerState._missionRosterView = roster;
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
}
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
function updateMissionTelemetry(force = false) {
|
||||
/**
|
||||
* Read the selected mission's altitude and speed, at most four times a
|
||||
* second unless forced, and republish when they change.
|
||||
*/
|
||||
function updateMissionTelemetry(force = false, republish = true) {
|
||||
if (
|
||||
!layerState._missionPanel ||
|
||||
!layerState._missionPanelReady ||
|
||||
!layerState._selectedLaunchId ||
|
||||
!layerState._dataSource
|
||||
)
|
||||
@@ -381,22 +319,18 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const altitudeM = position
|
||||
? Cesium.Cartographic.fromCartesian(position)?.height
|
||||
: null;
|
||||
setMissionPanelField(
|
||||
'[data-mission-distance]',
|
||||
Number.isFinite(altitudeM)
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
layerState._selectedLaunchId,
|
||||
)?.speedMps;
|
||||
layerState._missionTelemetry = {
|
||||
distance: Number.isFinite(altitudeM)
|
||||
? t('{value} KM', {
|
||||
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
layerState._selectedLaunchId,
|
||||
)?.speedMps;
|
||||
setMissionPanelField(
|
||||
'[data-mission-speed]',
|
||||
Number.isFinite(speedMps)
|
||||
speed: Number.isFinite(speedMps)
|
||||
? t('{value} KM/S', {
|
||||
value: (speedMps / 1000).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
@@ -404,10 +338,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
Number.isFinite(speedMps)
|
||||
speedTitle: Number.isFinite(speedMps)
|
||||
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
|
||||
: '',
|
||||
);
|
||||
: null,
|
||||
};
|
||||
if (republish) publishPanel();
|
||||
}
|
||||
|
||||
function selectMissionAt(index) {
|
||||
@@ -451,121 +386,86 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
}, 140);
|
||||
}
|
||||
|
||||
function createMissionPanel() {
|
||||
if (layerState._missionPanel || typeof document === 'undefined') return;
|
||||
const host =
|
||||
document.getElementById('space-mission-panel-host') ||
|
||||
document.getElementById('right-context-rail');
|
||||
if (!host) return;
|
||||
layerState._missionRoster = document.getElementById('space-mission-roster');
|
||||
if (layerState._missionRoster) {
|
||||
layerState._missionRosterPreviewOwnership =
|
||||
createMissionRosterPreviewOwnership({
|
||||
preview: scheduleMissionRosterPreview,
|
||||
clear: clearMissionRosterPreviewState,
|
||||
});
|
||||
layerState._missionRoster.onclick = (event) => {
|
||||
const button =
|
||||
event.target instanceof Element
|
||||
? event.target.closest('[data-mission-roster-index]')
|
||||
: null;
|
||||
if (!button) return;
|
||||
selectMissionAt(Number(button.dataset.missionRosterIndex));
|
||||
};
|
||||
}
|
||||
layerState._missionPanel = document.createElement('aside');
|
||||
layerState._missionPanel.id = 'space-mission-panel';
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
t('Selected Space Mission'),
|
||||
/** Select the mission before or after the selected one. */
|
||||
function selectAdjacentMission(step) {
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) + step,
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
|
||||
layerState._missionPanel
|
||||
.querySelector('.mission-action-row')
|
||||
.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
|
||||
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
|
||||
</div>`,
|
||||
);
|
||||
host.appendChild(layerState._missionPanel);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-prev]')
|
||||
.addEventListener('click', () =>
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) - 1,
|
||||
),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-next]')
|
||||
.addEventListener('click', () =>
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) + 1,
|
||||
),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-close]')
|
||||
.addEventListener('click', () =>
|
||||
parts.selection.setSelectedMission(null),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-show-all]')
|
||||
.addEventListener('click', () =>
|
||||
parts.selection.setSelectedMission(null),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-focus]')
|
||||
.addEventListener('click', () => {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
if (launch) parts.selection.focusLaunchSite(launch);
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay]')
|
||||
.addEventListener('click', () => {
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.replay.startMissionReplay(layerState._selectedLaunchId);
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-toggle]')
|
||||
.addEventListener('click', () => {
|
||||
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
|
||||
else parts.replay.pauseMissionReplay();
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-cancel]')
|
||||
.addEventListener('click', parts.replay.stopMissionReplay);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-speed]')
|
||||
.addEventListener('input', (event) => {
|
||||
parts.replay.setReplaySpeed(event.currentTarget.value);
|
||||
});
|
||||
parts.replay.syncReplaySpeedControl();
|
||||
}
|
||||
|
||||
/** The panel's commands, called by Core's mission components. */
|
||||
const commands = {
|
||||
selectMissionAt,
|
||||
selectPreviousMission: () => selectAdjacentMission(-1),
|
||||
selectNextMission: () => selectAdjacentMission(1),
|
||||
deselectMission: () => parts.selection.setSelectedMission(null),
|
||||
focusSelectedLaunchSite() {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
if (launch) parts.selection.focusLaunchSite(launch);
|
||||
},
|
||||
startSelectedReplay() {
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.replay.startMissionReplay(layerState._selectedLaunchId);
|
||||
},
|
||||
toggleReplayPause() {
|
||||
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
|
||||
else parts.replay.pauseMissionReplay();
|
||||
},
|
||||
cancelReplay: () => parts.replay.stopMissionReplay(),
|
||||
setReplaySpeed: (value) => parts.replay.setReplaySpeed(value),
|
||||
rosterPointerEnter: (index) =>
|
||||
layerState._missionRosterPreviewOwnership?.pointerEnter(index),
|
||||
rosterPointerLeave: () =>
|
||||
layerState._missionRosterPreviewOwnership?.pointerLeave(),
|
||||
rosterFocus: (index) =>
|
||||
layerState._missionRosterPreviewOwnership?.focus(index),
|
||||
rosterBlur: (nextIndex) =>
|
||||
layerState._missionRosterPreviewOwnership?.blur(nextIndex),
|
||||
};
|
||||
|
||||
/** Start presenting the roster and the selected mission. */
|
||||
function createMissionPanel() {
|
||||
if (layerState._missionPanelReady) return;
|
||||
layerState._missionPanelReady = true;
|
||||
layerState._missionRosterPreviewOwnership =
|
||||
createMissionRosterPreviewOwnership({
|
||||
preview: scheduleMissionRosterPreview,
|
||||
clear: clearMissionRosterPreviewState,
|
||||
});
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** Stop presenting; the components return to their initial markup. */
|
||||
function clearMissionPanel() {
|
||||
layerState._missionPanelReady = false;
|
||||
layerState._missionRosterPreviewOwnership = null;
|
||||
layerState._missionRosterView = null;
|
||||
layerState._missionRosterSignature = null;
|
||||
layerState._missionTelemetry = null;
|
||||
layerState._replayTransportLabel = null;
|
||||
publish({ roster: null, mission: null });
|
||||
}
|
||||
|
||||
return {
|
||||
missionHoverPreviewRange,
|
||||
createMissionRosterPreviewOwnership,
|
||||
bindMissionRosterItemKeyboardPreview,
|
||||
captureMissionRosterFocus,
|
||||
restoreMissionRosterFocus,
|
||||
resolveMissionRosterPreviewLaunch,
|
||||
missionTableRows,
|
||||
setMissionPanelField,
|
||||
subscribePanel,
|
||||
publishPanel,
|
||||
presentMission,
|
||||
renderMissionPanel,
|
||||
renderMissionRoster,
|
||||
escapeMissionText,
|
||||
updateMissionTelemetry,
|
||||
selectMissionAt,
|
||||
clearMissionRosterPreviewState,
|
||||
clearMissionRosterHover,
|
||||
scheduleMissionRosterPreview,
|
||||
createMissionPanel,
|
||||
clearMissionPanel,
|
||||
commands,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
'</aside>',
|
||||
html.indexOf('</aside>', 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"/);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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());
|
||||
</section>
|
||||
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
|
||||
<div id="space-mission-panel-host">
|
||||
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
|
||||
<div class="space-mission-roster-header">
|
||||
<div>
|
||||
<strong data-i18n>AVAILABLE MISSIONS</strong>
|
||||
<span data-i18n>SELECT A MISSION TO INSPECT</span>
|
||||
</div>
|
||||
<output data-mission-roster-count>—</output>
|
||||
</div>
|
||||
<div class="space-mission-roster-list" data-mission-roster-list>
|
||||
<div class="space-mission-roster-empty" data-i18n>LOADING 30-DAY MISSION INDEX</div>
|
||||
</div>
|
||||
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
|
||||
</div>
|
||||
<MissionRoster />
|
||||
<MissionDetail />
|
||||
</div>
|
||||
</section>
|
||||
<RadioPanel />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeUpdate, onUpdated, ref } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { useContextStore } from '../../stores/context.js';
|
||||
import AwarenessNames from './AwarenessNames.vue';
|
||||
|
||||
@@ -42,8 +43,8 @@ onUpdated(() => {
|
||||
<template>
|
||||
<aside id="military-awareness-panel" ref="root">
|
||||
<div v-if="!view" class="military-awareness-standby">
|
||||
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
|
||||
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
<strong>{{ t('CONTACTS CONTEXT OFF') }}</strong>
|
||||
<span>{{ t('SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY') }}</span>
|
||||
</div>
|
||||
<div v-else-if="view.kind === 'standby'" class="military-awareness-standby">
|
||||
<strong>{{ view.title }}</strong>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { vFieldValue } from '../../rail/fieldValue.js';
|
||||
import { useContextStore } from '../../stores/context.js';
|
||||
|
||||
// The selected space mission inside Context. The launches layer presents it
|
||||
// and hides the panel while none is selected; the roster beside it hides
|
||||
// while it shows. Buttons call the layer's panel commands.
|
||||
const { mission: m, missionsLayer: layer } = storeToRefs(useContextStore());
|
||||
const root = ref(null);
|
||||
|
||||
// Each newly presented selection returns the Context panel to the top.
|
||||
const SCROLLER = ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner";
|
||||
watch(
|
||||
() => m.value?.scrollRequest,
|
||||
async (request) => {
|
||||
if (request === undefined) return;
|
||||
await nextTick();
|
||||
const scroller = root.value?.closest(SCROLLER);
|
||||
if (scroller) scroller.scrollTop = 0;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside id="space-mission-panel" ref="root" class="context-space-mission-detail" :aria-label="t('Selected Space Mission')" :hidden="!m">
|
||||
<div class="space-mission-view-header"><span>{{ t('SELECTED SPACE MISSION') }}</span><button type="button" data-mission-close :title="t('Show all missions')" :aria-label="t('Deselect mission')" @click="layer?.deselectMission()">×</button></div>
|
||||
<div class="space-mission-detail">
|
||||
<strong data-mission-title>{{ m ? m.title : t('MISSION') }}</strong>
|
||||
<span data-mission-field data-mission-provider :hidden="m && !m.provider">{{ m?.provider }}</span>
|
||||
<span data-mission-field :hidden="m && !m.status">{{ t('STATUS · ') }}<b data-mission-status>{{ m?.status }}</b></span>
|
||||
<span data-mission-field :hidden="m && !m.site">{{ t('LAUNCH SITE · ') }}<b data-mission-site>{{ m?.site }}</b></span>
|
||||
<span data-mission-field :hidden="m && !m.time">{{ t('LAUNCH TIME · ') }}<b data-mission-time>{{ m?.time }}</b></span>
|
||||
<span data-mission-field :hidden="m && !m.orbit">{{ t('ORBIT · ') }}<b data-mission-orbit>{{ m?.orbit }}</b></span>
|
||||
<span>{{ t('ASCENT PATH · ') }}<b data-mission-ascent-source>{{ m?.ascent }}</b></span>
|
||||
<span data-mission-field :hidden="m && !m.distance">{{ t('CURRENT DISTANCE FROM EARTH · ') }}<b data-mission-distance>{{ m?.distance }}</b></span>
|
||||
<span data-mission-field :hidden="m && !m.speed">{{ t('SATELLITE SPEED · ') }}<b data-mission-speed :title="m?.speedTitle">{{ m?.speed }}</b></span>
|
||||
</div>
|
||||
<section class="mission-data-section">
|
||||
<h4>{{ t('PAYLOAD') }}</h4>
|
||||
<div class="mission-table-scroll">
|
||||
<table class="mission-data-table">
|
||||
<thead><tr><th>{{ t('NAME') }}</th><th>{{ t('TYPE') }}</th><th>{{ t('DESTINATION') }}</th></tr></thead>
|
||||
<tbody data-mission-payloads>
|
||||
<template v-if="m"><tr v-if="!m.payloads.length"><td colspan="3" class="mission-table-empty">{{ t('PAYLOAD DATA UNAVAILABLE') }}</td></tr><tr v-for="(payload, index) in m.payloads" :key="index"><td>{{ payload.name }}<small v-if="payload.detail">{{ payload.detail }}</small></td><td>{{ payload.type }}</td><td>{{ payload.destination }}</td></tr><tr v-if="m.payloadsMore"><td colspan="3" class="mission-table-empty">{{ m.payloadsMore }}</td></tr></template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mission-data-section" data-mission-stages-section :hidden="m && !m.stages.length">
|
||||
<h4>{{ t('STAGE / RE-ENTRY / RECOVERY') }}</h4>
|
||||
<div class="mission-table-scroll">
|
||||
<table class="mission-data-table">
|
||||
<thead><tr><th>{{ t('STAGE') }}</th><th>{{ t('STATUS') }}</th><th>{{ t('FINAL POSITION') }}</th></tr></thead>
|
||||
<tbody data-mission-stages>
|
||||
<template v-if="m"><tr v-if="!m.stages.length"><td colspan="3" class="mission-table-empty">{{ t('NO STAGE RE-ENTRY / RECOVERY DATA') }}</td></tr><tr v-for="(stage, index) in m.stages" :key="index"><td>{{ stage.name }}<small v-if="stage.detail">{{ stage.detail }}</small></td><td>{{ stage.status }}</td><td>{{ stage.destination }}<small>{{ stage.position }}</small></td></tr></template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<div class="mission-replay-speed-control" :hidden="m && !m.replay.available">
|
||||
<div class="mission-replay-speed-header"><label for="space-mission-replay-speed">{{ t('REPLAY SPEED') }}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>{{ m ? m.replay.speedText : '1×' }}</output></div>
|
||||
<input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed :aria-label="t('Replay speed multiplier')" v-field-value="m?.replay.speed" :style="m && { '--replay-speed-progress': m.replay.speedProgress }" @input="layer?.setReplaySpeed($event.currentTarget.value)">
|
||||
<div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div>
|
||||
</div>
|
||||
<div class="mission-action-row">
|
||||
<button type="button" class="mission-focus-button" data-mission-focus @click="layer?.focusSelectedLaunchSite()">{{ t('FOCUS') }}</button>
|
||||
<button type="button" class="mission-replay-button" data-mission-replay :aria-pressed="String(m?.replay.active ?? false)" :title="t('Replay the estimated ascent with a following camera')" :hidden="m && (m.replay.active || !m.replay.available)" :disabled="m && !m.replay.available" @click="layer?.startSelectedReplay()">{{ t('REPLAY ASCENT') }}</button>
|
||||
<div class="mission-replay-transport" :class="{ 'is-paused': m?.replay.paused }" data-mission-replay-transport :hidden="!m?.replay.active" :aria-label="m?.replay.transportLabel">
|
||||
<button type="button" data-mission-replay-toggle :title="m ? m.replay.toggleLabel : t('Pause replay')" :aria-label="m ? m.replay.toggleLabel : t('Pause replay')" :disabled="m && !m.replay.active" @click="layer?.toggleReplayPause()">{{ m ? m.replay.toggleText : 'Ⅱ' }}</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel :title="t('Cancel replay')" :aria-label="t('Cancel replay')" @click="layer?.cancelReplay()"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-mission-nav">
|
||||
<button type="button" class="mission-nav-button" data-mission-prev :title="t('Previous mission')" :disabled="m?.previousDisabled" @click="layer?.selectPreviousMission()"><span aria-hidden="true">‹</span> {{ t('PREV') }}</button>
|
||||
<span class="mission-nav-index" data-mission-index>{{ m ? m.index : '—' }}</span>
|
||||
<button type="button" class="mission-nav-button" data-mission-next :title="t('Next mission')" :disabled="m?.nextDisabled" @click="layer?.selectNextMission()">{{ t('NEXT') }} <span aria-hidden="true">›</span></button>
|
||||
</div>
|
||||
<button type="button" class="panel-layer-toggle" data-mission-show-all @click="layer?.deselectMission()">{{ t('SHOW ALL / DESELECT') }}</button>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeUpdate, onUpdated, ref } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { useContextStore } from '../../stores/context.js';
|
||||
|
||||
// The Space Missions roster. The launches layer presents it; until it does,
|
||||
// the list shows its loading line. Hover and keyboard focus drive the
|
||||
// layer's preview, and a click selects the mission.
|
||||
const { missionRoster: roster, missionsLayer: layer } = storeToRefs(useContextStore());
|
||||
const root = ref(null);
|
||||
|
||||
const CONTINUATION = '[data-mission-roster-focus-continuation]';
|
||||
const rosterIndex = (el) => {
|
||||
const item = el?.closest?.('[data-mission-roster-index]');
|
||||
return item && root.value?.contains(item) ? Number(item.dataset.missionRosterIndex) : null;
|
||||
};
|
||||
|
||||
// A changed roster resets the layer's preview. The old list rebuilt its
|
||||
// rows, so the next pointer movement entered the row under the pointer; a
|
||||
// row Vue keeps re-enters on its next movement the same way.
|
||||
let pointerStale = false;
|
||||
function move(index) {
|
||||
if (!pointerStale) return;
|
||||
pointerStale = false;
|
||||
layer.value?.rosterPointerEnter(index);
|
||||
}
|
||||
function enter(index) {
|
||||
pointerStale = false;
|
||||
layer.value?.rosterPointerEnter(index);
|
||||
}
|
||||
|
||||
function blur(event) {
|
||||
layer.value?.rosterBlur(rosterIndex(event.relatedTarget));
|
||||
}
|
||||
|
||||
// A refresh keeps keyboard focus on the same mission by identity: browsers
|
||||
// drop focus from a node Vue moves or removes, so it is found again by id,
|
||||
// or continues beyond the list when the mission departed. A changed roster
|
||||
// resets the layer's preview, so a mission still focused takes it back.
|
||||
let focusedId = null;
|
||||
onBeforeUpdate(() => {
|
||||
const active = document.activeElement;
|
||||
focusedId = root.value?.contains(active) ? active.closest?.('[data-mission-roster-id]')?.dataset.missionRosterId ?? null : null;
|
||||
});
|
||||
onUpdated(() => {
|
||||
pointerStale = true;
|
||||
const id = focusedId;
|
||||
focusedId = null;
|
||||
const list = root.value;
|
||||
if (!id || !list) return;
|
||||
const active = document.activeElement;
|
||||
if (!list.contains(active)) {
|
||||
const retained = [...list.querySelectorAll('[data-mission-roster-id]')].find((item) => item.dataset.missionRosterId === id);
|
||||
(retained || list.querySelector(CONTINUATION))?.focus({ preventScroll: true });
|
||||
}
|
||||
const index = rosterIndex(document.activeElement);
|
||||
if (index !== null) layer.value?.rosterFocus(index);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="space-mission-roster" ref="root" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
|
||||
<div class="space-mission-roster-header">
|
||||
<div>
|
||||
<strong data-i18n>AVAILABLE MISSIONS</strong>
|
||||
<span data-i18n>SELECT A MISSION TO INSPECT</span>
|
||||
</div>
|
||||
<output data-mission-roster-count>{{ roster ? roster.count : '—' }}</output>
|
||||
</div>
|
||||
<div class="space-mission-roster-list" data-mission-roster-list>
|
||||
<div v-if="!roster" class="space-mission-roster-empty">{{ t('LOADING 30-DAY MISSION INDEX') }}</div>
|
||||
<div v-else-if="!roster.entries.length" class="space-mission-roster-empty">{{ roster.empty }}</div>
|
||||
<button v-for="entry in roster?.entries" v-else :key="entry.id" type="button" class="space-mission-roster-item" :data-mission-roster-index="entry.index" :data-mission-roster-id="entry.id" :aria-label="entry.ariaLabel" @click="layer?.selectMissionAt(entry.index)" @mouseenter="enter(entry.index)" @mousemove="move(entry.index)" @mouseleave="layer?.rosterPointerLeave()" @focus="layer?.rosterFocus(entry.index)" @blur="blur"><span class="space-mission-roster-marker" :style="{ '--mission-roster-color': entry.color }" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>{{ entry.label }}</strong><small>{{ entry.provider }} · {{ entry.date }}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>
|
||||
</div>
|
||||
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user