Merge branch 'main' into upstream/traffic-paired-flow-density

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-11-02 06:51:22 -08:00
commit 2e9c146060
644 files changed
+42033 -19462

No files matched your search

+18
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { normalizeAlprNode } from './records.js';
import {
directionWedgePositions,
alprLabelDetails,
@@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => {
);
});
test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => {
const direction = (tag) =>
alprLabelDetails(
normalizeAlprNode({
type: 'node',
id: 1,
lat: 30,
lon: -97,
tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag },
}),
{ label: 'Custom directory' },
).find((line) => line.startsWith('DIRECTION'));
assert.equal(direction('359.6'), 'DIRECTION 0°');
assert.equal(direction('359.4'), 'DIRECTION 359°');
assert.equal(direction('0.4'), 'DIRECTION 0°');
});
test('overlay glyphs and native badges shrink alike with distance', () => {
assert.equal(markerScale(100), 1.15);
assert.equal(markerScale(40_000), 0.45);
+2 -1
View File
@@ -5,6 +5,7 @@ import {
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
@@ -50,7 +51,7 @@ export function alprLabelDetails(record, source) {
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
];
if (Number.isFinite(record.directionDeg))
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
const equipment = [
...new Set(
[record.manufacturer, record.operator, record.cameraType]
@@ -0,0 +1,185 @@
// src/layers/awareness/directionOverlay.test.mjs — the Global Context compass
// and contact bearings are presented to subscribers and rendered from a store
// inside the viewer's container.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useAwarenessDirectionStore } from '../../ui/stores/awarenessDirection.js';
import AwarenessDirection from '../../ui/views/panels/AwarenessDirection.vue';
import { createState } from './state.js';
import { createRendering } from './rendering.js';
/** The rendering part over a subject with one military contact due east. */
function overlay(screenAngle) {
const state = createState({ services: {} });
const container = document.body.appendChild(document.createElement('div'));
const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
Object.assign(state, {
viewer: {
container,
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
camera: {
heading: 0,
rightWC: new Cesium.Cartesian3(0, 1, 0),
upWC: new Cesium.Cartesian3(0, 0, 1),
},
},
enabled: true,
subject: { position: subject, layerId: 'military' },
results: {
cohorts: [
{
id: 'military',
summary: {
nearest: [
{
callsign: 'RCH123',
position: Cesium.Cartesian3.fromDegrees(0.05, 0, 1000),
distanceM: 5500,
track: 90,
},
],
},
},
],
},
cohortPages: new Map(),
});
const rendering = createRendering({
state,
services: {
geometry: {
getKeyholeGeometry: () => ({ centerX: 400, centerY: 300, radius: 250 }),
celestialScreenAngle:
screenAngle ||
((x, y) => ({
angle: Math.atan2(-y, x),
opacity: 1,
stable: true,
})),
},
render: { governorRequestRender() {} },
},
parts: {},
});
return { state, container, rendering };
}
test('the overlay publishes the compass and the nearest contact bearings', () => {
const { rendering, container } = overlay();
const views = [];
rendering.subscribeDirectionOverlay((view) => views.push(view));
assert.deepEqual(views, [null]);
rendering.updateDirectionOverlay();
const view = views.at(-1);
assert.ok(view.container === container);
assert.equal(view.hidden, false);
// The compass sits at 66% of the 250px keyhole: 165px.
assert.equal(view.ring.width, '330px');
assert.equal(view.ring['--compass-rotation'], '0deg');
assert.equal(view.heading.text, 'HDG 000°');
assert.deepEqual(
view.labels.map((label) => label.label),
['N', 'E', 'S', 'W'],
);
assert.equal(view.labels[0].left, '400px');
const [marker, second] = view.markers;
assert.equal(marker.hidden, false);
assert.match(marker.caption.text, /^RCH123 · .+\nBRG 090° · CRS 090°$/);
assert.equal(marker.arrow.opacity, '1');
assert.equal(second.hidden, true);
// An unchanged frame publishes nothing.
const count = views.length;
rendering.updateDirectionOverlay();
assert.equal(views.length, count);
rendering.hideDirectionOverlay();
assert.equal(views.at(-1).hidden, true);
assert.equal(views.at(-1).heading.text, 'HDG 000°', 'placement is kept');
rendering.removeDirectionOverlay();
assert.equal(views.at(-1), null);
});
test('an unstable frame keeps the last stable bearing, until the overlay is removed', () => {
const answers = [
{ angle: 1, opacity: 1, stable: true },
{ angle: 2, opacity: 0.5, stable: false },
];
const previous = [];
const { state, rendering } = overlay((x, y, last) => {
previous.push(last);
return answers.shift() ?? { angle: last ?? 0, opacity: 1, stable: true };
});
rendering.updateDirectionOverlay();
assert.equal(state.directionAngles[0], 1);
// The heading turns, so the next frame recomputes the contact.
state.viewer.camera.heading = 0.1;
rendering.updateDirectionOverlay();
assert.equal(previous[1], 1, 'the unstable frame is given the last angle');
assert.equal(state.directionAngles[0], 1, 'and does not replace it');
rendering.removeDirectionOverlay();
rendering.updateDirectionOverlay();
assert.equal(previous[2], 0, 'a rebuilt overlay starts afresh');
});
test('the scene chrome view renders the published overlay in its container', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const store = useAwarenessDirectionStore(pinia);
const app = createApp(AwarenessDirection).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const { rendering, container } = overlay();
rendering.subscribeDirectionOverlay((view) => store.publish(view));
rendering.ensureDirectionOverlay();
await nextTick();
const root = container.querySelector('#military-awareness-direction-overlay');
assert.equal(root.hidden, true);
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.deepEqual(
[...root.children].map((child) => child.className),
[
'military-awareness-compass-label cardinal-n',
'military-awareness-compass-label cardinal-e',
'military-awareness-compass-label cardinal-s',
'military-awareness-compass-label cardinal-w',
'military-awareness-compass-ring',
'military-awareness-compass-heading',
'military-awareness-direction-marker',
'military-awareness-direction-marker',
'military-awareness-direction-marker',
],
);
assert.equal(root.children[0].dataset.bearing, '0');
assert.equal(root.children[0].getAttribute('style'), null);
rendering.updateDirectionOverlay();
await nextTick();
assert.equal(root.hidden, false);
const ring = root.querySelector('.military-awareness-compass-ring');
assert.equal(ring.style.getPropertyValue('--compass-rotation'), '0deg');
assert.equal(
root.querySelector('.military-awareness-compass-heading').textContent,
'HDG 000°',
);
const [marker, second] = root.querySelectorAll(
'.military-awareness-direction-marker',
);
assert.equal(marker.hidden, false);
assert.equal(
marker.querySelector('.military-awareness-direction-arrow').textContent,
'➜',
);
assert.match(
marker.querySelector('.military-awareness-direction-label').textContent,
/^RCH123 · /,
);
assert.equal(second.hidden, true);
rendering.removeDirectionOverlay();
await nextTick();
assert.equal(
container.querySelector('#military-awareness-direction-overlay'),
null,
);
});
+4 -3
View File
@@ -51,12 +51,13 @@ export function createAwarenessLayer({ services, source }) {
parts.queries.collectAircraftProximityWindow,
_getAwarenessNavigationStateForTest:
parts.testing._getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest:
parts.testing._getAwarenessPanelRendersForTest,
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
historySubjectSnapshot: parts.history.historySubjectSnapshot,
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
subscribePanel: parts.panel.subscribePanel,
subscribeDirectionOverlay: parts.rendering.subscribeDirectionOverlay,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
+2 -16
View File
@@ -229,22 +229,8 @@ export function createLifecycle({
destroy() {
this.disable();
detachRuntimeListeners();
layerState.panel?.removeEventListener(
'click',
layerState.panelClickListener,
);
layerState.panelClickListener = null;
if (layerState.panelOwned) layerState.panel?.remove();
else if (layerState.panel) layerState.panel.replaceChildren();
layerState.panel = null;
layerState.panelOwned = false;
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
layerState.panelMarkup = '';
parts.panel.clearPanel();
parts.rendering.removeDirectionOverlay();
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
+132 -164
View File
@@ -2,9 +2,15 @@ import {
formatAwarenessLabel,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
import {
formatNumber,
onLocaleChange,
t,
translateKnown,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
@@ -26,13 +32,11 @@ function presentAwarenessCount(summary = {}) {
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
const parts = distanceParts(meters);
if (!parts) return '—';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
@@ -43,76 +47,95 @@ function presentAwarenessReason(summary = {}) {
: translateKnown(summary.reason || '');
}
export function createPanel({ state: layerState, services, parts, source }) {
/**
* The Contacts panel as a view renders it. The layer presents a plain view
* object (`standby`, `results` or `empty`) and publishes it to subscribers;
* Core's AwarenessPanel component renders it and calls the layer's
* navigation methods. Text arrives in the UI language.
*/
export function createPanel({ state: layerState, parts }) {
/** Publish a fresh view unless it is the one already shown. */
function publish(view) {
const signature = JSON.stringify(view);
if (layerState.panelSignature === signature) return;
layerState.panelSignature = signature;
layerState.panelView = view;
for (const listener of [...layerState.panelListeners]) listener(view);
}
/**
* Receive every published view, starting with the current one.
* @param {(view: object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribePanel(listener) {
layerState.panelListeners.add(listener);
if (layerState.panelView) listener(layerState.panelView);
return () => layerState.panelListeners.delete(listener);
}
/** The panel shows layer views from the first enable or result onwards. */
function ensurePanel() {
if (layerState.panel) return layerState.panel;
const existing = document.getElementById('military-awareness-panel');
const panel = existing || document.createElement('aside');
layerState.panelOwned = !existing;
if (!existing) {
panel.id = 'military-awareness-panel';
}
layerState.panelClickListener = (event) => {
const action = event.target.closest('button[data-awareness-action]');
if (action) {
event.preventDefault();
if (action.dataset.awarenessAction === 'previous')
parts.history.navigateHistory(-1, { origin: 'user' });
if (action.dataset.awarenessAction === 'next')
parts.history.navigateHistory(1, { origin: 'user' });
if (action.dataset.awarenessAction === 'focus')
parts.focus.focusCurrentSubject({ origin: 'user' });
return;
}
const target = event.target.closest(
'button[data-awareness-layer][data-awareness-id]',
);
if (!target) return;
event.preventDefault();
parts.focus.requestFocus(
target.dataset.awarenessLayer,
target.dataset.awarenessId,
false,
{ origin: 'user' },
);
};
panel.addEventListener('click', layerState.panelClickListener);
if (!existing) document.body.appendChild(panel);
layerState.panel = panel;
return panel;
layerState.panelShown = true;
// A shown view is worded again in a new locale.
layerState.offPanelLocale ??= onLocaleChange(() => {
const kind = layerState.panelView?.kind;
if (kind === 'results') renderResults();
else if (kind === 'standby') hidePanel();
});
}
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
layerState.panel.innerHTML = markup;
layerState.panelMarkup = markup;
}
}
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
if (layerState.panelShown)
publish({
kind: 'standby',
title: layerState.enabled
? t('CONTEXT READY')
: t('GLOBAL CONTEXT OFF'),
text: layerState.enabled
? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION')
: t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'),
});
parts.rendering.hideDirectionOverlay();
}
function rowHtml(cohort) {
/** Clear the panel when the layer is destroyed. */
function clearPanel() {
if (layerState.panelShown) publish({ kind: 'empty' });
layerState.offPanelLocale?.();
layerState.offPanelLocale = null;
layerState.panelShown = false;
layerState.panelView = null;
layerState.panelSignature = '';
}
function presentRow(cohort) {
const summary = cohort.summary;
const count = presentAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
.map((item) => {
.map((item, index) => {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const distance = presentAwarenessDistance(item.distanceM);
if (!targetId)
return {
key: `unavailable:${page + index}`,
label,
distance,
ariaLabel: t('Unavailable'),
};
const accessibleLabel = label === '—' ? t('Unavailable') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
return {
key: `target:${cohort.id}:${targetId}`,
layer: cohort.id,
targetId: String(targetId),
label,
distance,
ariaLabel: t('Focus {label}', { label: accessibleLabel }),
names: presentNames(item.memberNames),
};
});
const pageCount = Math.max(
1,
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
@@ -121,109 +144,58 @@ export function createPanel({ state: layerState, services, parts, source }) {
pageCount > 1
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
: '';
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
const coverage = cohort.coverage ? ` · ${cohort.coverage}` : '';
return {
id: cohort.id,
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`;
function presentNavigation() {
return {
label: t('Global Context navigation'),
previous: {
text: t('PREVIOUS'),
title: t('Previous — prior visited contact in the 250 km window'),
disabled: !(layerState.navigationIndex > 0),
},
focus: { text: t('FOCUS') },
next: {
text: t('NEXT'),
title: t('Next — nearest unvisited contact in the 250 km window'),
disabled: !parts.navigation.canNavigateNext(),
},
};
}
/** Stable identity for a delegated Contacts-panel button across live repaints. */
function awarenessPanelControlKey(element) {
const control = element?.closest?.(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
);
if (!control) return null;
if (control.dataset.awarenessAction)
return `action:${control.dataset.awarenessAction}`;
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
}
/** Capture stable identity so a live repaint can restore only the same control. */
function captureAwarenessPanelFocus(
panel,
activeElement = document.activeElement,
) {
if (!panel?.contains?.(activeElement)) return null;
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
return { key: 'continuation' };
}
const key = awarenessPanelControlKey(activeElement);
if (!key) return null;
return { key };
}
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
function restoreAwarenessPanelFocus(panel, snapshot) {
if (!panel || !snapshot) return null;
const continuation = panel.querySelector(
'[data-awareness-focus-continuation]',
);
const controls = [
...panel.querySelectorAll(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
),
].filter((control) => !control.disabled);
const retained =
snapshot.key === 'continuation'
? continuation
: controls.find(
(control) => awarenessPanelControlKey(control) === snapshot.key,
);
const target = retained || continuation;
target?.focus?.({ preventScroll: true });
return target || null;
}
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>'"]/g,
(char) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char],
);
}
function namedAreasHtml(names) {
function presentNames(names) {
return names?.length
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
? {
summary: t('Named areas ({n})', { n: formatNumber(names.length) }),
names: [...names].map((name) => String(name ?? '')),
}
: null;
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
ensurePanel();
layerState.panelRenders += 1;
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
layerState.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
publish({
kind: 'results',
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
subjectNames: presentNames(subject.memberNames),
navigation: presentNavigation(),
rows: cohorts.map(presentRow),
note: t(
'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.',
),
});
}
function rotateAwarenessPages() {
@@ -277,14 +249,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
}
return {
subscribePanel,
ensurePanel,
hidePanel,
rowHtml,
navigationControlsHtml,
awarenessPanelControlKey,
captureAwarenessPanelFocus,
restoreAwarenessPanelFocus,
escapeHtml,
clearPanel,
renderResults,
rotateAwarenessPages,
startAwarenessPageRotation,
+3 -2
View File
@@ -1,3 +1,5 @@
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Format a compass direction for the awareness overlay.
* @param {number} degrees
@@ -5,6 +7,5 @@
*/
export function formatAwarenessDirection(degrees) {
if (!Number.isFinite(degrees)) return '—';
const normalized = ((degrees % 360) + 360) % 360;
return String(Math.round(normalized) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
+116 -81
View File
@@ -22,55 +22,75 @@ export function createRendering({
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
const { governorRequestRender } = services.render;
const CARDINALS = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
/**
* The direction overlay as its view renders it: the compass ring, its
* cardinals and heading, and up to three contact markers inside the
* viewer's container. Each part keeps its last placement while hidden.
*/
function ensureDirectionOverlay() {
if (layerState.directionRoot || !layerState.viewer)
return layerState.directionRoot;
const root = document.createElement('div');
root.id = 'military-awareness-direction-overlay';
root.hidden = true;
root.setAttribute('aria-hidden', 'true');
const compass = document.createElement('div');
compass.className = 'military-awareness-compass-ring';
const cardinals = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
layerState.compassLabels = cardinals.map(([label, bearing]) => {
const element = document.createElement('span');
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
element.textContent = label;
element.dataset.bearing = String(bearing);
root.appendChild(element);
return element;
if (layerState.directionView || !layerState.viewer)
return layerState.directionView;
presentDirection({
container: layerState.viewer.container,
hidden: true,
ring: null,
heading: { text: '', left: null, top: null },
labels: CARDINALS.map(([label, bearing]) => ({
label,
bearing,
left: null,
top: null,
})),
markers: Array.from({ length: 3 }, () => ({
hidden: false,
style: null,
arrow: null,
caption: { text: '', style: null },
})),
});
const heading = document.createElement('span');
heading.className = 'military-awareness-compass-heading';
root.append(compass, heading);
return layerState.directionView;
}
layerState.directionMarkers = Array.from({ length: 3 }, () => {
const marker = document.createElement('div');
marker.className = 'military-awareness-direction-marker';
const arrow = document.createElement('span');
arrow.className = 'military-awareness-direction-arrow';
arrow.textContent = '➜';
const label = document.createElement('span');
label.className = 'military-awareness-direction-label';
marker.append(arrow, label);
marker._arrow = arrow;
marker._label = label;
marker._lastAngle = 0;
root.appendChild(marker);
return marker;
});
/** Publish a change to the overlay, skipping one that changes nothing. */
function presentDirection(patch) {
const next = patch && { ...layerState.directionView, ...patch };
const signature = JSON.stringify(next, (name, value) =>
name === 'container' ? undefined : value,
);
if (next && signature === layerState.directionSignature) return;
layerState.directionSignature = next ? signature : '';
layerState.directionView = next && Object.freeze(next);
for (const listener of [...layerState.directionListeners])
listener(layerState.directionView);
}
layerState.compassRing = compass;
layerState.compassHeading = heading;
layerState.viewer.container.appendChild(root);
layerState.directionRoot = root;
return root;
/**
* Receive the direction overlay now and after every change.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribeDirectionOverlay(listener) {
layerState.directionListeners.add(listener);
listener(layerState.directionView);
return () => layerState.directionListeners.delete(listener);
}
/** Hide the overlay, keeping its last placement. */
function hideDirectionOverlay() {
if (layerState.directionView) presentDirection({ hidden: true });
}
/** Withdraw the overlay when the layer is destroyed. */
function removeDirectionOverlay() {
if (layerState.directionView) presentDirection(null);
layerState.directionAngles = [0, 0, 0];
}
function scheduleDirectionOverlayUpdate(force = false) {
@@ -104,7 +124,7 @@ export function createRendering({
!layerState.subject?.position ||
!layerState.results
) {
if (root) root.hidden = true;
if (root) presentDirection({ hidden: true });
return;
}
@@ -116,41 +136,43 @@ export function createRendering({
: null;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
presentDirection({ hidden: true });
return;
}
root.hidden = false;
// Keep the compass comfortably inside the shader keyhole so its cardinals
// remain visible around the subject instead of hiding under edge panels.
const compassRadius = Math.max(
120,
Math.min(geometry.radius - 48, geometry.radius * 0.66),
);
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
layerState.compassRing.style.width = `${compassRadius * 2}px`;
layerState.compassRing.style.height = `${compassRadius * 2}px`;
const cameraHeading = layerState.viewer.camera.heading || 0;
const headingDeg =
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
layerState.compassRing.style.setProperty(
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
const ring = {
left: `${geometry.centerX - compassRadius}px`,
top: `${geometry.centerY - compassRadius}px`,
width: `${compassRadius * 2}px`,
height: `${compassRadius * 2}px`,
'--compass-rotation': `${-headingDeg}deg`,
};
const heading = {
text: t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
}),
left: `${geometry.centerX}px`,
top: `${geometry.centerY - compassRadius + 39}px`,
};
const labels = root.labels.map((label) => {
const bearing = Cesium.Math.toRadians(label.bearing);
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
const labelRadius = compassRadius - 17;
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
}
return {
...label,
left: `${geometry.centerX + Math.cos(angle) * labelRadius}px`,
top: `${geometry.centerY + Math.sin(angle) * labelRadius}px`,
};
});
const directionalCohort =
layerState.results.cohorts.find(
@@ -163,8 +185,9 @@ export function createRendering({
page,
page + AWARENESS_PAGE_SIZE,
) || [];
for (let index = 0; index < layerState.directionMarkers.length; index++) {
const marker = layerState.directionMarkers[index];
const markers = root.markers.map((marker) => ({ ...marker }));
for (let index = 0; index < markers.length; index++) {
const marker = markers[index];
const item = military[index];
if (!item?.position) {
marker.hidden = true;
@@ -183,27 +206,32 @@ export function createRendering({
const projection = celestialScreenAngle(
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
marker._lastAngle,
layerState.directionAngles[index],
);
if (projection.stable) marker._lastAngle = projection.angle;
if (projection.stable)
layerState.directionAngles[index] = projection.angle;
// Every detected contact shares the compass rim. Keeping the bearing
// markers on one radius makes them read as compass contacts rather than
// unconstrained labels floating over the map.
const markerRadius = compassRadius;
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
marker.style = {
left: `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`,
top: `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`,
opacity: String(projection.opacity),
};
const sonarFactor =
sonar?.at(
geometry.centerX + Math.cos(projection.angle) * markerRadius,
geometry.centerY + Math.sin(projection.angle) * markerRadius,
) ?? 1;
marker._arrow.style.opacity = String(sonarFactor);
marker._label.style.opacity = String(
sonar ? sonar.label(sonarFactor) : 1,
);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
marker.arrow = {
opacity: String(sonarFactor),
transform: `translate(-50%, -50%) rotate(${projection.angle}rad)`,
};
const labelStyle = {
opacity: String(sonar ? sonar.label(sonarFactor) : 1),
transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`,
};
const label = formatAwarenessLabel(item);
const subjectCartographic = Cesium.Cartographic.fromCartesian(
layerState.subject.position,
@@ -234,9 +262,13 @@ export function createRendering({
value: formatAwarenessDirection(item.track),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.caption = {
text: `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`,
style: labelStyle,
};
marker.hidden = false;
}
presentDirection({ hidden: false, ring, heading, labels, markers });
}
function clearVisual() {
@@ -303,6 +335,9 @@ export function createRendering({
}
return {
ensureDirectionOverlay,
subscribeDirectionOverlay,
hideDirectionOverlay,
removeDirectionOverlay,
scheduleDirectionOverlayUpdate,
cancelDirectionOverlayUpdate,
updateDirectionOverlay,
+14 -9
View File
@@ -29,20 +29,26 @@ export function createState({ services }) {
subject: null,
results: null,
visual: null,
panel: null,
panelOwned: false,
/** Whether layer views replace the panel's initial markup yet. */
panelShown: false,
/** The published panel view, its signature and its subscribers. */
panelView: null,
panelSignature: '',
panelListeners: new Set(),
/** Result renders, published or not; tests observe repaint storms. */
panelRenders: 0,
subjectListener: null,
contextListener: null,
clearListener: null,
subjectClearListener: null,
runtimeListenersAttached: false,
preRenderRemover: null,
panelClickListener: null,
directionRoot: null,
compassRing: null,
compassLabels: [],
compassHeading: null,
directionMarkers: [],
/** The published direction overlay, its signature and its subscribers. */
directionView: null,
directionSignature: '',
directionListeners: new Set(),
/** Each marker's last stable screen angle. */
directionAngles: [0, 0, 0],
navigationHistory: [],
navigationVisited: new Set(),
navigationIndex: -1,
@@ -57,7 +63,6 @@ export function createState({ services }) {
cameraMoving: false,
lastEvaluatedPosition: null,
sourceRevision: '',
panelMarkup: '',
directionFrame: null,
lastDirectionUpdateMs: 0,
activationId: 0,
+8 -1
View File
@@ -11,5 +11,12 @@ export function createTesting({ state: layerState, services, parts, source }) {
pendingSelectionKey: layerState.pendingSelectionKey,
};
}
return { _getAwarenessNavigationStateForTest };
/** @returns {number} How many result renders ran, published or not. */
function _getAwarenessPanelRendersForTest() {
return layerState.panelRenders;
}
return {
_getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest,
};
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* @module headingConfidence
@@ -44,7 +45,7 @@ export function isHeadingEstimated(camera) {
* @returns {string}
*/
export function headingHudToken(camera) {
const hdg = Math.round(Number(camera?.headingDeg) || 0);
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
@@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => {
);
});
test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => {
assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°');
assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°');
assert.equal(
headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }),
'HDG 0° (ESTIMATED)',
);
});
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
assert.equal(
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
+4 -1
View File
@@ -1,5 +1,5 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { msg, t } from '../../i18n/index.js';
import { isolate, msg, t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import {
@@ -97,6 +97,8 @@ export function createPresentation({
const health = layerState._healthById.get(active.camera.id) || null;
const calBadge = parts.calibration.deriveCalBadge(active.camera);
// Each part is isolated: in right-to-left text a code keeps its own value
// (FOV 44°) instead of trading it with its neighbour.
return [
t('{city} CCTV', {
city: active.camera.city.toUpperCase(),
@@ -140,6 +142,7 @@ export function createPresentation({
: null,
]
.filter(Boolean)
.map(isolate)
.join(' · ');
}
+4 -1
View File
@@ -16,6 +16,7 @@ import {
} from './labels.js';
import {
formatUtcDayMonth,
isolate,
t,
tn,
msg,
@@ -25,9 +26,11 @@ import { formatQuantity } from '../../ui/unitLabels.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
// The clock is isolated (a no-op outside RTL): a no-break space between two
// numbers joins them into one run, which in Arabic split the date around it.
const utc = (value) =>
value
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${isolate(value.slice(11, 16))}\u00a0UTC`
: t('Unavailable');
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
+7 -11
View File
@@ -31,6 +31,7 @@ import {
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
import { distanceParts } from '../../data/distanceFormat.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -108,17 +109,12 @@ export const FLIGHT_PROGRESS_MS = 250;
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
const parts = distanceParts(meters, { metreStep: 10, exactBelowM: 100 });
if (!parts) return '';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
/**
+8 -4
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import { formatNumber, getLocale, isolate, t, tn } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -407,7 +407,6 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
@@ -419,7 +418,12 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: 10,
accent: candidate.accent || accentForSeverity('yellow'),
title: `${cell.count} ${noun}`,
// The count picks the plural form of the language ("حريقان (2)",
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
// other card titles.
title: tn(cell.count, '{n} fire', '{n} fires', {
n: cell.count,
}).toLocaleUpperCase(getLocale()),
details: [parts.join(' · ')],
selected: false,
priority: Number(cell.maxFrp) || 0,
@@ -513,7 +517,7 @@ export function createModel({
lon >= 0
? t('{value}°E', { value: amount(lon) })
: t('{value}°W', { value: amount(lon) });
return `${latPart} ${lonPart}`;
return `${isolate(latPart)} ${isolate(lonPart)}`;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
+22
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createCivilFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createEnrichment } from './enrichment.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
await new Promise((done) => setImmediate(done));
assert.equal(flightState._enrichActive, 0);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: services(),
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, altitudeM: 9000 }),
_contactLabel: () => 'AAL1',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('abc123').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -24,6 +24,7 @@ import {
TRAIL_COLOR,
CYAN_TRANSPARENT,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -126,7 +127,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
route: route || '',
icao24,
+3 -4
View File
@@ -42,7 +42,10 @@ export function createRocketLaunchesLayer({ services, source }) {
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
parts.panel.commands,
{
subscribePanel: parts.panel.subscribePanel,
subscribeReplayOverlay: parts.overlays.subscribeReplayOverlay,
satelliteParamsForSpaceMissions:
parts.policyHelpers.satelliteParamsForSpaceMissions,
satelliteParamsAfterSpaceMissions:
@@ -69,10 +72,6 @@ export function createRocketLaunchesLayer({ services, source }) {
missionHoverPreviewRange: parts.panel.missionHoverPreviewRange,
createMissionRosterPreviewOwnership:
parts.panel.createMissionRosterPreviewOwnership,
bindMissionRosterItemKeyboardPreview:
parts.panel.bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus: parts.panel.captureMissionRosterFocus,
restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch:
parts.panel.resolveMissionRosterPreviewLaunch,
formatMissionEventTime: parts.policyHelpers.formatMissionEventTime,
+1
View File
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
renderMissionRoster: noop,
renderMissionPanel: noop,
clearMissionRosterHover: noop,
clearMissionPanel: noop,
},
replay: { stopMissionReplay: noop },
selection: {
+1 -8
View File
@@ -201,7 +201,6 @@ export function createLifecycle({
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
parts.selection.focusFullGlobe(layerState._viewer);
document.getElementById('cockpit-context')?.setAttribute('hidden', '');
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
layerState._selectedLaunchId,
@@ -262,13 +261,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', () => {
+62 -77
View File
@@ -280,60 +280,64 @@ export function createOverlays({ state: layerState, services, parts, source }) {
return ascending ? 'ascent' : 'orbit';
}
function createReplayVehicleOverlay() {
if (layerState._replayVehicleOverlay || typeof document === 'undefined')
/**
* Present the replay vehicle overlay to its subscribers, or null to remove
* it. An unchanged presentation is not sent again.
*/
function presentReplayVehicleOverlay(patch) {
const current = layerState._replayVehicleOverlay;
const next = patch && { ...current, ...patch };
if (
next === current ||
(next &&
current &&
Object.keys(next).every((k) => next[k] === current[k]))
)
return;
const host = document.getElementById('cesiumContainer') || document.body;
layerState._replayVehicleOverlay = document.createElement('div');
layerState._replayVehicleOverlay.className =
'mission-replay-vehicle-overlay';
layerState._replayVehicleOverlay.hidden = true;
layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true');
layerState._replayVehicleOverlay.innerHTML = `
<div class="mission-replay-flight-symbol">
<svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true">
<path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path>
<circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle>
</svg>
<svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true">
<ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse>
<ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse>
<ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse>
<ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse>
<ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse>
<ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse>
</svg>
</div>
<div class="mission-replay-orbit-dot" aria-hidden="true"></div>
<div class="mission-replay-overlay-callout">
<strong data-replay-overlay-title></strong>
<span data-replay-overlay-detail></span>
</div>`;
host.appendChild(layerState._replayVehicleOverlay);
layerState._replayVehicleOverlay._sonarIcons = [
...layerState._replayVehicleOverlay.querySelectorAll(
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
),
];
layerState._replayVehicleOverlay._sonarLabel =
layerState._replayVehicleOverlay.querySelector(
'.mission-replay-overlay-callout',
);
layerState._replayVehicleOverlay = next;
for (const listener of [...layerState._replayVehicleOverlayListeners])
listener(next);
}
/**
* Receive every presentation of the replay vehicle overlay, starting with
* the current one.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribeReplayOverlay(listener) {
layerState._replayVehicleOverlayListeners.add(listener);
listener(layerState._replayVehicleOverlay);
return () => layerState._replayVehicleOverlayListeners.delete(listener);
}
function createReplayVehicleOverlay() {
if (layerState._replayVehicleOverlay) return;
presentReplayVehicleOverlay({
visible: false,
mode: null,
thrusting: false,
paused: false,
transform: null,
rotation: null,
iconOpacity: null,
labelOpacity: null,
title: '',
detail: '',
});
}
function hideReplayVehicleOverlay() {
if (!layerState._replayVehicleOverlay) return;
layerState._replayVehicleOverlay.hidden = true;
layerState._replayVehicleOverlay.classList.remove(
'is-thrusting',
'is-paused',
);
presentReplayVehicleOverlay({
visible: false,
thrusting: false,
paused: false,
});
}
function destroyReplayVehicleOverlay() {
layerState._replayVehicleOverlay?.remove();
layerState._replayVehicleOverlay = null;
layerState._replayVehicleOverlayText = '';
presentReplayVehicleOverlay(null);
}
function clearMissionOverlaySources() {
@@ -523,23 +527,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
: null;
const factor =
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
icon.style.opacity = String(factor);
if (layerState._replayVehicleOverlay._sonarLabel)
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
sonar ? sonar.label(factor) : 1,
);
layerState._replayVehicleOverlay.hidden = false;
layerState._replayVehicleOverlay.dataset.mode = mode;
layerState._replayVehicleOverlay.classList.toggle(
'is-thrusting',
replayActive && state.ascending && !state.countdownActive,
);
layerState._replayVehicleOverlay.classList.toggle(
'is-paused',
replayActive && layerState._replayPaused,
);
layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`;
const frame = {
visible: true,
mode,
thrusting: replayActive && state.ascending && !state.countdownActive,
paused: replayActive && layerState._replayPaused,
transform: `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`,
iconOpacity: String(factor),
labelOpacity: String(sonar ? sonar.label(factor) : 1),
};
let vehicleRotation = 0;
if (replayActive && !state.countdownActive) {
const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1));
@@ -581,10 +577,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
}
}
}
layerState._replayVehicleOverlay.style.setProperty(
'--mission-replay-rotation',
`${Cesium.Math.toDegrees(vehicleRotation)}deg`,
);
frame.rotation = `${Cesium.Math.toDegrees(vehicleRotation)}deg`;
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite);
@@ -620,16 +613,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
layerState._replayVehicleOverlay.querySelector(
'[data-replay-overlay-title]',
).textContent = title;
layerState._replayVehicleOverlay.querySelector(
'[data-replay-overlay-detail]',
).textContent = detail;
}
presentReplayVehicleOverlay({ ...frame, title, detail });
return mode;
}
return {
@@ -643,6 +627,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
missionHoverReticleImage,
replayOverlayMode,
createReplayVehicleOverlay,
subscribeReplayOverlay,
hideReplayVehicleOverlay,
destroyReplayVehicleOverlay,
clearMissionOverlaySources,
+280 -363
View File
@@ -1,11 +1,20 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { formatNumber, isolate, onLocaleChange, t } from '../../i18n/index.js';
import {
MISSION_CLOSE_VIEW_RANGE_M,
MISSION_GLOBE_VIEW_RANGE_M,
} from './policy.js';
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
// Landing endpoints carry an English accuracy code (the panel and the map
// entity compare it); only the text shown in the stage table is translated.
// The downrange estimate arrives already translated.
function accuracyLabel(accuracy) {
if (accuracy === 'CONFIRMED') return t('CONFIRMED');
if (accuracy === 'PAD / RTLS') return t('PAD / RTLS');
return accuracy;
}
export function createPanel({ state: layerState, services, parts, source }) {
/**
* Preserve the user's globe scale for roster previews while avoiding an
@@ -88,68 +97,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 +105,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)
? `${formatNumber(payload.massKg)} 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
? `${isolate(`${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)}`)} · ${accuracyLabel(endpoint.accuracy)}`
: stage.downrangeKm > 0
? t('{value} KM DOWNRANGE', {
value: formatNumber(stage.downrangeKm),
})
: t('POSITION UNAVAILABLE');
const detail = [
Number.isFinite(stage.flightNumber)
? t('FLIGHT {count}', {
count: stage.flightNumber,
})
: null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
.join(' · ');
return {
name: String(stage.name ?? ''),
detail,
status: String(stage.status ?? ''),
destination: String(destination ?? ''),
position,
};
}
/**
* The selected mission as the panel shows it, or null when none is
* selected and the panel hides. Text arrives in the UI language.
*/
function presentMission() {
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
const index = launch ? layerState._launches.indexOf(launch) : -1;
layerState._missionPanel.hidden = !launch;
if (!launch) return;
layerState._missionPanel.querySelector('[data-mission-title]').textContent =
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
setMissionPanelField('[data-mission-provider]', launch.provider);
// Launch Library status names are provider data, shown as sent; only the
// fallback for a launch without one is ours to translate.
setMissionPanelField(
'[data-mission-status]',
launch.status === UNKNOWN_LAUNCH_STATUS
? t(UNKNOWN_LAUNCH_STATUS)
: launch.status,
);
setMissionPanelField(
'[data-mission-site]',
launch.launchSite && launch.launchSite !== 'Unknown launch site'
? launch.launchSite
: null,
);
setMissionPanelField('[data-mission-time]', launch.launchTime);
if (!launch) return null;
const index = layerState._launches.indexOf(launch);
const pathPresentation = parts.policyHelpers.missionPathPresentation(
launch,
layerState._replayTracks.has(launch.id),
);
setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit);
layerState._missionPanel.querySelector(
'[data-mission-ascent-source]',
).textContent = pathPresentation.ascent;
const payloadRows = launch.payloads.length
? launch.payloads.slice(0, 5).map((payload) => {
const detail = [
payload.manufacturer,
payload.operator && payload.operator !== payload.manufacturer
? payload.operator
: null,
Number.isFinite(payload.massKg)
? `${payload.massKg.toLocaleString()} KG`
: null,
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
})
: [];
if (launch.payloads.length > 5) {
payloadRows.push(
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
);
}
layerState._missionPanel.querySelector(
'[data-mission-payloads]',
).innerHTML = missionTableRows(
payloadRows,
3,
t('PAYLOAD DATA UNAVAILABLE'),
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
const destination =
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
const telemetry = layerState._missionTelemetry;
return {
scrollRequest: layerState._missionPanelScrollRequest,
title: parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(),
provider: presentField(launch.provider),
// Launch Library status names are provider data, shown as sent; only
// the fallback for a launch without one is ours to translate.
status: presentField(
launch.status === UNKNOWN_LAUNCH_STATUS
? t(UNKNOWN_LAUNCH_STATUS)
: launch.status,
),
site: presentField(
launch.launchSite && launch.launchSite !== 'Unknown launch site'
? launch.launchSite
: t('UNAVAILABLE'));
const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
: stage.downrangeKm > 0
? t('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(),
})
: t('POSITION UNAVAILABLE');
const stageDetail = [
Number.isFinite(stage.flightNumber)
? t('FLIGHT {count}', {
count: stage.flightNumber,
: null,
),
time: presentField(launch.launchTime),
orbit: presentField(pathPresentation.orbit),
ascent: pathPresentation.ascent ?? '',
distance: telemetry?.distance ?? null,
speed: telemetry?.speed ?? null,
speedTitle: telemetry?.speedTitle ?? null,
payloads: launch.payloads
.slice(0, 5)
.map((payload) => presentPayload(payload, launch)),
payloadsMore:
launch.payloads.length > 5
? t('+{count} additional payload records', {
count: launch.payloads.length - 5,
})
: null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
});
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
if (stageSection) stageSection.hidden = stageRows.length === 0;
updateMissionTelemetry(true);
layerState._missionPanel.querySelector('[data-mission-index]').textContent =
`${index + 1} / ${layerState._launches.length}`;
layerState._missionPanel.querySelector('[data-mission-prev]').disabled =
index <= 0;
layerState._missionPanel.querySelector('[data-mission-next]').disabled =
index < 0 || index >= layerState._launches.length - 1;
parts.replay.syncReplayButton();
const panelScroller = layerState._missionPanel.closest(
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
);
if (panelScroller) panelScroller.scrollTop = 0;
stages: launch.recoveryStages.map((stage) => presentStage(stage, launch)),
index: `${index + 1} / ${layerState._launches.length}`,
previousDisabled: index <= 0,
nextDisabled: index < 0 || index >= layerState._launches.length - 1,
replay: parts.replay.presentReplay(),
};
}
function renderMissionRoster() {
if (!layerState._missionRoster) return;
const list = layerState._missionRoster.querySelector(
'[data-mission-roster-list]',
);
const count = layerState._missionRoster.querySelector(
'[data-mission-roster-count]',
);
if (count)
count.textContent = t('{count} / 30D', {
count: layerState._launches.length,
});
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
layerState._missionRosterPreviewOwnership?.reset();
const entries = parts.policyHelpers.missionRosterEntries(
layerState._launches,
);
if (!entries.length) {
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
layerState._missionRoster.querySelector(
'[data-mission-roster-focus-continuation]',
),
);
return;
}
list.innerHTML = entries
/** Present the newly selected mission and return its panel to the top. */
function renderMissionPanel() {
if (!layerState._missionPanelReady) return;
layerState._missionTelemetry = null;
updateMissionTelemetry(true, false);
layerState._missionPanelScrollRequest++;
publishPanel();
}
/** The roster entries as the list shows them. */
function presentRoster() {
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
const color = parts.model.missionMarkerColor(launch).toCssColorString();
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
const label = parts.overlays
.shortMissionLabel(launch.name, 27)
.toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
})
.join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
const index = Number(button.dataset.missionRosterIndex);
bindMissionRosterItemKeyboardPreview(
button,
index,
layerState._missionRosterPreviewOwnership,
layerState._missionRoster,
);
});
restoreMissionRosterFocus(
list,
focusSnapshot,
layerState._missionRoster.querySelector(
'[data-mission-roster-focus-continuation]',
),
);
return {
index,
id: String(launch.id ?? ''),
color: parts.model.missionMarkerColor(launch).toCssColorString(),
label,
ariaLabel: t('Select {mission}', { mission: label }),
provider: launch.provider || t('UNSPECIFIED OPERATOR'),
date: launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE'),
};
});
return {
count: t('{count} / 30D', { count: layerState._launches.length }),
entries,
empty: t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'),
};
}
function escapeMissionText(value) {
return String(value ?? '').replace(
/[&<>"']/g,
(character) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character],
);
/**
* Present the roster. A changed roster drops the hover and keyboard
* preview; the list restores keyboard focus by mission identity, which
* resumes the preview.
*/
function renderMissionRoster() {
if (!layerState._missionPanelReady) return;
const roster = presentRoster();
const signature = JSON.stringify(roster);
if (signature !== layerState._missionRosterSignature) {
layerState._missionRosterSignature = signature;
layerState._missionRosterView = roster;
layerState._missionRosterPreviewOwnership?.reset();
}
publishPanel();
}
function updateMissionTelemetry(force = false) {
/**
* Read the selected mission's altitude and speed, at most four times a
* second unless forced, and republish when they change.
*/
function updateMissionTelemetry(force = false, republish = true) {
if (
!layerState._missionPanel ||
!layerState._missionPanelReady ||
!layerState._selectedLaunchId ||
!layerState._dataSource
)
@@ -381,33 +328,30 @@ 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, {
value: formatNumber(Math.max(0, altitudeM / 1000), {
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, {
value: formatNumber(speedMps / 1000, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
})
: null,
Number.isFinite(speedMps)
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
: '',
);
speedTitle: Number.isFinite(speedMps)
? `${formatNumber(speedMps * 3.6, { maximumFractionDigits: 0 })} km/h`
: null,
};
if (republish) publishPanel();
}
function selectMissionAt(index) {
@@ -451,121 +395,94 @@ 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,
});
// The roster and mission are worded again in a new locale.
layerState._offMissionLocale = onLocaleChange(() => {
renderMissionRoster();
updateMissionTelemetry(true, false);
publishPanel();
});
publishPanel();
}
/** Stop presenting; the components return to their initial markup. */
function clearMissionPanel() {
layerState._missionPanelReady = false;
layerState._offMissionLocale?.();
layerState._offMissionLocale = null;
layerState._missionRosterPreviewOwnership = null;
layerState._missionRosterView = null;
layerState._missionRosterSignature = null;
layerState._missionTelemetry = null;
layerState._replayTransportLabel = null;
publish({ roster: null, mission: null });
}
return {
missionHoverPreviewRange,
createMissionRosterPreviewOwnership,
bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus,
restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch,
missionTableRows,
setMissionPanelField,
subscribePanel,
publishPanel,
presentMission,
renderMissionPanel,
renderMissionRoster,
escapeMissionText,
updateMissionTelemetry,
selectMissionAt,
clearMissionRosterPreviewState,
clearMissionRosterHover,
scheduleMissionRosterPreview,
createMissionPanel,
clearMissionPanel,
commands,
};
}
+196
View File
@@ -0,0 +1,196 @@
// The launches layer presents its roster and selected mission as plain views
// and publishes them to subscribers; Core's mission components render them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
import { createState } from './state.js';
function fixture() {
const state = createState({
services: { overlays: {}, render: {} },
});
const calls = [];
const parts = {
overlays: {
shortMissionLabel: (name) => name,
syncMissionOverlayEntries: () => calls.push(['overlays']),
},
policyHelpers: {
missionRosterEntries: (launches) =>
launches.map((launch, index) => ({ launch, index })),
missionPathPresentation: () => ({ orbit: 'LEO', ascent: 'ESTIMATED' }),
previewMissionFromRoster: (launch) => calls.push(['preview', launch.id]),
},
model: {
missionMarkerColor: () => ({ toCssColorString: () => 'rgb(1, 2, 3)' }),
},
selection: {
setSelectedMission: (id, isolate) => calls.push(['select', id, isolate]),
focusMission: (launch) => calls.push(['focus-mission', launch.id]),
focusLaunchSite: (launch) => calls.push(['focus-site', launch.id]),
},
replay: {
presentReplay: () => ({ available: false }),
startMissionReplay: (id) => calls.push(['replay', id]),
stopMissionReplay: () => calls.push(['cancel']),
pauseMissionReplay: () => calls.push(['pause']),
resumeMissionReplay: () => calls.push(['resume']),
setReplaySpeed: (value) => calls.push(['speed', value]),
},
};
const panel = createPanel({ state, parts });
const views = [];
panel.subscribePanel((view) => views.push(view));
return { state, panel, views, calls };
}
const launch = (id, extra = {}) => ({
id,
name: `Mission ${id}`,
provider: 'SpaceX',
status: 'Go',
launchSite: 'Unknown launch site',
launchTime: '2026-11-01T10:00:00Z',
payloads: [],
recoveryStages: [],
...extra,
});
test('the panel publishes only once the layer starts presenting', () => {
const { state, panel, views } = fixture();
state._launches = [launch('a')];
panel.renderMissionRoster();
assert.equal(views.length, 0);
panel.createMissionPanel();
assert.deepEqual(views, [{ roster: null, mission: null }]);
panel.renderMissionRoster();
const { roster } = views.at(-1);
assert.equal(roster.count, '1 / 30D');
assert.deepEqual(roster.entries, [
{
index: 0,
id: 'a',
color: 'rgb(1, 2, 3)',
label: 'MISSION A',
ariaLabel: 'Select MISSION A',
provider: 'SpaceX',
date: '2026-11-01',
},
]);
const late = [];
panel.subscribePanel((view) => late.push(view));
assert.equal(late[0], views.at(-1));
panel.clearMissionPanel();
assert.deepEqual(views.at(-1), { roster: null, mission: null });
});
test('an unchanged roster keeps its view and the preview; a changed one drops it', async () => {
const { state, panel, views, calls } = fixture();
state._launches = [launch('a'), launch('b')];
panel.createMissionPanel();
panel.renderMissionRoster();
const published = views.length;
const roster = views.at(-1).roster;
panel.commands.rosterFocus(1);
assert.equal(state._hoveredRosterLaunchId, 'b');
panel.renderMissionRoster();
assert.equal(views.length, published);
assert.equal(state._hoveredRosterLaunchId, 'b');
await new Promise((resolve) => setTimeout(resolve, 160));
assert.deepEqual(calls.at(-1), ['preview', 'b']);
state._launches = [launch('b'), launch('a')];
panel.renderMissionRoster();
assert.equal(state._hoveredRosterLaunchId, null);
assert.notEqual(views.at(-1).roster, roster);
// The list restores focus on the mission, which takes the preview back.
panel.commands.rosterFocus(0);
assert.equal(state._hoveredRosterLaunchId, 'b');
panel.clearMissionPanel();
});
test('the selected mission is presented with its rows and returns to the top', () => {
const { state, panel, views } = fixture();
state._launches = [
launch('a'),
launch('b', {
payloads: Array.from({ length: 6 }, (_, index) => ({
name: `Sat ${index}`,
amount: index === 0 ? 2 : 1,
manufacturer: 'Maker',
massKg: 500,
})),
recoveryStages: [
{
name: 'B1080',
status: 'Landed',
flightNumber: 9,
reused: true,
endpoint: { lat: 28.4, lon: -77.1, accuracy: 'DRONESHIP' },
},
],
}),
];
panel.createMissionPanel();
state._selectedLaunchId = 'b';
panel.renderMissionPanel();
const first = views.at(-1).mission;
assert.equal(first.title, 'MISSION B');
assert.equal(first.site, null);
assert.equal(first.orbit, 'LEO');
assert.equal(first.index, '2 / 2');
assert.equal(first.previousDisabled, false);
assert.equal(first.nextDisabled, true);
assert.equal(first.payloads.length, 5);
assert.deepEqual(first.payloads[0], {
name: 'Sat 0 ×2',
detail: 'Maker · 500 KG',
type: 'UNSPECIFIED',
destination: 'UNAVAILABLE',
});
assert.equal(first.payloadsMore, '+1 additional payload records');
assert.deepEqual(first.stages, [
{
name: 'B1080',
detail: 'FLIGHT 9 · REUSED',
status: 'Landed',
destination: 'UNAVAILABLE',
position: '28.40, -77.10 · DRONESHIP',
},
]);
panel.renderMissionPanel();
assert.equal(views.at(-1).mission.scrollRequest, first.scrollRequest + 1);
state._selectedLaunchId = null;
panel.renderMissionPanel();
assert.equal(views.at(-1).mission, null);
});
test('the panel commands act on the selected mission', () => {
const { state, panel, calls } = fixture();
state._launches = [launch('a'), launch('b'), launch('c')];
state._selectedLaunchId = 'b';
const { commands } = panel;
commands.selectPreviousMission();
commands.selectNextMission();
commands.selectMissionAt(9);
commands.focusSelectedLaunchSite();
commands.startSelectedReplay();
commands.toggleReplayPause();
state._replayPaused = true;
commands.toggleReplayPause();
commands.cancelReplay();
commands.setReplaySpeed('2');
commands.deselectMission();
assert.deepEqual(calls, [
['select', 'a', true],
['focus-mission', 'a'],
['select', 'c', true],
['focus-mission', 'c'],
['focus-site', 'b'],
['replay', 'b'],
['pause'],
['resume'],
['cancel'],
['speed', '2'],
['select', null, undefined],
]);
});
-34
View File
@@ -1,34 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
for (const cyber of [true, false]) {
const body = { scrollTop: 180 };
const inner = { scrollTop: 90 };
const output = { closest: () => null, removeAttribute() {} };
const state = {
_missionPanel: {
querySelector: () => output,
closest(selector) {
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
return cyber ? body : inner;
},
},
_selectedLaunchId: 'mission-1',
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
_replayTracks: new Map(),
};
const panel = createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
});
panel.renderMissionPanel();
assert.equal(body.scrollTop, cyber ? 0 : 180);
assert.equal(inner.scrollTop, cyber ? 90 : 0);
}
});
+35 -65
View File
@@ -139,8 +139,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
0,
Number(preCountdownDurationSec) || 0,
);
// The hold precedes the whole countdown; ending it at -hold cut T-10 to T-5.
const preCountdownActive =
preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration;
preCountdownDuration > 0 &&
realSecondsSinceStart < -REPLAY_COUNTDOWN_DURATION_SEC;
const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive;
const countdownSeconds = countdownActive
? Math.ceil(-realSecondsSinceStart)
@@ -179,60 +181,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 +227,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) {
@@ -281,7 +249,8 @@ export function createReplay({ state: layerState, services, parts, source }) {
? layerState._replayPausedAtMs
: Date.now();
for (const [launchId, startedAt] of layerState._animationStarts) {
if (!Number.isFinite(startedAt)) continue;
// A countdown runs in real time; rescaling it turned T-8 into T-32 at 0.25x.
if (!Number.isFinite(startedAt) || startedAt > now) continue;
const elapsedMissionMs = (now - startedAt) * previousSpeed;
layerState._animationStarts.set(
launchId,
@@ -548,6 +517,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
replayStartAfterPause,
replayState,
syncReplayButton,
presentReplay,
syncReplayCountdownButton,
syncReplaySpeedControl,
setReplaySpeed,
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createReplay } from './replay.js';
import { createPaths } from './paths.js';
const noop = () => {};
function replayRig() {
const state = createState({
services: {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
},
});
const parts = { panel: { publishPanel: noop } };
parts.paths = createPaths({ state, parts });
return { state, replay: createReplay({ state, parts }) };
}
test('changing replay speed during the countdown keeps its real-time liftoff', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
const { state, replay } = replayRig();
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
state._animationStarts.set('countdown', 1_008_000); // T-8
state._animationStarts.set('flying', 990_000); // 10 s into the mission
replay.setReplaySpeed(0.25);
const startedAt = state._animationStarts.get('countdown');
assert.equal(startedAt, 1_008_000);
const countdown = replay.replayState(
launch,
startedAt,
12,
28,
5400,
state._replaySpeed,
Date.now(),
);
assert.equal(countdown.countdownSeconds, 8);
// Mission time already flown is still preserved across the change.
assert.equal(state._animationStarts.get('flying'), 960_000);
});
+121
View File
@@ -0,0 +1,121 @@
// src/layers/launches/replayOverlay.test.mjs — the launches layer presents
// its replay vehicle overlay to subscribers, and the scene chrome renders it
// inside the globe container.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useMissionReplayStore } from '../../ui/stores/missionReplay.js';
import SceneChromeView from '../../ui/views/SceneChromeView.vue';
import { createOverlays } from './overlays.js';
function overlays() {
const state = {
_replayVehicleOverlay: null,
_replayVehicleOverlayListeners: new Set(),
};
const parts = createOverlays({ state, services: {}, parts: {}, source: {} });
return Object.assign(parts, { state });
}
test('subscribers get the overlay as it is created, hidden and removed, never twice the same', () => {
const parts = overlays();
const views = [];
const unsubscribe = parts.subscribeReplayOverlay((view) => views.push(view));
assert.deepEqual(views, [null], 'a subscriber starts from the current one');
parts.createReplayVehicleOverlay();
parts.createReplayVehicleOverlay();
assert.equal(views.length, 2);
assert.equal(views[1].visible, false);
parts.hideReplayVehicleOverlay();
assert.equal(views.length, 2, 'an unchanged overlay is not sent again');
parts.destroyReplayVehicleOverlay();
assert.equal(views.at(-1), null);
parts.hideReplayVehicleOverlay();
unsubscribe();
parts.createReplayVehicleOverlay();
assert.equal(views.length, 3);
});
test('the scene chrome renders the overlay in the globe container', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(SceneChromeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const store = useMissionReplayStore(pinia);
const node = () => document.querySelector('.mission-replay-vehicle-overlay');
const parts = overlays();
parts.subscribeReplayOverlay((view) => store.publish(view));
await nextTick();
assert.equal(node(), null);
parts.createReplayVehicleOverlay();
await nextTick();
const element = node();
assert.equal(element.parentElement.id, 'cesiumContainer');
assert.equal(element.hidden, true);
assert.equal(element.getAttribute('aria-hidden'), 'true');
assert.equal(element.hasAttribute('data-mode'), false);
assert.equal(
element.querySelectorAll('.mission-replay-thrust ellipse').length,
6,
);
// A frame as updateReplayVehicleOverlay presents it.
parts.state._replayVehicleOverlay = {
...parts.state._replayVehicleOverlay,
visible: true,
mode: 'ascent',
thrusting: true,
paused: false,
transform: 'translate3d(10px, 20px, 0) translate(-50%, -50%)',
rotation: '12deg',
iconOpacity: '0.5',
labelOpacity: '1',
title: 'ASCENT REPLAY · TEST',
detail: 'T+00:10',
};
store.publish(parts.state._replayVehicleOverlay);
await nextTick();
assert.equal(element.hidden, false);
assert.equal(element.dataset.mode, 'ascent');
assert.ok(element.classList.contains('is-thrusting'));
assert.equal(element.classList.contains('is-paused'), false);
assert.equal(
element.style.getPropertyValue('--mission-replay-rotation'),
'12deg',
);
assert.match(element.style.transform, /translate3d\(10px, 20px, 0(px)?\)/);
assert.equal(
element.querySelector('.mission-replay-flight-symbol').style.opacity,
'0.5',
);
assert.equal(
element.querySelector('.mission-replay-orbit-dot').style.opacity,
'0.5',
);
assert.equal(
element.querySelector('.mission-replay-overlay-callout').style.opacity,
'1',
);
assert.equal(
element.querySelector('[data-replay-overlay-title]').textContent,
'ASCENT REPLAY · TEST',
);
assert.equal(
element.querySelector('[data-replay-overlay-detail]').textContent,
'T+00:10',
);
parts.hideReplayVehicleOverlay();
await nextTick();
assert.equal(element.hidden, true);
assert.equal(element.classList.contains('is-thrusting'), false);
assert.equal(
element.dataset.mode,
'ascent',
'hiding keeps the mode, as before',
);
parts.destroyReplayVehicleOverlay();
await nextTick();
assert.equal(node(), null);
});
+18 -3
View File
@@ -53,9 +53,21 @@ export function createState({ services }) {
state._launches = [];
state._missionPanel = null;
state._missionPanelReady = false;
state._missionRoster = null;
state._missionPanelView = null;
state._missionPanelSignature = null;
state._missionPanelListeners = new Set();
state._missionRosterView = null;
state._missionRosterSignature = null;
state._missionTelemetry = null;
state._missionPanelScrollRequest = 0;
state._missionRosterHoverTimer = null;
@@ -65,9 +77,10 @@ export function createState({ services }) {
state._missionHoverReticleImage = null;
// The replay vehicle overlay as its view renders it; null without one.
state._replayVehicleOverlay = null;
state._replayVehicleOverlayText = '';
state._replayVehicleOverlayListeners = new Set();
state._animationStarts = new Map();
@@ -99,6 +112,8 @@ export function createState({ services }) {
state._replayPaused = false;
state._replayTransportLabel = null;
state._replayPausedAtMs = null;
state._replaySpeed = 1;
+2 -1
View File
@@ -1,6 +1,7 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const DASH = '—';
@@ -152,7 +153,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track % 360}°`,
track: track === null ? DASH : `${roundHeadingDegrees(track)}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
+25
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createMilitaryFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const stubs = services();
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: stubs,
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }),
_toCleanText: (value) => value || '',
_formatAltitude: () => '',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('ae1234').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -25,6 +25,7 @@ import {
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -120,7 +121,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
+20
View File
@@ -1,3 +1,5 @@
import { getLocale } from '../../i18n/index.js';
export function createPresentation({
state: layerState,
services,
@@ -10,9 +12,26 @@ export function createPresentation({
return layerState._acceptedCatalogSnapshot;
}
/** Build the station categories, labelled in the current locale. */
function buildCategories() {
layerState._categories = Object.freeze(
parts.categories
.buildRadioCategories(layerState._stations)
.map((category) => Object.freeze(category)),
);
layerState._categoriesLocale = getLocale();
}
/** Return an immutable snapshot consumed by the right-rail UI. */
function getRadioUIState() {
// Labels follow a language switch the next time the state is read.
if (
layerState._categories.length &&
layerState._categoriesLocale !== getLocale()
)
buildCategories();
const visible = parts.queries.visibleStations();
const selected = parts.queries.selectedStation();
return Object.freeze({
@@ -98,6 +117,7 @@ export function createPresentation({
return {
getRadioAcceptedCatalogSnapshot,
getRadioUIState,
buildCategories,
emitState,
subscribeToRadio,
subscribeToRadioPlaybackControls,
+1 -5
View File
@@ -295,11 +295,7 @@ export function createRendering({
layerState._stationById = new Map(
stations.map((station) => [station.id, station]),
);
layerState._categories = Object.freeze(
parts.categories
.buildRadioCategories(stations)
.map((category) => Object.freeze(category)),
);
parts.presentation.buildCategories();
if (
layerState._filter === DEFAULT_RADIO_FILTER &&
!parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER)
+1
View File
@@ -38,6 +38,7 @@ export function createState({ services }) {
layerState._stationById = new Map();
layerState._categories = [];
layerState._categoriesLocale = null;
layerState._renderById = new Map();
+2 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
@@ -123,6 +123,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
name: entry.name,
satrec,
group: 'dense',
launchYear: tleLineLaunchYear(entry.line1),
});
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.fromDegrees(
+3 -1
View File
@@ -1,7 +1,7 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
export function createIngestion({
@@ -129,6 +129,8 @@ export function createIngestion({
name: entry.name,
satrec,
group: entry.group,
// satellite.js does not keep the designator the year match reads.
launchYear: tleLineLaunchYear(entry.line1),
});
coreStale ||= entry.stale;
+42 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTleText } from '../../sources/tle.js';
import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js';
import { createIngestion } from './ingestion.js';
import { createOrbits } from './orbits.js';
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
const ISS_LINE1 =
@@ -78,3 +79,43 @@ test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
assert.equal(state._count, 3);
});
test('a mission payload does not match a loaded satellite from another launch year', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
// SXM-11, international designator 2026-148A.
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26148A 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
const track = (launchTime) =>
getSatelliteOrbitTrack('Sirius SXM-11', { launchTime });
assert.equal(track('2026-06-29T02:25:00Z')?.noradId, 69728);
assert.equal(track('2030-06-29T02:25:00Z'), null);
});
test('a satellite with a blank designator keeps matching by name', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
assert.equal(tleLineLaunchYear('1 69728U 26208.65166678'), null);
assert.equal(
getSatelliteOrbitTrack('Sirius SXM-11', {
launchTime: '2030-06-29T02:25:00Z',
})?.noradId,
69728,
);
});
+6 -16
View File
@@ -8,7 +8,11 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
import {
noradIdFromSatnum,
parseTleText,
tleLineLaunchYear,
} from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -258,13 +262,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return score;
}
function internationalDesignatorYear(satrec) {
const match = String(satrec?.intldesg || '').match(/^(\d{2})/);
if (!match) return null;
const shortYear = Number(match[1]);
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function lookupTleEntries(tleText) {
if (tleText !== layerState._lookupTleText) {
layerState._lookupTleText = tleText;
@@ -273,12 +270,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return layerState._lookupTleEntries;
}
function tleLineLaunchYear(line1) {
const shortYear = Number(String(line1 || '').slice(9, 11));
if (!Number.isFinite(shortYear)) return null;
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function orbitTrackFromRecord(name, satrec) {
const referenceDate = new Date();
const current = propagatePosition(satrec, referenceDate);
@@ -350,7 +341,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
: null;
let bestScore = 0;
for (const [id, sat] of layerState._catalog) {
const designatorYear = internationalDesignatorYear(sat.satrec);
const designatorYear = sat.launchYear ?? null;
if (
launchYear !== null &&
designatorYear !== null &&
@@ -379,7 +370,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
tleLineLaunchYear,
orbitTrackFromRecord,
+14
View File
@@ -235,6 +235,20 @@ test('dense provenance belongs to its admitted records and clears on removal', a
);
});
test('dense extras keep their launch year for mission orbit matching', async (t) => {
const f = await fixture(t);
await f.update();
f.state._params.catalog = 'dense';
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
// Every fixture set carries the 1998-067A designator.
const track = (launchTime) =>
f.parts.orbits.getSatelliteOrbitTrack('OWNED ORBIT FIXTURE 25551', {
launchTime,
});
assert.equal(track('1998-11-20T06:40:00Z')?.noradId, 25551);
assert.equal(track('2026-06-29T02:25:00Z'), null);
});
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
const f = await fixture(t);
await f.update();
+2 -1
View File
@@ -365,8 +365,9 @@ export function createStreetLevelLayer({
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
// No-break spaces keep a provider name (Google Street View) on one line.
return t('zoom in to a street to open {name}', {
name: translateKnown(entry.def.name),
name: translateKnown(entry.def.name).replace(/ /g, '\u00a0'),
});
return stats.hint || '';
}
+13 -2
View File
@@ -259,7 +259,8 @@ export function createControls({ state: layerState, services, parts, source }) {
* `error` carries this instant's health, so a live-configured layer whose
* flow feed went down reads DEGRADED with the reason instead of a stale
* LIVE coverage number. `flowCoveragePct` is shown dots on matched roads /
* all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests
* all shown dots (0–100 int, kept within 1–99 while both kinds are shown),
* excluding closures; `tilesFetched` counts flow-tile requests
* issued to the proxy this session (decode-cache hits excluded).
* @returns {{count:number, lastUpdate:number|null, loading:boolean,
* mode:'live'|'sim', error:string|null, flowCoveragePct:number,
@@ -304,8 +305,18 @@ export function createControls({ state: layerState, services, parts, source }) {
Boolean(layerState._heightPending);
const { free, slow, jam, sim } = layerState._bucketCounts;
const matched = free + slow + jam;
// The label reads 0 as "no matches" and 100 as nothing simulated, so
// rounding must not reach either while both kinds of dot are shown.
const flowCoveragePct =
matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0;
matched + sim > 0
? Math.min(
sim > 0 ? 99 : 100,
Math.max(
matched > 0 ? 1 : 0,
Math.round((100 * matched) / (matched + sim)),
),
)
: 0;
const feed = parts.model.trafficFeedPresentation({
liveMode: layerState._liveMode,
fetching: loading,
+20
View File
@@ -516,6 +516,26 @@ test('late closures hide and stop dots; coverage counts shown matched dots, and
assert.equal(controls.getStats().flowCoveragePct, 80);
});
test('coverage never rounds to "no matches" or full coverage while both kinds of dot are shown', async () => {
const { state, controls } = await matchingContext(async () => []);
state._bucketCounts = { free: 1, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 1);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 1% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 1 };
assert.equal(controls.getStats().flowCoveragePct, 99);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 99% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 0 };
assert.equal(controls.getStats().flowCoveragePct, 100);
state._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 0);
});
test('refresh failure removes cached matches and reports simulated flow', async () => {
const { flow, state, model, controls } = await matchingContext(async () => {
throw new Error('HTTP 429');
+4 -1
View File
@@ -9,6 +9,7 @@ import {
transitRouteLabel,
} from '../../data/transitFeeds.js';
import { displayMotion, playbackPosition } from './movement.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -407,7 +408,9 @@ export function buildTransitSelectionCopy(
);
if (Number.isFinite(record.bearing))
reportedMotion.push(
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
t('reported hdg {heading}°', {
heading: roundHeadingDegrees(record.bearing),
}),
);
// The operator's own claim, kept as a REPORT rather than as the live state.
// It describes the moment of the report, which the display may not have
+12
View File
@@ -43,6 +43,18 @@ test('the selection card calls a Toronto 501 a streetcar', () => {
assert.doesNotMatch(copy.details.join(' | '), /Tram/);
});
test('the selection card reads a bearing that rounds to north as hdg 0°', () => {
const hdg = (bearing) =>
buildTransitSelectionCopy(
ttc(),
{ id: '4400', routeId: '501', timestamp: 0, bearing },
'tram',
0,
).details.find((line) => line.includes('hdg'));
assert.equal(hdg(359.4), 'reported hdg 359°');
assert.equal(hdg(359.6), 'reported hdg 0°');
});
test('detection labels carry the operator vocabulary within the field width', () => {
const klass = transitDetectionClass('tram', ttc());
assert.equal(klass, 'STREETCAR TTC');
+19 -20
View File
@@ -4,6 +4,7 @@ import {
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { formatNumber, t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createCards({
vesselState,
@@ -13,28 +14,23 @@ export function createCards({
options,
}) {
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
// Pinned vessels missing from recent refreshes get a stale marker
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n');
services.readouts?.presentSelectedVessel({
text: [
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n'),
});
}
function resetSelectedVesselHud() {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = t('AIS: --');
services.readouts?.presentSelectedVessel(null);
}
function trimHudValue(value, maxLength) {
@@ -60,7 +56,8 @@ export function createCards({
if (record.speed !== null && record.speed !== undefined)
parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
if (Number.isFinite(direction))
parts.push(`${roundHeadingDegrees(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
@@ -91,7 +88,9 @@ export function createCards({
[
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
Number.isFinite(direction)
? `${roundHeadingDegrees(direction)}°`
: '--°',
].join(' · '),
];
const destination = String(record.destination || '').trim();
@@ -181,7 +180,7 @@ export function createCards({
function formatHeading(heading) {
return t('{heading}DEG', {
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
heading: roundHeadingDegrees(heading) ?? '--',
});
}
+2 -5
View File
@@ -6,11 +6,8 @@ import {
windReadingResult,
windUnitChips,
} from './presentation.js';
import {
inspectWindAtCenter,
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
import { inspectWindAtCenter, WIND_UNITS } from './inspection.js';
import { createWindInspectionMarker } from './inspectionMarker.js';
import { t, msg, translateKnown } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
+10 -8
View File
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import { applicationPinia } from '../../ui/vueRuntime.js';
const snapshot = (model) => ({
model,
@@ -302,12 +304,12 @@ test('weather summary describes the selected forecast and count remains numeric'
});
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
const nodes = [];
// The marker the scene chrome renders, as the wind layer presents it.
const marker = () => useWindInspectionStore(applicationPinia()).marker;
let listener;
let samples = 0;
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
ownerDocument: {},
getBoundingClientRect: () => ({ left: 0, top: 0 }),
};
const viewer = {
@@ -338,9 +340,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
layer.setParams({ inspect: true });
const captured = layer.getRowControls().summary.reading;
assert.equal(samples, 1);
const retained = nodes[0];
const retained = marker().owner;
layer.setParams({ units: 'mph' });
assert.equal(nodes[0], retained);
assert.ok(marker().owner === retained);
const changed = layer.getRowControls().summary.reading;
assert.equal(changed.speed, captured.speed);
assert.equal(changed.coordinates, captured.coordinates);
@@ -348,7 +350,7 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
assert.equal(samples, 1, 'units do not resample');
assert.equal(layer.getRowControls().summary.result.id, 'reading');
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
assert.equal(nodes.length, 1);
assert.ok(marker());
assert.equal(typeof listener, 'function');
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
layer.setParams({ overlay: 'speed' });
@@ -368,9 +370,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
assert.equal(samples, 2, 'the next explicit read samples the new center');
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
layer.setParams({ inspect: true });
assert.equal(nodes.length, 1);
assert.ok(marker());
change();
assert.equal(nodes.length, 0);
assert.equal(marker(), null);
assert.equal(layer.getRowControls().summary.reading, null);
assert.equal(layer.getRowControls().summary.result, null);
assert.equal(listener, null);
-59
View File
@@ -112,62 +112,3 @@ export function inspectWindAtCenter(
),
};
}
/** One passive marker for the captured location; it never samples on camera movement. */
export function createWindInspectionMarker({ container, viewer, cesium }) {
let removeRender = null;
let marker = null;
const clear = () => {
removeRender?.();
removeRender = null;
marker?.remove();
marker = null;
viewer?.scene?.requestRender?.();
};
return {
show(position) {
clear();
const scene = viewer?.scene;
if (
!position ||
!container?.ownerDocument?.createElement ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
)
return;
marker = container.ownerDocument.createElement('div');
marker.className = 'gev-wind-inspection-marker';
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText =
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
container.appendChild(marker);
const occluder = new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
marker.hidden = !visible;
if (!visible) return;
const canvasRect = scene.canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
};
removeRender = scene.postRender.addEventListener(render);
render();
scene.requestRender?.();
},
clear,
destroy: clear,
};
}
-52
View File
@@ -58,55 +58,3 @@ test('center inspection uses a fixed forecast reading without double-converting
'No surface reading',
);
});
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
const { createWindInspectionMarker } = await import('./inspection.js');
let listener = null;
let visible = true;
const projected = [];
const nodes = [];
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
getBoundingClientRect: () => ({ left: 5, top: 10 }),
};
const viewer = {
camera: { positionWC: {} },
scene: {
mode: 3,
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
requestRender() {},
},
};
const cesium = {
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
};
const owner = createWindInspectionMarker({ container, viewer, cesium });
const point = { x: 1, y: 2, z: 3 };
owner.show(point);
assert.equal(nodes.length, 1);
assert.match(nodes[0].style.cssText, /pointer-events:none/);
assert.equal(nodes[0].style.left, '110px');
assert.equal(nodes[0].style.top, '220px');
viewer.camera.positionWC = { moved: true };
visible = false;
listener();
assert.equal(nodes[0].hidden, true);
assert.ok(projected.every((value) => value === point));
visible = true;
listener();
assert.equal(nodes[0].hidden, false);
owner.show({ x: 4 });
assert.equal(nodes.length, 1);
owner.clear();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
owner.show(point);
owner.destroy();
owner.destroy();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
});
+80
View File
@@ -0,0 +1,80 @@
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import { applicationPinia } from '../../ui/vueRuntime.js';
/**
* One passive marker for the captured location; it never samples on camera
* movement. Where the point falls is presented to the wind inspection store
* after every rendered frame, and the scene chrome renders the marker in
* `container`.
*/
export function createWindInspectionMarker({
container,
viewer,
cesium,
store = useWindInspectionStore(applicationPinia()),
}) {
const owner = {};
let removeRender = null;
let marker = null;
// Only the owner whose marker the store shows may move or remove it.
const shown = () => marker && store.marker?.owner === owner;
const present = (patch) => {
marker = { ...marker, ...patch };
store.publish(marker);
};
const clear = () => {
removeRender?.();
removeRender = null;
if (shown()) store.publish(null);
marker = null;
viewer?.scene?.requestRender?.();
};
return {
show(position) {
clear();
const scene = viewer?.scene;
if (
!position ||
!container?.ownerDocument ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
)
return;
present({ owner, container, visible: false, left: 0, top: 0 });
const occluder = new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
if (!shown()) return;
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
if (!visible) {
// A hidden marker keeps its last place, as the element did.
if (marker.visible) present({ visible: false });
return;
}
const canvasRect = scene.canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const left = point.x + canvasRect.left - containerRect.left;
const top = point.y + canvasRect.top - containerRect.top;
if (marker.visible && marker.left === left && marker.top === top)
return;
present({ visible: true, left, top });
};
removeRender = scene.postRender.addEventListener(render);
render();
scene.requestRender?.();
},
clear,
destroy: clear,
};
}
+137
View File
@@ -0,0 +1,137 @@
// src/layers/wind/inspectionMarker.test.mjs — the wind inspection marker
// follows only its captured point, renders in its container, and releases
// its frame listener.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue';
import { createWindInspectionMarker } from './inspectionMarker.js';
const cesium = {
SceneMode: { SCENE3D: 3 },
Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class {
isPointVisible() {
return cesium.visible;
}
},
visible: true,
};
function fixture(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.getBoundingClientRect = () => ({ left: 5, top: 10 });
const pinia = createPinia();
const app = createApp(WindInspectionMarker).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const frames = { listener: null };
const projected = [];
const viewer = {
camera: { positionWC: {} },
scene: {
mode: 3,
canvas: {
clientWidth: 800,
clientHeight: 600,
getBoundingClientRect: () => ({ left: 15, top: 30 }),
},
postRender: {
addEventListener(fn) {
assert.equal(frames.listener, null);
frames.listener = fn;
return () => {
frames.listener = null;
};
},
},
cartesianToCanvasCoordinates(point) {
projected.push(point);
return { x: 100, y: 200 };
},
requestRender() {},
},
};
const store = useWindInspectionStore(pinia);
cesium.visible = true;
const owner = createWindInspectionMarker({
container,
viewer,
cesium,
store,
});
const nodes = () => [
...container.querySelectorAll('.gev-wind-inspection-marker'),
];
return { owner, viewer, store, frames, projected, nodes, container, cesium };
}
test('the marker follows only its captured point, hides beyond the limb, and releases its listener', async (t) => {
const { owner, viewer, frames, projected, nodes } = fixture(t);
const point = { x: 1, y: 2, z: 3 };
owner.show(point);
await nextTick();
assert.equal(nodes().length, 1);
const [marker] = nodes();
assert.equal(marker.getAttribute('aria-hidden'), 'true');
assert.equal(marker.style.pointerEvents, 'none');
assert.equal(marker.style.position, 'absolute');
assert.equal(marker.style.zIndex, '150');
assert.equal(marker.style.left, '110px');
assert.equal(marker.style.top, '220px');
assert.equal(marker.hidden, false);
viewer.camera.positionWC = { moved: true };
cesium.visible = false;
frames.listener();
await nextTick();
assert.equal(marker.hidden, true);
assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place');
assert.ok(projected.every((value) => value === point));
cesium.visible = true;
frames.listener();
await nextTick();
assert.equal(marker.hidden, false);
owner.show({ x: 4 });
await nextTick();
assert.equal(nodes().length, 1);
owner.clear();
await nextTick();
assert.equal(nodes().length, 0);
assert.equal(frames.listener, null);
owner.show(point);
owner.destroy();
owner.destroy();
await nextTick();
assert.equal(nodes().length, 0);
assert.equal(frames.listener, null);
});
test('an unchanged frame publishes nothing, and an older owner leaves a newer marker', (t) => {
const { owner, viewer, store, frames, container } = fixture(t);
owner.show({ x: 1 });
let publishes = 0;
const publish = store.publish;
store.publish = (next) => {
publishes += 1;
publish(next);
};
frames.listener();
assert.equal(publishes, 0);
frames.listener = null;
const newer = createWindInspectionMarker({
container,
viewer,
cesium,
store,
});
newer.show({ x: 2 });
const shown = store.marker;
owner.destroy();
assert.ok(store.marker === shown);
newer.destroy();
assert.equal(store.marker, null);
});