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

+2 -1
View File
@@ -211,7 +211,8 @@ out `scene-chrome` the before/after imagery dividers (the imagery still splits,
with no line or handle to move it), the status chips, the toast, the ORBIT
indicator, the FPS readout, the Scene actions panel, the data-pack attribution
cards, the wind inspection marker, the Space Missions replay vehicle, the
full-globe celestial ring and the screen-space map annotations;
full-globe celestial ring, the screen-space map annotations and the
visible map target list;
leaving out `command-dock` the voice control and its pointer reticle; and
leaving out `context` the Bhote Koshi reconstruction panel. An unknown
view name is an error. A host that does not initialize Core's locale passes
+7 -1
View File
@@ -183,7 +183,13 @@ a feature supplying only its accent.
parts as attribute records, the fading marks until they leave; the view
hands back the callout text and mark groups the renderer measures, and
the tracked-entity fade is measured once the frame is in the document;
the styles it injected now live in `src/ui/styles/annotations.css`), then
the styles it injected now live in `src/ui/styles/annotations.css`), the
visible map target list (the scene chrome renders the buttons and status
the world overlay presents through `subscribeWorldOverlayActions()`, which
the application shell publishes to the `gev-world-overlay-actions` store
and answers with `activateWorldOverlayAction()`; the module needs no Vue
for the portable transit entry, and its card and detection canvases stay
painting surfaces it draws on, like the Cesium canvas), then
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
pages, cycle toggle, regional news and local conditions, and
`CockpitContext` the Contact card's scope, navigation, counts, nearest
+3 -1
View File
@@ -1149,5 +1149,7 @@
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js"
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs"
]
+6 -3
View File
@@ -112,7 +112,8 @@
"src/ui/stores/celestialRing.js",
"src/ui/views/panels/CelestialRing.vue",
"src/ui/stores/screenAnnotations.js",
"src/ui/views/panels/ScreenAnnotations.vue"
"src/ui/views/panels/ScreenAnnotations.vue",
"src/ui/stores/worldOverlayActions.js"
],
"external": ["vue", "pinia"]
},
@@ -1034,7 +1035,8 @@
"src/ui/stores/status.js",
"src/ui/toast.js",
"src/ui/stores/missionReplay.js",
"src/ui/stores/celestialRing.js"
"src/ui/stores/celestialRing.js",
"src/ui/stores/worldOverlayActions.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -3033,7 +3035,8 @@
"src/ui/stores/missionReplay.js",
"src/ui/stores/bhoteKoshiPanel.js",
"src/ui/stores/celestialRing.js",
"src/ui/stores/screenAnnotations.js"
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js"
],
"external": [
"hls.js",
+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 -->