mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
59e3aaa271
commit
e0abf74308
8 files changed
+290
-69
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user