feat: add declarative Director feature actions

This commit is contained in:
Sameh Khamis committed 2026-09-15 19:16:49 -07:00
1 parent a30c42322c
commit 80027756ef
22 files changed
+1150 -17

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ test('v3 unversioned bloom already uses scale 2 and is not migrated again in lat
}),
),
);
assert.equal(project.version, 5);
assert.equal(project.version, 6);
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
});
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
+5 -2
View File
@@ -15,7 +15,8 @@ import {
import { validateSceneDataPacks } from './packs/manifest.js';
import { validateSceneCameras } from './cameraDocument.js';
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js';
export const SCENE_DOCUMENT_VERSION = 5;
import { validateSceneInteractions } from './interactions/document.js';
export const SCENE_DOCUMENT_VERSION = 6;
function visual(value, path, legacy) {
fields(value, path, [
@@ -66,7 +67,7 @@ export function validateSceneDocument(project) {
'scenes',
]);
const version = Object.hasOwn(project, 'version') ? project.version : 1;
if (![1, 2, 3, 4, 5].includes(version))
if (![1, 2, 3, 4, 5, 6].includes(version))
fail('$.version', 'unsupported scene project version');
const legacy = version < 3;
for (const key of ['createdAt', 'updatedAt'])
@@ -126,6 +127,7 @@ export function validateSceneDocument(project) {
'sourcePackVersion',
...(version >= 4 ? ['move'] : []),
...(version >= 5 ? ['dataPackIds'] : []),
...(version >= 6 ? ['interactions'] : []),
]);
uniqueId(shot, at, shotIds);
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
@@ -153,6 +155,7 @@ export function validateSceneDocument(project) {
});
validateSceneCameras(scene, path, version);
validateSceneDataPacks(scene, path);
validateSceneInteractions(scene, path);
});
return project;
}
+1 -1
View File
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.equal(migrated.version, 5);
assert.equal(migrated.version, 6);
assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150);
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
assert.deepEqual(
+96
View File
@@ -0,0 +1,96 @@
import {
array,
fields,
string,
optional,
uniqueId,
fail,
} from '../documentFields.js';
/** Validate inert, scene-local actions. Feature existence is checked after asset loading. */
export function validateSceneInteractions(scene, path) {
const packs = new Map((scene.dataPacks || []).map((p) => [p.id, p]));
const anchors = new Set((scene.anchors || []).map((a) => a.id));
const shots = new Map(scene.shots.map((s) => [s.id, s]));
scene.shots.forEach((shot, index) => {
optional(shot, 'interactions', `${path}.shots[${index}]`, (items, at) => {
array(items, at, 64);
const seen = new Set();
items.forEach((item, i) => {
const field = `${at}[${i}]`;
fields(item, field, ['id', 'label', 'target', 'action']);
string(item.id, field);
uniqueId(item, field, seen);
string(item.label, field, 256);
fields(item.target, `${field}.target`, ['packId', 'featureId']);
string(item.target.packId, field);
string(item.target.featureId, field);
if (
packs.get(item.target.packId)?.format !== 'geojson' ||
!shot.dataPackIds?.includes(item.target.packId)
)
fail(field, 'target must belong to a selected GeoJSON pack');
const a = item.action;
const specs = {
card: ['text', 'url'],
focus: ['anchorId'],
shot: ['shotId'],
layer: ['layerId', 'enabled'],
};
if (!a || !Object.hasOwn(specs, a.type))
fail(field, 'unsupported action');
string(a.type, `${field}.action.type`);
fields(a, `${field}.action`, ['type', ...specs[a.type]]);
const reference = {
focus: 'anchorId',
shot: 'shotId',
layer: 'layerId',
}[a.type];
if (reference) string(a[reference], `${field}.action.${reference}`);
if (a.type === 'card') {
string(a.text, field, 4096);
optional(a, 'url', field, (url, at) => {
string(url, at, 2048);
let parsed;
try {
parsed = new URL(url);
} catch {
fail(at, 'expected HTTPS source URL');
}
if (
parsed.protocol !== 'https:' ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
fail(
at,
'expected HTTPS source URL without credentials, query or fragment',
);
});
} else if (a.type === 'focus' && !anchors.has(a.anchorId))
fail(field, 'unknown anchor');
else if (a.type === 'shot') {
const target = shots.get(a.shotId);
if (!target) fail(field, 'unknown shot');
// A transition must restore every layer this shot can change.
for (const entry of items)
if (
entry?.action?.type === 'layer' &&
!Object.hasOwn(target.layers || {}, entry.action.layerId)
)
fail(
field,
'target shot must declare interactive layer baseline',
);
} else if (a.type === 'layer') {
if (!Object.hasOwn(shot.layers || {}, a.layerId))
fail(field, 'layer must have an explicit shot baseline');
if (typeof a.enabled !== 'boolean')
fail(field, 'expected enabled boolean');
}
});
});
});
}
@@ -0,0 +1,247 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
import { normalizeProject } from '../../scenes/project.js';
import { createInteractionSession } from './session.js';
const fixture = () => ({
version: 6,
scenes: [
{
id: 'scene',
anchors: [
{
id: 'view',
lat: 1,
lon: 2,
alt: 3000,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'test.json' },
attribution: { text: 'Example', license: 'CC0' },
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'one',
layers: { traffic: false },
dataPackIds: ['data'],
interactions: [
{
id: 'card',
label: 'Read',
target: { packId: 'data', featureId: 'point' },
action: {
type: 'card',
text: '<script>text only</script>',
url: 'https://example.org/source',
},
},
{
id: 'focus',
label: 'Focus',
target: { packId: 'data', featureId: 'point' },
action: { type: 'focus', anchorId: 'view' },
},
{
id: 'layer',
label: 'Traffic',
target: { packId: 'data', featureId: 'point' },
action: { type: 'layer', layerId: 'traffic', enabled: true },
},
{
id: 'next',
label: 'Next',
target: { packId: 'data', featureId: 'point' },
action: { type: 'shot', shotId: 'two' },
},
],
},
{ id: 'two', layers: { traffic: false } },
],
},
],
});
test('all four inert actions survive validation, migration and export without executing content', () => {
const input = fixture();
const output = JSON.parse(
stringifySceneDocument(
normalizeProject(parseSceneDocument(JSON.stringify(input))),
),
);
assert.deepEqual(
output.scenes[0].shots[0].interactions,
input.scenes[0].shots[0].interactions,
);
});
test('reject unknown fields, executable syntax, invalid references and missing reset baselines', () => {
const mutations = [
(p) => (p.version = 5),
(p) => {
p.scenes[0].shots[0].layers.undefined = false;
delete p.scenes[0].shots[0].interactions[2].action.layerId;
},
(p) => (p.scenes[0].shots[0].interactions[0].action.type = ['card']),
(p) => (p.scenes[0].shots[0].interactions[2].action.layerId = ['traffic']),
(p) => (p.scenes[0].shots[0].interactions = null),
(p) =>
p.scenes[0].shots[0].interactions.push(
p.scenes[0].shots[0].interactions[0],
),
(p) => (p.scenes[0].shots[0].interactions[0].action.code = 'alert(1)'),
(p) =>
(p.scenes[0].shots[0].interactions[0].action.url = 'javascript:alert(1)'),
(p) =>
(p.scenes[0].shots[0].interactions[0].action.url =
'https://user:[email protected]/'),
(p) =>
(p.scenes[0].shots[0].interactions[0].action.url =
'https://example.org/?token=secret'),
(p) => (p.scenes[0].shots[0].interactions[0].target.packId = 'missing'),
(p) => (p.scenes[0].shots[0].dataPackIds = []),
(p) => (p.scenes[0].shots[0].interactions[1].action.anchorId = 'missing'),
(p) => (p.scenes[0].shots[0].interactions[2].action.layerId = 'missing'),
(p) => (p.scenes[0].shots[0].interactions[2].action.enabled = 'yes'),
(p) => (p.scenes[0].shots[0].interactions[3].action.shotId = 'missing'),
(p) => (p.scenes[0].shots[1].layers = {}),
(p) =>
(p.scenes[0].shots[0].interactions = Array.from(
{ length: 65 },
(_, i) => ({ ...p.scenes[0].shots[0].interactions[0], id: String(i) }),
)),
];
for (const mutate of mutations) {
const p = fixture();
mutate(p);
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
}
});
test('pending actions cancel promptly, refuse overlap and cannot update a replacement session', async () => {
let resolve,
signal,
calls = 0;
const session = createInteractionSession({
execute: (_, s) => {
calls++;
signal = s;
return new Promise((r) => (resolve = r));
},
});
session.activate([{ id: 'old' }]);
const work = session.dispatch('old');
await Promise.resolve();
assert.equal(await session.dispatch('old'), false);
assert.equal(await session.dispatch('missing'), false);
session.activate([{ id: 'new' }]);
assert.equal(signal.aborted, true);
assert.equal(await work, false);
resolve(true);
await Promise.resolve();
assert.deepEqual(session.getState(), {
active: true,
busy: false,
selected: null,
count: 1,
});
assert.equal(calls, 1);
session.clear();
assert.equal(await session.dispatch('new'), false);
});
test('synchronous stop before execution prevents any side effect; rejection unlocks retry', async () => {
let calls = 0;
const session = createInteractionSession({
execute: () => {
calls++;
throw new Error('refused');
},
});
session.activate([{ id: 'a' }]);
const work = session.dispatch('a');
session.clear();
assert.equal(await work, false);
assert.equal(calls, 0);
session.activate([{ id: 'a' }]);
assert.equal(await session.dispatch('a'), false);
assert.equal(session.getState().busy, false);
assert.equal(await session.dispatch('a'), false);
assert.equal(calls, 2);
});
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
const { SceneDirector } = await import('../../scenes/director.js');
const shot = { id: 'shot', dataPackIds: ['pack'] };
const director = Object.assign(Object.create(SceneDirector.prototype), {
_destroyed: false,
_loadedSceneId: 'scene',
_selectedShotId: 'shot',
_claimCameraOwnership: () =>
assert.fail('Must reload the released pack before camera seek'),
});
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
});
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
const { SceneDirector } = await import('../../scenes/director.js');
const owner = new AbortController();
const director = Object.assign(Object.create(SceneDirector.prototype), {
_running: false,
_destroyed: false,
_interactionTransitions: 64,
_getShot: () => ({ scene: { id: 'scene' }, shot: { id: 'shot' } }),
_claimCameraOwnership: () => false,
_setCameraView: () => assert.fail('Refused camera cannot move'),
_updateStatus: () => {},
_loadShot: () => assert.fail('Transition budget exceeded'),
dataManager: {
getAll: () => [{ id: 'traffic' }],
setEnabled: (id, enabled, options) => {
assert.equal(id, 'traffic');
assert.equal(enabled, true);
assert.equal(options.signal, owner.signal);
assert.equal(options.origin, 'scene');
return false;
},
},
});
assert.equal(
await director._executeInteraction({ type: 'focus' }, owner.signal),
false,
);
assert.equal(
await director._executeInteraction(
{ type: 'shot', shotId: 'next' },
owner.signal,
),
false,
);
assert.equal(
await director._executeInteraction(
{ type: 'layer', layerId: 'traffic', enabled: true },
owner.signal,
),
false,
);
assert.equal(
await director._executeInteraction(
{ type: 'layer', layerId: 'unknown', enabled: true },
owner.signal,
),
false,
);
owner.abort();
assert.equal(
await director._executeInteraction({ type: 'focus' }, owner.signal),
false,
);
});
+61
View File
@@ -0,0 +1,61 @@
/** Own one user-triggered action at a time; cancellation settles even an uncooperative adapter. */
export function createInteractionSession({ execute, changed = () => {} }) {
let controller,
active = false,
busy = false,
selected = null;
let actions = new Map();
const state = () => ({ active, busy, selected, count: actions.size });
function clear() {
active = false;
busy = false;
selected = null;
actions.clear();
controller?.abort();
controller = null;
changed(state());
}
return {
clear,
getState: state,
activate(items) {
clear();
actions = new Map(items.map((item) => [item.id, item]));
active = !!actions.size;
changed(state());
},
async dispatch(id) {
const item = actions.get(id);
if (!active || busy || !item) return false;
const current = new AbortController();
controller = current;
busy = true;
selected = id;
changed(state());
let abort;
try {
const cancelled = new Promise((resolve) => {
abort = () => resolve(false);
current.signal.addEventListener('abort', abort, { once: true });
});
const work = Promise.resolve().then(() => {
if (current.signal.aborted) return false;
return execute(item, current.signal);
});
return (
(await Promise.race([work, cancelled])) !== false &&
!current.signal.aborted
);
} catch {
return false;
} finally {
current.signal.removeEventListener('abort', abort);
if (controller === current) {
busy = false;
controller = null;
changed(state());
}
}
},
};
}
+1 -1
View File
@@ -51,7 +51,7 @@ test('v5 packs and shot references round trip through project migration without
JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }),
),
);
assert.equal(old.version, 5);
assert.equal(old.version, 6);
assert.equal(old.scenes[0].dataPacks, undefined);
}
});
+2
View File
@@ -122,3 +122,5 @@ export {
export { createAssetDirectorySource } from './packs/source.js';
export { createDataPackSession } from './packs/session.js';
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
export { createInteractionSession } from './interactions/session.js';
+3 -1
View File
@@ -3,12 +3,14 @@ import { createPackPresentations } from './presentation.js';
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
const targets = new Map();
const session = createDataPackSession({
sources,
adapters: createPackPresentations(viewer),
adapters: createPackPresentations(viewer, targets),
});
return {
...session,
getTargets: () => new Map(targets),
apply(scene, shot, token) {
const wanted = new Set(shot.dataPackIds || []);
return session.load(
+8 -4
View File
@@ -11,8 +11,8 @@ export function renderPackGeometry({ asset, pack }, handle) {
let lines;
for (const feature of features) {
const id = { packId: pack.id, featureId: feature.id };
if (feature.type === 'Point')
handle.add({
if (feature.type === 'Point') {
const entity = handle.add({
name: `${pack.id} / ${feature.id}`,
position: xyz(feature.coordinates),
point: {
@@ -22,7 +22,9 @@ export function renderPackGeometry({ asset, pack }, handle) {
outlineWidth: 2,
},
});
else if (feature.type === 'LineString') {
handle.feature?.(id, entity);
} else if (feature.type === 'LineString') {
handle.feature?.(id, id);
if (!lines) {
lines = new Cesium.PolylineCollection();
handle.primitive(lines);
@@ -35,7 +37,8 @@ export function renderPackGeometry({ asset, pack }, handle) {
color: Cesium.Color.CYAN,
}),
});
} else
} else {
handle.feature?.(id, id);
polygons.push(
new Cesium.GeometryInstance({
id,
@@ -57,6 +60,7 @@ export function renderPackGeometry({ asset, pack }, handle) {
},
}),
);
}
}
if (polygons.length)
handle.primitive(
+8 -1
View File
@@ -30,7 +30,7 @@ function caption(viewer, pack) {
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Create rendering adapters; data acquisition and authored placement stay independent. */
export function createPackPresentations(viewer) {
export function createPackPresentations(viewer, targets = new Map()) {
let panel;
function addCard(pack) {
if (!panel) {
@@ -55,9 +55,15 @@ export function createPackPresentations(viewer) {
primitives = [],
materials = [],
urls = [];
const featureKeys = [];
let card,
destroyed = false;
return {
feature(id, pickedId) {
const key = JSON.stringify([id.packId, id.featureId]);
targets.set(key, pickedId);
featureKeys.push(key);
},
add(spec) {
const entity = viewer.entities.add(spec);
entities.push(entity);
@@ -81,6 +87,7 @@ export function createPackPresentations(viewer) {
dispose() {
if (destroyed) return;
destroyed = true;
for (const key of featureKeys.splice(0)) targets.delete(key);
for (const media of card?.querySelectorAll('video,audio') || []) {
media.pause();
media.removeAttribute('src');
+90 -1
View File
@@ -26,6 +26,7 @@ import {
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
import { createSceneInteractions } from './interactions.js';
import { createDefaultScenePacks } from './packs/defaults.js';
import {
layerStatesForShot,
@@ -89,10 +90,23 @@ export class SceneDirector {
this._isMapStackAvailable = isMapStackAvailable;
this._scenePacks = scenePacks;
this._dataPacks = createSceneDataPacks(viewer, dataPacks);
this._interactionTransitions = 0;
this._interactions = createSceneInteractions(viewer, {
available: () =>
!this._destroyed && !this._running && !this.viewer.trackedEntity,
execute: (action, signal) =>
this._trackWork(this._executeInteraction(action, signal)),
});
this._cameraMotion = createCameraMotion({
applyPose: (pose) => this._setCameraView(pose),
});
const yieldCamera = () => {
const yieldCamera = (event) => {
// A settled feature click belongs to the action picker, not a new camera gesture.
if (
event?.type === 'pointerdown' &&
this._interactions?.getState().active
)
return;
if (this._usesAuthoredCamera && !this._claimingCamera)
this.stopScene('Camera ownership changed');
};
@@ -203,6 +217,7 @@ export class SceneDirector {
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
this._cameraMotion?.destroy();
this._interactions?.destroy();
this._dataPacks?.destroy();
this._controls?.destroy();
this._state.destroy();
@@ -976,6 +991,7 @@ export class SceneDirector {
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
this._interactionTransitions = 0;
return this._trackWork(this._loadShot(sceneId, shotId, options));
}
@@ -1001,6 +1017,7 @@ export class SceneDirector {
// manager) rather than merely ignored once it has already committed.
this._cancelActiveSceneTravel();
this._usesAuthoredCamera = !!shot.move;
this._interactions?.clear();
this._dataPacks?.clear();
this._loadAbort?.abort();
const controller = new AbortController();
@@ -1060,6 +1077,7 @@ export class SceneDirector {
});
if (this._loadAbort === controller) this._loadAbort = null;
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
this._activateInteractions(scene, shot);
return { started: true, shotId };
}
const holdSec = this._effectiveShotHoldSec(scene, shot);
@@ -1109,6 +1127,7 @@ export class SceneDirector {
);
if (this._loadAbort === controller) this._loadAbort = null;
this._activateInteractions(scene, shot);
this._shotOutcome('shot-loaded', scene, shot);
this._updateRuntime('');
return { started: true, shotId };
@@ -1359,6 +1378,8 @@ export class SceneDirector {
if (
!scene ||
!shot ||
shot.dataPackIds?.length ||
shot.interactions?.length ||
this._loadedSceneId !== scene.id ||
this._selectedShotId !== shot.id
)
@@ -1621,6 +1642,7 @@ export class SceneDirector {
// Aborting cancels a layer transition already in flight; bumping the
// generation disowns everything the load has not yet started.
this._cancelActiveSceneTravel();
this._interactions?.clear();
this._dataPacks?.clear();
this._loadAbort?.abort();
this._loadAbort = null;
@@ -1732,6 +1754,7 @@ export class SceneDirector {
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
*/
stopScene(reason = 'Stopped') {
this._interactions?.clear();
this._dataPacks?.clear();
this._sceneSeekGeneration++;
this._loadAbort?.abort();
@@ -1763,6 +1786,71 @@ export class SceneDirector {
return this._dataPacks?.getState() || { status: 'idle', count: 0 };
}
/** Activate only after LOAD/seek settles. Running timelines never branch automatically. */
_activateInteractions(scene, shot) {
try {
this._interactions?.activate(shot, this._dataPacks.getTargets());
} catch (error) {
this._updateStatus(error.message);
}
}
/** Execute a validated action through the existing camera and layer admission paths. */
async _executeInteraction(action, signal) {
if (signal.aborted || this._running || this._destroyed) return false;
const { scene, shot } = this._getShot(
this._selectedSceneId,
this._selectedShotId,
);
if (!scene || !shot) return false;
if (action.type === 'focus') {
if (!this._claimCameraOwnership()) return false;
this._cancelActiveSceneTravel();
this._clock.stopShot();
return this._setCameraView(
resolveCameraPose(scene, { anchorId: action.anchorId, pitch: -90 }),
);
}
if (action.type === 'layer') {
if (
!this.dataManager.getAll().some((layer) => layer.id === action.layerId)
)
return false;
return this.dataManager.setEnabled(action.layerId, action.enabled, {
signal,
origin: 'scene',
});
}
if (action.type === 'shot') {
if (this._interactionTransitions >= 64) {
this._updateStatus(
'Scene transition limit reached — load a shot to reset',
);
return false;
}
this._interactionTransitions++;
// Replacement clears this action's owner; the new LOAD owns its own cancellation.
const target = scene.shots.find((item) => item.id === action.shotId);
return this.seekScene(
scene.id,
this._sceneTimingForShot(scene, target).startProgress,
);
}
return false;
}
/** Copied interaction state for lifecycle diagnostics. */
getInteractionState() {
return (
this._interactions?.getState() || {
active: false,
busy: false,
selected: null,
count: 0,
}
);
}
/** Export the entire project as a timestamped JSON file download. */
exportProject() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -1937,6 +2025,7 @@ export class SceneDirector {
* @returns {Promise<boolean>} True only when every owned layer is released
*/
async _releaseSceneLayers(scene, token = null) {
this._interactions?.clear();
this._dataPacks?.clear();
const layerIds = Array.isArray(scene?.releaseLayerIds)
? scene.releaseLayerIds
+1 -1
View File
@@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
assert.equal(director._storageReadError, null);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 5);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 6);
} finally { restore(); }
});
+165
View File
@@ -0,0 +1,165 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../data/inputOwnership.js';
import { createInteractionSession } from '../director/interactions/session.js';
/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
export function createSceneInteractions(
viewer,
{ execute, available = () => true },
) {
let panel,
status,
card,
handler,
items = [],
targets = new Map();
const buttons = new Map();
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 = 'Source';
link.style.color = '#6eeaff';
link.href = item.action.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
card.append(link);
}
return true;
}
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' : '';
}
},
});
function clear() {
session.clear();
handler?.destroy();
handler = null;
panel?.remove();
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
? 'Action complete'
: 'Action unavailable or cancelled';
return ok;
}
return {
clear,
destroy: clear,
getState: session.getState,
activate(shot, registered) {
clear();
items = shot.interactions || [];
if (!items.length) return;
targets = registered;
if (
items.some(
(item) =>
!targets.has(
JSON.stringify([item.target.packId, item.target.featureId]),
),
)
) {
items = [];
targets.clear();
throw new Error('Interaction feature is missing from the loaded pack');
}
panel = document.createElement('section');
panel.dataset.directorInteractions = '';
panel.setAttribute('aria-label', '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',
});
const owner = panel;
const title = document.createElement('strong');
title.textContent = 'Scene actions';
panel.append(title);
status = document.createElement('p');
status.setAttribute('role', 'status');
status.textContent =
'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;
const picked = viewer.scene.pick(event.position)?.id;
const matches = items.filter(
(item) =>
targets.get(
JSON.stringify([item.target.packId, item.target.featureId]),
) === 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 = `Selected feature: ${matches[0].target.featureId}. Choose an action.`;
buttons.get(matches[0].id)?.focus({ preventScroll: true });
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
},
};
}
+3
View File
@@ -253,6 +253,9 @@ export function normalizeShot(
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
: {}),
},
...(rawShot?.interactions
? { interactions: deepClone(rawShot.interactions) }
: {}),
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
visual: {