mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into upstream/traffic-paired-flow-density
This commit is contained in:
644 files changed
+42033
-19462
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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°',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
clearMissionPanel: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// The launches layer presents its roster and selected mission as plain views
|
||||
// and publishes them to subscribers; Core's mission components render them.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
function fixture() {
|
||||
const state = createState({
|
||||
services: { overlays: {}, render: {} },
|
||||
});
|
||||
const calls = [];
|
||||
const parts = {
|
||||
overlays: {
|
||||
shortMissionLabel: (name) => name,
|
||||
syncMissionOverlayEntries: () => calls.push(['overlays']),
|
||||
},
|
||||
policyHelpers: {
|
||||
missionRosterEntries: (launches) =>
|
||||
launches.map((launch, index) => ({ launch, index })),
|
||||
missionPathPresentation: () => ({ orbit: 'LEO', ascent: 'ESTIMATED' }),
|
||||
previewMissionFromRoster: (launch) => calls.push(['preview', launch.id]),
|
||||
},
|
||||
model: {
|
||||
missionMarkerColor: () => ({ toCssColorString: () => 'rgb(1, 2, 3)' }),
|
||||
},
|
||||
selection: {
|
||||
setSelectedMission: (id, isolate) => calls.push(['select', id, isolate]),
|
||||
focusMission: (launch) => calls.push(['focus-mission', launch.id]),
|
||||
focusLaunchSite: (launch) => calls.push(['focus-site', launch.id]),
|
||||
},
|
||||
replay: {
|
||||
presentReplay: () => ({ available: false }),
|
||||
startMissionReplay: (id) => calls.push(['replay', id]),
|
||||
stopMissionReplay: () => calls.push(['cancel']),
|
||||
pauseMissionReplay: () => calls.push(['pause']),
|
||||
resumeMissionReplay: () => calls.push(['resume']),
|
||||
setReplaySpeed: (value) => calls.push(['speed', value]),
|
||||
},
|
||||
};
|
||||
const panel = createPanel({ state, parts });
|
||||
const views = [];
|
||||
panel.subscribePanel((view) => views.push(view));
|
||||
return { state, panel, views, calls };
|
||||
}
|
||||
|
||||
const launch = (id, extra = {}) => ({
|
||||
id,
|
||||
name: `Mission ${id}`,
|
||||
provider: 'SpaceX',
|
||||
status: 'Go',
|
||||
launchSite: 'Unknown launch site',
|
||||
launchTime: '2026-11-01T10:00:00Z',
|
||||
payloads: [],
|
||||
recoveryStages: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('the panel publishes only once the layer starts presenting', () => {
|
||||
const { state, panel, views } = fixture();
|
||||
state._launches = [launch('a')];
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(views.length, 0);
|
||||
panel.createMissionPanel();
|
||||
assert.deepEqual(views, [{ roster: null, mission: null }]);
|
||||
panel.renderMissionRoster();
|
||||
const { roster } = views.at(-1);
|
||||
assert.equal(roster.count, '1 / 30D');
|
||||
assert.deepEqual(roster.entries, [
|
||||
{
|
||||
index: 0,
|
||||
id: 'a',
|
||||
color: 'rgb(1, 2, 3)',
|
||||
label: 'MISSION A',
|
||||
ariaLabel: 'Select MISSION A',
|
||||
provider: 'SpaceX',
|
||||
date: '2026-11-01',
|
||||
},
|
||||
]);
|
||||
const late = [];
|
||||
panel.subscribePanel((view) => late.push(view));
|
||||
assert.equal(late[0], views.at(-1));
|
||||
panel.clearMissionPanel();
|
||||
assert.deepEqual(views.at(-1), { roster: null, mission: null });
|
||||
});
|
||||
|
||||
test('an unchanged roster keeps its view and the preview; a changed one drops it', async () => {
|
||||
const { state, panel, views, calls } = fixture();
|
||||
state._launches = [launch('a'), launch('b')];
|
||||
panel.createMissionPanel();
|
||||
panel.renderMissionRoster();
|
||||
const published = views.length;
|
||||
const roster = views.at(-1).roster;
|
||||
panel.commands.rosterFocus(1);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(views.length, published);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
await new Promise((resolve) => setTimeout(resolve, 160));
|
||||
assert.deepEqual(calls.at(-1), ['preview', 'b']);
|
||||
state._launches = [launch('b'), launch('a')];
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(state._hoveredRosterLaunchId, null);
|
||||
assert.notEqual(views.at(-1).roster, roster);
|
||||
// The list restores focus on the mission, which takes the preview back.
|
||||
panel.commands.rosterFocus(0);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
panel.clearMissionPanel();
|
||||
});
|
||||
|
||||
test('the selected mission is presented with its rows and returns to the top', () => {
|
||||
const { state, panel, views } = fixture();
|
||||
state._launches = [
|
||||
launch('a'),
|
||||
launch('b', {
|
||||
payloads: Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `Sat ${index}`,
|
||||
amount: index === 0 ? 2 : 1,
|
||||
manufacturer: 'Maker',
|
||||
massKg: 500,
|
||||
})),
|
||||
recoveryStages: [
|
||||
{
|
||||
name: 'B1080',
|
||||
status: 'Landed',
|
||||
flightNumber: 9,
|
||||
reused: true,
|
||||
endpoint: { lat: 28.4, lon: -77.1, accuracy: 'DRONESHIP' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
panel.createMissionPanel();
|
||||
state._selectedLaunchId = 'b';
|
||||
panel.renderMissionPanel();
|
||||
const first = views.at(-1).mission;
|
||||
assert.equal(first.title, 'MISSION B');
|
||||
assert.equal(first.site, null);
|
||||
assert.equal(first.orbit, 'LEO');
|
||||
assert.equal(first.index, '2 / 2');
|
||||
assert.equal(first.previousDisabled, false);
|
||||
assert.equal(first.nextDisabled, true);
|
||||
assert.equal(first.payloads.length, 5);
|
||||
assert.deepEqual(first.payloads[0], {
|
||||
name: 'Sat 0 ×2',
|
||||
detail: 'Maker · 500 KG',
|
||||
type: 'UNSPECIFIED',
|
||||
destination: 'UNAVAILABLE',
|
||||
});
|
||||
assert.equal(first.payloadsMore, '+1 additional payload records');
|
||||
assert.deepEqual(first.stages, [
|
||||
{
|
||||
name: 'B1080',
|
||||
detail: 'FLIGHT 9 · REUSED',
|
||||
status: 'Landed',
|
||||
destination: 'UNAVAILABLE',
|
||||
position: '28.40, -77.10 · DRONESHIP',
|
||||
},
|
||||
]);
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(views.at(-1).mission.scrollRequest, first.scrollRequest + 1);
|
||||
state._selectedLaunchId = null;
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(views.at(-1).mission, null);
|
||||
});
|
||||
|
||||
test('the panel commands act on the selected mission', () => {
|
||||
const { state, panel, calls } = fixture();
|
||||
state._launches = [launch('a'), launch('b'), launch('c')];
|
||||
state._selectedLaunchId = 'b';
|
||||
const { commands } = panel;
|
||||
commands.selectPreviousMission();
|
||||
commands.selectNextMission();
|
||||
commands.selectMissionAt(9);
|
||||
commands.focusSelectedLaunchSite();
|
||||
commands.startSelectedReplay();
|
||||
commands.toggleReplayPause();
|
||||
state._replayPaused = true;
|
||||
commands.toggleReplayPause();
|
||||
commands.cancelReplay();
|
||||
commands.setReplaySpeed('2');
|
||||
commands.deselectMission();
|
||||
assert.deepEqual(calls, [
|
||||
['select', 'a', true],
|
||||
['focus-mission', 'a'],
|
||||
['select', 'c', true],
|
||||
['focus-mission', 'c'],
|
||||
['focus-site', 'b'],
|
||||
['replay', 'b'],
|
||||
['pause'],
|
||||
['resume'],
|
||||
['cancel'],
|
||||
['speed', '2'],
|
||||
['select', null, undefined],
|
||||
]);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPanel } from './panel.js';
|
||||
|
||||
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
|
||||
for (const cyber of [true, false]) {
|
||||
const body = { scrollTop: 180 };
|
||||
const inner = { scrollTop: 90 };
|
||||
const output = { closest: () => null, removeAttribute() {} };
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector: () => output,
|
||||
closest(selector) {
|
||||
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
|
||||
return cyber ? body : inner;
|
||||
},
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
const panel = createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
},
|
||||
});
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(body.scrollTop, cyber ? 0 : 180);
|
||||
assert.equal(inner.scrollTop, cyber ? 90 : 0);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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°',
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createState({ services }) {
|
||||
layerState._stationById = new Map();
|
||||
|
||||
layerState._categories = [];
|
||||
layerState._categoriesLocale = null;
|
||||
|
||||
layerState._renderById = new Map();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
Vendored
+13
-2
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) ?? '--',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user