Files
gods-eye-view/src/scenes/dataPacks/presentation.js
T

263 lines
8.2 KiB
JavaScript

import * as Cesium from 'cesium';
import { renderDecodedGeometry } from './geometry.js';
import { decodePackGeoJSON } from '../../director/packs/geojson.js';
import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
import { applicationPinia } from '../../ui/vueRuntime.js';
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
// 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()),
native = null,
container = viewer?.container,
} = {},
) {
const owner = {};
const resources = new Set();
let key,
cards = [];
function present() {
store.publish(
owner,
cards.length ? { key, container, cards } : null,
);
}
function addCard(pack) {
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 = [],
primitives = [],
materials = [],
urls = [];
const featureKeys = [];
let card,
draw,
destroyed = false;
const nativeRemovers = [];
const handle = {
own(remove) {
nativeRemovers.push(remove);
},
display(render) {
draw = render;
if (viewer || native) draw(native);
},
redraw() {
if (!destroyed && (viewer || native)) draw?.(native);
},
detach() {
for (const key of featureKeys.splice(0)) targets.delete(key);
for (const remove of nativeRemovers.splice(0).reverse()) remove();
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
for (const primitive of primitives.splice(0))
viewer.scene.primitives.remove(primitive);
for (const material of materials.splice(0)) {
if (!material.isDestroyed()) material.destroy();
}
viewer?.scene.requestRender();
},
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);
viewer.scene.requestRender();
return entity;
},
primitive(value) {
primitives.push(viewer.scene.primitives.add(value));
if (value.appearance?.material)
materials.push(value.appearance.material);
viewer.scene.requestRender();
},
card() {
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);
urls.push(url);
return url;
},
dispose() {
if (destroyed) return;
destroyed = true;
handle.detach();
resources.delete(handle);
if (card) removeCard(card);
for (const url of urls.splice(0)) URL.revokeObjectURL(url);
},
};
resources.add(handle);
return handle;
}
function guarded(render) {
return async (context) => {
const handle = resource(context.pack);
const abort = () => handle.dispose();
context.signal.addEventListener('abort', abort, { once: true });
try {
context.signal.throwIfAborted();
await render(context, handle);
context.signal.throwIfAborted();
handle.card();
return handle;
} catch (error) {
handle.dispose();
throw error;
} finally {
context.signal.removeEventListener('abort', abort);
}
};
}
return {
setScene(nextViewer, nextNative, nextContainer) {
for (const handle of resources) handle.detach();
viewer = nextViewer;
native = nextNative;
if (nextContainer) container = nextContainer;
for (const handle of resources) handle.redraw();
if (cards.length) present();
},
geojson: guarded(({ asset, pack }, handle) => {
if (
!['application/json', 'application/geo+json'].includes(asset.mimeType)
)
throw new Error('Expected GeoJSON');
const features = decodePackGeoJSON(asset.bytes);
handle.display((native) =>
native
? native.geometry(pack, features, handle)
: renderDecodedGeometry(features, pack, handle),
);
}),
image: guarded(async ({ asset, pack, signal }, handle) => {
if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image');
const bytes = asset.bytes;
if (
bytes.length < 24 ||
![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].every(
(v, i) => bytes[i] === v,
)
)
throw new Error('Invalid PNG header');
const header = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
const width = header.getUint32(16),
height = header.getUint32(20);
if (!width || !height || width > 4096 || height > 4096)
throw new Error('Image dimensions exceed limit');
const image = new Image();
image.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
const abort = () => {
image.src = '';
};
signal.addEventListener('abort', abort, { once: true });
try {
await image.decode();
} finally {
signal.removeEventListener('abort', abort);
}
signal.throwIfAborted();
if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024)
throw new Error('Image dimensions exceed limit');
handle.display(native => native ? native.image(pack,image,handle) : handle.primitive(
new Cesium.Primitive({
asynchronous: false,
geometryInstances: new Cesium.GeometryInstance({
id: { packId: pack.id },
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds),
height: pack.placement.height,
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
}),
}),
appearance: new Cesium.MaterialAppearance({
material: Cesium.Material.fromType('Image', { image }),
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
flat: true,
faceForward: true,
translucent: true,
}),
}),
));
}),
media: guarded(({ asset, pack, anchors }, handle) => {
if (
![
'video/mp4',
'video/webm',
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/webm',
].includes(asset.mimeType)
)
throw new Error('Unsupported media');
const anchor = anchors.find(
(item) => item.id === pack.placement.anchorId,
);
if (!anchor) throw new Error('Missing media anchor');
handle.display(native => native ? native.media(pack,anchor,handle) : handle.add({
name: pack.id,
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
point: { pixelSize: 16, color: Cesium.Color.YELLOW },
label: {
text: pack.id,
font: '14px sans-serif',
pixelOffset: new Cesium.Cartesian2(0, -24),
},
}));
handle.media({
kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio',
src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })),
});
}),
};
}