diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index f8ecd7437..9d51ed11a 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 7dd134384..78fb2be45 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 47ad70e80..74163968a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0cc156f1d..79b16fb5a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 1fcfdd8dc..7b0b9d5fd 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -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; diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index d2087bc37..f2e608b78 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -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', () => { diff --git a/src/overlays/worldOverlayActionsView.test.mjs b/src/overlays/worldOverlayActionsView.test.mjs new file mode 100644 index 000000000..fc5285bb1 --- /dev/null +++ b/src/overlays/worldOverlayActionsView.test.mjs @@ -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'); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 79a04a560..52b674b8b 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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(); } diff --git a/src/ui/composition.js b/src/ui/composition.js index c56c6f37c..3b95c1a8c 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -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, diff --git a/src/ui/stores/worldOverlayActions.js b/src/ui/stores/worldOverlayActions.js new file mode 100644 index 000000000..240777ac9 --- /dev/null +++ b/src/ui/stores/worldOverlayActions.js @@ -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 }; + }, +); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 57e973428..db467aabe 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -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 = {