feat(scenes): render the Scene actions panel from a store

The scene interactions owner keeps the pick handler and the interaction
session and presents the panel to the gev-scene-interactions store; the
SceneInteractions view teleports it into the viewer's container with the
same markup and inline styles. Each activation keys its own panel, each
button carries the owner it was rendered for, and only the owner the
store shows may change or remove it.
This commit is contained in:
Sameh Khamis committed 2026-10-31 20:14:35 -07:00
1 parent 6398103b60
commit 59e3aaa271
8 files changed
+376 -104

No files matched your search

+4 -1
View File
@@ -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
+3 -1
View File
@@ -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"
]
+7 -3
View File
@@ -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",
+83 -99
View File
@@ -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);
},
};
+192
View File
@@ -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');
});
+35
View File
@@ -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 };
},
);
+2
View File
@@ -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 = {
</Teleport>
<ImagerySplitView />
<SceneInteractions />
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import { useSceneInteractionsStore } from '../../stores/sceneInteractions.js';
// The active shot's Scene actions panel, inside the viewer's container, as
// the scene interactions owner (src/scenes/interactions.js) presents it.
const { panel, controls } = storeToRefs(useSceneInteractionsStore());
const panelStyle = {
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',
};
const buttonStyle = {
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',
};
// Each button keeps the panel it was rendered for, so one left over from an
// earlier panel cannot run the current actions.
const onClick = (owner, id) => () => controls.value?.dispatch(id, owner);
const outline = (id) => ({
...buttonStyle,
outline: panel.value.outlined.includes(id) ? '2px solid #6eeaff' : '',
});
</script>
<template>
<Teleport v-if="panel" :to="panel.container">
<section :key="panel.id" data-director-interactions="" :aria-label="t('Scene actions')" :style="panelStyle"><strong>{{ t('Scene actions') }}</strong><p role="status">{{ panel.status }}</p><button v-for="item in panel.items" :key="item.id" type="button" :data-director-action="item.id" :disabled="panel.busy" :aria-pressed="String(panel.selected === item.id)" :style="outline(item.id)" :onClick="onClick(panel.owner, item.id)">{{ item.label }}</button><div data-director-action-card=""><template v-if="panel.card"><p>{{ panel.card.text }}</p><a v-if="panel.card.url" :href="panel.card.url" target="_blank" rel="noopener noreferrer" style="color: #6eeaff">{{ t('Source') }}</a></template></div></section>
</Teleport>
</template>