mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user