diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index e12681ca6..25405aac0 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -149,7 +149,10 @@ a feature supplying only its accent.
`gev-status` store; `SplitFlapText` plays the departure-board flip that
`splitFlap.js` plans, writing only the label's one text node; the toast
renders what each owner's `createToast()` publishes there, on that
- owner's own timer), then
+ owner's own timer), the Scene actions panel (`SceneInteractions`
+ teleports what the scene interactions owner publishes to the
+ `gev-scene-interactions` store into the viewer's container; the owner
+ keeps the pick handler and focuses the picked action), 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 c4cb057aa..c5593ccb2 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -1132,5 +1132,7 @@
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
- "src/ui/toastView.test.mjs"
+ "src/ui/toastView.test.mjs",
+ "src/ui/stores/sceneInteractions.js",
+ "src/scenes/interactionsView.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index d3f80f6dd..ec8b207b9 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -97,7 +97,9 @@
"src/ui/stores/imagerySplit.js",
"src/ui/stores/status.js",
"src/ui/views/panels/SplitFlapText.vue",
- "src/splitFlap.js"
+ "src/splitFlap.js",
+ "src/ui/stores/sceneInteractions.js",
+ "src/ui/views/panels/SceneInteractions.vue"
],
"external": ["vue", "pinia"]
},
@@ -1147,7 +1149,8 @@
"src/visualStylePolicy.js",
"src/ui/vueRuntime.js",
"src/ui/stores/status.js",
- "src/ui/toast.js"
+ "src/ui/toast.js",
+ "src/ui/stores/sceneInteractions.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -3004,7 +3007,8 @@
"src/ui/stores/display.js",
"src/ui/stores/imagerySplit.js",
"src/ui/stores/status.js",
- "src/ui/toast.js"
+ "src/ui/toast.js",
+ "src/ui/stores/sceneInteractions.js"
],
"external": [
"hls.js",
diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js
index c7cc22f31..57cf6b436 100644
--- a/src/scenes/interactions.js
+++ b/src/scenes/interactions.js
@@ -1,68 +1,79 @@
import * as Cesium from 'cesium';
+import { nextTick } from 'vue';
import { isPointerFree } from '../data/inputOwnership.js';
import { createInteractionSession } from '../director/interactions/session.js';
import { t } from '../i18n/index.js';
+import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js';
+import { applicationPinia } from '../ui/vueRuntime.js';
+
+/**
+ * Own the active shot's pick handler, accessible actions and plain-text
+ * feedback. The Scene actions panel is presented to the scene interactions
+ * store, which its view renders inside the viewer's container.
+ */
+// Keys each activation's panel, so a new one never reuses an old one's DOM.
+let activations = 0;
-/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
export function createSceneInteractions(
viewer,
- { execute, available = () => true },
+ {
+ execute,
+ available = () => true,
+ store = useSceneInteractionsStore(applicationPinia()),
+ },
) {
- let panel,
- status,
- card,
+ let panel = null,
handler,
items = [],
targets = new Map();
- const buttons = new Map();
+ // Only the owner whose panel the store shows may change or remove it.
+ const shown = () => panel && store.panel?.owner === panel.owner;
+ const update = (patch) => {
+ if (!shown()) return;
+ panel = { ...panel, ...patch };
+ store.publish(panel);
+ };
const session = createInteractionSession({
execute: (item, signal) => {
if (!isPointerFree() || !available()) return false;
- card.replaceChildren();
if (item.action.type === 'card') {
- const text = document.createElement('p');
- text.textContent = item.action.text;
- card.append(text);
- if (item.action.url) {
- const link = document.createElement('a');
- link.textContent = t('Source');
- link.style.color = '#6eeaff';
- link.href = item.action.url;
- link.target = '_blank';
- link.rel = 'noopener noreferrer';
- card.append(link);
- }
+ update({
+ card: { text: item.action.text, url: item.action.url || null },
+ });
return true;
}
+ update({ card: null });
return execute(item.action, signal);
},
changed(state) {
- for (const [id, button] of buttons) {
- button.disabled = state.busy;
- button.setAttribute('aria-pressed', String(state.selected === id));
- button.style.outline = state.selected === id ? '2px solid #6eeaff' : '';
- }
+ update({
+ busy: state.busy,
+ selected: state.selected,
+ outlined: state.selected ? [state.selected] : [],
+ });
},
});
function clear() {
session.clear();
handler?.destroy();
handler = null;
- panel?.remove();
+ if (shown()) {
+ store.connect(null);
+ store.publish(null);
+ }
panel = null;
- status = null;
- card = null;
- buttons.clear();
items = [];
targets.clear();
}
async function dispatch(id) {
const current = panel;
const ok = await session.dispatch(id);
- if (current && current === panel && status)
- status.textContent = ok
- ? t('Action complete')
- : t('Action unavailable or cancelled');
+ if (current && panel?.owner === current.owner)
+ update({
+ status: ok
+ ? t('Action complete')
+ : t('Action unavailable or cancelled'),
+ });
return ok;
}
return {
@@ -86,65 +97,35 @@ export function createSceneInteractions(
targets.clear();
throw new Error('Interaction feature is missing from the loaded pack');
}
- panel = document.createElement('section');
- panel.dataset.directorInteractions = '';
- panel.setAttribute('aria-label', t('Scene actions'));
- Object.assign(panel.style, {
- position: 'absolute',
- left: '12px',
- bottom: '80px',
- zIndex: '31',
- maxWidth: '300px',
- maxHeight: '40vh',
- overflowY: 'auto',
- background: '#08141eed',
- color: '#e3faff',
- border: '1px solid #2491a8',
- padding: '10px',
- font: '13px sans-serif',
+ // Each activation owns its panel; a click on an older one is ignored.
+ const owner = {};
+ panel = {
+ id: ++activations,
+ owner,
+ container: viewer.container,
+ items: items.map(({ id, label }) => ({ id, label })),
+ status: t('Select a feature or use Tab and Enter to choose an action.'),
+ busy: false,
+ selected: null,
+ outlined: [],
+ card: null,
+ };
+ store.publish(panel);
+ store.connect({
+ dispatch(id, from) {
+ if (from === owner && panel?.owner === owner) void dispatch(id);
+ },
});
- const owner = panel;
- const title = document.createElement('strong');
- title.textContent = t('Scene actions');
- panel.append(title);
- status = document.createElement('p');
- status.setAttribute('role', 'status');
- status.textContent = t(
- 'Select a feature or use Tab and Enter to choose an action.',
- );
- panel.append(status);
- for (const item of items) {
- const button = document.createElement('button');
- button.type = 'button';
- button.textContent = item.label;
- button.dataset.directorAction = item.id;
- Object.assign(button.style, {
- display: 'block',
- background: '#10242e',
- color: '#e3faff',
- border: '1px solid #397080',
- borderRadius: '3px',
- padding: '6px 10px',
- cursor: 'pointer',
- font: 'inherit',
- margin: '6px 0',
- maxWidth: '100%',
- whiteSpace: 'normal',
- });
- button.addEventListener('click', () => {
- if (panel === owner) void dispatch(item.id);
- });
- buttons.set(item.id, button);
- panel.append(button);
- }
- card = document.createElement('div');
- card.dataset.directorActionCard = '';
- panel.append(card);
- viewer.container.append(panel);
session.activate(items);
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((event) => {
- if (panel !== owner || !isPointerFree() || !available()) return;
+ if (
+ panel?.owner !== owner ||
+ !shown() ||
+ !isPointerFree() ||
+ !available()
+ )
+ return;
const picked = viewer.scene.pick(event.position)?.id;
const matches = items.filter(
(item) =>
@@ -153,19 +134,22 @@ export function createSceneInteractions(
) === picked,
);
if (!matches.length) return;
- for (const item of items) {
- const button = buttons.get(item.id);
- button.style.outline = matches.includes(item)
- ? '2px solid #6eeaff'
- : '';
- }
- status.textContent = t(
- 'Selected feature: {feature}. Choose an action.',
- {
+ update({
+ outlined: matches.map((item) => item.id),
+ status: t('Selected feature: {feature}. Choose an action.', {
feature: matches[0].target.featureId,
- },
- );
- buttons.get(matches[0].id)?.focus({ preventScroll: true });
+ }),
+ });
+ // Focus the first match once the panel shows the selection.
+ void nextTick(() => {
+ if (panel?.owner !== owner || !shown()) return;
+ const id = globalThis.CSS?.escape?.(matches[0].id) ?? matches[0].id;
+ viewer.container
+ ?.querySelector?.(
+ `[data-director-interactions] [data-director-action="${id}"]`,
+ )
+ ?.focus({ preventScroll: true });
+ });
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
},
};
diff --git a/src/scenes/interactionsView.test.mjs b/src/scenes/interactionsView.test.mjs
new file mode 100644
index 000000000..089459b5d
--- /dev/null
+++ b/src/scenes/interactionsView.test.mjs
@@ -0,0 +1,192 @@
+// src/scenes/interactionsView.test.mjs — the Scene actions panel renders in
+// the viewer's container from what the scene interactions owner presents.
+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 { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js';
+import SceneInteractions from '../ui/views/panels/SceneInteractions.vue';
+import { createSceneInteractions } from './interactions.js';
+
+const target = (featureId) => ({ packId: 'qa', featureId });
+const key = (featureId) => JSON.stringify(['qa', featureId]);
+const SHOT = {
+ interactions: [
+ {
+ id: 'card',
+ label: 'Read about it',
+ target: target('point'),
+ action: { type: 'card', text: 'A note.', url: 'https://example.com/a' },
+ },
+ {
+ id: 'fly',
+ label: 'Fly there',
+ target: target('point'),
+ action: { type: 'fly' },
+ },
+ ],
+};
+
+function fixture(t, { execute = async () => true } = {}) {
+ document.body.replaceChildren();
+ const container = document.body.appendChild(document.createElement('div'));
+ const canvas = container.appendChild(document.createElement('canvas'));
+ const pinia = createPinia();
+ const app = createApp(SceneInteractions).use(pinia);
+ app.mount(document.body.appendChild(document.createElement('div')));
+ const executed = [];
+ const interactions = createSceneInteractions(
+ { container, scene: { canvas, pick: () => null } },
+ {
+ execute: (action, signal) => {
+ executed.push(action.type);
+ return execute(action, signal);
+ },
+ store: useSceneInteractionsStore(pinia),
+ },
+ );
+ t.after(() => {
+ interactions.destroy();
+ app.unmount();
+ });
+ const $ = (selector) => container.querySelector(selector);
+ const settle = async () => {
+ for (let flush = 0; flush < 4; flush += 1) await nextTick();
+ };
+ return { interactions, container, executed, $, settle, pinia };
+}
+
+test('a shot with actions shows its panel in the viewer container', async (t) => {
+ const { interactions, $, settle } = fixture(t);
+ assert.equal($('[data-director-interactions]'), null);
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ await settle();
+ const panel = $('[data-director-interactions]');
+ assert.equal(panel.tagName, 'SECTION');
+ assert.equal(panel.getAttribute('aria-label'), 'Scene actions');
+ assert.equal(panel.style.position, 'absolute');
+ assert.equal(panel.style.zIndex, '31');
+ assert.equal(panel.querySelector('strong').textContent, 'Scene actions');
+ assert.equal(
+ panel.querySelector('[role=status]').textContent,
+ 'Select a feature or use Tab and Enter to choose an action.',
+ );
+ const buttons = [...panel.querySelectorAll('button')];
+ assert.deepEqual(
+ buttons.map((button) => [
+ button.dataset.directorAction,
+ button.textContent,
+ ]),
+ [
+ ['card', 'Read about it'],
+ ['fly', 'Fly there'],
+ ],
+ );
+ assert.ok(buttons.every((button) => button.type === 'button'));
+ assert.deepEqual(
+ buttons.map((button) => button.getAttribute('aria-pressed')),
+ ['false', 'false'],
+ );
+ assert.equal($('[data-director-action-card]').children.length, 0);
+});
+
+test('an action reports its outcome and a card action shows its text and source', async (t) => {
+ const { interactions, executed, $, settle } = fixture(t);
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ await settle();
+ $('[data-director-action="card"]').click();
+ await settle();
+ const card = $('[data-director-action-card]');
+ assert.equal(card.querySelector('p').textContent, 'A note.');
+ const link = card.querySelector('a');
+ assert.equal(link.textContent, 'Source');
+ assert.equal(link.getAttribute('href'), 'https://example.com/a');
+ assert.equal(link.target, '_blank');
+ assert.equal(link.rel, 'noopener noreferrer');
+ assert.equal($('[role=status]').textContent, 'Action complete');
+ const card1 = $('[data-director-action="card"]');
+ assert.equal(card1.getAttribute('aria-pressed'), 'true');
+ // happy-dom orders the shorthand its own way.
+ assert.deepEqual(card1.style.outline.split(' ').sort(), [
+ '#6eeaff',
+ '2px',
+ 'solid',
+ ]);
+ $('[data-director-action="fly"]').click();
+ await settle();
+ assert.deepEqual(executed, ['fly']);
+ assert.equal(card.children.length, 0, 'another action clears the card');
+ assert.equal($('[data-director-action="card"]').style.outline, '');
+});
+
+test('buttons are disabled while an action runs, and clearing removes the panel', async (t) => {
+ let finish;
+ const { interactions, $, settle } = fixture(t, {
+ execute: () => new Promise((resolve) => (finish = resolve)),
+ });
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ await settle();
+ $('[data-director-action="fly"]').click();
+ await settle();
+ assert.ok(
+ [...$('[data-director-interactions]').querySelectorAll('button')].every(
+ (button) => button.disabled,
+ ),
+ );
+ finish(false);
+ await settle();
+ assert.equal(
+ $('[role=status]').textContent,
+ 'Action unavailable or cancelled',
+ );
+ interactions.clear();
+ await settle();
+ assert.equal($('[data-director-interactions]'), null);
+});
+
+test('a button from an earlier panel cannot run the replacement actions', async (t) => {
+ const { interactions, executed, $, settle } = fixture(t);
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ await settle();
+ const old = $('[data-director-action="fly"]');
+ interactions.clear();
+ await settle();
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ await settle();
+ assert.ok(old !== $('[data-director-action="fly"]'));
+ old.click();
+ await settle();
+ assert.deepEqual(executed, []);
+ assert.equal(interactions.getState().selected, null);
+});
+
+test('a button reused before the next render still belongs to its own panel', async (t) => {
+ const { interactions, executed, $, settle } = fixture(t);
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ await settle();
+ const old = $('[data-director-action="fly"]');
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ old.click();
+ await settle();
+ assert.deepEqual(executed, []);
+ assert.ok(old !== $('[data-director-action="fly"]'));
+ assert.equal(old.isConnected, false);
+});
+
+test("an earlier owner leaves a later owner's panel alone", async (t) => {
+ const { interactions, container, $, settle, pinia } = fixture(t);
+ const later = createSceneInteractions(
+ { container, scene: { canvas: $('canvas'), pick: () => null } },
+ { execute: async () => true, store: useSceneInteractionsStore(pinia) },
+ );
+ t.after(() => later.destroy());
+ interactions.activate(SHOT, new Map([[key('point'), {}]]));
+ later.activate(SHOT, new Map([[key('point'), {}]]));
+ interactions.destroy();
+ await settle();
+ assert.ok($('[data-director-interactions]'));
+ $('[data-director-action="fly"]').click();
+ await settle();
+ assert.equal(later.getState().selected, 'fly');
+});
diff --git a/src/ui/stores/sceneInteractions.js b/src/ui/stores/sceneInteractions.js
new file mode 100644
index 000000000..6e30af9a9
--- /dev/null
+++ b/src/ui/stores/sceneInteractions.js
@@ -0,0 +1,35 @@
+import { defineStore } from 'pinia';
+import { markRaw, shallowRef } from 'vue';
+
+/**
+ * The active shot's Scene actions panel as its view renders it. The scene
+ * interactions owner (src/scenes/interactions.js) presents the actions, the
+ * status line and the source card here; the view sends action clicks back to
+ * the connected controls.
+ */
+export const useSceneInteractionsStore = defineStore(
+ 'gev-scene-interactions',
+ () => {
+ /**
+ * `{id, owner, container, items, status, busy, selected, outlined,
+ * card}`, or null while the shot has no actions. `container` is the
+ * element it sits in; `owner` is passed back with each dispatch.
+ */
+ const panel = shallowRef(null);
+
+ /** @param {?object} next A fresh presentation; every publish re-renders. */
+ function publish(next) {
+ panel.value = next && markRaw(next);
+ }
+
+ /** The controls whose `dispatch(id, owner)` an action button calls. */
+ const controls = shallowRef(null);
+
+ /** @param {?object} next The controls, or null to disconnect. */
+ function connect(next) {
+ controls.value = next && markRaw(next);
+ }
+
+ return { panel, publish, controls, connect };
+ },
+);
diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue
index b165d1adf..b763f7211 100644
--- a/src/ui/views/SceneChromeView.vue
+++ b/src/ui/views/SceneChromeView.vue
@@ -6,6 +6,7 @@ import { useLocationStore } from '../stores/location.js';
import { useStatusStore } from '../stores/status.js';
import { vClassToggle } from '../rail/classToggle.js';
import ImagerySplitView from './panels/ImagerySplitView.vue';
+import SceneInteractions from './panels/SceneInteractions.vue';
import SplitFlapText from './panels/SplitFlapText.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
@@ -95,4 +96,5 @@ const vToastText = {
+
diff --git a/src/ui/views/panels/SceneInteractions.vue b/src/ui/views/panels/SceneInteractions.vue
new file mode 100644
index 000000000..88d1013e4
--- /dev/null
+++ b/src/ui/views/panels/SceneInteractions.vue
@@ -0,0 +1,50 @@
+
+
+
+
+ {{ t('Scene actions') }}{{ panel.status }}
+
+