feat(scenes): render the data-pack attribution cards from a store

The data-pack presentations keep the Cesium resources, object URLs and
pick targets and present each pack's attribution card, with its media
player, to the gev-data-packs store; the DataPacks view teleports one
panel per set of presentations into the viewer's container with the same
markup and inline styles, and stops a removed card's player as it goes.
This commit is contained in:
Sameh Khamis committed 2026-10-31 20:22:22 -07:00
1 parent 59e3aaa271
commit e0abf74308
8 files changed
+290 -69

No files matched your search

+4 -1
View File
@@ -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
+2
View File
@@ -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"
]
+7 -3
View File
@@ -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",
+59 -65
View File
@@ -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 })),
});
}),
};
}
@@ -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']]);
});
+34
View File
@@ -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 };
});
+2
View File
@@ -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 = {
</Teleport>
<ImagerySplitView />
<DataPacks />
<SceneInteractions />
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import { useDataPacksStore } from '../../stores/dataPacks.js';
// The loaded data packs' attribution cards, one panel per set of data-pack
// presentations (src/scenes/dataPacks/presentation.js), inside the viewer's
// container.
const { panels } = storeToRefs(useDataPacksStore());
const panelStyle = {
position: 'absolute',
right: '10px',
bottom: '70px',
maxHeight: '40vh',
overflowY: 'auto',
zIndex: '30',
};
const cardStyle = {
color: '#e3faff',
background: '#08141eed',
border: '1px solid #2491a8',
padding: '8px',
margin: '4px',
maxWidth: '260px',
font: '12px sans-serif',
};
// A removed player stops and lets go of its media, which a detached
// element would otherwise keep playing. This runs as Vue renders the
// removal, in the same task as the dispose that removed it.
const vPackMedia = {
beforeUnmount(el) {
el.pause();
el.removeAttribute('src');
el.load();
},
};
</script>
<template>
<Teleport v-for="panel in panels" :key="panel.key" :to="panel.container">
<div data-director-packs="" :style="panelStyle"><section v-for="card in panel.cards" :key="card.key" :data-director-pack="card.id" :style="cardStyle"><div>{{ card.title }}</div><a v-if="card.url" :href="card.url" target="_blank" rel="noopener noreferrer" style="color: #6eeaff">{{ t('Source') }}</a><video v-if="card.media?.kind === 'video'" v-pack-media controls preload="none" style="width: 240px" :src="card.media.src"></video><audio v-else-if="card.media?.kind === 'audio'" v-pack-media controls preload="none" style="width: 240px" :src="card.media.src"></audio></section></div>
</Teleport>
</template>