mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
749bfd5cd4
commit
1e4df63efe
16 files changed
+582
-380
No files matched your search
@@ -25,6 +25,14 @@
|
|||||||
publish the panel's state to a store that the CCTV and Radio views render,
|
publish the panel's state to a store that the CCTV and Radio views render,
|
||||||
instead of writing the panel's markup themselves (samehkhamis).
|
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
|
## [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
|
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||||
|
|||||||
@@ -110,8 +110,10 @@ They contain no application state.
|
|||||||
the tuner and the Cockpit radio controls keep their owners' element
|
the tuner and the Cockpit radio controls keep their owners' element
|
||||||
bindings; `ContextView` renders the mode switch and the nearby sites
|
bindings; `ContextView` renders the mode switch and the nearby sites
|
||||||
search from the `gev-context` store, which ContextControls reaches only
|
search from the `gev-context` store, which ContextControls reaches only
|
||||||
through its `publish` option, so `./ui/context` needs no Vue), the
|
through its `publish` option, so `./ui/context` needs no Vue;
|
||||||
command dock,
|
`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
|
the phone shell, then Cockpit. Layers that write into the right rail
|
||||||
directly gain a slot instead.
|
directly gain a slot instead.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
"src/ui/views/ContextView.vue",
|
"src/ui/views/ContextView.vue",
|
||||||
"src/ui/views/panels/RadioPanel.vue",
|
"src/ui/views/panels/RadioPanel.vue",
|
||||||
"src/ui/views/panels/SdrCard.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/DisplayControlsView.vue",
|
||||||
"src/ui/views/LayerPanelsView.vue",
|
"src/ui/views/LayerPanelsView.vue",
|
||||||
"src/ui/views/ProviderSettingsView.vue",
|
"src/ui/views/ProviderSettingsView.vue",
|
||||||
|
|||||||
@@ -25,12 +25,11 @@ export const collectAircraftProximityWindow =
|
|||||||
layer.collectAircraftProximityWindow;
|
layer.collectAircraftProximityWindow;
|
||||||
export const _getAwarenessNavigationStateForTest =
|
export const _getAwarenessNavigationStateForTest =
|
||||||
layer._getAwarenessNavigationStateForTest;
|
layer._getAwarenessNavigationStateForTest;
|
||||||
|
export const _getAwarenessPanelRendersForTest =
|
||||||
|
layer._getAwarenessPanelRendersForTest;
|
||||||
export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext;
|
export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext;
|
||||||
export const historySubjectSnapshot = layer.historySubjectSnapshot;
|
export const historySubjectSnapshot = layer.historySubjectSnapshot;
|
||||||
export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex;
|
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 awarenessResultsAreLive = layer.awarenessResultsAreLive;
|
||||||
export const awarenessNeedsContinuousRender =
|
export const awarenessNeedsContinuousRender =
|
||||||
layer.awarenessNeedsContinuousRender;
|
layer.awarenessNeedsContinuousRender;
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import militaryAwarenessLayer, {
|
import militaryAwarenessLayer, {
|
||||||
_getAwarenessNavigationStateForTest,
|
_getAwarenessNavigationStateForTest,
|
||||||
|
_getAwarenessPanelRendersForTest,
|
||||||
awarenessClearMatchesSubject,
|
awarenessClearMatchesSubject,
|
||||||
awarenessNeedsContinuousRender,
|
awarenessNeedsContinuousRender,
|
||||||
awarenessRefreshDecision,
|
awarenessRefreshDecision,
|
||||||
awarenessRefreshIntervalMs,
|
awarenessRefreshIntervalMs,
|
||||||
awarenessRefreshRequired,
|
awarenessRefreshRequired,
|
||||||
awarenessResultsAreLive,
|
awarenessResultsAreLive,
|
||||||
awarenessPanelControlKey,
|
|
||||||
buildAwarenessContextSnapshot,
|
buildAwarenessContextSnapshot,
|
||||||
canNavigateAwarenessNext,
|
canNavigateAwarenessNext,
|
||||||
contactsWindowFromSnapshot,
|
contactsWindowFromSnapshot,
|
||||||
contextTargetFlyToAllowed,
|
contextTargetFlyToAllowed,
|
||||||
captureAwarenessPanelFocus,
|
|
||||||
summarizeInstallationViewport,
|
summarizeInstallationViewport,
|
||||||
findCompatibleHistoryIndex,
|
findCompatibleHistoryIndex,
|
||||||
AWARENESS_QUERY_LIMIT,
|
AWARENESS_QUERY_LIMIT,
|
||||||
historySubjectSnapshot,
|
historySubjectSnapshot,
|
||||||
restoreAwarenessPanelFocus,
|
|
||||||
} from './militaryAwareness.js';
|
} from './militaryAwareness.js';
|
||||||
import flightsLayer, {
|
import flightsLayer, {
|
||||||
_setTrackedFlightRefreshStateForTest,
|
_setTrackedFlightRefreshStateForTest,
|
||||||
@@ -52,6 +51,10 @@ const militaryAwarenessSource = readLayerSource(
|
|||||||
new URL('./militaryAwareness.js', import.meta.url),
|
new URL('./militaryAwareness.js', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
|
const awarenessPanelSource = readFileSync(
|
||||||
|
new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
const AWARENESS_DEPENDENCIES = [
|
const AWARENESS_DEPENDENCIES = [
|
||||||
'flights',
|
'flights',
|
||||||
@@ -98,27 +101,12 @@ function installAwarenessRuntime({
|
|||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
let nextTimer = 1;
|
let nextTimer = 1;
|
||||||
let preRenderListener = null;
|
let preRenderListener = null;
|
||||||
const createdElements = [];
|
|
||||||
|
|
||||||
class FakeElement {
|
class FakeElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.classList = { contains: () => false };
|
this.classList = { contains: () => false };
|
||||||
this.style = { setProperty() {} };
|
this.style = { setProperty() {} };
|
||||||
this._hidden = false;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAttribute() {}
|
setAttribute() {}
|
||||||
@@ -213,9 +201,7 @@ function installAwarenessRuntime({
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
createElement() {
|
createElement() {
|
||||||
const element = new FakeElement();
|
return new FakeElement();
|
||||||
createdElements.push(element);
|
|
||||||
return element;
|
|
||||||
},
|
},
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
removeEventListener() {},
|
removeEventListener() {},
|
||||||
@@ -229,6 +215,10 @@ function installAwarenessRuntime({
|
|||||||
});
|
});
|
||||||
militaryAwarenessLayer.setParams({ passive: true });
|
militaryAwarenessLayer.setParams({ passive: true });
|
||||||
militaryAwarenessLayer.init(viewer);
|
militaryAwarenessLayer.init(viewer);
|
||||||
|
const panelViews = [];
|
||||||
|
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
|
||||||
|
panelViews.push(view),
|
||||||
|
);
|
||||||
militaryAwarenessLayer.enable();
|
militaryAwarenessLayer.enable();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -248,15 +238,16 @@ function installAwarenessRuntime({
|
|||||||
);
|
);
|
||||||
return taken;
|
return taken;
|
||||||
},
|
},
|
||||||
/** The standalone Context panel element renderResults() paints into. */
|
/** The Contacts panel as the layer publishes it to the view. */
|
||||||
panel() {
|
panel() {
|
||||||
return (
|
return {
|
||||||
createdElements.find(
|
view: panelViews.at(-1) ?? null,
|
||||||
(element) => element.id === 'military-awareness-panel',
|
publishes: panelViews.length,
|
||||||
) || null
|
renders: _getAwarenessPanelRendersForTest(),
|
||||||
);
|
};
|
||||||
},
|
},
|
||||||
restore() {
|
restore() {
|
||||||
|
unsubscribePanel();
|
||||||
militaryAwarenessLayer.destroy();
|
militaryAwarenessLayer.destroy();
|
||||||
globalThis.window = originalWindow;
|
globalThis.window = originalWindow;
|
||||||
globalThis.document = originalDocument;
|
globalThis.document = originalDocument;
|
||||||
@@ -264,144 +255,24 @@ function installAwarenessRuntime({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function awarenessControl(dataset, { disabled = false } = {}) {
|
/** The count a published Contacts panel shows for one cohort row. */
|
||||||
return {
|
function panelCount(runtime, label) {
|
||||||
dataset,
|
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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', () => {
|
test('Contacts continuation target is the visible explanatory note', () => {
|
||||||
const source = readLayerSource(
|
assert.match(
|
||||||
new URL('./militaryAwareness.js', import.meta.url),
|
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(
|
assert.match(
|
||||||
source,
|
militaryAwarenessSource,
|
||||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
|
/'Open-source mapped\/observed context\./,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('awareness disable settles every owned dependency release before resolving', async () => {
|
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',
|
'loading is not an observed empty cohort',
|
||||||
);
|
);
|
||||||
assert.match(installations?.reason || '', /unavailable/i);
|
assert.match(installations?.reason || '', /unavailable/i);
|
||||||
assert.match(
|
assert.equal(
|
||||||
runtime.panel()?.innerHTML || '',
|
panelCount(runtime, 'Mapped installations'),
|
||||||
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
|
'?',
|
||||||
'the operator sees ? rather than an all-clear 0 while installations load',
|
'the operator sees ? rather than an all-clear 0 while installations load',
|
||||||
);
|
);
|
||||||
} finally {
|
} 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',
|
'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(vessels?.reason || '', /unavailable/i);
|
||||||
assert.match(
|
assert.equal(
|
||||||
runtime.panel()?.innerHTML || '',
|
panelCount(runtime, 'AIS vessels'),
|
||||||
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
|
'?',
|
||||||
'the panel prints ? for the whole settled-but-connecting window',
|
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
|
||||||
);
|
|
||||||
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',
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
restoreCollections();
|
restoreCollections();
|
||||||
@@ -789,9 +655,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
|
|||||||
0,
|
0,
|
||||||
'an answered empty viewport is a real observation',
|
'an answered empty viewport is a real observation',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.equal(
|
||||||
runtime.panel()?.innerHTML || '',
|
panelCount(runtime, 'AIS vessels'),
|
||||||
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
'0',
|
||||||
'the operator sees the real count once the feed answers',
|
'the operator sees the real count once the feed answers',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1254,17 +1120,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
|||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
const panel = runtime.panel();
|
const panel = runtime.panel();
|
||||||
assert.ok(panel, 'the Context panel must exist');
|
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
|
||||||
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
|
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
|
||||||
|
|
||||||
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
|
// 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();
|
await militaryAwarenessLayer.update();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
panel.hiddenWrites,
|
runtime.panel().renders,
|
||||||
settledWrites,
|
settledRenders,
|
||||||
'an unchanged label must not trigger a panel repaint',
|
'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
|
// Enrichment answers. The contact has NOT moved and no source revision
|
||||||
// changed, so the label is the only thing making this render-worthy.
|
// 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',
|
'subject identity must stay the ICAO hex',
|
||||||
);
|
);
|
||||||
// The RENDERED panel is the surface the operator actually reads.
|
// The RENDERED panel is the surface the operator actually reads.
|
||||||
|
const repainted = runtime.panel();
|
||||||
assert.ok(
|
assert.ok(
|
||||||
panel.hiddenWrites > settledWrites,
|
repainted.publishes > settledPublishes,
|
||||||
'a changed label must repaint the standalone Context panel',
|
'a changed label must repaint the standalone Context panel',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
panel.innerHTML,
|
repainted.view.subject,
|
||||||
/N123AB/,
|
/N123AB/,
|
||||||
'the rendered Context panel must show the registration without a position change',
|
'the rendered Context panel must show the registration without a position change',
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
panel.innerHTML,
|
JSON.stringify(repainted.view),
|
||||||
/ae1fa4/,
|
/ae1fa4/,
|
||||||
'the hex must be gone from the panel',
|
'the hex must be gone from the panel',
|
||||||
);
|
);
|
||||||
@@ -2240,9 +2113,10 @@ test('expanded Context results omit the redundant status heading', () => {
|
|||||||
militaryAwarenessSource,
|
militaryAwarenessSource,
|
||||||
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
|
/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, /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 () => {
|
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 runtime = installAwarenessRuntime();
|
||||||
const restores = [];
|
const restores = [];
|
||||||
try {
|
try {
|
||||||
@@ -4252,16 +4126,15 @@ test('a selected installation card keeps every member name and escapes source te
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
militaryAwarenessLayer.update();
|
militaryAwarenessLayer.update();
|
||||||
const html = runtime.panel().innerHTML;
|
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
|
||||||
assert.match(html, /Named areas \(4\)/);
|
const names = runtime.panel().view?.subjectNames;
|
||||||
for (const name of [
|
assert.equal(names?.summary, 'Named areas (4)');
|
||||||
|
assert.deepEqual(names?.names, [
|
||||||
'Area one',
|
'Area one',
|
||||||
'Area two',
|
'Area two',
|
||||||
'Area three',
|
'Area three',
|
||||||
'<Area four>',
|
'<Area four>',
|
||||||
])
|
]);
|
||||||
assert.ok(html.includes(name));
|
|
||||||
assert.ok(!html.includes('<Area four>'));
|
|
||||||
} finally {
|
} finally {
|
||||||
restores.reverse().forEach((restore) => restore());
|
restores.reverse().forEach((restore) => restore());
|
||||||
runtime.restore();
|
runtime.restore();
|
||||||
|
|||||||
@@ -51,12 +51,12 @@ export function createAwarenessLayer({ services, source }) {
|
|||||||
parts.queries.collectAircraftProximityWindow,
|
parts.queries.collectAircraftProximityWindow,
|
||||||
_getAwarenessNavigationStateForTest:
|
_getAwarenessNavigationStateForTest:
|
||||||
parts.testing._getAwarenessNavigationStateForTest,
|
parts.testing._getAwarenessNavigationStateForTest,
|
||||||
|
_getAwarenessPanelRendersForTest:
|
||||||
|
parts.testing._getAwarenessPanelRendersForTest,
|
||||||
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
|
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
|
||||||
historySubjectSnapshot: parts.history.historySubjectSnapshot,
|
historySubjectSnapshot: parts.history.historySubjectSnapshot,
|
||||||
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
|
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
|
||||||
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
|
subscribePanel: parts.panel.subscribePanel,
|
||||||
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
|
|
||||||
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
|
|
||||||
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
|
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
|
||||||
awarenessNeedsContinuousRender:
|
awarenessNeedsContinuousRender:
|
||||||
parts.model.awarenessNeedsContinuousRender,
|
parts.model.awarenessNeedsContinuousRender,
|
||||||
|
|||||||
@@ -229,22 +229,13 @@ export function createLifecycle({
|
|||||||
destroy() {
|
destroy() {
|
||||||
this.disable();
|
this.disable();
|
||||||
detachRuntimeListeners();
|
detachRuntimeListeners();
|
||||||
layerState.panel?.removeEventListener(
|
parts.panel.clearPanel();
|
||||||
'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?.remove();
|
||||||
layerState.directionRoot = null;
|
layerState.directionRoot = null;
|
||||||
layerState.compassRing = null;
|
layerState.compassRing = null;
|
||||||
layerState.compassLabels = [];
|
layerState.compassLabels = [];
|
||||||
layerState.compassHeading = null;
|
layerState.compassHeading = null;
|
||||||
layerState.directionMarkers = [];
|
layerState.directionMarkers = [];
|
||||||
layerState.panelMarkup = '';
|
|
||||||
layerState.subjectListener = null;
|
layerState.subjectListener = null;
|
||||||
layerState.contextListener = null;
|
layerState.contextListener = null;
|
||||||
layerState.clearListener = null;
|
layerState.clearListener = null;
|
||||||
|
|||||||
+111
-155
@@ -43,76 +43,87 @@ function presentAwarenessReason(summary = {}) {
|
|||||||
: translateKnown(summary.reason || '');
|
: 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() {
|
function ensurePanel() {
|
||||||
if (layerState.panel) return layerState.panel;
|
layerState.panelShown = true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hidePanel() {
|
function hidePanel() {
|
||||||
if (layerState.panel) {
|
if (layerState.panelShown)
|
||||||
const markup = `<div class="military-awareness-standby">
|
publish({
|
||||||
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
|
kind: 'standby',
|
||||||
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
|
title: layerState.enabled
|
||||||
</div>`;
|
? t('CONTEXT READY')
|
||||||
layerState.panel.hidden = false;
|
: t('GLOBAL CONTEXT OFF'),
|
||||||
if (layerState.panelMarkup !== markup) {
|
text: layerState.enabled
|
||||||
layerState.panel.innerHTML = markup;
|
? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION')
|
||||||
layerState.panelMarkup = markup;
|
: t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'),
|
||||||
}
|
});
|
||||||
}
|
|
||||||
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
|
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 summary = cohort.summary;
|
||||||
const count = presentAwarenessCount(summary);
|
|
||||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||||
const nearest = summary.nearest
|
const nearest = summary.nearest
|
||||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||||
.map((item) => {
|
.map((item, index) => {
|
||||||
const label = formatAwarenessLabel(item);
|
const label = formatAwarenessLabel(item);
|
||||||
const targetId = item.icao24 || item.mmsi || item.id;
|
const targetId = item.icao24 || item.mmsi || item.id;
|
||||||
if (!targetId) {
|
const distance = presentAwarenessDistance(item.distanceM);
|
||||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
|
if (!targetId)
|
||||||
}
|
return {
|
||||||
|
key: `unavailable:${page + index}`,
|
||||||
|
label,
|
||||||
|
distance,
|
||||||
|
ariaLabel: t('Unavailable'),
|
||||||
|
};
|
||||||
const accessibleLabel = label === '—' ? t('Unavailable') : label;
|
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>`;
|
return {
|
||||||
})
|
key: `target:${cohort.id}:${targetId}`,
|
||||||
.join('');
|
layer: cohort.id,
|
||||||
|
targetId: String(targetId),
|
||||||
|
label,
|
||||||
|
distance,
|
||||||
|
ariaLabel: t('Focus {label}', { label: accessibleLabel }),
|
||||||
|
names: presentNames(item.memberNames),
|
||||||
|
};
|
||||||
|
});
|
||||||
const pageCount = Math.max(
|
const pageCount = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
|
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
|
||||||
@@ -121,109 +132,58 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
pageCount > 1
|
pageCount > 1
|
||||||
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
|
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
|
||||||
: '';
|
: '';
|
||||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
const coverage = cohort.coverage ? ` · ${cohort.coverage}` : '';
|
||||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
return {
|
||||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
id: cohort.id,
|
||||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
|
relationship: summary.relationship.toLowerCase(),
|
||||||
${nearest ? `<ul>${nearest}</ul>` : ''}
|
label: cohort.label,
|
||||||
</section>`;
|
count: `${presentAwarenessCount(summary)}${pageLabel}`,
|
||||||
|
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
|
||||||
|
nearest,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigationControlsHtml() {
|
function presentNavigation() {
|
||||||
const canPrevious = layerState.navigationIndex > 0;
|
return {
|
||||||
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
|
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>
|
previous: {
|
||||||
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
|
text: t('PREVIOUS'),
|
||||||
<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>
|
title: t('Previous — prior visited contact in the 250 km window'),
|
||||||
</div>`;
|
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 presentNames(names) {
|
||||||
|
|
||||||
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) {
|
|
||||||
return names?.length
|
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() {
|
function renderResults() {
|
||||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||||
const panel = ensurePanel();
|
ensurePanel();
|
||||||
|
layerState.panelRenders += 1;
|
||||||
const { subject, cohorts } = layerState.results;
|
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>
|
publish({
|
||||||
${namedAreasHtml(subject.memberNames)}
|
kind: 'results',
|
||||||
${navigationControlsHtml()}
|
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
|
||||||
${cohorts.map(rowHtml).join('')}
|
subjectNames: presentNames(subject.memberNames),
|
||||||
<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>`;
|
navigation: presentNavigation(),
|
||||||
panel.hidden = false;
|
rows: cohorts.map(presentRow),
|
||||||
if (layerState.panelMarkup !== markup) {
|
note: t(
|
||||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.',
|
||||||
panel.innerHTML = markup;
|
),
|
||||||
layerState.panelMarkup = markup;
|
});
|
||||||
restoreAwarenessPanelFocus(panel, focusSnapshot);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateAwarenessPages() {
|
function rotateAwarenessPages() {
|
||||||
@@ -277,14 +237,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
|
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
subscribePanel,
|
||||||
ensurePanel,
|
ensurePanel,
|
||||||
hidePanel,
|
hidePanel,
|
||||||
rowHtml,
|
clearPanel,
|
||||||
navigationControlsHtml,
|
|
||||||
awarenessPanelControlKey,
|
|
||||||
captureAwarenessPanelFocus,
|
|
||||||
restoreAwarenessPanelFocus,
|
|
||||||
escapeHtml,
|
|
||||||
renderResults,
|
renderResults,
|
||||||
rotateAwarenessPages,
|
rotateAwarenessPages,
|
||||||
startAwarenessPageRotation,
|
startAwarenessPageRotation,
|
||||||
|
|||||||
@@ -29,15 +29,20 @@ export function createState({ services }) {
|
|||||||
subject: null,
|
subject: null,
|
||||||
results: null,
|
results: null,
|
||||||
visual: null,
|
visual: null,
|
||||||
panel: null,
|
/** Whether layer views replace the panel's initial markup yet. */
|
||||||
panelOwned: false,
|
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,
|
subjectListener: null,
|
||||||
contextListener: null,
|
contextListener: null,
|
||||||
clearListener: null,
|
clearListener: null,
|
||||||
subjectClearListener: null,
|
subjectClearListener: null,
|
||||||
runtimeListenersAttached: false,
|
runtimeListenersAttached: false,
|
||||||
preRenderRemover: null,
|
preRenderRemover: null,
|
||||||
panelClickListener: null,
|
|
||||||
directionRoot: null,
|
directionRoot: null,
|
||||||
compassRing: null,
|
compassRing: null,
|
||||||
compassLabels: [],
|
compassLabels: [],
|
||||||
@@ -57,7 +62,6 @@ export function createState({ services }) {
|
|||||||
cameraMoving: false,
|
cameraMoving: false,
|
||||||
lastEvaluatedPosition: null,
|
lastEvaluatedPosition: null,
|
||||||
sourceRevision: '',
|
sourceRevision: '',
|
||||||
panelMarkup: '',
|
|
||||||
directionFrame: null,
|
directionFrame: null,
|
||||||
lastDirectionUpdateMs: 0,
|
lastDirectionUpdateMs: 0,
|
||||||
activationId: 0,
|
activationId: 0,
|
||||||
|
|||||||
@@ -11,5 +11,12 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
pendingSelectionKey: layerState.pendingSelectionKey,
|
pendingSelectionKey: layerState.pendingSelectionKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { _getAwarenessNavigationStateForTest };
|
/** @returns {number} How many result renders ran, published or not. */
|
||||||
|
function _getAwarenessPanelRendersForTest() {
|
||||||
|
return layerState.panelRenders;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
_getAwarenessNavigationStateForTest,
|
||||||
|
_getAwarenessPanelRendersForTest,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -973,7 +973,8 @@ export class StyleManager extends ShellFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_initGlobalContextPanel() {
|
_initGlobalContextPanel() {
|
||||||
const { radioLayer, militaryInstallationsLayer } = this.services;
|
const { radioLayer, militaryInstallationsLayer, militaryAwarenessLayer } =
|
||||||
|
this.services;
|
||||||
const contextStore = useContextStore(applicationPinia());
|
const contextStore = useContextStore(applicationPinia());
|
||||||
this._contextControls = new ContextControls({
|
this._contextControls = new ContextControls({
|
||||||
elements: {
|
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. */
|
/** Wire the independent Radio companion controls. */
|
||||||
@@ -1757,6 +1766,9 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._streetLevelControls?.destroy();
|
this._streetLevelControls?.destroy();
|
||||||
this._radioControls?.destroy();
|
this._radioControls?.destroy();
|
||||||
this._localSdrControls?.destroy();
|
this._localSdrControls?.destroy();
|
||||||
|
this._awarenessPanelUnsubscribe?.();
|
||||||
|
this._awarenessPanelUnsubscribe = null;
|
||||||
|
useContextStore(applicationPinia()).connectAwareness(null);
|
||||||
this._cockpitCoordinator.stop();
|
this._cockpitCoordinator.stop();
|
||||||
this._visualSettings.stop();
|
this._visualSettings.stop();
|
||||||
this.shareLinkManager?.destroy();
|
this.shareLinkManager?.destroy();
|
||||||
|
|||||||
@@ -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><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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
|
|||||||
* The Context panel's mode switch as its view renders it. ContextControls
|
* The Context panel's mode switch as its view renders it. ContextControls
|
||||||
* presents the selected mode, the transition busy state and the nearby
|
* presents the selected mode, the transition busy state and the nearby
|
||||||
* sites search (`_presentContext()`) and publishes them here; it
|
* 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', () => {
|
export const useContextStore = defineStore('gev-context', () => {
|
||||||
/** The presented mode switch; null until the controls first render. */
|
/** The presented mode switch; null until the controls first render. */
|
||||||
@@ -16,5 +17,31 @@ export const useContextStore = defineStore('gev-context', () => {
|
|||||||
view.value = markRaw(next);
|
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 +2,7 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { vClassToggle } from '../rail/classToggle.js';
|
import { vClassToggle } from '../rail/classToggle.js';
|
||||||
import { useContextStore } from '../stores/context.js';
|
import { useContextStore } from '../stores/context.js';
|
||||||
|
import AwarenessPanel from './panels/AwarenessPanel.vue';
|
||||||
import RadioPanel from './panels/RadioPanel.vue';
|
import RadioPanel from './panels/RadioPanel.vue';
|
||||||
|
|
||||||
// ContextControls presents the mode switch and the nearby sites search and
|
// 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="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>
|
<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>
|
</div>
|
||||||
<aside id="military-awareness-panel">
|
<AwarenessPanel />
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
|
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
|
||||||
<div id="space-mission-panel-host">
|
<div id="space-mission-panel-host">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user