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

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