diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 81892edba..b68da8fa3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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:` 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, diff --git a/docs/DIRECTOR-DATA-PACKS.md b/docs/DIRECTOR-DATA-PACKS.md index bf64efd0b..3c223e33f 100644 --- a/docs/DIRECTOR-DATA-PACKS.md +++ b/docs/DIRECTOR-DATA-PACKS.md @@ -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 diff --git a/src/app/tools.js b/src/app/tools.js index 0556ccc55..767e49eeb 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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, }); diff --git a/src/data/directions.js b/src/data/directions.js index 8a871bfbf..d9a6afe0d 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -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, diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index ae3ac51ff..9d7aea50e 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -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)); +}); diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 273a9dc56..0ae690f05 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -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 }); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index fbe774638..cc7eb7875 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -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; }, diff --git a/src/noCesiumLabels.test.mjs b/src/noCesiumLabels.test.mjs index a8105b4eb..d7abff47f 100644 --- a/src/noCesiumLabels.test.mjs +++ b/src/noCesiumLabels.test.mjs @@ -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', () => { diff --git a/src/scenes/dataPacks/controller.js b/src/scenes/dataPacks/controller.js index 42800845a..a4adf2bc4 100644 --- a/src/scenes/dataPacks/controller.js +++ b/src/scenes/dataPacks/controller.js @@ -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, diff --git a/src/scenes/dataPacks/presentation.js b/src/scenes/dataPacks/presentation.js index ac4517a02..9695c8187 100644 --- a/src/scenes/dataPacks/presentation.js +++ b/src/scenes/dataPacks/presentation.js @@ -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 })), diff --git a/src/scenes/dataPacks/presentation.test.mjs b/src/scenes/dataPacks/presentation.test.mjs index 094516520..0844f06c1 100644 --- a/src/scenes/dataPacks/presentation.test.mjs +++ b/src/scenes/dataPacks/presentation.test.mjs @@ -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'); +}); diff --git a/src/scenes/dataPacks/presentationView.test.mjs b/src/scenes/dataPacks/presentationView.test.mjs index c6c173d89..8d970c0de 100644 --- a/src/scenes/dataPacks/presentationView.test.mjs +++ b/src/scenes/dataPacks/presentationView.test.mjs @@ -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 = () =>