mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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
@@ -199,6 +199,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
|
||||||
|
|
||||||
|
|
||||||
- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice.
|
- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice.
|
||||||
|
|
||||||
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
|
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Director version-6 documents add bounded, scene-local feature actions. Settled
|
||||||
|
LOAD/seek exposes keyboard-accessible text/source cards, anchor focus, explicit
|
||||||
|
shot transitions and admitted layer state changes. Pointer claims take priority;
|
||||||
|
Stop/replacement/teardown cancel pending actions and release handlers and UI.
|
||||||
|
Same-shot seeks rebuild selected packs and restore declared layer baselines.
|
||||||
|
See [scene actions](DIRECTOR-INTERACTIONS.md). Existing scenes/assets and credit remain.
|
||||||
|
|
||||||
Director version-5 documents add scene-local data-pack manifests and per-shot
|
Director version-5 documents add scene-local data-pack manifests and per-shot
|
||||||
selection. Registered sources acquire bounded, cancellable assets separately from
|
selection. Registered sources acquire bounded, cancellable assets separately from
|
||||||
GeoJSON, PNG and manually played media presentation. Storage paths, attribution
|
GeoJSON, PNG and manually played media presentation. Storage paths, attribution
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Director scene actions
|
||||||
|
|
||||||
|
Version-6 scene documents can attach actions to stable GeoJSON feature IDs in a
|
||||||
|
shot's selected data packs. Existing versions 1–5 migrate without adding actions.
|
||||||
|
Import validates data and references; it does not load assets or execute actions.
|
||||||
|
|
||||||
|
Add `interactions` to a shot, for example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "read-outline",
|
||||||
|
"label": "About this outline",
|
||||||
|
"target": { "packId": "outline", "featureId": "boundary" },
|
||||||
|
"action": {
|
||||||
|
"type": "card",
|
||||||
|
"text": "An authored explanation, displayed as plain text.",
|
||||||
|
"url": "https://example.org/source"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The target must be a feature in a GeoJSON pack selected by that shot. Points,
|
||||||
|
lines and polygons retain stable pack/feature identities. Assets load before
|
||||||
|
feature existence is checked; a missing feature refuses the entire action set.
|
||||||
|
Images and native media are not interaction targets in this version.
|
||||||
|
|
||||||
|
| Action | Fields | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `card` | `text`, optional `url` | Show plain text and an optional HTTPS source link; never HTML |
|
||||||
|
| `focus` | `anchorId` | Set a top-down camera pose at a scene anchor through camera ownership arbitration |
|
||||||
|
| `shot` | `shotId` | Load another shot in the same scene at its authored start |
|
||||||
|
| `layer` | `layerId`, boolean `enabled` | Request an explicit state through the ordinary layer admission path |
|
||||||
|
|
||||||
|
There are at most 64 interactions per shot. Card text is limited to 4,096
|
||||||
|
characters; source links cannot contain credentials, query strings or fragments.
|
||||||
|
No script, module, arbitrary fetch endpoint or request header is accepted.
|
||||||
|
|
||||||
|
Actions appear after LOAD or seek finishes. Click a pack feature to select its
|
||||||
|
actions and focus the first button; choose a button explicitly to execute it.
|
||||||
|
Tab traverses buttons and Enter/Space activates them. The selected buttons have
|
||||||
|
visible outlines, and status feedback is announced to assistive technology.
|
||||||
|
There are no global keyboard shortcuts to collide with navigation or typing.
|
||||||
|
Drawing ownership blocks both pointer selection and action execution. Camera
|
||||||
|
focus uses existing navigation admission, including Cockpit restrictions.
|
||||||
|
|
||||||
|
Actions are inactive during automatic playback and camera travel. They do not
|
||||||
|
introduce automatic branching or loops. A chain of user-triggered shot changes
|
||||||
|
is limited to 64 transitions; an explicit LOAD starts a new chain. Import/Stop,
|
||||||
|
replacement, playback and teardown remove selection, cards and picking handlers,
|
||||||
|
and abort pending layer requests. No per-frame interaction loop runs.
|
||||||
|
|
||||||
|
A layer action must name a layer with an explicit baseline in the current shot.
|
||||||
|
A target shot must declare a baseline for every layer the departing shot can
|
||||||
|
change. LOAD and seek reapply those baselines and clear selection. Stop cancels
|
||||||
|
pending transitions; already completed layer changes remain visible, consistent
|
||||||
|
with ordinary scene controls. Camera focus is an immediate anchor pose, not a
|
||||||
|
new timeline segment; seek restores the authored camera pose. Scene JSON is not
|
||||||
|
modified by executing an action.
|
||||||
|
|
||||||
|
The portable interaction session owns action admission/cancellation. The scene
|
||||||
|
interaction owner supplies accessible DOM and picking; pack renderers own the
|
||||||
|
feature identities and release them with their geometry. Layer/source admission
|
||||||
|
and camera arbitration remain with their existing owners.
|
||||||
|
|
||||||
|
Visual authoring, import previews and share bundles remain separate follow-up
|
||||||
|
work. For now, author actions in scene JSON and use the existing import/export
|
||||||
|
controls. Existing content, assets, notices and contributor credit are preserved.
|
||||||
@@ -105,3 +105,5 @@ arbitration, layer cleanup, media timing and provider fallback. Acceptance inclu
|
|||||||
portable runner tests, existing Director regressions and browser playback with
|
portable runner tests, existing Director regressions and browser playback with
|
||||||
Stop/seek/replay, plus tracking and voice checks. Use reproducible local fixtures
|
Stop/seek/replay, plus tracking and voice checks. Use reproducible local fixtures
|
||||||
for failures and cancellation, alongside live visual checks for actual content.
|
for failures and cancellation, alongside live visual checks for actual content.
|
||||||
|
|
||||||
|
Declarative actions are implemented in version 6; see [scene actions](DIRECTOR-INTERACTIONS.md) for supported targets, execution and reset semantics.
|
||||||
@@ -739,5 +739,7 @@
|
|||||||
"scripts/qa-director-camera.mjs",
|
"scripts/qa-director-camera.mjs",
|
||||||
"src/director/packs/packs.test.mjs",
|
"src/director/packs/packs.test.mjs",
|
||||||
"scripts/qa-director-packs.mjs",
|
"scripts/qa-director-packs.mjs",
|
||||||
"src/scenes/dataPacks/presentation.test.mjs"
|
"src/scenes/dataPacks/presentation.test.mjs",
|
||||||
|
"src/director/interactions/interactions.test.mjs",
|
||||||
|
"scripts/qa-director-interactions.mjs"
|
||||||
]
|
]
|
||||||
@@ -625,7 +625,9 @@
|
|||||||
"src/director/packs/source.js",
|
"src/director/packs/source.js",
|
||||||
"src/director/packs/session.js",
|
"src/director/packs/session.js",
|
||||||
"src/director/packs/geojson.js",
|
"src/director/packs/geojson.js",
|
||||||
"src/director/packs/manifest.js"
|
"src/director/packs/manifest.js",
|
||||||
|
"src/director/interactions/document.js",
|
||||||
|
"src/director/interactions/session.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -666,7 +668,11 @@
|
|||||||
"src/director/packs/manifest.js",
|
"src/director/packs/manifest.js",
|
||||||
"src/scenes/dataPacks/controller.js",
|
"src/scenes/dataPacks/controller.js",
|
||||||
"src/scenes/dataPacks/presentation.js",
|
"src/scenes/dataPacks/presentation.js",
|
||||||
"src/scenes/dataPacks/geometry.js"
|
"src/scenes/dataPacks/geometry.js",
|
||||||
|
"src/director/interactions/document.js",
|
||||||
|
"src/director/interactions/session.js",
|
||||||
|
"src/scenes/interactions.js",
|
||||||
|
"src/data/inputOwnership.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1784,7 +1790,10 @@
|
|||||||
"src/director/packs/manifest.js",
|
"src/director/packs/manifest.js",
|
||||||
"src/scenes/dataPacks/controller.js",
|
"src/scenes/dataPacks/controller.js",
|
||||||
"src/scenes/dataPacks/presentation.js",
|
"src/scenes/dataPacks/presentation.js",
|
||||||
"src/scenes/dataPacks/geometry.js"
|
"src/scenes/dataPacks/geometry.js",
|
||||||
|
"src/director/interactions/document.js",
|
||||||
|
"src/director/interactions/session.js",
|
||||||
|
"src/scenes/interactions.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
const output = 'qa-shots/director-interactions';
|
||||||
|
fs.mkdirSync(output, { recursive: true });
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
let failures = 0;
|
||||||
|
const errors = [];
|
||||||
|
const check = (name, ok) => {
|
||||||
|
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!ok) failures++;
|
||||||
|
};
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (new URL(request.url()).pathname === '/scene-assets/qa/actions.json')
|
||||||
|
void request
|
||||||
|
.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/geo+json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
id: 'point',
|
||||||
|
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 600] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
else void request.continue().catch(() => {});
|
||||||
|
});
|
||||||
|
const action = (id) => `[data-director-action="${id}"]`;
|
||||||
|
const load = () =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
window.__godsEyeView.sceneDirector.loadShot('interactive', 'one', {
|
||||||
|
flyDuration: 0.2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.sceneDirector &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
const camera = {
|
||||||
|
lat: 30.2672,
|
||||||
|
lon: -97.742,
|
||||||
|
alt: 4000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -90,
|
||||||
|
roll: 0,
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
};
|
||||||
|
const target = { packId: 'data', featureId: 'point' };
|
||||||
|
const interaction = (id, label, action) => ({ id, label, target, action });
|
||||||
|
window.__interactionProject = {
|
||||||
|
version: 6,
|
||||||
|
scenes: [
|
||||||
|
{
|
||||||
|
id: 'interactive',
|
||||||
|
title: 'Interactive fixture',
|
||||||
|
anchors: [
|
||||||
|
{
|
||||||
|
id: 'view',
|
||||||
|
lat: 30.27,
|
||||||
|
lon: -97.74,
|
||||||
|
alt: 5000,
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataPacks: [
|
||||||
|
{
|
||||||
|
id: 'data',
|
||||||
|
version: 1,
|
||||||
|
format: 'geojson',
|
||||||
|
source: { adapter: 'assets', path: 'qa/actions.json' },
|
||||||
|
attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' },
|
||||||
|
placement: { altitudeReference: 'ellipsoid' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shots: [
|
||||||
|
{
|
||||||
|
id: 'one',
|
||||||
|
title: 'Choose an action',
|
||||||
|
camera,
|
||||||
|
durationSec: 0.2,
|
||||||
|
holdSec: 0,
|
||||||
|
move: {
|
||||||
|
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
|
||||||
|
easing: 'linear',
|
||||||
|
},
|
||||||
|
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||||
|
layers: { traffic: false },
|
||||||
|
dataPackIds: ['data'],
|
||||||
|
interactions: [
|
||||||
|
interaction('card', 'Read source', {
|
||||||
|
type: 'card',
|
||||||
|
text: '<b>Plain text only</b>',
|
||||||
|
url: 'https://example.org/source',
|
||||||
|
}),
|
||||||
|
interaction('focus', 'Focus anchor', {
|
||||||
|
type: 'focus',
|
||||||
|
anchorId: 'view',
|
||||||
|
}),
|
||||||
|
interaction('layer', 'Show traffic', {
|
||||||
|
type: 'layer',
|
||||||
|
layerId: 'traffic',
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
interaction('next', 'Next shot', {
|
||||||
|
type: 'shot',
|
||||||
|
shotId: 'two',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'two',
|
||||||
|
title: 'Next',
|
||||||
|
camera: { ...camera, heading: 30 },
|
||||||
|
durationSec: 0.2,
|
||||||
|
holdSec: 0,
|
||||||
|
layers: { traffic: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await d.importProjectFile(
|
||||||
|
new File([JSON.stringify(window.__interactionProject)], 'actions.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'import installs no action handlers or UI',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||||
|
!document.querySelector('[data-director-interactions]'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
check(
|
||||||
|
'settled LOAD activates four actions',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector.getInteractionState().count === 4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const point = await page.evaluate(async () => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
const entity = d.viewer.entities.values.find(
|
||||||
|
(e) => e.name === 'data / point',
|
||||||
|
);
|
||||||
|
const pos = entity.position.getValue(d.viewer.clock.currentTime);
|
||||||
|
const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
|
||||||
|
const rect = d.viewer.scene.canvas.getBoundingClientRect();
|
||||||
|
return { x: screen.x + rect.left, y: screen.y + rect.top };
|
||||||
|
});
|
||||||
|
await page.mouse.click(point.x, point.y);
|
||||||
|
check(
|
||||||
|
'real globe pick selects the feature and focuses its keyboard action',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.activeElement?.dataset.directorAction === 'card' &&
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-interactions] [role=status]')
|
||||||
|
.textContent.includes('point'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
check(
|
||||||
|
'Enter shows literal text and a safe source link',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const card = document.querySelector('[data-director-action-card]');
|
||||||
|
return (
|
||||||
|
card?.textContent.includes('<b>Plain text only</b>') &&
|
||||||
|
!card.querySelector('b') &&
|
||||||
|
card.querySelector('a')?.rel === 'noopener noreferrer'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: `${output}/selected.png` });
|
||||||
|
fs.writeFileSync(
|
||||||
|
`${output}/report.json`,
|
||||||
|
JSON.stringify(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
check(
|
||||||
|
'Tab/Space focuses the authored anchor through camera ownership',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
Math.abs(
|
||||||
|
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
|
||||||
|
.height - 5000,
|
||||||
|
) < 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: `${output}/anchor.png` });
|
||||||
|
const sourceBase = process.env.QA_SOURCE_BASE || '/src';
|
||||||
|
// Resolve ownership from the same module graph as the active application.
|
||||||
|
await page.evaluate(async (base) => {
|
||||||
|
window.__inputOwner = await import(`${base}/data/inputOwnership.js`);
|
||||||
|
window.__lease = window.__inputOwner.claimPointer('qa-draw');
|
||||||
|
}, sourceBase);
|
||||||
|
await page.click(action('card'));
|
||||||
|
check(
|
||||||
|
'drawing ownership refuses actions',
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-director-interactions] [role=status]')
|
||||||
|
.textContent.includes('unavailable'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => window.__inputOwner.releasePointer(window.__lease));
|
||||||
|
// Hold the actual manager admission call to exercise Stop before an enable settles.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector,
|
||||||
|
m = d.dataManager;
|
||||||
|
window.__originalEnable = m.setEnabled;
|
||||||
|
m.setEnabled = function (id, enabled, options) {
|
||||||
|
if (id !== 'traffic' || !enabled)
|
||||||
|
return window.__originalEnable.call(this, id, enabled, options);
|
||||||
|
window.__actionSignal = options.signal;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.__lateAction = () => resolve(true);
|
||||||
|
options.signal.addEventListener('abort', () => resolve(false), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.click(action('layer'));
|
||||||
|
await page.waitForFunction(() => window.__actionSignal);
|
||||||
|
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
|
||||||
|
check(
|
||||||
|
'Stop aborts pending layer admission and removes handlers/cards/selection',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__actionSignal.aborted &&
|
||||||
|
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||||
|
!document.querySelector('[data-director-interactions]'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__lateAction();
|
||||||
|
window.__godsEyeView.sceneDirector.dataManager.setEnabled =
|
||||||
|
window.__originalEnable;
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.sceneDirector.seekScene('interactive', 0.1),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'same-shot seek restores pack geometry and resets action state',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector.getInteractionState().count === 4 &&
|
||||||
|
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
|
||||||
|
null &&
|
||||||
|
window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__oldActionButton = document.querySelector(
|
||||||
|
'[data-director-action=card]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.click(action('next'));
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.sceneDirector._selectedShotId === 'two',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'explicit shot transition replaces resources and removes actions',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||||
|
window.__godsEyeView.sceneDirector.getDataPackState().count === 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
await page.evaluate(() => window.__oldActionButton.click());
|
||||||
|
check(
|
||||||
|
'detached buttons cannot invoke replacement actions with the same ID',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
|
||||||
|
null &&
|
||||||
|
!document.querySelector('[data-director-action-card]').textContent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__godsEyeView.sceneDirector._interactionTransitions = 64;
|
||||||
|
});
|
||||||
|
await page.click(action('next'));
|
||||||
|
check(
|
||||||
|
'bounded transition chain cannot take another branch',
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.sceneDirector._selectedShotId === 'one',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const p = structuredClone(window.__interactionProject);
|
||||||
|
p.scenes[0].shots[0].interactions[0].target.featureId = 'missing';
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
await d.importProjectFile(new File([JSON.stringify(p)], 'missing.json'));
|
||||||
|
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'unknown loaded feature refuses the whole action set',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||||
|
!document.querySelector('[data-director-interactions]'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
|
await d.importProjectFile(
|
||||||
|
new File([JSON.stringify(window.__interactionProject)], 'restore.json'),
|
||||||
|
);
|
||||||
|
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
|
||||||
|
await d.destroy();
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'teardown releases interactive resources',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.sceneDirector.getInteractionState().count === 0 &&
|
||||||
|
!document.querySelector('[data-director-interactions]'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check('no uncaught browser errors', errors.length === 0);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -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);
|
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
|
||||||
});
|
});
|
||||||
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
|
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
import { validateSceneDataPacks } from './packs/manifest.js';
|
import { validateSceneDataPacks } from './packs/manifest.js';
|
||||||
import { validateSceneCameras } from './cameraDocument.js';
|
import { validateSceneCameras } from './cameraDocument.js';
|
||||||
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.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) {
|
function visual(value, path, legacy) {
|
||||||
fields(value, path, [
|
fields(value, path, [
|
||||||
@@ -66,7 +67,7 @@ export function validateSceneDocument(project) {
|
|||||||
'scenes',
|
'scenes',
|
||||||
]);
|
]);
|
||||||
const version = Object.hasOwn(project, 'version') ? project.version : 1;
|
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');
|
fail('$.version', 'unsupported scene project version');
|
||||||
const legacy = version < 3;
|
const legacy = version < 3;
|
||||||
for (const key of ['createdAt', 'updatedAt'])
|
for (const key of ['createdAt', 'updatedAt'])
|
||||||
@@ -126,6 +127,7 @@ export function validateSceneDocument(project) {
|
|||||||
'sourcePackVersion',
|
'sourcePackVersion',
|
||||||
...(version >= 4 ? ['move'] : []),
|
...(version >= 4 ? ['move'] : []),
|
||||||
...(version >= 5 ? ['dataPackIds'] : []),
|
...(version >= 5 ? ['dataPackIds'] : []),
|
||||||
|
...(version >= 6 ? ['interactions'] : []),
|
||||||
]);
|
]);
|
||||||
uniqueId(shot, at, shotIds);
|
uniqueId(shot, at, shotIds);
|
||||||
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
|
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
|
||||||
@@ -153,6 +155,7 @@ export function validateSceneDocument(project) {
|
|||||||
});
|
});
|
||||||
validateSceneCameras(scene, path, version);
|
validateSceneCameras(scene, path, version);
|
||||||
validateSceneDataPacks(scene, path);
|
validateSceneDataPacks(scene, path);
|
||||||
|
validateSceneInteractions(scene, path);
|
||||||
});
|
});
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
|
|||||||
const migrated = normalizeProject(
|
const migrated = normalizeProject(
|
||||||
parseSceneDocument(JSON.stringify(project)),
|
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].visual.bloom.intensity, 150);
|
||||||
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
|
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
|
||||||
assert.deepEqual(
|
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: [] }] }),
|
JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.equal(old.version, 5);
|
assert.equal(old.version, 6);
|
||||||
assert.equal(old.scenes[0].dataPacks, undefined);
|
assert.equal(old.scenes[0].dataPacks, undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,3 +122,5 @@ export {
|
|||||||
export { createAssetDirectorySource } from './packs/source.js';
|
export { createAssetDirectorySource } from './packs/source.js';
|
||||||
export { createDataPackSession } from './packs/session.js';
|
export { createDataPackSession } from './packs/session.js';
|
||||||
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
|
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
|
||||||
|
|
||||||
|
export { createInteractionSession } from './interactions/session.js';
|
||||||
@@ -3,12 +3,14 @@ import { createPackPresentations } from './presentation.js';
|
|||||||
|
|
||||||
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
|
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
|
||||||
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
|
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
|
||||||
|
const targets = new Map();
|
||||||
const session = createDataPackSession({
|
const session = createDataPackSession({
|
||||||
sources,
|
sources,
|
||||||
adapters: createPackPresentations(viewer),
|
adapters: createPackPresentations(viewer, targets),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
|
getTargets: () => new Map(targets),
|
||||||
apply(scene, shot, token) {
|
apply(scene, shot, token) {
|
||||||
const wanted = new Set(shot.dataPackIds || []);
|
const wanted = new Set(shot.dataPackIds || []);
|
||||||
return session.load(
|
return session.load(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export function renderPackGeometry({ asset, pack }, handle) {
|
|||||||
let lines;
|
let lines;
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
const id = { packId: pack.id, featureId: feature.id };
|
const id = { packId: pack.id, featureId: feature.id };
|
||||||
if (feature.type === 'Point')
|
if (feature.type === 'Point') {
|
||||||
handle.add({
|
const entity = handle.add({
|
||||||
name: `${pack.id} / ${feature.id}`,
|
name: `${pack.id} / ${feature.id}`,
|
||||||
position: xyz(feature.coordinates),
|
position: xyz(feature.coordinates),
|
||||||
point: {
|
point: {
|
||||||
@@ -22,7 +22,9 @@ export function renderPackGeometry({ asset, pack }, handle) {
|
|||||||
outlineWidth: 2,
|
outlineWidth: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
else if (feature.type === 'LineString') {
|
handle.feature?.(id, entity);
|
||||||
|
} else if (feature.type === 'LineString') {
|
||||||
|
handle.feature?.(id, id);
|
||||||
if (!lines) {
|
if (!lines) {
|
||||||
lines = new Cesium.PolylineCollection();
|
lines = new Cesium.PolylineCollection();
|
||||||
handle.primitive(lines);
|
handle.primitive(lines);
|
||||||
@@ -35,7 +37,8 @@ export function renderPackGeometry({ asset, pack }, handle) {
|
|||||||
color: Cesium.Color.CYAN,
|
color: Cesium.Color.CYAN,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else
|
} else {
|
||||||
|
handle.feature?.(id, id);
|
||||||
polygons.push(
|
polygons.push(
|
||||||
new Cesium.GeometryInstance({
|
new Cesium.GeometryInstance({
|
||||||
id,
|
id,
|
||||||
@@ -57,6 +60,7 @@ export function renderPackGeometry({ asset, pack }, handle) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (polygons.length)
|
if (polygons.length)
|
||||||
handle.primitive(
|
handle.primitive(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function caption(viewer, pack) {
|
|||||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||||
|
|
||||||
/** Create rendering adapters; data acquisition and authored placement stay independent. */
|
/** Create rendering adapters; data acquisition and authored placement stay independent. */
|
||||||
export function createPackPresentations(viewer) {
|
export function createPackPresentations(viewer, targets = new Map()) {
|
||||||
let panel;
|
let panel;
|
||||||
function addCard(pack) {
|
function addCard(pack) {
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
@@ -55,9 +55,15 @@ export function createPackPresentations(viewer) {
|
|||||||
primitives = [],
|
primitives = [],
|
||||||
materials = [],
|
materials = [],
|
||||||
urls = [];
|
urls = [];
|
||||||
|
const featureKeys = [];
|
||||||
let card,
|
let card,
|
||||||
destroyed = false;
|
destroyed = false;
|
||||||
return {
|
return {
|
||||||
|
feature(id, pickedId) {
|
||||||
|
const key = JSON.stringify([id.packId, id.featureId]);
|
||||||
|
targets.set(key, pickedId);
|
||||||
|
featureKeys.push(key);
|
||||||
|
},
|
||||||
add(spec) {
|
add(spec) {
|
||||||
const entity = viewer.entities.add(spec);
|
const entity = viewer.entities.add(spec);
|
||||||
entities.push(entity);
|
entities.push(entity);
|
||||||
@@ -81,6 +87,7 @@ export function createPackPresentations(viewer) {
|
|||||||
dispose() {
|
dispose() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
for (const key of featureKeys.splice(0)) targets.delete(key);
|
||||||
for (const media of card?.querySelectorAll('video,audio') || []) {
|
for (const media of card?.querySelectorAll('video,audio') || []) {
|
||||||
media.pause();
|
media.pause();
|
||||||
media.removeAttribute('src');
|
media.removeAttribute('src');
|
||||||
|
|||||||
+90
-1
@@ -26,6 +26,7 @@ import {
|
|||||||
} from './recipes.js';
|
} from './recipes.js';
|
||||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||||
|
import { createSceneInteractions } from './interactions.js';
|
||||||
import { createDefaultScenePacks } from './packs/defaults.js';
|
import { createDefaultScenePacks } from './packs/defaults.js';
|
||||||
import {
|
import {
|
||||||
layerStatesForShot,
|
layerStatesForShot,
|
||||||
@@ -89,10 +90,23 @@ export class SceneDirector {
|
|||||||
this._isMapStackAvailable = isMapStackAvailable;
|
this._isMapStackAvailable = isMapStackAvailable;
|
||||||
this._scenePacks = scenePacks;
|
this._scenePacks = scenePacks;
|
||||||
this._dataPacks = createSceneDataPacks(viewer, dataPacks);
|
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({
|
this._cameraMotion = createCameraMotion({
|
||||||
applyPose: (pose) => this._setCameraView(pose),
|
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)
|
if (this._usesAuthoredCamera && !this._claimingCamera)
|
||||||
this.stopScene('Camera ownership changed');
|
this.stopScene('Camera ownership changed');
|
||||||
};
|
};
|
||||||
@@ -203,6 +217,7 @@ export class SceneDirector {
|
|||||||
this._cameraHandoffUnsubscribe?.();
|
this._cameraHandoffUnsubscribe?.();
|
||||||
this._removeCameraInput?.();
|
this._removeCameraInput?.();
|
||||||
this._cameraMotion?.destroy();
|
this._cameraMotion?.destroy();
|
||||||
|
this._interactions?.destroy();
|
||||||
this._dataPacks?.destroy();
|
this._dataPacks?.destroy();
|
||||||
this._controls?.destroy();
|
this._controls?.destroy();
|
||||||
this._state.destroy();
|
this._state.destroy();
|
||||||
@@ -976,6 +991,7 @@ export class SceneDirector {
|
|||||||
if (this._destroyed)
|
if (this._destroyed)
|
||||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||||
this._sceneSeekGeneration++;
|
this._sceneSeekGeneration++;
|
||||||
|
this._interactionTransitions = 0;
|
||||||
return this._trackWork(this._loadShot(sceneId, shotId, options));
|
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.
|
// manager) rather than merely ignored once it has already committed.
|
||||||
this._cancelActiveSceneTravel();
|
this._cancelActiveSceneTravel();
|
||||||
this._usesAuthoredCamera = !!shot.move;
|
this._usesAuthoredCamera = !!shot.move;
|
||||||
|
this._interactions?.clear();
|
||||||
this._dataPacks?.clear();
|
this._dataPacks?.clear();
|
||||||
this._loadAbort?.abort();
|
this._loadAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -1060,6 +1077,7 @@ export class SceneDirector {
|
|||||||
});
|
});
|
||||||
if (this._loadAbort === controller) this._loadAbort = null;
|
if (this._loadAbort === controller) this._loadAbort = null;
|
||||||
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
|
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
|
||||||
|
this._activateInteractions(scene, shot);
|
||||||
return { started: true, shotId };
|
return { started: true, shotId };
|
||||||
}
|
}
|
||||||
const holdSec = this._effectiveShotHoldSec(scene, shot);
|
const holdSec = this._effectiveShotHoldSec(scene, shot);
|
||||||
@@ -1109,6 +1127,7 @@ export class SceneDirector {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (this._loadAbort === controller) this._loadAbort = null;
|
if (this._loadAbort === controller) this._loadAbort = null;
|
||||||
|
this._activateInteractions(scene, shot);
|
||||||
this._shotOutcome('shot-loaded', scene, shot);
|
this._shotOutcome('shot-loaded', scene, shot);
|
||||||
this._updateRuntime('');
|
this._updateRuntime('');
|
||||||
return { started: true, shotId };
|
return { started: true, shotId };
|
||||||
@@ -1359,6 +1378,8 @@ export class SceneDirector {
|
|||||||
if (
|
if (
|
||||||
!scene ||
|
!scene ||
|
||||||
!shot ||
|
!shot ||
|
||||||
|
shot.dataPackIds?.length ||
|
||||||
|
shot.interactions?.length ||
|
||||||
this._loadedSceneId !== scene.id ||
|
this._loadedSceneId !== scene.id ||
|
||||||
this._selectedShotId !== shot.id
|
this._selectedShotId !== shot.id
|
||||||
)
|
)
|
||||||
@@ -1621,6 +1642,7 @@ export class SceneDirector {
|
|||||||
// Aborting cancels a layer transition already in flight; bumping the
|
// Aborting cancels a layer transition already in flight; bumping the
|
||||||
// generation disowns everything the load has not yet started.
|
// generation disowns everything the load has not yet started.
|
||||||
this._cancelActiveSceneTravel();
|
this._cancelActiveSceneTravel();
|
||||||
|
this._interactions?.clear();
|
||||||
this._dataPacks?.clear();
|
this._dataPacks?.clear();
|
||||||
this._loadAbort?.abort();
|
this._loadAbort?.abort();
|
||||||
this._loadAbort = null;
|
this._loadAbort = null;
|
||||||
@@ -1732,6 +1754,7 @@ export class SceneDirector {
|
|||||||
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
|
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
|
||||||
*/
|
*/
|
||||||
stopScene(reason = 'Stopped') {
|
stopScene(reason = 'Stopped') {
|
||||||
|
this._interactions?.clear();
|
||||||
this._dataPacks?.clear();
|
this._dataPacks?.clear();
|
||||||
this._sceneSeekGeneration++;
|
this._sceneSeekGeneration++;
|
||||||
this._loadAbort?.abort();
|
this._loadAbort?.abort();
|
||||||
@@ -1763,6 +1786,71 @@ export class SceneDirector {
|
|||||||
return this._dataPacks?.getState() || { status: 'idle', count: 0 };
|
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. */
|
/** Export the entire project as a timestamped JSON file download. */
|
||||||
exportProject() {
|
exportProject() {
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
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
|
* @returns {Promise<boolean>} True only when every owned layer is released
|
||||||
*/
|
*/
|
||||||
async _releaseSceneLayers(scene, token = null) {
|
async _releaseSceneLayers(scene, token = null) {
|
||||||
|
this._interactions?.clear();
|
||||||
this._dataPacks?.clear();
|
this._dataPacks?.clear();
|
||||||
const layerIds = Array.isArray(scene?.releaseLayerIds)
|
const layerIds = Array.isArray(scene?.releaseLayerIds)
|
||||||
? 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);
|
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||||
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
||||||
assert.equal(director._storageReadError, null);
|
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(); }
|
} 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' }
|
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
...(rawShot?.interactions
|
||||||
|
? { interactions: deepClone(rawShot.interactions) }
|
||||||
|
: {}),
|
||||||
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
|
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
|
||||||
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
|
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
|
||||||
visual: {
|
visual: {
|
||||||
|
|||||||
Reference in new issue
Block a user