From 1e4df63efe8240e6a0c6edd9fddf15f2378a0f79 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 01:03:28 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 8 + docs/VUE-MIGRATION.md | 6 +- scripts/package-boundaries.json | 2 + src/data/militaryAwareness.js | 5 +- src/data/militaryAwareness.test.mjs | 259 ++++++------------------ src/layers/awareness/index.js | 6 +- src/layers/awareness/lifecycle.js | 11 +- src/layers/awareness/panel.js | 266 +++++++++++-------------- src/layers/awareness/state.js | 12 +- src/layers/awareness/testing.js | 9 +- src/ui/applicationShell.js | 14 +- src/ui/awarenessPanel.test.mjs | 243 ++++++++++++++++++++++ src/ui/stores/context.js | 31 ++- src/ui/views/ContextView.vue | 8 +- src/ui/views/panels/AwarenessNames.vue | 8 + src/ui/views/panels/AwarenessPanel.vue | 74 +++++++ 16 files changed, 582 insertions(+), 380 deletions(-) create mode 100644 src/ui/awarenessPanel.test.mjs create mode 100644 src/ui/views/panels/AwarenessNames.vue create mode 100644 src/ui/views/panels/AwarenessPanel.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f5257d4..2a4b64f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 72b33c1b7..89b1fcdd5 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dc216dd7b..013f6e0b8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/data/militaryAwareness.js b/src/data/militaryAwareness.js index 756f142d2..d1907fd2b 100644 --- a/src/data/militaryAwareness.js +++ b/src/data/militaryAwareness.js @@ -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; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index ccb5739f2..ea63280dc 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -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, + /

\{\{ view\.note \}\}<\/p>/, + ); + assert.doesNotMatch( + awarenessPanelSource, + /]*data-awareness-focus-continuation/, ); assert.match( - source, - /

\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/, + militaryAwarenessSource, + /'Open-source mapped\/observed context\./, ); - assert.doesNotMatch(source, /]*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>/, + 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>/, - 'the panel prints ? for the whole settled-but-connecting window', - ); - assert.doesNotMatch( - runtime.panel()?.innerHTML || '', - /AIS vessels<\/strong>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>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 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', - '<Area four>', - ]) - assert.ok(html.includes(name)); - assert.ok(!html.includes('')); + '', + ]); } finally { restores.reverse().forEach((restore) => restore()); runtime.restore(); diff --git a/src/layers/awareness/index.js b/src/layers/awareness/index.js index cd01e0cfc..1998ca426 100644 --- a/src/layers/awareness/index.js +++ b/src/layers/awareness/index.js @@ -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, diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index e72e041b2..34ee5a2f2 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -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; diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 120e91b2c..d5711b194 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -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 = `

- ${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')} - ${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')} -
`; - 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 `
  • ${escapeHtml(label)} ${presentAwarenessDistance(item.distanceM)}
  • `; - } + const distance = presentAwarenessDistance(item.distanceM); + if (!targetId) + return { + key: `unavailable:${page + index}`, + label, + distance, + ariaLabel: t('Unavailable'), + }; const accessibleLabel = label === '—' ? t('Unavailable') : label; - return `
  • ${namedAreasHtml(item.memberNames)}
  • `; - }) - .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 `
    -
    ${escapeHtml(cohort.label)}${count}${pageLabel}
    - ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))} - ${nearest ? `
      ${nearest}
    ` : ''} -
    `; + 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 `
    - - - -
    `; + 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 - ? `
    ${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}
    ${names.map((name) => escapeHtml(name)).join('
    ')}
    ` - : ''; + ? { + 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 = `
    ${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}
    - ${namedAreasHtml(subject.memberNames)} - ${navigationControlsHtml()} - ${cohorts.map(rowHtml).join('')} -

    ${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}

    `; - 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, diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js index 17ffd0e28..ac530cb60 100644 --- a/src/layers/awareness/state.js +++ b/src/layers/awareness/state.js @@ -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, diff --git a/src/layers/awareness/testing.js b/src/layers/awareness/testing.js index d7bee51cd..2aff5fd50 100644 --- a/src/layers/awareness/testing.js +++ b/src/layers/awareness/testing.js @@ -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, + }; } diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index be1f8495b..f366e61fc 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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(); diff --git a/src/ui/awarenessPanel.test.mjs b/src/ui/awarenessPanel.test.mjs new file mode 100644 index 000000000..f1de3dd70 --- /dev/null +++ b/src/ui/awarenessPanel.test.mjs @@ -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', ''], + }, + }), + { + 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
    <Area two>', + ); + 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', + ); +}); diff --git a/src/ui/stores/context.js b/src/ui/stores/context.js index f41daecd2..74162a367 100644 --- a/src/ui/stores/context.js +++ b/src/ui/stores/context.js @@ -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, + }; }); diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index 0636d86ef..38efa30fe 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -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()); - +
    diff --git a/src/ui/views/panels/AwarenessNames.vue b/src/ui/views/panels/AwarenessNames.vue new file mode 100644 index 000000000..0a9329319 --- /dev/null +++ b/src/ui/views/panels/AwarenessNames.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/ui/views/panels/AwarenessPanel.vue b/src/ui/views/panels/AwarenessPanel.vue new file mode 100644 index 000000000..1408481c5 --- /dev/null +++ b/src/ui/views/panels/AwarenessPanel.vue @@ -0,0 +1,74 @@ + + +