mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
662f51fdf3
commit
b22e138c55
11 files changed
+297
-82
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user