From 80027756ef59a0cb1c8a40e13f1488655cf1d650 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 19:16:49 -0700 Subject: [PATCH] feat: add declarative Director feature actions --- CHANGELOG.md | 3 + docs/CURRENT-STATE.md | 7 + docs/DIRECTOR-INTERACTIONS.md | 67 ++++ docs/DIRECTOR.md | 2 + scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 15 +- scripts/qa-director-interactions.mjs | 364 ++++++++++++++++++ src/director/camera.test.mjs | 2 +- src/director/document.js | 7 +- src/director/document.test.mjs | 2 +- src/director/interactions/document.js | 96 +++++ .../interactions/interactions.test.mjs | 247 ++++++++++++ src/director/interactions/session.js | 61 +++ src/director/packs/packs.test.mjs | 2 +- src/director/playback.js | 2 + src/scenes/dataPacks/controller.js | 4 +- src/scenes/dataPacks/geometry.js | 12 +- src/scenes/dataPacks/presentation.js | 9 +- src/scenes/director.js | 91 ++++- src/scenes/director.test.mjs | 2 +- src/scenes/interactions.js | 165 ++++++++ src/scenes/project.js | 3 + 22 files changed, 1150 insertions(+), 17 deletions(-) create mode 100644 docs/DIRECTOR-INTERACTIONS.md create mode 100644 scripts/qa-director-interactions.mjs create mode 100644 src/director/interactions/document.js create mode 100644 src/director/interactions/interactions.test.mjs create mode 100644 src/director/interactions/session.js create mode 100644 src/scenes/interactions.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d29775e6..58a5bb48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [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. - Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 91a1c1871..7535b8693 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # 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 selection. Registered sources acquire bounded, cancellable assets separately from GeoJSON, PNG and manually played media presentation. Storage paths, attribution diff --git a/docs/DIRECTOR-INTERACTIONS.md b/docs/DIRECTOR-INTERACTIONS.md new file mode 100644 index 000000000..740fe808e --- /dev/null +++ b/docs/DIRECTOR-INTERACTIONS.md @@ -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. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md index 2ba452b7d..bf37c91bb 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -105,3 +105,5 @@ arbitration, layer cleanup, media timing and provider fallback. Acceptance inclu portable runner tests, existing Director regressions and browser playback with Stop/seek/replay, plus tracking and voice checks. Use reproducible local fixtures 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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3374b88fc..1304f2901 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -739,5 +739,7 @@ "scripts/qa-director-camera.mjs", "src/director/packs/packs.test.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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8147f85e4..002e478ba 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -625,7 +625,9 @@ "src/director/packs/source.js", "src/director/packs/session.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": [] }, @@ -666,7 +668,11 @@ "src/director/packs/manifest.js", "src/scenes/dataPacks/controller.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"] }, @@ -1784,7 +1790,10 @@ "src/director/packs/manifest.js", "src/scenes/dataPacks/controller.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": [ "@mapbox/vector-tile", diff --git a/scripts/qa-director-interactions.mjs b/scripts/qa-director-interactions.mjs new file mode 100644 index 000000000..6c04f4b80 --- /dev/null +++ b/scripts/qa-director-interactions.mjs @@ -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: 'Plain text only', + 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('Plain text only') && + !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; diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs index f3ecb0fbc..cf7c43bb0 100644 --- a/src/director/camera.test.mjs +++ b/src/director/camera.test.mjs @@ -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', () => { diff --git a/src/director/document.js b/src/director/document.js index 0def9ff4d..48804d83b 100644 --- a/src/director/document.js +++ b/src/director/document.js @@ -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; } diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs index 18f4f017a..c0b8b0935 100644 --- a/src/director/document.test.mjs +++ b/src/director/document.test.mjs @@ -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( diff --git a/src/director/interactions/document.js b/src/director/interactions/document.js new file mode 100644 index 000000000..7d1522608 --- /dev/null +++ b/src/director/interactions/document.js @@ -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'); + } + }); + }); + }); +} diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs new file mode 100644 index 000000000..fa0aa10ae --- /dev/null +++ b/src/director/interactions/interactions.test.mjs @@ -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: '', + 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:pass@example.org/'), + (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, + ); +}); diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js new file mode 100644 index 000000000..2f305b675 --- /dev/null +++ b/src/director/interactions/session.js @@ -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()); + } + } + }, + }; +} diff --git a/src/director/packs/packs.test.mjs b/src/director/packs/packs.test.mjs index f31d65090..ed5d42500 100644 --- a/src/director/packs/packs.test.mjs +++ b/src/director/packs/packs.test.mjs @@ -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); } }); diff --git a/src/director/playback.js b/src/director/playback.js index 5cdeb0297..4542d16f1 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -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'; diff --git a/src/scenes/dataPacks/controller.js b/src/scenes/dataPacks/controller.js index c031ab6d8..6d70a31fd 100644 --- a/src/scenes/dataPacks/controller.js +++ b/src/scenes/dataPacks/controller.js @@ -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( diff --git a/src/scenes/dataPacks/geometry.js b/src/scenes/dataPacks/geometry.js index 5023386fb..f81b1e58f 100644 --- a/src/scenes/dataPacks/geometry.js +++ b/src/scenes/dataPacks/geometry.js @@ -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( diff --git a/src/scenes/dataPacks/presentation.js b/src/scenes/dataPacks/presentation.js index 77c05b76d..efdf5cbac 100644 --- a/src/scenes/dataPacks/presentation.js +++ b/src/scenes/dataPacks/presentation.js @@ -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'); diff --git a/src/scenes/director.js b/src/scenes/director.js index 7c53be152..087a50dc9 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -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} 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 diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 4bdccaac9..de00c8cc4 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -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(); } }); diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js new file mode 100644 index 000000000..8bb660715 --- /dev/null +++ b/src/scenes/interactions.js @@ -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); + }, + }; +} diff --git a/src/scenes/project.js b/src/scenes/project.js index eb1d0d49f..ecbc14b56 100644 --- a/src/scenes/project.js +++ b/src/scenes/project.js @@ -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: {