mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Draw the last native world labels through the label overlay
The Directions A and B letters and a data pack's media label were the two Cesium text labels left, kept by an allow-list in noCesiumLabels.test.mjs. Both now publish entries to the shared world-overlay host, and the allow-list is gone, so any Cesium label in src is a test failure. Directions publishes the letters to the directions-markers source; the markers keep only their points. A clicked endpoint's letter hangs over the surface that was clicked. One placed without a click stays hidden until its ground-floor cell resolves, then requests a render, so a still camera shows it; a cell that settles with no height falls back to the ellipsoid. Each position is computed once. A pack resource handle gains label(entry). The application passes its shared overlay host to SceneDirector through dataPacks.overlayHost; without one a media pack fails to load rather than drawing a native label. Each labelled resource takes a source from a pool and returns it on dispose, as the host never removes a source, so two presentations of one pack never clear each other's labels and repeat loads add no sources. A label written after dispose is ignored. The media label is protected, so it always draws as before. Live (Denver, keyless globe): a clicked A and a programmatic B both anchor on their dots over 1.58 km ground. Documented in docs/CURRENT-STATE.md and docs/DIRECTOR-DATA-PACKS.md.
This commit is contained in:
1 parent
ad8adc85f4
commit
fb30287898
12 files changed
+382
-44
No files matched your search
@@ -3463,8 +3463,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> celestial ring, annotations SVG, missions frame tick, and traffic).
|
||||
> - **World-label accounting:** All intended label/card migrations use the
|
||||
> shared host. The former native `LabelGraphics` exception for cable reference
|
||||
> labels was retired on 2026-08-18; cable text may render over tile geometry,
|
||||
> so **zero native world-label creation sites remain**. The mission replay DOM vehicle, awareness
|
||||
> labels was retired on 2026-08-18; cable text may render over tile geometry.
|
||||
> The Directions A/B letters (`directions-markers` source) and data-pack media
|
||||
> labels (a pooled `data-pack-labels:<n>` source per pack resource, through
|
||||
> the pack handle's `label`) moved to the host on 2026-11-04, and
|
||||
> `src/noCesiumLabels.test.mjs` no longer keeps an allow-list, so **zero
|
||||
> native world-label creation sites remain**. A media pack shown without an
|
||||
> `overlayHost` fails to load rather than drawing a native label. The mission
|
||||
> replay DOM vehicle, awareness
|
||||
> compass, annotation SVG callouts, and host-owned detection isolation target
|
||||
> are explicit exceptions; the awareness ring and cockpit contact pips are
|
||||
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
|
||||
|
||||
@@ -95,6 +95,11 @@ registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||
signal, bound reads and return the real MIME type. They remain application code,
|
||||
not scene document fields. Without registrations, referenced sources fail closed.
|
||||
A standalone `SceneDirector` also passes `dataPacks.overlayHost`, the
|
||||
world-overlay host that draws pack world text such as a media pack's label (an
|
||||
object with `setEntries(sourceId, entries)` and `clearSource(sourceId)`);
|
||||
without it a media pack fails to load. `createApplicationTools` passes the
|
||||
application's shared host.
|
||||
|
||||
The portable session receives format adapters separately. Each adapter receives
|
||||
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ import {
|
||||
releaseContinuousRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { entityRecord, readEntityRecord } from '../data/entityRecords.js';
|
||||
import { overlayHost } from './layers/overlayHost.js';
|
||||
|
||||
/** Attach scene tools, rendering listeners and the application debug handle. */
|
||||
export function createApplicationTools({
|
||||
@@ -37,7 +38,7 @@ export function createApplicationTools({
|
||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
dataPacks: sceneDataPacks,
|
||||
dataPacks: { overlayHost, ...sceneDataPacks },
|
||||
isMapStackAvailable: (id) =>
|
||||
mapStackController?.isStackAvailable(id) === true,
|
||||
});
|
||||
|
||||
@@ -54,6 +54,8 @@ export {
|
||||
STEP_ANCHOR_FAST_ATTEMPTS,
|
||||
STEP_ANCHOR_RETRY_MS,
|
||||
STEP_ANCHOR_SLOW_RETRY_MS,
|
||||
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
|
||||
createDirectionsMarkerOverlayEntry,
|
||||
createDirectionsStepOverlayEntry,
|
||||
directionsRequestUrl,
|
||||
directionsRowControls,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
DIRECTIONS_MODES,
|
||||
DIRECTIONS_POINTER_OWNER,
|
||||
DIRECTIONS_ROUTE_COLOR,
|
||||
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
|
||||
createDirectionsMarkerOverlayEntry,
|
||||
createDirectionsStepOverlayEntry,
|
||||
directionsRequestUrl,
|
||||
directionsRowControls,
|
||||
@@ -950,3 +952,83 @@ test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
|
||||
test('the A and B letters are protected overlay labels in the marker colour', () => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(-122.39, 37.79);
|
||||
const entry = createDirectionsMarkerOverlayEntry('B', position, '#ff6b6b');
|
||||
assert.equal(entry.id, 'directions-marker-b');
|
||||
assert.equal(entry.title, 'B');
|
||||
assert.equal(entry.variant, 'label');
|
||||
assert.equal(entry.accent, '#ff6b6b');
|
||||
assert.equal(entry.protected, true);
|
||||
assert.equal(entry.position, position);
|
||||
assert.equal(entry.placement, 'above');
|
||||
});
|
||||
|
||||
test('a placed endpoint draws its letter through the overlay, not as entity text', async (t) => {
|
||||
const published = [];
|
||||
const cleared = [];
|
||||
const visibility = [];
|
||||
const entities = [];
|
||||
const markerVisibility = () =>
|
||||
visibility
|
||||
.filter(([source]) => source === DIRECTIONS_MARKER_OVERLAY_SOURCE_ID)
|
||||
.at(-1)?.[1];
|
||||
directionsLayer = createApplicationDirectionsLayer();
|
||||
directionsLayer._setOverlayHostForTest({
|
||||
setEntries: (source, entries) => published.push([source, entries]),
|
||||
setVisible: (source, visible) => visibility.push([source, visible]),
|
||||
clearSource: (source) => cleared.push(source),
|
||||
});
|
||||
resetPointerOwnership();
|
||||
const viewer = {
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add: (spec) => (entities.push(spec), spec),
|
||||
remove() {},
|
||||
},
|
||||
scene: {
|
||||
requestRender() {},
|
||||
primitives: {
|
||||
add: (primitive) => primitive,
|
||||
remove() {},
|
||||
raiseToTop() {},
|
||||
lowerToBottom() {},
|
||||
contains: () => true,
|
||||
length: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
directionsLayer.destroy(viewer);
|
||||
directionsLayer._setOverlayHostForTest(null);
|
||||
resetPointerOwnership();
|
||||
});
|
||||
directionsLayer.init(viewer);
|
||||
assert.equal(markerVisibility(), false, 'hidden until enabled');
|
||||
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
|
||||
|
||||
assert.equal(entities.length, 1);
|
||||
assert.equal(entities[0].label, undefined, 'no Cesium label on the marker');
|
||||
assert.ok(entities[0].point, 'the marker keeps its point');
|
||||
const [source, entries] = published.at(-1);
|
||||
assert.equal(source, DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.title),
|
||||
['A'],
|
||||
);
|
||||
// Hidden until the floor cell settles, then anchored (the ellipsoid here,
|
||||
// where the test resolver finds no height).
|
||||
assert.equal(entries[0].position(), null);
|
||||
const started = Date.now();
|
||||
while (!entries[0].position() && Date.now() - started < 5000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const anchor = Cesium.Cartographic.fromCartesian(entries[0].position());
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(anchor.latitude) - 37.7955) < 1e-9);
|
||||
|
||||
directionsLayer.setParams({ clear: true });
|
||||
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
|
||||
cleared.length = 0;
|
||||
directionsLayer.destroy(viewer);
|
||||
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
|
||||
});
|
||||
@@ -71,7 +71,7 @@ test('Directions introduces the shared OSM credit when the requested route draws
|
||||
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop },
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
ground: { warmGroundFloor: noop },
|
||||
ground: { warmGroundFloor: noop, resolveGroundFloorCells: async () => {} },
|
||||
} });
|
||||
layer.init(v); layer.enable(v);
|
||||
layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 });
|
||||
|
||||
+108
-14
@@ -40,6 +40,13 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
collisionCapacity: 0,
|
||||
moving: false,
|
||||
});
|
||||
/** The A and B letters above the endpoint markers. */
|
||||
export const DIRECTIONS_MARKER_OVERLAY_SOURCE_ID = 'directions-markers';
|
||||
export const DIRECTIONS_MARKER_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: 2,
|
||||
collisionCapacity: 0,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
/** Travel modes, keyed by the `/api/route` profile name. */
|
||||
export const DIRECTIONS_MODES = Object.freeze({
|
||||
@@ -69,8 +76,10 @@ export function presentRoutingFailure(error) {
|
||||
|
||||
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
|
||||
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
|
||||
const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f');
|
||||
const MARKER_B_COLOR = Cesium.Color.fromCssColorString('#ff6b6b');
|
||||
const MARKER_A_CSS = '#5dff9f';
|
||||
const MARKER_B_CSS = '#ff6b6b';
|
||||
const MARKER_A_COLOR = Cesium.Color.fromCssColorString(MARKER_A_CSS);
|
||||
const MARKER_B_COLOR = Cesium.Color.fromCssColorString(MARKER_B_CSS);
|
||||
const STEP_COLOR = Cesium.Color.WHITE.withAlpha(0.95);
|
||||
const STEP_OUTLINE = Cesium.Color.fromCssColorString(DIRECTIONS_ROUTE_COLOR);
|
||||
const STEP_PIXEL_SIZE = 8;
|
||||
@@ -796,6 +805,32 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The letter above one endpoint marker, as a world-overlay label. It is
|
||||
* protected, like the step card, so it never yields to ambient labels.
|
||||
* @param {'A'|'B'} letter
|
||||
* @param {Cesium.Cartesian3|(() => Cesium.Cartesian3)} position
|
||||
* @param {string} accent CSS colour of the marker.
|
||||
*/
|
||||
export function createDirectionsMarkerOverlayEntry(letter, position, accent) {
|
||||
return {
|
||||
id: `directions-marker-${letter.toLowerCase()}`,
|
||||
position,
|
||||
variant: 'label',
|
||||
title: letter,
|
||||
accent,
|
||||
protected: true,
|
||||
priority: Number.MAX_SAFE_INTEGER - 1,
|
||||
interactive: false,
|
||||
gapPx: 13,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `/api/route` URL for two endpoints.
|
||||
* @param {string} mode car | foot | bike
|
||||
@@ -881,6 +916,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
let _mode = DEFAULT_DIRECTIONS_MODE;
|
||||
/** @type {null|'a'|'b'} which endpoint the next globe click places */
|
||||
let _armed = null;
|
||||
/** Surface heights of clicked endpoints, by point. */
|
||||
const _surfaceHeights = new WeakMap();
|
||||
/** Endpoints whose floor cell resolve has settled, height or not. */
|
||||
const _floorSettled = new WeakSet();
|
||||
/** @type {{lat:number, lon:number}|null} */
|
||||
let _a = null;
|
||||
/** @type {{lat:number, lon:number}|null} */
|
||||
@@ -975,10 +1014,14 @@ export function createDirectionsLayer({ services, source }) {
|
||||
if (!cartesian) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(cartesian);
|
||||
if (!carto) return null;
|
||||
return {
|
||||
const point = {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
// A pick at the ellipsoid itself is the fallback when the drawn surface
|
||||
// gave no position; the floor path places that letter instead.
|
||||
if (Math.abs(carto.height) > 0.01) _surfaceHeights.set(point, carto.height);
|
||||
return point;
|
||||
}
|
||||
|
||||
function markerEntity(letter, point, color) {
|
||||
@@ -993,20 +1036,55 @@ export function createDirectionsLayer({ services, source }) {
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
label: {
|
||||
text: letter,
|
||||
font: 'bold 13px "JetBrains Mono", "SF Mono", monospace',
|
||||
fillColor: Cesium.Color.WHITE,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -20),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a marker's letter hangs: over the surface the user clicked, or for an
|
||||
* endpoint placed without a click, over the shared ground floor once its cell
|
||||
* is known. Until then the letter stays hidden, as the step dots do, rather
|
||||
* than hanging at the ellipsoid far below its point; only a cell that settled
|
||||
* with no height falls back to the ellipsoid. Resolved once.
|
||||
*/
|
||||
function markerLabelPosition(point) {
|
||||
let position = null;
|
||||
return () => {
|
||||
if (position) return position;
|
||||
const height =
|
||||
_surfaceHeights.get(point) ??
|
||||
services.ground.cachedGroundFloor(point.lat, point.lon);
|
||||
if (!Number.isFinite(height) && !_floorSettled.has(point)) return null;
|
||||
position = Cesium.Cartesian3.fromDegrees(
|
||||
point.lon,
|
||||
point.lat,
|
||||
Number.isFinite(height) ? height : 0,
|
||||
);
|
||||
return position;
|
||||
};
|
||||
}
|
||||
|
||||
/** Publish the letters for whichever endpoints are placed. */
|
||||
function publishMarkerLabels() {
|
||||
const entries = [];
|
||||
for (const [letter, point, accent] of [
|
||||
['A', _a, MARKER_A_CSS],
|
||||
['B', _b, MARKER_B_CSS],
|
||||
])
|
||||
if (point)
|
||||
entries.push(
|
||||
createDirectionsMarkerOverlayEntry(
|
||||
letter,
|
||||
markerLabelPosition(point),
|
||||
accent,
|
||||
),
|
||||
);
|
||||
_overlayHost.setEntries(
|
||||
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
|
||||
entries,
|
||||
DIRECTIONS_MARKER_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
function removeEntity(entity) {
|
||||
if (entity && _viewer && !_viewer.isDestroyed?.())
|
||||
_viewer.entities.remove(entity);
|
||||
@@ -1021,6 +1099,17 @@ export function createDirectionsLayer({ services, source }) {
|
||||
removeEntity(_markerB);
|
||||
_markerB = markerEntity('B', point, MARKER_B_COLOR);
|
||||
}
|
||||
publishMarkerLabels();
|
||||
if (_surfaceHeights.has(point)) return;
|
||||
// The letter appears when its floor cell lands, even under a still camera.
|
||||
services.ground
|
||||
.resolveGroundFloorCells([{ lat: point.lat, lon: point.lon }])
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
_floorSettled.add(point);
|
||||
if (_viewer && (point === _a || point === _b))
|
||||
services.render.governorRequestRender('directions-place');
|
||||
});
|
||||
}
|
||||
|
||||
function clearRouteGraphics() {
|
||||
@@ -1326,6 +1415,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
removeEntity(_markerB);
|
||||
_markerA = null;
|
||||
_markerB = null;
|
||||
_overlayHost.clearSource(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
|
||||
clearRouteGraphics();
|
||||
services.render.governorRequestRender('directions-clear');
|
||||
}
|
||||
@@ -1603,6 +1693,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_error = null;
|
||||
_flightStep = null;
|
||||
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
|
||||
_overlayHost.setVisible(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID, false);
|
||||
services.sprites.restoreSpriteOrder(viewer);
|
||||
console.log('[Data:Directions] Initialized');
|
||||
},
|
||||
@@ -1616,6 +1707,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_pointerBlocked = false;
|
||||
_stepPoints.show = true;
|
||||
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
|
||||
_overlayHost.setVisible(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID, true);
|
||||
_installClickHandler(viewer);
|
||||
services.picking.registerPickOwner(
|
||||
'directions',
|
||||
@@ -1634,6 +1726,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_enabled = false;
|
||||
clearAll();
|
||||
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
|
||||
_overlayHost.setVisible(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID, false);
|
||||
_removeClickHandler();
|
||||
services.picking.unregisterPickOwner('directions');
|
||||
if (_stepPoints) _stepPoints.show = false;
|
||||
@@ -1740,6 +1833,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_stepPoints = null;
|
||||
}
|
||||
_overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
|
||||
_overlayHost.clearSource(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
|
||||
_viewer = null;
|
||||
},
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@ import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parsers } from 'prettier/plugins/babel.mjs';
|
||||
|
||||
// Existing entity graphics only. Remove entries as these callers migrate.
|
||||
const legacyFiles = new Set([
|
||||
'src/scenes/dataPacks/presentation.js',
|
||||
'src/layers/directions/index.js',
|
||||
]);
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const propertyName = (node) => node?.name ?? node?.value;
|
||||
const memberName = (node) =>
|
||||
@@ -110,7 +105,6 @@ function cesiumLabels(source) {
|
||||
|
||||
test('runtime world text uses the world-overlay host, never new Cesium labels', () => {
|
||||
const violations = [];
|
||||
const legacySeen = new Set();
|
||||
function scan(directory) {
|
||||
for (const entry of readdirSync(root + directory, {
|
||||
withFileTypes: true,
|
||||
@@ -123,9 +117,7 @@ test('runtime world text uses the world-overlay host, never new Cesium labels',
|
||||
!/\.(?:test|spec)\.js$/.test(entry.name)
|
||||
) {
|
||||
const hits = cesiumLabels(readFileSync(root + file, 'utf8'));
|
||||
if (!hits.length) continue;
|
||||
if (legacyFiles.has(file)) legacySeen.add(file);
|
||||
else violations.push(...hits.map((hit) => `${file}:${hit}`));
|
||||
violations.push(...hits.map((hit) => `${file}:${hit}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,11 +127,6 @@ test('runtime world text uses the world-overlay host, never new Cesium labels',
|
||||
[],
|
||||
`Cesium text labels are forbidden; use the world-overlay host.\n${violations.join('\n')}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
legacySeen,
|
||||
legacyFiles,
|
||||
'Remove stale or false-positive Cesium-label exceptions',
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard catches label APIs and entity graphics, including aliases and multiline options', () => {
|
||||
|
||||
@@ -2,11 +2,14 @@ import { createDataPackSession } from '../../director/packs/session.js';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
|
||||
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
|
||||
export function createSceneDataPacks(
|
||||
viewer,
|
||||
{ sources = {}, overlayHost = null } = {},
|
||||
) {
|
||||
const targets = new Map();
|
||||
const session = createDataPackSession({
|
||||
sources,
|
||||
adapters: createPackPresentations(viewer, targets),
|
||||
adapters: createPackPresentations(viewer, targets, { overlayHost }),
|
||||
});
|
||||
return {
|
||||
...session,
|
||||
|
||||
@@ -5,11 +5,20 @@ import { applicationPinia } from '../../ui/vueRuntime.js';
|
||||
|
||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||
|
||||
const MEDIA_MARKER_CSS = '#ffff00';
|
||||
|
||||
// Key every panel and card on the page, so a new one never reuses an old
|
||||
// one's DOM.
|
||||
let panelKeys = 0,
|
||||
cardKeys = 0;
|
||||
|
||||
// Label overlay sources, one per pack resource while it shows text, so two
|
||||
// presentations of one pack never share or clear each other's labels. The host
|
||||
// keeps a source once made, so a disposed resource returns its id for reuse
|
||||
// and the count stays at the most labelled resources ever shown at once.
|
||||
let labelSources = 0;
|
||||
const freeLabelSources = [];
|
||||
|
||||
/**
|
||||
* Create rendering adapters; data acquisition and authored placement stay
|
||||
* independent. Each pack's attribution card is presented to the data packs
|
||||
@@ -18,7 +27,7 @@ let panelKeys = 0,
|
||||
export function createPackPresentations(
|
||||
viewer,
|
||||
targets = new Map(),
|
||||
{ store = useDataPacksStore(applicationPinia()) } = {},
|
||||
{ store = useDataPacksStore(applicationPinia()), overlayHost = null } = {},
|
||||
) {
|
||||
const owner = {};
|
||||
let key,
|
||||
@@ -56,8 +65,10 @@ export function createPackPresentations(
|
||||
const entities = [],
|
||||
primitives = [],
|
||||
materials = [],
|
||||
urls = [];
|
||||
urls = [],
|
||||
labels = [];
|
||||
const featureKeys = [];
|
||||
let labelSource = null;
|
||||
let card,
|
||||
destroyed = false;
|
||||
return {
|
||||
@@ -72,6 +83,21 @@ export function createPackPresentations(
|
||||
viewer.scene.requestRender();
|
||||
return entity;
|
||||
},
|
||||
/**
|
||||
* World text for this pack, as a label-overlay entry in the pack's own
|
||||
* source. The application passes the shared host; there is no text
|
||||
* without one, so a pack that needs it fails to load instead.
|
||||
*/
|
||||
label(entry) {
|
||||
if (!overlayHost) throw new Error('Pack labels need an overlay host');
|
||||
// A late adapter writing after disposal must not take a source again.
|
||||
if (destroyed) return;
|
||||
labelSource ||=
|
||||
freeLabelSources.pop() ?? `data-pack-labels:${++labelSources}`;
|
||||
labels.push(entry);
|
||||
overlayHost.setEntries(labelSource, labels.slice());
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
primitive(value) {
|
||||
primitives.push(viewer.scene.primitives.add(value));
|
||||
if (value.appearance?.material)
|
||||
@@ -98,6 +124,12 @@ export function createPackPresentations(
|
||||
// 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);
|
||||
if (labelSource) {
|
||||
labels.length = 0;
|
||||
overlayHost.clearSource(labelSource);
|
||||
freeLabelSources.push(labelSource);
|
||||
labelSource = null;
|
||||
}
|
||||
for (const primitive of primitives.splice(0))
|
||||
viewer.scene.primitives.remove(primitive);
|
||||
for (const material of materials.splice(0)) {
|
||||
@@ -200,16 +232,27 @@ export function createPackPresentations(
|
||||
(item) => item.id === pack.placement.anchorId,
|
||||
);
|
||||
if (!anchor) throw new Error('Missing media anchor');
|
||||
const position = xyz([anchor.lon, anchor.lat, anchor.alt]);
|
||||
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),
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 16,
|
||||
color: Cesium.Color.fromCssColorString(MEDIA_MARKER_CSS),
|
||||
},
|
||||
});
|
||||
handle.label({
|
||||
id: 'media',
|
||||
position,
|
||||
variant: 'label',
|
||||
title: pack.id,
|
||||
accent: MEDIA_MARKER_CSS,
|
||||
protected: true,
|
||||
interactive: false,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
});
|
||||
handle.media({
|
||||
kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio',
|
||||
src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })),
|
||||
|
||||
@@ -55,3 +55,110 @@ test('abort releases an in-flight image URL before the decoder settles or the vi
|
||||
URL.revokeObjectURL = priorRevoke;
|
||||
}
|
||||
});
|
||||
|
||||
test('a media pack names its marker through the label overlay, cleared on dispose', async () => {
|
||||
const published = [];
|
||||
const cleared = [];
|
||||
const added = [];
|
||||
const viewer = {
|
||||
container: {},
|
||||
scene: { requestRender() {} },
|
||||
entities: {
|
||||
add: (spec) => (added.push(spec), spec),
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
const overlayHost = {
|
||||
setEntries: (source, entries) => published.push([source, entries]),
|
||||
clearSource: (source) => cleared.push(source),
|
||||
};
|
||||
const handle = await createPackPresentations(viewer, new Map(), {
|
||||
overlayHost,
|
||||
}).media({
|
||||
pack: {
|
||||
id: 'harbour-audio',
|
||||
placement: { anchorId: 'pier' },
|
||||
attribution: { text: 'Port', license: 'CC-BY' },
|
||||
},
|
||||
anchors: [{ id: 'pier', lon: -122.39, lat: 37.79, alt: 5 }],
|
||||
asset: { bytes: new Uint8Array(4), mimeType: 'audio/mpeg' },
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
assert.equal(added.length, 1);
|
||||
assert.equal(added[0].label, undefined, 'no Cesium label on the marker');
|
||||
const [source, entries] = published.at(-1);
|
||||
assert.match(source, /^data-pack-labels:\d+$/);
|
||||
assert.deepEqual(
|
||||
entries.map(({ title, variant }) => ({ title, variant })),
|
||||
[{ title: 'harbour-audio', variant: 'label' }],
|
||||
);
|
||||
assert.equal(entries[0].position, added[0].position);
|
||||
assert.equal(entries[0].protected, true, 'it always draws, as before');
|
||||
handle.dispose();
|
||||
assert.deepEqual(cleared, [source]);
|
||||
});
|
||||
|
||||
test('without an overlay host a media pack fails to load instead of losing its name', async () => {
|
||||
const removed = [];
|
||||
const viewer = {
|
||||
container: {},
|
||||
scene: { requestRender() {} },
|
||||
entities: { add: (spec) => spec, remove: (entity) => removed.push(entity) },
|
||||
};
|
||||
await assert.rejects(
|
||||
createPackPresentations(viewer).media({
|
||||
pack: {
|
||||
id: 'harbour-audio',
|
||||
placement: { anchorId: 'pier' },
|
||||
attribution: { text: 'Port', license: 'CC-BY' },
|
||||
},
|
||||
anchors: [{ id: 'pier', lon: -122.39, lat: 37.79, alt: 5 }],
|
||||
asset: { bytes: new Uint8Array(4), mimeType: 'audio/mpeg' },
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
/overlay host/,
|
||||
);
|
||||
assert.equal(removed.length, 1, 'the marker it placed is removed again');
|
||||
});
|
||||
|
||||
test('two presentations of one media pack keep their labels apart, and reuse sources', async () => {
|
||||
// Like the real host, clearing a source empties it but keeps it registered.
|
||||
const sources = new Map();
|
||||
const overlayHost = {
|
||||
setEntries: (source, entries) => sources.set(source, entries),
|
||||
clearSource: (source) => sources.set(source, []),
|
||||
};
|
||||
const shown = () =>
|
||||
[...sources.values()].filter((entries) => entries.length).length;
|
||||
const viewer = {
|
||||
container: {},
|
||||
scene: { requestRender() {} },
|
||||
entities: { add: (spec) => spec, remove() {} },
|
||||
};
|
||||
const show = () =>
|
||||
createPackPresentations(viewer, new Map(), { overlayHost }).media({
|
||||
pack: {
|
||||
id: 'harbour-audio',
|
||||
placement: { anchorId: 'pier' },
|
||||
attribution: { text: 'Port', license: 'CC-BY' },
|
||||
},
|
||||
anchors: [{ id: 'pier', lon: -122.39, lat: 37.79, alt: 5 }],
|
||||
asset: { bytes: new Uint8Array(4), mimeType: 'audio/mpeg' },
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const first = await show();
|
||||
const second = await show();
|
||||
assert.equal(shown(), 2);
|
||||
first.dispose();
|
||||
assert.equal(shown(), 1, "the second presentation's label stays");
|
||||
second.dispose();
|
||||
assert.equal(shown(), 0);
|
||||
const registered = sources.size;
|
||||
for (let i = 0; i < 5; i++) (await show()).dispose();
|
||||
assert.equal(sources.size, registered, 'repeat loads reuse freed sources');
|
||||
const late = await show();
|
||||
late.dispose();
|
||||
late.label({ id: 'late', position: { x: 1, y: 0, z: 0 }, title: 'late' });
|
||||
late.dispose();
|
||||
assert.equal(shown(), 0, 'a label after dispose is ignored');
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
|
||||
import DataPacks from '../../ui/views/panels/DataPacks.vue';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
const overlayHost = { setEntries() {}, clearSource() {} };
|
||||
|
||||
const ANCHOR = { id: 'here', lat: 30, lon: -97, alt: 10 };
|
||||
const pack = (id, url) => ({
|
||||
id,
|
||||
@@ -29,7 +31,10 @@ function fixture(t) {
|
||||
entities: { add: (spec) => spec, remove() {} },
|
||||
};
|
||||
const store = useDataPacksStore(pinia);
|
||||
const presentations = createPackPresentations(viewer, new Map(), { store });
|
||||
const presentations = createPackPresentations(viewer, new Map(), {
|
||||
store,
|
||||
overlayHost,
|
||||
});
|
||||
t.after(() => app.unmount());
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
@@ -118,7 +123,10 @@ 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 later = createPackPresentations(viewer, new Map(), {
|
||||
store,
|
||||
overlayHost,
|
||||
});
|
||||
const three = await media(later, 'three');
|
||||
await settle();
|
||||
const cards = () =>
|
||||
|
||||
Reference in new issue
Block a user