feat(ui): render the Contacts panel from the awareness layer's published view

The awareness layer presents its panel as a plain view and publishes it through subscribePanel() instead of writing markup into #military-awareness-panel. AwarenessPanel renders it from the gev-context store, calls the layer's navigation, and keeps focus on the same control by identity across live repaints, continuing at the note when that control leaves or is disabled.
This commit is contained in:
Sameh Khamis committed 2026-10-31 01:03:28 -07:00
1 parent 749bfd5cd4
commit 1e4df63efe
16 files changed
+582 -380

No files matched your search

+8
View File
@@ -25,6 +25,14 @@
publish the panel's state to a store that the CCTV and Radio views render,
instead of writing the panel's markup themselves (samehkhamis).
- `./layers/awareness` no longer writes the Contacts panel into
`#military-awareness-panel`: the layer publishes a plain panel view through
`subscribePanel(listener)` and Core's Contacts view renders it, calling the
layer's `navigatePrevious`, `focusCurrent`, `navigateNext` and `focusTarget`.
The `awarenessPanelControlKey`, `captureAwarenessPanelFocus` and
`restoreAwarenessPanelFocus` helpers are gone; the view keeps focus itself
(samehkhamis).
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
+4 -2
View File
@@ -110,8 +110,10 @@ They contain no application state.
the tuner and the Cockpit radio controls keep their owners' element
bindings; `ContextView` renders the mode switch and the nearby sites
search from the `gev-context` store, which ContextControls reaches only
through its `publish` option, so `./ui/context` needs no Vue), the
command dock,
through its `publish` option, so `./ui/context` needs no Vue;
`AwarenessPanel` renders the Contacts panel that the awareness layer
publishes through `subscribePanel()`, the first layer to hand its rail
content to a view), the command dock,
the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead.
+2
View File
@@ -26,6 +26,8 @@
"src/ui/views/ContextView.vue",
"src/ui/views/panels/RadioPanel.vue",
"src/ui/views/panels/SdrCard.vue",
"src/ui/views/panels/AwarenessPanel.vue",
"src/ui/views/panels/AwarenessNames.vue",
"src/ui/views/DisplayControlsView.vue",
"src/ui/views/LayerPanelsView.vue",
"src/ui/views/ProviderSettingsView.vue",
+2 -3
View File
@@ -25,12 +25,11 @@ export const collectAircraftProximityWindow =
layer.collectAircraftProximityWindow;
export const _getAwarenessNavigationStateForTest =
layer._getAwarenessNavigationStateForTest;
export const _getAwarenessPanelRendersForTest =
layer._getAwarenessPanelRendersForTest;
export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext;
export const historySubjectSnapshot = layer.historySubjectSnapshot;
export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex;
export const awarenessPanelControlKey = layer.awarenessPanelControlKey;
export const captureAwarenessPanelFocus = layer.captureAwarenessPanelFocus;
export const restoreAwarenessPanelFocus = layer.restoreAwarenessPanelFocus;
export const awarenessResultsAreLive = layer.awarenessResultsAreLive;
export const awarenessNeedsContinuousRender =
layer.awarenessNeedsContinuousRender;
+66 -193
View File
@@ -1,26 +1,25 @@
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import militaryAwarenessLayer, {
_getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest,
awarenessClearMatchesSubject,
awarenessNeedsContinuousRender,
awarenessRefreshDecision,
awarenessRefreshIntervalMs,
awarenessRefreshRequired,
awarenessResultsAreLive,
awarenessPanelControlKey,
buildAwarenessContextSnapshot,
canNavigateAwarenessNext,
contactsWindowFromSnapshot,
contextTargetFlyToAllowed,
captureAwarenessPanelFocus,
summarizeInstallationViewport,
findCompatibleHistoryIndex,
AWARENESS_QUERY_LIMIT,
historySubjectSnapshot,
restoreAwarenessPanelFocus,
} from './militaryAwareness.js';
import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
@@ -52,6 +51,10 @@ const militaryAwarenessSource = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url),
'utf8',
);
const awarenessPanelSource = readFileSync(
new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url),
'utf8',
);
const AWARENESS_DEPENDENCIES = [
'flights',
@@ -98,27 +101,12 @@ function installAwarenessRuntime({
const listeners = new Map();
let nextTimer = 1;
let preRenderListener = null;
const createdElements = [];
class FakeElement {
constructor() {
this.classList = { contains: () => false };
this.style = { setProperty() {} };
this._hidden = false;
// renderResults() writes `hidden = false` on EVERY call, ahead of its
// markup diff, so counting these writes observes "was the panel
// repaint path entered?" independently of whether the markup changed.
this.hiddenWrites = 0;
this.innerHTML = '';
}
get hidden() {
return this._hidden;
}
set hidden(value) {
this._hidden = value;
this.hiddenWrites += 1;
this.hidden = false;
}
setAttribute() {}
@@ -213,9 +201,7 @@ function installAwarenessRuntime({
return null;
},
createElement() {
const element = new FakeElement();
createdElements.push(element);
return element;
return new FakeElement();
},
addEventListener() {},
removeEventListener() {},
@@ -229,6 +215,10 @@ function installAwarenessRuntime({
});
militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer);
const panelViews = [];
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
panelViews.push(view),
);
militaryAwarenessLayer.enable();
return {
@@ -248,15 +238,16 @@ function installAwarenessRuntime({
);
return taken;
},
/** The standalone Context panel element renderResults() paints into. */
/** The Contacts panel as the layer publishes it to the view. */
panel() {
return (
createdElements.find(
(element) => element.id === 'military-awareness-panel',
) || null
);
return {
view: panelViews.at(-1) ?? null,
publishes: panelViews.length,
renders: _getAwarenessPanelRendersForTest(),
};
},
restore() {
unsubscribePanel();
militaryAwarenessLayer.destroy();
globalThis.window = originalWindow;
globalThis.document = originalDocument;
@@ -264,144 +255,24 @@ function installAwarenessRuntime({
};
}
function awarenessControl(dataset, { disabled = false } = {}) {
return {
dataset,
disabled,
focusCalls: 0,
closest(selector) {
if (selector === '[data-awareness-focus-continuation]') {
return this.isContinuation ? this : null;
}
return selector.includes('button') && !this.isContinuation ? this : null;
},
matches(selector) {
return (
selector === '[data-awareness-focus-continuation]' &&
this.isContinuation
);
},
focus(options) {
this.focusCalls += 1;
this.focusOptions = options;
},
};
/** The count a published Contacts panel shows for one cohort row. */
function panelCount(runtime, label) {
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
}
function awarenessPanel(controls) {
const continuation = awarenessControl({});
continuation.isContinuation = true;
return {
controls,
continuation,
contains(element) {
return controls.includes(element) || element === continuation;
},
querySelectorAll() {
return this.controls;
},
querySelector(selector) {
return selector === '[data-awareness-focus-continuation]'
? continuation
: null;
},
};
}
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
const previous = awarenessControl({ awarenessAction: 'previous' });
const focus = awarenessControl({ awarenessAction: 'focus' });
const next = awarenessControl({ awarenessAction: 'next' });
const alpha = awarenessControl({
awarenessLayer: 'flights',
awarenessId: 'abc123',
});
const bravo = awarenessControl({
awarenessLayer: 'military',
awarenessId: 'def456',
});
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
assert.equal(awarenessPanelControlKey(next), 'action:next');
const retainedAlpha = awarenessControl({
awarenessLayer: 'flights',
awarenessId: 'abc123',
});
const retained = awarenessPanel([
previous,
focus,
next,
bravo,
retainedAlpha,
]);
assert.equal(
restoreAwarenessPanelFocus(retained, alphaSnapshot),
retainedAlpha,
);
assert.equal(
retainedAlpha.focusCalls,
1,
'identity wins even when live distance reorders rows',
);
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
const forward = awarenessControl({
awarenessLayer: 'ais-live-vessels',
awarenessId: '789',
});
const departed = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(departed, alphaSnapshot),
departed.continuation,
);
assert.equal(
forward.focusCalls,
0,
'automatic paging must not transfer focus to another contact',
);
assert.equal(departed.continuation.focusCalls, 1);
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
assert.equal(
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
noRemainingRow.continuation,
);
assert.equal(
noRemainingRow.continuation.focusCalls,
1,
'the end sentinel continues Tab beyond the list',
);
const continuationSnapshot = captureAwarenessPanelFocus(
departed,
departed.continuation,
);
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
repaintedAgain.continuation,
);
assert.equal(
repaintedAgain.continuation.focusCalls,
1,
'later live repaints keep focus on the continuation point until the user tabs onward',
);
});
test('Contacts continuation target is the visible explanatory note', () => {
const source = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url),
assert.match(
awarenessPanelSource,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\{\{ view\.note \}\}<\/p>/,
);
assert.doesNotMatch(
awarenessPanelSource,
/<span[^>]*data-awareness-focus-continuation/,
);
assert.match(
source,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
militaryAwarenessSource,
/'Open-source mapped\/observed context\./,
);
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
});
test('awareness disable settles every owned dependency release before resolving', async () => {
@@ -599,9 +470,9 @@ test('pending mapped installations render as unknown instead of a false zero', a
'loading is not an observed empty cohort',
);
assert.match(installations?.reason || '', /unavailable/i);
assert.match(
runtime.panel()?.innerHTML || '',
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
assert.equal(
panelCount(runtime, 'Mapped installations'),
'?',
'the operator sees ? rather than an all-clear 0 while installations load',
);
} finally {
@@ -741,15 +612,10 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
);
assert.match(vessels?.reason || '', /unavailable/i);
assert.match(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
'the panel prints ? for the whole settled-but-connecting window',
);
assert.doesNotMatch(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
'the panel must never print an all-clear 0 before the feed has answered once',
assert.equal(
panelCount(runtime, 'AIS vessels'),
'?',
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
);
} finally {
restoreCollections();
@@ -789,9 +655,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
0,
'an answered empty viewport is a real observation',
);
assert.match(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
assert.equal(
panelCount(runtime, 'AIS vessels'),
'0',
'the operator sees the real count once the feed answers',
);
} finally {
@@ -1254,17 +1120,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
id,
);
const panel = runtime.panel();
assert.ok(panel, 'the Context panel must exist');
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
const settledWrites = panel.hiddenWrites;
const settledRenders = runtime.panel().renders;
const settledPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(
panel.hiddenWrites,
settledWrites,
runtime.panel().renders,
settledRenders,
'an unchanged label must not trigger a panel repaint',
);
assert.equal(
runtime.panel().publishes,
settledPublishes,
'an unchanged refresh publishes no new panel, results or standby',
);
// Enrichment answers. The contact has NOT moved and no source revision
// changed, so the label is the only thing making this render-worthy.
@@ -1283,17 +1155,18 @@ test('a cached Context subject re-reads its label when enrichment lands after se
'subject identity must stay the ICAO hex',
);
// The RENDERED panel is the surface the operator actually reads.
const repainted = runtime.panel();
assert.ok(
panel.hiddenWrites > settledWrites,
repainted.publishes > settledPublishes,
'a changed label must repaint the standalone Context panel',
);
assert.match(
panel.innerHTML,
repainted.view.subject,
/N123AB/,
'the rendered Context panel must show the registration without a position change',
);
assert.doesNotMatch(
panel.innerHTML,
JSON.stringify(repainted.view),
/ae1fa4/,
'the hex must be gone from the panel',
);
@@ -2240,9 +2113,10 @@ test('expanded Context results omit the redundant status heading', () => {
militaryAwarenessSource,
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
);
assert.match(militaryAwarenessSource, /military-awareness-subject/);
assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
assert.match(awarenessPanelSource, /military-awareness-subject/);
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
assert.match(militaryAwarenessSource, /military-awareness-controls/);
assert.match(awarenessPanelSource, /military-awareness-controls/);
});
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
@@ -4229,7 +4103,7 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
}
});
test('a selected installation card keeps every member name and escapes source text', () => {
test('a selected installation card keeps every member name as text', () => {
const runtime = installAwarenessRuntime();
const restores = [];
try {
@@ -4252,16 +4126,15 @@ test('a selected installation card keeps every member name and escapes source te
},
});
militaryAwarenessLayer.update();
const html = runtime.panel().innerHTML;
assert.match(html, /Named areas \(4\)/);
for (const name of [
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
const names = runtime.panel().view?.subjectNames;
assert.equal(names?.summary, 'Named areas (4)');
assert.deepEqual(names?.names, [
'Area one',
'Area two',
'Area three',
'&lt;Area four&gt;',
])
assert.ok(html.includes(name));
assert.ok(!html.includes('<Area four>'));
'<Area four>',
]);
} finally {
restores.reverse().forEach((restore) => restore());
runtime.restore();
+3 -3
View File
@@ -51,12 +51,12 @@ 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,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
+1 -10
View File
@@ -229,22 +229,13 @@ 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;
parts.panel.clearPanel();
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
layerState.panelMarkup = '';
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
+111 -155
View File
@@ -43,76 +43,87 @@ 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;
}
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.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'),
});
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
}
function rowHtml(cohort) {
/** Clear the panel when the layer is destroyed. */
function clearPanel() {
if (layerState.panelShown) publish({ kind: 'empty' });
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 +132,58 @@ export function createPanel({ state: layerState, services, parts, source }) {
pageCount > 1
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
: '';
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
const coverage = cohort.coverage ? ` · ${cohort.coverage}` : '';
return {
id: cohort.id,
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`;
function presentNavigation() {
return {
label: t('Global Context navigation'),
previous: {
text: t('PREVIOUS'),
title: t('Previous — prior visited contact in the 250 km window'),
disabled: !(layerState.navigationIndex > 0),
},
focus: { text: t('FOCUS') },
next: {
text: t('NEXT'),
title: t('Next — nearest unvisited contact in the 250 km window'),
disabled: !parts.navigation.canNavigateNext(),
},
};
}
/** Stable identity for a delegated Contacts-panel button across live repaints. */
function awarenessPanelControlKey(element) {
const control = element?.closest?.(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
);
if (!control) return null;
if (control.dataset.awarenessAction)
return `action:${control.dataset.awarenessAction}`;
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
}
/** Capture stable identity so a live repaint can restore only the same control. */
function captureAwarenessPanelFocus(
panel,
activeElement = document.activeElement,
) {
if (!panel?.contains?.(activeElement)) return null;
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
return { key: 'continuation' };
}
const key = awarenessPanelControlKey(activeElement);
if (!key) return null;
return { key };
}
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
function restoreAwarenessPanelFocus(panel, snapshot) {
if (!panel || !snapshot) return null;
const continuation = panel.querySelector(
'[data-awareness-focus-continuation]',
);
const controls = [
...panel.querySelectorAll(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
),
].filter((control) => !control.disabled);
const retained =
snapshot.key === 'continuation'
? continuation
: controls.find(
(control) => awarenessPanelControlKey(control) === snapshot.key,
);
const target = retained || continuation;
target?.focus?.({ preventScroll: true });
return target || null;
}
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>'"]/g,
(char) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char],
);
}
function namedAreasHtml(names) {
function presentNames(names) {
return names?.length
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
? {
summary: t('Named areas ({n})', { n: formatNumber(names.length) }),
names: [...names].map((name) => String(name ?? '')),
}
: null;
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
ensurePanel();
layerState.panelRenders += 1;
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
layerState.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
publish({
kind: 'results',
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
subjectNames: presentNames(subject.memberNames),
navigation: presentNavigation(),
rows: cohorts.map(presentRow),
note: t(
'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.',
),
});
}
function rotateAwarenessPages() {
@@ -277,14 +237,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,
+8 -4
View File
@@ -29,15 +29,20 @@ 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: [],
@@ -57,7 +62,6 @@ export function createState({ services }) {
cameraMoving: false,
lastEvaluatedPosition: null,
sourceRevision: '',
panelMarkup: '',
directionFrame: null,
lastDirectionUpdateMs: 0,
activationId: 0,
+8 -1
View File
@@ -11,5 +11,12 @@ export function createTesting({ state: layerState, services, parts, source }) {
pendingSelectionKey: layerState.pendingSelectionKey,
};
}
return { _getAwarenessNavigationStateForTest };
/** @returns {number} How many result renders ran, published or not. */
function _getAwarenessPanelRendersForTest() {
return layerState.panelRenders;
}
return {
_getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest,
};
}
+13 -1
View File
@@ -973,7 +973,8 @@ export class StyleManager extends ShellFacade {
}
_initGlobalContextPanel() {
const { radioLayer, militaryInstallationsLayer } = this.services;
const { radioLayer, militaryInstallationsLayer, militaryAwarenessLayer } =
this.services;
const contextStore = useContextStore(applicationPinia());
this._contextControls = new ContextControls({
elements: {
@@ -999,6 +1000,14 @@ export class StyleManager extends ShellFacade {
},
},
});
// The Contacts layer publishes its panel; the view calls its navigation.
contextStore.connectAwareness(militaryAwarenessLayer);
// A panel left by an earlier shell must not outlive its layer.
this._awarenessPanelUnsubscribe?.();
contextStore.publishAwareness(null);
this._awarenessPanelUnsubscribe = militaryAwarenessLayer?.subscribePanel?.(
(view) => contextStore.publishAwareness(view),
);
}
/** Wire the independent Radio companion controls. */
@@ -1757,6 +1766,9 @@ export class StyleManager extends ShellFacade {
this._streetLevelControls?.destroy();
this._radioControls?.destroy();
this._localSdrControls?.destroy();
this._awarenessPanelUnsubscribe?.();
this._awarenessPanelUnsubscribe = null;
useContextStore(applicationPinia()).connectAwareness(null);
this._cockpitCoordinator.stop();
this._visualSettings.stop();
this.shareLinkManager?.destroy();
+243
View File
@@ -0,0 +1,243 @@
// src/ui/awarenessPanel.test.mjs — the Contacts panel renders what the
// Contacts layer publishes (`subscribePanel()`), over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { useContextStore } from './stores/context.js';
import AwarenessPanel from './views/panels/AwarenessPanel.vue';
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(AwarenessPanel).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useContextStore(pinia);
const calls = [];
store.connectAwareness({
navigatePrevious: (options) => calls.push(['previous', options]),
focusCurrent: (options) => calls.push(['focus', options]),
navigateNext: (options) => calls.push(['next', options]),
focusTarget: (layer, id, options) =>
calls.push([`target:${layer}:${id}`, options]),
});
const panel = () => document.getElementById('military-awareness-panel');
const publish = async (view) => {
store.publishAwareness(view);
await nextTick();
};
return { panel, publish, calls };
}
const target = (layer, id, label, extra = {}) => ({
key: `target:${layer}:${id}`,
layer,
targetId: id,
label,
distance: '4.6 km',
ariaLabel: `Focus ${label}`,
names: null,
...extra,
});
const results = ({ nearest = [], previousDisabled = true } = {}) => ({
kind: 'results',
subject: 'N123AB · 250 km FLIGHT / VESSEL WINDOW',
subjectNames: null,
navigation: {
label: 'Global Context navigation',
previous: {
text: 'PREVIOUS',
title: 'Previous',
disabled: previousDisabled,
},
focus: { text: 'FOCUS' },
next: { text: 'NEXT', title: 'Next', disabled: false },
},
rows: [
{
id: 'flights',
relationship: 'observed',
label: 'Flights',
count: '2',
detail: 'ADS-B · CURRENT VIEWPORT ONLY · nearest',
nearest,
},
],
note: 'Open-source mapped/observed context.',
});
test('before the layer publishes, the panel shows its initial markup', (t) => {
const { panel } = fixture(t);
const standby = panel().querySelector('.military-awareness-standby');
assert.equal(
standby.querySelector('strong').textContent,
'CONTACTS CONTEXT OFF',
);
assert.equal(standby.querySelector('strong').hasAttribute('data-i18n'), true);
});
test('a standby view replaces the initial copy', async (t) => {
const { panel, publish } = fixture(t);
await publish({
kind: 'standby',
title: 'CONTEXT READY',
text: 'SELECT A FLIGHT',
});
const standby = panel().querySelector('.military-awareness-standby');
assert.equal(standby.querySelector('strong').textContent, 'CONTEXT READY');
assert.equal(standby.querySelector('span').textContent, 'SELECT A FLIGHT');
await publish({ kind: 'empty' });
assert.equal(panel().children.length, 0);
});
test('results render the rows, controls and names as text, and buttons call the layer', async (t) => {
const { panel, publish, calls } = fixture(t);
const view = results({
nearest: [
target('flights', 'abc123', 'ALPHA', {
names: {
summary: 'Named areas (2)',
names: ['Area one', '<Area two>'],
},
}),
{
key: 'unavailable:1',
label: '—',
distance: '9 km',
ariaLabel: 'Unavailable',
},
],
});
await publish(view);
const root = panel();
assert.equal(
root.querySelector('.military-awareness-subject').textContent,
view.subject,
);
const row = root.querySelector('.military-awareness-row');
assert.equal(row.className, 'military-awareness-row observed');
assert.equal(row.querySelector('b').getAttribute('aria-live'), 'polite');
assert.equal(row.querySelector('b').textContent, '2');
assert.equal(row.querySelector('small').textContent, view.rows[0].detail);
const alpha = row.querySelector('button[data-awareness-id="abc123"]');
assert.equal(alpha.dataset.awarenessLayer, 'flights');
assert.equal(alpha.getAttribute('aria-label'), 'Focus ALPHA');
assert.equal(alpha.textContent, 'ALPHA 4.6 km');
const names = row.querySelector('.military-awareness-names div');
assert.equal(
names.innerHTML.replace(/<!--[^>]*-->/g, ''),
'Area one<br>&lt;Area two&gt;',
);
const unavailable = row.querySelector(
'span.military-awareness-target.unavailable',
);
assert.equal(unavailable.getAttribute('aria-label'), 'Unavailable');
const [previous, focus, next] = root.querySelectorAll(
'[data-awareness-action]',
);
assert.equal(previous.disabled, true);
assert.equal(next.title, 'Next');
focus.click();
next.click();
alpha.click();
const user = { origin: 'user' };
assert.deepEqual(calls, [
['focus', user],
['next', user],
['target:flights:abc123', user],
]);
const note = root.querySelector('[data-awareness-focus-continuation]');
assert.equal(note.tagName, 'P');
assert.equal(note.getAttribute('tabindex'), '-1');
});
test('a live repaint keeps a focused contact by identity and never retargets another', async (t) => {
const { panel, publish } = fixture(t);
const alpha = target('flights', 'abc123', 'ALPHA');
const bravo = target('flights', 'def456', 'BRAVO');
await publish(results({ nearest: [alpha, bravo] }));
const alphaButton = panel().querySelector('[data-awareness-id="abc123"]');
alphaButton.focus();
await publish(results({ nearest: [bravo, alpha] }));
assert.equal(
document.activeElement === alphaButton,
true,
'identity wins when rows reorder',
);
const charlie = target('ais-live-vessels', '789', 'CHARLIE');
await publish(results({ nearest: [charlie] }));
const note = panel().querySelector('[data-awareness-focus-continuation]');
assert.equal(
document.activeElement === note,
true,
'a departed contact continues at the note',
);
await publish(results({ nearest: [bravo] }));
assert.equal(
document.activeElement === note,
true,
'later repaints keep the continuation point',
);
});
test('a reorder that drops focus from a moved contact restores the same contact', async (t) => {
const { panel, publish } = fixture(t);
const alpha = target('flights', 'abc123', 'ALPHA');
const bravo = target('flights', 'def456', 'BRAVO');
await publish(results({ nearest: [alpha, bravo] }));
// Browsers drop focus from a node that is moved; happy-dom does not, so
// moving the focused node sends focus outside the panel here.
const sink = document.body.appendChild(document.createElement('button'));
const list = panel().querySelector('ul');
const insertBefore = list.insertBefore.bind(list);
list.insertBefore = (node, ref) => {
const moved = node.contains(document.activeElement);
const result = insertBefore(node, ref);
if (moved) sink.focus();
return result;
};
const focusNode = (id) =>
panel().querySelector(`[data-awareness-id="${id}"]`);
focusNode('def456').focus();
await publish(results({ nearest: [bravo, alpha] }));
focusNode('abc123').focus();
await publish(results({ nearest: [alpha, bravo] }));
assert.equal(
document.activeElement === focusNode('abc123'),
true,
'the same contact keeps focus, not another one or the note',
);
});
test('a repaint that disables the focused control continues at the note', async (t) => {
const { panel, publish } = fixture(t);
await publish(results({ previousDisabled: false }));
const previous = panel().querySelector('[data-awareness-action="previous"]');
previous.focus();
await publish(results({ previousDisabled: true }));
assert.equal(previous.disabled, true);
const note = panel().querySelector('[data-awareness-focus-continuation]');
assert.equal(document.activeElement === note, true);
});
test('a repaint that leaves focus outside the panel does not pull it back', async (t) => {
const { panel, publish } = fixture(t);
const outside = document.body.appendChild(document.createElement('button'));
await publish(results({ nearest: [target('flights', 'abc123', 'ALPHA')] }));
outside.focus();
await publish(results({ nearest: [] }));
assert.equal(document.activeElement === outside, true);
assert.equal(
panel().querySelector('ul'),
null,
'an empty cohort renders no list',
);
});
+29 -2
View File
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
* The Context panel's mode switch as its view renders it. ContextControls
* presents the selected mode, the transition busy state and the nearby
* sites search (`_presentContext()`) and publishes them here; it
* keeps its listeners and transactions.
* keeps its listeners and transactions. The Contacts layer publishes its
* panel beside it, and the app connects that layer's navigation.
*/
export const useContextStore = defineStore('gev-context', () => {
/** The presented mode switch; null until the controls first render. */
@@ -16,5 +17,31 @@ export const useContextStore = defineStore('gev-context', () => {
view.value = markRaw(next);
}
return { view, publish };
/** The Contacts panel; null until the layer first publishes. */
const awareness = shallowRef(null);
/** @param {object} next A fresh presentation of the Contacts panel. */
function publishAwareness(next) {
awareness.value = next && markRaw(next);
}
/**
* The Contacts layer whose navigation the panel calls: `navigatePrevious`,
* `focusCurrent`, `navigateNext` and `focusTarget`.
*/
const awarenessLayer = shallowRef(null);
/** @param {?object} layer The Contacts layer, or null to disconnect. */
function connectAwareness(layer) {
awarenessLayer.value = layer && markRaw(layer);
}
return {
view,
publish,
awareness,
publishAwareness,
awarenessLayer,
connectAwareness,
};
});
+2 -6
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { vClassToggle } from '../rail/classToggle.js';
import { useContextStore } from '../stores/context.js';
import AwarenessPanel from './panels/AwarenessPanel.vue';
import RadioPanel from './panels/RadioPanel.vue';
// ContextControls presents the mode switch and the nearby sites search and
@@ -68,12 +69,7 @@ const { view: ctx } = storeToRefs(useContextStore());
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button>
</div>
<aside id="military-awareness-panel">
<div class="military-awareness-standby">
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
</div>
</aside>
<AwarenessPanel />
</section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
<div id="space-mission-panel-host">
+8
View File
@@ -0,0 +1,8 @@
<script setup>
// The named areas of a Contacts subject or contact, one per line.
defineProps({ names: { type: Object, required: true } });
</script>
<template>
<details class="military-awareness-names"><summary>{{ names.summary }}</summary><div><template v-for="(name, index) in names.names" :key="index"><br v-if="index">{{ name }}</template></div></details>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup>
import { storeToRefs } from 'pinia';
import { onBeforeUpdate, onUpdated, ref } from 'vue';
import { useContextStore } from '../../stores/context.js';
import AwarenessNames from './AwarenessNames.vue';
// The Contacts panel inside Context. The Contacts layer publishes its view
// (standby, results or empty); until it does, the panel shows its initial
// markup. Buttons call the layer's navigation as the person's own.
const { awareness: view, awarenessLayer: layer } = storeToRefs(useContextStore());
const user = { origin: 'user' };
const root = ref(null);
// A live repaint keeps focus on the same control by identity: browsers drop
// focus from a node Vue moves, removes or disables, so the control is found
// again by its key. When it is gone or disabled, focus continues at the note
// beyond the list rather than jumping to another contact.
const CONTINUATION = '[data-awareness-focus-continuation]';
const controlKey = (el) => {
if (el?.matches?.(CONTINUATION)) return 'continuation';
const { awarenessAction: action, awarenessLayer: layer, awarenessId: id } = el?.dataset ?? {};
if (action) return `action:${action}`;
return layer && id ? `target:${layer}:${id}` : null;
};
let focusedKey = null;
onBeforeUpdate(() => {
const active = document.activeElement;
focusedKey = root.value?.contains(active) ? controlKey(active) ?? 'continuation' : null;
});
onUpdated(() => {
const key = focusedKey;
focusedKey = null;
const panel = root.value;
// A disabled control may keep focus until the browser's next frame.
const active = document.activeElement;
if (!key || !panel || (panel.contains(active) && !active.disabled)) return;
const retained = key === 'continuation' ? null : [...panel.querySelectorAll('button[data-awareness-action], button[data-awareness-id]')].find((control) => !control.disabled && controlKey(control) === key);
(retained || panel.querySelector(CONTINUATION))?.focus({ preventScroll: true });
});
</script>
<template>
<aside id="military-awareness-panel" ref="root">
<div v-if="!view" class="military-awareness-standby">
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
</div>
<div v-else-if="view.kind === 'standby'" class="military-awareness-standby">
<strong>{{ view.title }}</strong>
<span>{{ view.text }}</span>
</div>
<template v-else-if="view.kind === 'results'">
<div class="military-awareness-subject">{{ view.subject }}</div>
<AwarenessNames v-if="view.subjectNames" :names="view.subjectNames" />
<div class="military-awareness-controls" role="group" :aria-label="view.navigation.label">
<button type="button" data-awareness-action="previous" :title="view.navigation.previous.title" :disabled="view.navigation.previous.disabled" @click="layer?.navigatePrevious(user)">{{ view.navigation.previous.text }}</button>
<button type="button" data-awareness-action="focus" @click="layer?.focusCurrent(user)">{{ view.navigation.focus.text }}</button>
<button type="button" data-awareness-action="next" :title="view.navigation.next.title" :disabled="view.navigation.next.disabled" @click="layer?.navigateNext(user)">{{ view.navigation.next.text }}</button>
</div>
<section v-for="row in view.rows" :key="row.id" :class="['military-awareness-row', row.relationship]">
<div><strong>{{ row.label }}</strong><b aria-live="polite">{{ row.count }}</b></div>
<small>{{ row.detail }}</small>
<ul v-if="row.nearest.length">
<li v-for="item in row.nearest" :key="item.key">
<button v-if="item.targetId" type="button" class="military-awareness-target" :data-awareness-layer="item.layer" :data-awareness-id="item.targetId" :aria-label="item.ariaLabel" @click="layer?.focusTarget(item.layer, item.targetId, user)">{{ item.label }} <span>{{ item.distance }}</span></button>
<span v-else class="military-awareness-target unavailable" :aria-label="item.ariaLabel">{{ item.label }} <span>{{ item.distance }}</span></span>
<AwarenessNames v-if="item.names" :names="item.names" />
</li>
</ul>
</section>
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>{{ view.note }}</p>
</template>
</aside>
</template>