mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: add declarative Director feature actions
This commit is contained in:
1 parent
a30c42322c
commit
80027756ef
22 files changed
+1150
-17
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user