diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index 25405aac0..77572f4c1 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -152,7 +152,10 @@ a feature supplying only its accent.
owner's own timer), the Scene actions panel (`SceneInteractions`
teleports what the scene interactions owner publishes to the
`gev-scene-interactions` store into the viewer's container; the owner
- keeps the pick handler and focuses the picked action), then
+ keeps the pick handler and focuses the picked action), the data-pack
+ attribution cards (`DataPacks` teleports what the data-pack presentations
+ publish to the `gev-data-packs` store into the viewer's container and
+ stops a removed card's player), then
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
pages, cycle toggle, regional news and local conditions, and
`CockpitContext` the Contact card's scope, navigation, counts, nearest
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index c5593ccb2..661a9e563 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -1134,5 +1134,7 @@
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
+ "src/scenes/dataPacks/presentationView.test.mjs",
+ "src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index ec8b207b9..a115c18af 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -99,7 +99,9 @@
"src/ui/views/panels/SplitFlapText.vue",
"src/splitFlap.js",
"src/ui/stores/sceneInteractions.js",
- "src/ui/views/panels/SceneInteractions.vue"
+ "src/ui/views/panels/SceneInteractions.vue",
+ "src/ui/stores/dataPacks.js",
+ "src/ui/views/panels/DataPacks.vue"
],
"external": ["vue", "pinia"]
},
@@ -1150,7 +1152,8 @@
"src/ui/vueRuntime.js",
"src/ui/stores/status.js",
"src/ui/toast.js",
- "src/ui/stores/sceneInteractions.js"
+ "src/ui/stores/sceneInteractions.js",
+ "src/ui/stores/dataPacks.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -3008,7 +3011,8 @@
"src/ui/stores/imagerySplit.js",
"src/ui/stores/status.js",
"src/ui/toast.js",
- "src/ui/stores/sceneInteractions.js"
+ "src/ui/stores/sceneInteractions.js",
+ "src/ui/stores/dataPacks.js"
],
"external": [
"hls.js",
diff --git a/src/scenes/dataPacks/presentation.js b/src/scenes/dataPacks/presentation.js
index b3823db97..ac4517a02 100644
--- a/src/scenes/dataPacks/presentation.js
+++ b/src/scenes/dataPacks/presentation.js
@@ -1,55 +1,56 @@
import * as Cesium from 'cesium';
import { renderPackGeometry } from './geometry.js';
-import { t } from '../../i18n/index.js';
+import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
+import { applicationPinia } from '../../ui/vueRuntime.js';
-function caption(viewer, pack) {
- const card = document.createElement('section');
- card.dataset.directorPack = pack.id;
- Object.assign(card.style, {
- color: '#e3faff',
- background: '#08141eed',
- border: '1px solid #2491a8',
- padding: '8px',
- margin: '4px',
- maxWidth: '260px',
- font: '12px sans-serif',
- });
- const title = document.createElement('div');
- title.textContent = `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`;
- card.append(title);
- if (pack.attribution.url) {
- const link = document.createElement('a');
- link.textContent = t('Source');
- link.href = pack.attribution.url;
- link.target = '_blank';
- link.rel = 'noopener noreferrer';
- link.style.color = '#6eeaff';
- card.append(link);
- }
- return card;
-}
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
-/** Create rendering adapters; data acquisition and authored placement stay independent. */
-export function createPackPresentations(viewer, targets = new Map()) {
- let panel;
+// Key every panel and card on the page, so a new one never reuses an old
+// one's DOM.
+let panelKeys = 0,
+ cardKeys = 0;
+
+/**
+ * Create rendering adapters; data acquisition and authored placement stay
+ * independent. Each pack's attribution card is presented to the data packs
+ * store, which its view renders inside the viewer's container.
+ */
+export function createPackPresentations(
+ viewer,
+ targets = new Map(),
+ { store = useDataPacksStore(applicationPinia()) } = {},
+) {
+ const owner = {};
+ let key,
+ cards = [];
+ function present() {
+ store.publish(
+ owner,
+ cards.length ? { key, container: viewer.container, cards } : null,
+ );
+ }
function addCard(pack) {
- if (!panel) {
- panel = document.createElement('div');
- panel.dataset.directorPacks = '';
- Object.assign(panel.style, {
- position: 'absolute',
- right: '10px',
- bottom: '70px',
- maxHeight: '40vh',
- overflowY: 'auto',
- zIndex: '30',
- });
- viewer.container.append(panel);
- }
- const card = caption(viewer, pack);
- panel.append(card);
- return card;
+ if (!cards.length) key = ++panelKeys;
+ const card = {
+ key: ++cardKeys,
+ id: pack.id,
+ title: `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`,
+ url: pack.attribution.url || null,
+ media: null,
+ };
+ cards = [...cards, card];
+ present();
+ return card.key;
+ }
+ function updateCard(key, patch) {
+ cards = cards.map((card) =>
+ card.key === key ? { ...card, ...patch } : card,
+ );
+ present();
+ }
+ function removeCard(key) {
+ cards = cards.filter((card) => card.key !== key);
+ present();
}
function resource(pack) {
const entities = [],
@@ -78,7 +79,12 @@ export function createPackPresentations(viewer, targets = new Map()) {
viewer.scene.requestRender();
},
card() {
- return (card ||= addCard(pack));
+ card ||= addCard(pack);
+ },
+ /** @param {{kind: 'video'|'audio', src: string}} media */
+ media(media) {
+ card ||= addCard(pack);
+ updateCard(card, { media });
},
url(blob) {
const url = URL.createObjectURL(blob);
@@ -89,16 +95,8 @@ export function createPackPresentations(viewer, targets = new Map()) {
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');
- media.load();
- }
- card?.remove();
- if (panel && !panel.childElementCount) {
- panel.remove();
- panel = null;
- }
+ // The view stops a removed card's player as it goes.
+ if (card) removeCard(card);
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
for (const primitive of primitives.splice(0))
viewer.scene.primitives.remove(primitive);
@@ -212,14 +210,10 @@ export function createPackPresentations(viewer, targets = new Map()) {
pixelOffset: new Cesium.Cartesian2(0, -24),
},
});
- const media = document.createElement(
- asset.mimeType.startsWith('video/') ? 'video' : 'audio',
- );
- media.controls = true;
- media.preload = 'none';
- media.style.width = '240px';
- media.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
- handle.card().append(media);
+ handle.media({
+ kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio',
+ src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })),
+ });
}),
};
}
diff --git a/src/scenes/dataPacks/presentationView.test.mjs b/src/scenes/dataPacks/presentationView.test.mjs
new file mode 100644
index 000000000..c6c173d89
--- /dev/null
+++ b/src/scenes/dataPacks/presentationView.test.mjs
@@ -0,0 +1,137 @@
+// src/scenes/dataPacks/presentationView.test.mjs — loaded packs' attribution
+// cards render in the viewer's container from what the data-pack
+// presentations present, and a removed card's player stops.
+import '../../testSupport/registerDom.mjs';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createApp, nextTick } from 'vue';
+import { createPinia } from 'pinia';
+import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
+import DataPacks from '../../ui/views/panels/DataPacks.vue';
+import { createPackPresentations } from './presentation.js';
+
+const ANCHOR = { id: 'here', lat: 30, lon: -97, alt: 10 };
+const pack = (id, url) => ({
+ id,
+ attribution: { text: 'Synthetic', license: 'CC0-1.0', url },
+ placement: { anchorId: 'here' },
+});
+
+function fixture(t) {
+ document.body.replaceChildren();
+ const container = document.body.appendChild(document.createElement('div'));
+ const pinia = createPinia();
+ const app = createApp(DataPacks).use(pinia);
+ app.mount(document.body.appendChild(document.createElement('div')));
+ const viewer = {
+ container,
+ scene: { requestRender() {} },
+ entities: { add: (spec) => spec, remove() {} },
+ };
+ const store = useDataPacksStore(pinia);
+ const presentations = createPackPresentations(viewer, new Map(), { store });
+ t.after(() => app.unmount());
+ const settle = async () => {
+ for (let flush = 0; flush < 4; flush += 1) await nextTick();
+ };
+ const $ = (selector) => container.querySelector(selector);
+ const $$ = (selector) => [...container.querySelectorAll(selector)];
+ return { presentations, viewer, store, settle, $, $$ };
+}
+
+const media = (presentations, id, mimeType = 'audio/mpeg') =>
+ presentations.media({
+ pack: pack(id),
+ asset: { bytes: new Uint8Array([1, 2, 3]), mimeType },
+ anchors: [ANCHOR],
+ signal: new AbortController().signal,
+ });
+
+test('a loaded pack shows its attribution card in the viewer container', async (t) => {
+ const { presentations, settle, $, $$ } = fixture(t);
+ assert.equal($('[data-director-packs]'), null);
+ const handle = await media(presentations, 'clip', 'video/webm');
+ await settle();
+ const panel = $('[data-director-packs]');
+ assert.equal(panel.tagName, 'DIV');
+ assert.equal(panel.style.position, 'absolute');
+ assert.equal(panel.style.right, '10px');
+ assert.equal(panel.style.bottom, '70px');
+ assert.equal(panel.style.zIndex, '30');
+ const [card] = $$('[data-director-pack]');
+ assert.equal(card.tagName, 'SECTION');
+ assert.equal(card.dataset.directorPack, 'clip');
+ assert.equal(card.style.maxWidth, '260px');
+ assert.equal(
+ card.querySelector('div').textContent,
+ 'clip · Synthetic · CC0-1.0',
+ );
+ assert.equal(card.querySelector('a'), null, 'no link without a source URL');
+ const video = card.querySelector('video');
+ assert.equal(video.controls, true);
+ assert.equal(video.preload, 'none');
+ assert.equal(video.style.width, '240px');
+ assert.match(video.getAttribute('src'), /^blob:/);
+ handle.dispose();
+ await settle();
+ assert.equal($('[data-director-packs]'), null);
+});
+
+test('a card links its source, and a removed card stops its player', async (t) => {
+ const { presentations, settle, $, $$ } = fixture(t);
+ const first = await media(presentations, 'one');
+ const second = await presentations.media({
+ pack: pack('two', 'https://example.org/two'),
+ asset: { bytes: new Uint8Array([1]), mimeType: 'audio/ogg' },
+ anchors: [ANCHOR],
+ signal: new AbortController().signal,
+ });
+ await settle();
+ assert.deepEqual(
+ $$('[data-director-pack]').map((card) => card.dataset.directorPack),
+ ['one', 'two'],
+ );
+ const link = $('[data-director-pack="two"] a');
+ assert.equal(link.textContent, 'Source');
+ assert.equal(link.getAttribute('href'), 'https://example.org/two');
+ assert.equal(link.target, '_blank');
+ assert.equal(link.rel, 'noopener noreferrer');
+ const audio = $('[data-director-pack="one"] audio');
+ const calls = [];
+ audio.pause = () => calls.push('pause');
+ audio.load = () => calls.push('load');
+ first.dispose();
+ await settle();
+ assert.deepEqual(calls, ['pause', 'load']);
+ assert.equal(audio.getAttribute('src'), null);
+ assert.equal(audio.isConnected, false);
+ assert.deepEqual(
+ $$('[data-director-pack]').map((card) => card.dataset.directorPack),
+ ['two'],
+ );
+ second.dispose();
+ await settle();
+ assert.equal($('[data-director-packs]'), null);
+});
+
+test('each set of presentations keeps its own panel', async (t) => {
+ const { presentations, viewer, store, settle, $$ } = fixture(t);
+ const one = await media(presentations, 'one');
+ await media(presentations, 'two');
+ const later = createPackPresentations(viewer, new Map(), { store });
+ const three = await media(later, 'three');
+ await settle();
+ const cards = () =>
+ $$('[data-director-packs]').map((panel) =>
+ [...panel.querySelectorAll('[data-director-pack]')].map(
+ (card) => card.dataset.directorPack,
+ ),
+ );
+ assert.deepEqual(cards(), [['one', 'two'], ['three']]);
+ one.dispose();
+ await settle();
+ assert.deepEqual(cards(), [['two'], ['three']]);
+ three.dispose();
+ await settle();
+ assert.deepEqual(cards(), [['two']]);
+});
diff --git a/src/ui/stores/dataPacks.js b/src/ui/stores/dataPacks.js
new file mode 100644
index 000000000..c2555b46e
--- /dev/null
+++ b/src/ui/stores/dataPacks.js
@@ -0,0 +1,34 @@
+import { defineStore } from 'pinia';
+import { markRaw, shallowRef } from 'vue';
+
+/**
+ * The loaded data packs' attribution cards as their view renders them. Each
+ * set of data-pack presentations (src/scenes/dataPacks/presentation.js)
+ * presents its own panel, with a card for each pack and its media player
+ * when the pack is media.
+ */
+export const useDataPacksStore = defineStore('gev-data-packs', () => {
+ /**
+ * `[{owner, key, container, cards: [{key, id, title, url, media}]}]`, one
+ * per owner showing cards. `container` is the element the panel sits in;
+ * `media` is `{kind: 'video'|'audio', src}` or null.
+ */
+ const panels = shallowRef([]);
+
+ /**
+ * @param {object} owner The presentations publishing.
+ * @param {?object} panel Their fresh panel, or null to remove it.
+ */
+ function publish(owner, panel) {
+ const index = panels.value.findIndex((item) => item.owner === owner);
+ const next = panels.value.slice();
+ if (!panel) {
+ if (index < 0) return;
+ next.splice(index, 1);
+ } else if (index < 0) next.push(markRaw({ ...panel, owner }));
+ else next[index] = markRaw({ ...panel, owner });
+ panels.value = next;
+ }
+
+ return { panels, publish };
+});
diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue
index b763f7211..430226bae 100644
--- a/src/ui/views/SceneChromeView.vue
+++ b/src/ui/views/SceneChromeView.vue
@@ -6,6 +6,7 @@ import { useLocationStore } from '../stores/location.js';
import { useStatusStore } from '../stores/status.js';
import { vClassToggle } from '../rail/classToggle.js';
import ImagerySplitView from './panels/ImagerySplitView.vue';
+import DataPacks from './panels/DataPacks.vue';
import SceneInteractions from './panels/SceneInteractions.vue';
import SplitFlapText from './panels/SplitFlapText.vue';
@@ -96,5 +97,6 @@ const vToastText = {
+
diff --git a/src/ui/views/panels/DataPacks.vue b/src/ui/views/panels/DataPacks.vue
new file mode 100644
index 000000000..f6e91a723
--- /dev/null
+++ b/src/ui/views/panels/DataPacks.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+