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 @@ + + +