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');
});