feat(world-overlay): render the visible map target list from a store

The world overlay presents its focusable mirror of painted cards through
subscribeWorldOverlayActions() and answers activateWorldOverlayAction();
the application shell publishes the list to a store and the scene chrome
renders the buttons and the status, announcing a repeated focus again.
The module stays free of Vue for the portable transit entry, and its
canvases remain painting surfaces.
This commit is contained in:
Sameh Khamis committed 2026-10-31 21:52:32 -07:00
1 parent 662f51fdf3
commit b22e138c55
11 files changed
+297 -82

No files matched your search

+54 -63
View File
@@ -33,9 +33,6 @@ import { t } from '../i18n/index.js';
const ROOT_ID = 'world-overlay-root';
const CANVAS_ID = 'world-overlay-canvas';
const DETECTION_SURFACE_ID = 'world-overlay-detection-surface';
const ACCESSIBILITY_ROOT_ID = 'world-overlay-actions';
const ACCESSIBILITY_LIST_ID = 'world-overlay-action-list';
const ACCESSIBILITY_STATUS_ID = 'world-overlay-status';
const PAINT_TARGET_SHARED = 'shared';
const PAINT_TARGET_DETECTION = 'detection';
const OCCLUDER_PADDING_PX = 6;
@@ -156,12 +153,16 @@ let _root = null;
let _canvas = null;
/** @type {CanvasRenderingContext2D|null} */
let _ctx = null;
/** @type {HTMLElement|null} Focusable mirror of painted actionable cards. */
let _accessibilityRoot = null;
/** @type {HTMLElement|null} */
let _accessibilityList = null;
/** @type {HTMLElement|null} */
let _accessibilityStatus = null;
/**
* The focusable mirror of painted actionable cards, as its view renders it:
* `{owner, items, status, announced}`, or null without a host. `announced`
* counts accepted focuses, so a repeated announcement is spoken again. Subscribers (the
* application shell, which publishes it to a store) receive every change;
* the module itself needs no Vue, for the portable transit entry.
* @type {?{owner: object, items: object[], status: string, announced: number}}
*/
let _actions = null;
const _actionsListeners = new Set();
let _accessibilitySignature = '';
const _accessibleActivatorByKey = new Map();
/** @type {HTMLCanvasElement|null} Host-owned detection blend-isolation surface. */
@@ -1259,30 +1260,10 @@ function ensureOverlayDom() {
_root.setAttribute('aria-hidden', 'true');
_canvas.setAttribute('aria-hidden', 'true');
_detectionSurface.setAttribute('aria-hidden', 'true');
_accessibilityRoot = document.getElementById(ACCESSIBILITY_ROOT_ID);
if (!_accessibilityRoot) {
_accessibilityRoot = document.createElement('div');
_accessibilityRoot.id = ACCESSIBILITY_ROOT_ID;
_accessibilityRoot.className = 'world-overlay-accessibility';
_accessibilityRoot.setAttribute('role', 'region');
_accessibilityRoot.setAttribute('aria-label', t('Visible map targets'));
document.body.appendChild(_accessibilityRoot);
}
_accessibilityList = document.getElementById(ACCESSIBILITY_LIST_ID);
if (!_accessibilityList) {
_accessibilityList = document.createElement('div');
_accessibilityList.id = ACCESSIBILITY_LIST_ID;
_accessibilityRoot.appendChild(_accessibilityList);
}
_accessibilityStatus = document.getElementById(ACCESSIBILITY_STATUS_ID);
if (!_accessibilityStatus) {
_accessibilityStatus = document.createElement('div');
_accessibilityStatus.id = ACCESSIBILITY_STATUS_ID;
_accessibilityStatus.setAttribute('role', 'status');
_accessibilityStatus.setAttribute('aria-live', 'polite');
_accessibilityStatus.setAttribute('aria-atomic', 'true');
_accessibilityRoot.appendChild(_accessibilityStatus);
}
// Each host owns its action list; a click from an earlier host's list is
// ignored.
if (!_actions)
presentActions({ owner: {}, items: [], status: '', announced: 0 });
_ctx = _canvas.getContext('2d', { alpha: true, desynchronized: true });
_detectionCtx = _detectionSurface.getContext('2d', {
alpha: true,
@@ -2371,9 +2352,46 @@ function publishPaintRect(item) {
if (record.entry.interactive) _hitRects[_hitRectCount++] = rect;
}
/** Publish the action list to every subscriber. */
function presentActions(next) {
_actions = next && Object.freeze(next);
for (const listener of _actionsListeners) listener(_actions);
}
/**
* Receive the visible map target list now and after every change.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
export function subscribeWorldOverlayActions(listener) {
_actionsListeners.add(listener);
listener(_actions);
return () => _actionsListeners.delete(listener);
}
/**
* Focus a listed target, as its button does, announcing an accepted focus.
* @param {object} owner The `owner` of the list the button belongs to.
* @param {string} key The item's key.
*/
export function activateWorldOverlayAction(owner, key) {
if (!_actions || owner !== _actions.owner) return;
const item = _actions.items.find((candidate) => candidate.key === key);
const activate = _accessibleActivatorByKey.get(key);
if (!item || !activate) return;
const accepted = activate();
if (accepted !== false && _actions?.owner === owner) {
presentActions({
..._actions,
status: t('Focusing {label}', { label: item.label }),
announced: _actions.announced + 1,
});
}
}
/** Keep a stable, bounded accessible mirror of currently painted actions. */
function syncAccessibleActions() {
if (!_accessibilityList) return;
if (!_actions) return;
_accessibleActivatorByKey.clear();
const items = [];
for (let i = _hitRectCount - 1; i >= 0; i--) {
@@ -2397,31 +2415,7 @@ function syncAccessibleActions() {
.join('\u0001');
if (signature === _accessibilitySignature) return;
_accessibilitySignature = signature;
if (typeof _accessibilityList.replaceChildren === 'function') {
_accessibilityList.replaceChildren();
} else {
while (_accessibilityList.firstChild)
_accessibilityList.removeChild(_accessibilityList.firstChild);
}
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.label;
button.setAttribute('aria-label', item.label);
button.setAttribute('aria-pressed', String(item.selected));
button.dataset.overlayActionKey = item.key;
button.addEventListener?.('click', () => {
const activate = _accessibleActivatorByKey.get(item.key);
if (!activate) return;
const accepted = activate();
if (accepted !== false && _accessibilityStatus) {
_accessibilityStatus.textContent = t('Focusing {label}', {
label: item.label,
});
}
});
_accessibilityList.appendChild(button);
}
presentActions({ ..._actions, items });
}
let _detectionSurfacePrepared = false;
@@ -2739,7 +2733,6 @@ export function destroyWorldOverlay() {
if (_occluderRefreshTimer != null) clearTimeout(_occluderRefreshTimer);
_occluderRefreshTimer = null;
_root?.remove?.();
_accessibilityRoot?.remove?.();
// The detection surface is not a child of `_root` (it lives in the Cesium
// container so its `screen` blend reaches the scene), so it has to be torn
// down explicitly rather than by the root's removal.
@@ -2787,9 +2780,7 @@ export function destroyWorldOverlay() {
_root = null;
_canvas = null;
_ctx = null;
_accessibilityRoot = null;
_accessibilityList = null;
_accessibilityStatus = null;
if (_actions) presentActions(null);
_detectionSurface = null;
_detectionCtx = null;
_occluder = null;
+29 -11
View File
@@ -21,7 +21,9 @@ import {
getOverlayPaintRect,
getWorldOverlayDiagnostics,
hitTestWorldOverlay,
activateWorldOverlayAction,
initWorldOverlay,
subscribeWorldOverlayActions,
isOverlayPointVisible,
normalizeOverlayEntry,
paintLaneForOverlayEntry,
@@ -1019,7 +1021,14 @@ test('accessible actions announce only accepted focus and expose selected state'
const env = installMockEnvironment();
let acceptedActivations = 0;
let staleActivations = 0;
let actions = null;
const unsubscribe = subscribeWorldOverlayActions((view) => {
actions = view;
});
initWorldOverlay(env.viewer);
assert.deepEqual(actions.items, []);
assert.equal(actions.status, '');
assert.equal(actions.announced, 0);
setOverlayEntries('accessible-actions', [selectedEntry('vessel:123', {
interactive: true,
accessibilityLabel: 'Focus vessel TEST, MMSI 123',
@@ -1030,15 +1039,20 @@ test('accessible actions announce only accepted focus and expose selected state'
})]);
env.postRender.raise();
const list = env.document.getElementById('world-overlay-action-list');
const status = env.document.getElementById('world-overlay-status');
assert.equal(list.children.length, 1);
assert.equal(list.children[0]['aria-pressed'], 'true');
list.children[0].click();
assert.equal(actions.items.length, 1);
const [item] = actions.items;
assert.equal(item.label, 'Focus vessel TEST, MMSI 123');
assert.equal(item.selected, true);
const owner = actions.owner;
activateWorldOverlayAction({}, item.key);
assert.equal(acceptedActivations, 0, 'a list from another host cannot activate');
activateWorldOverlayAction(owner, item.key);
assert.equal(acceptedActivations, 1);
assert.equal(status.textContent, 'Focusing Focus vessel TEST, MMSI 123');
assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123');
assert.equal(actions.announced, 1);
activateWorldOverlayAction(owner, item.key);
assert.equal(actions.announced, 2, 'a repeated focus is a new announcement');
status.textContent = 'Focus unchanged';
setOverlayEntries('accessible-actions', [selectedEntry('vessel:123', {
selected: false,
interactive: true,
@@ -1049,12 +1063,16 @@ test('accessible actions announce only accepted focus and expose selected state'
},
})]);
env.postRender.raise();
assert.equal(list.children.length, 1);
assert.equal(list.children[0]['aria-pressed'], 'false');
list.children[0].click();
assert.equal(actions.items.length, 1);
assert.equal(actions.items[0].selected, false);
assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123', 'the list keeps its last announcement');
activateWorldOverlayAction(owner, actions.items[0].key);
assert.equal(staleActivations, 1);
assert.equal(status.textContent, 'Focus unchanged');
assert.equal(actions.status, 'Focusing Focus vessel TEST, MMSI 123', 'a refused focus announces nothing');
assert.equal(actions.announced, 2);
env.cleanup();
assert.equal(actions, null, 'teardown withdraws the list');
unsubscribe();
});
test('viewport and horizon culling obey explicit policy inputs', () => {
@@ -0,0 +1,122 @@
// src/overlays/worldOverlayActionsView.test.mjs — the scene chrome's visible
// map target list renders what the world overlay presents and activates the
// target of the list each button belongs to.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useWorldOverlayActionsStore } from '../ui/stores/worldOverlayActions.js';
import SceneChromeView from '../ui/views/SceneChromeView.vue';
function mount(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useWorldOverlayActionsStore(pinia);
const app = createApp(SceneChromeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const $ = (selector) => document.querySelector(selector);
return { store, $ };
}
test('the target list renders each painted action and the last announcement', async (t) => {
const { store, $ } = mount(t);
const region = $('#world-overlay-actions');
assert.equal(region.getAttribute('role'), 'region');
assert.equal(region.className, 'world-overlay-accessibility');
assert.equal($('#world-overlay-action-list').children.length, 0);
assert.equal($('#world-overlay-status').textContent, '');
const owner = {};
store.publish({
owner,
items: [
{ key: 'vessel\u0000123', label: 'Focus vessel TEST', selected: true },
{ key: 'fire\u000042', label: 'Focus fire 42', selected: false },
],
status: 'Focusing Focus vessel TEST',
announced: 1,
});
await nextTick();
const buttons = [...$('#world-overlay-action-list').children];
assert.deepEqual(
buttons.map((button) => [
button.type,
button.textContent,
button.getAttribute('aria-label'),
button.getAttribute('aria-pressed'),
button.dataset.overlayActionKey,
]),
[
[
'button',
'Focus vessel TEST',
'Focus vessel TEST',
'true',
'vessel\u0000123',
],
['button', 'Focus fire 42', 'Focus fire 42', 'false', 'fire\u000042'],
],
);
assert.equal(
$('#world-overlay-status').textContent,
'Focusing Focus vessel TEST',
);
});
test('a button activates its own list’s target', async (t) => {
const { store, $ } = mount(t);
const calls = [];
store.connect({ activate: (owner, key) => calls.push([owner, key]) });
const owner = {};
store.publish({
owner,
items: [{ key: 'a', label: 'Focus A', selected: false }],
status: '',
});
await nextTick();
$('#world-overlay-action-list button').click();
assert.equal(calls.length, 1);
assert.ok(calls[0][0] === owner);
assert.equal(calls[0][1], 'a');
store.publish(null);
await nextTick();
assert.equal($('#world-overlay-action-list').children.length, 0);
assert.ok($('#world-overlay-actions'), 'the region itself stays');
});
test('a repeated focus rewrites the status so it is announced again', async (t) => {
const { store, $ } = mount(t);
const owner = {};
const list = (announced) => ({
owner,
items: [{ key: 'a', label: 'Focus A', selected: false }],
status: 'Focusing Focus A',
announced,
});
store.publish(list(1));
await nextTick();
const status = $('#world-overlay-status');
assert.equal(status.textContent, 'Focusing Focus A');
let writes = 0;
new MutationObserver((records) => (writes += records.length)).observe(
status,
{
childList: true,
characterData: true,
subtree: true,
},
);
store.publish({
...list(1),
items: [{ key: 'a', label: 'Focus A', selected: true }],
});
await nextTick();
await new Promise((resolve) => setTimeout(resolve));
assert.equal(writes, 0, 'a list change alone announces nothing');
store.publish(list(2));
await nextTick();
await new Promise((resolve) => setTimeout(resolve));
assert.ok(writes > 0, 'the second focus of the same target is written again');
assert.equal(status.textContent, 'Focusing Focus A');
});
+15
View File
@@ -56,6 +56,7 @@ import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js';
import { useMissionReplayStore } from './stores/missionReplay.js';
import { useCelestialRingStore } from './stores/celestialRing.js';
import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js';
import { useDisplayStore } from './stores/display.js';
import { useLocaleStore } from './stores/locale.js';
import { useLocationStore } from './stores/location.js';
@@ -106,6 +107,8 @@ export class StyleManager extends ShellFacade {
CelestialRing,
initTrackedReadout,
initWorldOverlay,
subscribeWorldOverlayActions,
activateWorldOverlayAction,
initDetection,
setDetectionStyle,
trafficLayer,
@@ -479,6 +482,13 @@ export class StyleManager extends ShellFacade {
// detection and tracked-readout initialize. It stays transparent until a
// production source explicitly registers entries.
initWorldOverlay(viewer);
// Its focusable mirror of the painted cards, which the scene chrome
// renders.
const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia());
overlayActionsStore.connect({ activate: activateWorldOverlayAction });
this._overlayActionsUnsubscribe = subscribeWorldOverlayActions((view) =>
overlayActionsStore.publish(view),
);
// Initialize detection overlay BEFORE style stages so the composite
// stage is first in the post-process pipeline
@@ -1834,7 +1844,12 @@ export class StyleManager extends ShellFacade {
}
destroyTrackedReadout();
destroyDetection();
this._overlayActionsUnsubscribe?.();
this._overlayActionsUnsubscribe = null;
destroyWorldOverlay();
const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia());
overlayActionsStore.connect(null);
overlayActionsStore.publish(null);
this.celestialRing?.destroy();
this._visualSettings.destroy();
}
+4
View File
@@ -27,8 +27,10 @@ import {
initTrackedReadout,
} from '../data/trackedReadout.js';
import {
activateWorldOverlayAction,
destroyWorldOverlay,
initWorldOverlay,
subscribeWorldOverlayActions,
} from '../overlays/worldOverlay.js';
import {
destroyDetection,
@@ -88,6 +90,8 @@ export class StyleManager extends ApplicationShell {
initTrackedReadout,
destroyWorldOverlay,
initWorldOverlay,
subscribeWorldOverlayActions,
activateWorldOverlayAction,
destroyDetection,
initDetection,
cycleDetectionMode,
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The visible map target list as its view renders it. The world overlay
* (src/overlays/worldOverlay.js) presents the actionable cards it painted and
* the last focus announcement; the application shell publishes them here and
* connects the overlay's activation.
*/
export const useWorldOverlayActionsStore = defineStore(
'gev-world-overlay-actions',
() => {
/**
* `{owner, items: [{key, label, selected}], status}`, or null without an
* overlay host. `owner` is passed back with each activation.
*/
const actions = shallowRef(null);
/** @param {?object} next A fresh presentation; every publish re-renders. */
function publish(next) {
actions.value = next && markRaw(next);
}
/** The controls whose `activate(owner, key)` a target button calls. */
const controls = shallowRef(null);
/** @param {?object} next The controls, or null to disconnect. */
function connect(next) {
controls.value = next && markRaw(next);
}
return { actions, publish, controls, connect };
},
);
+21 -2
View File
@@ -4,6 +4,7 @@ import { t } from '../../i18n/index.js';
import { useDisplayStore } from '../stores/display.js';
import { useLocationStore } from '../stores/location.js';
import { useStatusStore } from '../stores/status.js';
import { useWorldOverlayActionsStore } from '../stores/worldOverlayActions.js';
import { vClassToggle } from '../rail/classToggle.js';
import ImagerySplitView from './panels/ImagerySplitView.vue';
import MissionReplayOverlay from './panels/MissionReplayOverlay.vue';
@@ -21,6 +22,24 @@ const { style } = storeToRefs(useDisplayStore());
const { loading, traffic, cctv, toast, fps } = storeToRefs(useStatusStore());
// The ORBIT indicator LocationControls offers with the location tray.
const { view: location } = storeToRefs(useLocationStore());
// The focusable mirror of the overlay cards the world overlay painted.
const { actions: targets, controls: overlayControls } = storeToRefs(
useWorldOverlayActionsStore(),
);
// Each button activates the target of the list it was rendered for.
const onOverlayAction = (owner, key) => () =>
overlayControls.value?.activate(owner, key);
// Every accepted focus rewrites the status, so the live region announces a
// repeated focus again.
const vOverlayStatus = {
mounted(el, { value }) {
el.textContent = value?.status ?? '';
},
updated(el, { value, oldValue }) {
if (value?.announced !== oldValue?.announced || !value)
el.textContent = value?.status ?? '';
},
};
// Every show rewrites the toast's text, so the live region announces a
// repeated message again; hiding leaves it as it fades.
const vToastText = {
@@ -39,8 +58,8 @@ const vToastText = {
<canvas id="world-overlay-canvas"></canvas>
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets" data-i18n-aria-label>
<div id="world-overlay-action-list"></div>
<div id="world-overlay-status" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="world-overlay-action-list"><button v-for="item in targets?.items ?? []" :key="item.key" type="button" :aria-label="item.label" :aria-pressed="String(item.selected)" :data-overlay-action-key="item.key" :onClick="onOverlayAction(targets.owner, item.key)">{{ item.label }}</button></div>
<div id="world-overlay-status" v-overlay-status="targets" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
<!-- Title -->