Drape the open-data map overlays through the renderer's globe port

The sea-mark and ski-piste overlays add an XYZ imagery layer through the
globe port instead of building Cesium layers into a host collection. The
port drapes it on the globe on globe stacks and on the Google 3D tiles,
which the map stack now marks as its base tilesets. The row's hidden
reason reads the map stack's new getDrapeSurface, or the viewer's globe
when there is no map stack, and an overlay enabled before its viewer has
a globe port adds its layer once there is one. The overlays no longer
reach Cesium, so they leave the allow-list.
This commit is contained in:
Sameh Khamis committed 2026-11-06 18:29:40 -08:00
1 parent fbcac37f13
commit 70123fe4a9
6 files changed
+152 -126

No files matched your search

+3
View File
@@ -2566,7 +2566,9 @@
"src/geo/index.js", "src/geo/index.js",
"src/maps/catalog.js", "src/maps/catalog.js",
"src/maps/controller.js", "src/maps/controller.js",
"src/render/cesium/globe.js",
"src/render/cesium/credits.js", "src/render/cesium/credits.js",
"src/render/cesium/tileRequests.js",
"src/render/cesium/cameraInput.js", "src/render/cesium/cameraInput.js",
"src/render/cesium/camera.js", "src/render/cesium/camera.js",
"src/render/cesium/cameraControls.js", "src/render/cesium/cameraControls.js",
@@ -2584,6 +2586,7 @@
"src/render/cesium/common.js", "src/render/cesium/common.js",
"src/render/creditMarkup.js", "src/render/creditMarkup.js",
"src/maps/defaultSources.js", "src/maps/defaultSources.js",
"src/render/cesium/imagery.js",
"src/maps/google3d.js", "src/maps/google3d.js",
"src/maps/imagery.js", "src/maps/imagery.js",
"src/maps/registry.js", "src/maps/registry.js",
+49 -69
View File
@@ -1,5 +1,7 @@
import * as Cesium from 'cesium'; import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js'; // The application's renderer gives the globe port; a viewer with no
// renderer bound gets one from the factory its Cesium side loads.
import { findGlobe } from '../render/rendererOf.js';
/** /**
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski * @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
@@ -16,10 +18,11 @@ import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That * - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in. * needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
* *
* The overlays drape on whatever surface the active map stack offers (see * Each overlay is an imagery layer of the renderer's globe port, draped on
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google * whatever is drawn: the globe on the OSM and Bing stacks, the Google
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with * Photorealistic tiles on Google 3D. The map stack's own record says which
* neither leaves them hidden, and the row says so. * (`getDrapeSurface`); only a stack with neither leaves them hidden, and the
* row says so.
* @module data/osmOverlays * @module data/osmOverlays
*/ */
@@ -54,76 +57,53 @@ export function osmOverlayHiddenReason(enabled, hostKind) {
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null; return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
} }
/** The imagery spec of one overlay. */
export function osmOverlaySpec(overlay) {
return {
kind: 'xyz',
url: overlay.urlTemplate,
maxLevel: overlay.maximumLevel,
credit: { text: overlay.credit },
};
}
/** /**
* Create the layer for one overlay descriptor. Each application catalog gets * Create the layer for one overlay descriptor. Each application catalog gets
* its own instances; the Cesium factories are injectable so tests run without * its own instances; `globeOf` (the viewer's globe port) is injectable so
* WebGL. * tests run without WebGL.
*/ */
export function createOsmOverlayLayer( export function createOsmOverlayLayer(overlay, { globeOf = findGlobe } = {}) {
overlay,
{
host = resolveImageryHost,
createProvider = (descriptor) =>
new Cesium.UrlTemplateImageryProvider({
url: descriptor.urlTemplate,
credit: descriptor.credit,
maximumLevel: descriptor.maximumLevel,
}),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
} = {},
) {
if (!OSM_OVERLAYS.includes(overlay)) if (!OSM_OVERLAYS.includes(overlay))
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`); throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
let viewer = null; let viewer = null;
let mapStack = null; let mapStack = null;
let unsubscribeMap = null; let unsubscribeMap = null;
let enabled = false; let enabled = false;
let hostInfo = { collection: null, kind: 'none' }; let surface = 'none';
/** The imagery layer this instance owns while enabled, and where it sits. */ /** The imagery layer this instance owns while enabled. */
let owned = null; let owned = null;
const usable = (collection) => collection && !collection.isDestroyed?.(); /**
* What the overlay drapes on: the map stack's record, or with no map
function detach(destroy) { * stack, whether the viewer's globe is shown.
if (!owned) return; */
const { collection, layer } = owned; function drapeSurface() {
if (usable(collection) && collection.contains(layer)) const recorded = mapStack?.getDrapeSurface?.();
collection.remove(layer, destroy); if (recorded) return recorded;
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.(); return viewer?.scene?.globe?.show === true ? 'globe' : 'none';
owned.collection = null;
if (destroy) owned = null;
} }
function resolveHost() { /** Add or remove the owned imagery layer to match the state. */
return (
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
collection: null,
kind: 'none',
}
);
}
/** Create, move or drop the owned imagery layer to match the state. */
function sync() { function sync() {
if (!viewer) return; if (!viewer) return;
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' }; surface = enabled ? drapeSurface() : 'none';
if (!enabled) { if (!enabled) {
detach(true); owned?.remove();
} else { owned = null;
owned ||= { } else if (!owned) {
layer: createImageryLayer(createProvider(overlay)), // Added last, so it draws above the map stack's base imagery.
collection: null, owned = globeOf(viewer)?.imagery.add(osmOverlaySpec(overlay)) ?? null;
};
if (owned.collection !== hostInfo.collection) {
detach(false);
if (usable(hostInfo.collection)) {
// Appended, so it draws above the map stack's base imagery.
hostInfo.collection.add(owned.layer);
owned.collection = hostInfo.collection;
}
}
} }
viewer.scene?.requestRender?.();
} }
return { return {
@@ -135,7 +115,7 @@ export function createOsmOverlayLayer(
init(nextViewer) { init(nextViewer) {
viewer = nextViewer; viewer = nextViewer;
}, },
/** Follow map-stack switches so the drape moves with the surface. */ /** Follow map-stack switches, for the row's hidden reason. */
attachMapStackController(controller) { attachMapStackController(controller) {
unsubscribeMap?.(); unsubscribeMap?.();
unsubscribeMap = null; unsubscribeMap = null;
@@ -159,27 +139,27 @@ export function createOsmOverlayLayer(
getStats() { getStats() {
// Without a controller subscription the stats poll doubles as the // Without a controller subscription the stats poll doubles as the
// map-stack watch. // map-stack watch.
if (enabled && !unsubscribeMap) { if (enabled && !unsubscribeMap) surface = drapeSurface();
const next = resolveHost(); // No globe port yet (a viewer whose renderer came later): try again.
if (next.collection !== hostInfo.collection) sync(); if (enabled && !owned) sync();
}
return { return {
count: enabled ? 1 : 0, count: enabled ? 1 : 0,
error: osmOverlayHiddenReason(enabled, hostInfo.kind), error: osmOverlayHiddenReason(enabled, surface),
}; };
}, },
/** Owned layer count and host kind, for tests and live gates. */ /** Owned layer count and drape surface, for tests and live gates. */
diagnostics() { diagnostics() {
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 }; return { host: surface, ownedCount: owned ? 1 : 0 };
}, },
destroy() { destroy() {
enabled = false; enabled = false;
detach(true); owned?.remove();
owned = null;
unsubscribeMap?.(); unsubscribeMap?.();
unsubscribeMap = null; unsubscribeMap = null;
mapStack = null; mapStack = null;
viewer = null; viewer = null;
hostInfo = { collection: null, kind: 'none' }; surface = 'none';
}, },
}; };
} }
+77 -56
View File
@@ -5,28 +5,29 @@ import {
createOsmOverlayLayer, createOsmOverlayLayer,
createOsmOverlayLayers, createOsmOverlayLayers,
osmOverlayHiddenReason, osmOverlayHiddenReason,
osmOverlaySpec,
} from './osmOverlays.js'; } from './osmOverlays.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js'; import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
function fakeCollection() { /** A globe port recording the imagery layers added and removed through it. */
function fakeGlobe() {
const layers = []; const layers = [];
const removed = [];
return { return {
layers, layers,
removed, imagery: {
add(layer) { add(spec) {
layers.push(layer); const layer = {
}, spec,
contains(layer) { removed: false,
return layers.includes(layer); remove() {
}, layer.removed = true;
remove(layer, destroy) { layers.splice(layers.indexOf(layer), 1);
const index = layers.indexOf(layer); },
if (index < 0) return false; };
layers.splice(index, 1); layers.push(layer);
removed.push({ layer, destroy }); return layer;
if (destroy) layer.destroyed = true; },
return true; list: () => layers.slice(),
}, },
}; };
} }
@@ -34,9 +35,9 @@ function fakeCollection() {
function fakeMapStack() { function fakeMapStack() {
const subscribers = new Set(); const subscribers = new Set();
return { return {
tileset: null, surface: 'globe',
getImageryHostTileset() { getDrapeSurface() {
return this.tileset; return this.surface;
}, },
subscribe(fn) { subscribe(fn) {
subscribers.add(fn); subscribers.add(fn);
@@ -50,24 +51,15 @@ function fakeMapStack() {
} }
function setup(overlay = OSM_OVERLAYS[0]) { function setup(overlay = OSM_OVERLAYS[0]) {
const globe = fakeCollection(); const globe = fakeGlobe();
const viewer = { const viewer = { scene: { globe: { show: true } } };
imageryLayers: globe,
scene: { globe: { show: true }, requestRender() {} },
};
const created = [];
const layer = createOsmOverlayLayer(overlay, { const layer = createOsmOverlayLayer(overlay, {
createProvider: (descriptor) => ({ descriptor }), globeOf: (given) => (given === viewer ? globe : null),
createImageryLayer: (provider) => {
const imagery = { provider, destroyed: false };
created.push(imagery);
return imagery;
},
}); });
const mapStack = fakeMapStack(); const mapStack = fakeMapStack();
layer.init(viewer); layer.init(viewer);
layer.attachMapStackController(mapStack); layer.attachMapStackController(mapStack);
return { layer, viewer, globe, created, mapStack }; return { layer, viewer, globe, mapStack };
} }
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => { test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
@@ -103,65 +95,94 @@ test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST); assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST);
}); });
test('a layer owns no imagery until enabled and destroys it on disable', () => { test('a layer owns no imagery until enabled and removes it on disable', () => {
const { layer, globe, created } = setup(OSM_OVERLAYS[1]); const { layer, globe } = setup(OSM_OVERLAYS[1]);
assert.equal(created.length, 0); assert.equal(globe.layers.length, 0);
layer.enable(); layer.enable();
assert.equal(globe.layers.length, 1); assert.equal(globe.layers.length, 1);
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes'); assert.deepEqual(globe.layers[0].spec, osmOverlaySpec(OSM_OVERLAYS[1]));
assert.deepEqual(globe.layers[0].spec, {
kind: 'xyz',
url: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png',
maxLevel: 18,
credit: { text: OSM_OVERLAYS[1].credit },
});
assert.deepEqual(layer.getStats(), { count: 1, error: null }); assert.deepEqual(layer.getStats(), { count: 1, error: null });
const [first] = globe.layers;
layer.disable(); layer.disable();
assert.equal(globe.layers.length, 0); assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true); assert.equal(first.removed, true);
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 }); assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
assert.deepEqual(layer.getStats(), { count: 0, error: null }); assert.deepEqual(layer.getStats(), { count: 0, error: null });
layer.enable(); layer.enable();
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer'); assert.notEqual(globe.layers[0], first, 're-enabling adds a fresh layer');
}); });
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => { test('a map-stack switch onto Google 3D keeps the one layer, which the renderer drapes there', () => {
const { layer, viewer, globe, mapStack } = setup(); const { layer, globe, mapStack } = setup();
layer.enable(); layer.enable();
const [imagery] = globe.layers; const [imagery] = globe.layers;
const tileset = { imageryLayers: fakeCollection() }; mapStack.surface = 'tileset';
viewer.scene.globe.show = false;
mapStack.tileset = tileset;
mapStack.emit(); mapStack.emit();
assert.equal(globe.layers.length, 0); assert.deepEqual(globe.layers, [imagery]);
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
assert.equal(globe.removed[0].destroy, false);
assert.equal(layer.diagnostics().host, 'tileset'); assert.equal(layer.diagnostics().host, 'tileset');
assert.equal(layer.getStats().error, null); assert.equal(layer.getStats().error, null);
viewer.scene.globe.show = true; mapStack.surface = 'globe';
mapStack.tileset = null;
mapStack.emit(); mapStack.emit();
assert.deepEqual(globe.layers, [imagery]); assert.deepEqual(globe.layers, [imagery]);
assert.equal(tileset.imageryLayers.layers.length, 0); assert.equal(layer.diagnostics().host, 'globe');
}); });
test('with no surface to drape on the row explains itself, and recovers when one appears', () => { test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
const { layer, viewer, globe, mapStack } = setup(); const { layer, globe, mapStack } = setup();
viewer.scene.globe.show = false; mapStack.surface = 'none';
layer.enable(); layer.enable();
assert.equal(globe.layers.length, 0); assert.equal(globe.layers.length, 1, 'kept, drawn when a surface is');
assert.equal(layer.getStats().error, NO_IMAGERY_HOST); assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
viewer.scene.globe.show = true; mapStack.surface = 'globe';
mapStack.emit(); mapStack.emit();
assert.equal(globe.layers.length, 1); assert.equal(layer.getStats().error, null);
});
test('without a map stack, the viewer’s globe decides, polled by the stats', () => {
const globe = fakeGlobe();
const viewer = { scene: { globe: { show: false } } };
const layer = createOsmOverlayLayer(OSM_OVERLAYS[0], {
globeOf: () => globe,
});
layer.init(viewer);
layer.enable();
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
viewer.scene.globe.show = true;
assert.equal(layer.getStats().error, null); assert.equal(layer.getStats().error, null);
}); });
test('destroy removes owned imagery and unsubscribes from the map stack', () => { test('destroy removes owned imagery and unsubscribes from the map stack', () => {
const { layer, globe, created, mapStack } = setup(); const { layer, globe, mapStack } = setup();
layer.enable(); layer.enable();
const [imagery] = globe.layers;
layer.destroy(); layer.destroy();
assert.equal(globe.layers.length, 0); assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true); assert.equal(imagery.removed, true);
assert.equal(mapStack.subscribers.size, 0); assert.equal(mapStack.subscribers.size, 0);
}); });
test('an overlay enabled before its viewer has a globe port adds it once one does', () => {
const globe = fakeGlobe();
let port = null;
const layer = createOsmOverlayLayer(OSM_OVERLAYS[0], { globeOf: () => port });
layer.init({ scene: { globe: { show: true } } });
layer.enable();
assert.equal(layer.diagnostics().ownedCount, 0);
port = globe;
layer.getStats();
assert.equal(globe.layers.length, 1);
layer.getStats();
assert.equal(globe.layers.length, 1, 'added once');
});
+17
View File
@@ -4,6 +4,7 @@ import { createMapCredits } from './credits.js';
// A viewer of a consumer's own gets its credits port (and camera) from the // A viewer of a consumer's own gets its credits port (and camera) from the
// factories this loads. // factories this loads.
import '../render/cesium/camera.js'; import '../render/cesium/camera.js';
import { markBaseTileset } from '../render/cesium/globe.js';
import { acquireImageryComparison } from './imageryComparison.js'; import { acquireImageryComparison } from './imageryComparison.js';
/** /**
@@ -75,6 +76,9 @@ export class MapSourceController {
this._replacedTerrain = null; this._replacedTerrain = null;
this._subscribers = new Set(); this._subscribers = new Set();
this._destroyed = false; this._destroyed = false;
// The renderer's imagery drapes on these while they are shown.
for (const source of this._sources.values())
if (source.tileset) markBaseTileset(source.tileset);
} }
getStack(id) { getStack(id) {
@@ -112,6 +116,18 @@ export class MapSourceController {
if (tileset.show === true) return tileset; if (tileset.show === true) return tileset;
return null; return null;
} }
/**
* What draped imagery lands on now: `'globe'` while the globe is shown
* (a globe stack), `'tileset'` for a shown 3D stack, `'none'` when
* neither is drawn; null once destroyed, when it knows nothing.
* @returns {'globe' | 'tileset' | 'none' | null}
*/
getDrapeSurface() {
if (this._destroyed) return null;
if (this.viewer?.scene?.globe?.show === true) return 'globe';
const tileset = this.getImageryHostTileset();
return tileset && !tileset.isDestroyed?.() ? 'tileset' : 'none';
}
getActiveId() { getActiveId() {
return this._activeId; return this._activeId;
} }
@@ -270,6 +286,7 @@ export class MapSourceController {
if (gen !== this._switchGen) return; if (gen !== this._switchGen) return;
if (!source.tileset && !this._ownedTilesets.has(tileset)) { if (!source.tileset && !this._ownedTilesets.has(tileset)) {
tileset.show = false; tileset.show = false;
markBaseTileset(tileset);
this.viewer.scene.primitives.add(tileset); this.viewer.scene.primitives.add(tileset);
this._ownedTilesets.add(tileset); this._ownedTilesets.add(tileset);
} }
+6
View File
@@ -453,12 +453,18 @@ test('imagery host tileset follows supplied, owned and globe map sources', async
assert.equal(controller.getImageryHostTileset(), supplied); assert.equal(controller.getImageryHostTileset(), supplied);
await controller.setStack('owned'); await controller.setStack('owned');
assert.equal(controller.getImageryHostTileset(), owned); assert.equal(controller.getImageryHostTileset(), owned);
assert.equal(controller.getDrapeSurface(), 'tileset');
await controller.setStack('globe'); await controller.setStack('globe');
assert.equal(controller.getImageryHostTileset(), null); assert.equal(controller.getImageryHostTileset(), null);
assert.equal(controller.getDrapeSurface(), 'globe');
await controller.setStack('supplied'); await controller.setStack('supplied');
assert.equal(controller.getImageryHostTileset(), supplied); assert.equal(controller.getImageryHostTileset(), supplied);
assert.equal(controller.getDrapeSurface(), 'tileset');
supplied.show = false;
assert.equal(controller.getDrapeSurface(), 'none');
controller.destroy(); controller.destroy();
assert.equal(controller.getImageryHostTileset(), null); assert.equal(controller.getImageryHostTileset(), null);
assert.equal(controller.getDrapeSurface(), null);
}); });
function leaseFixture() { function leaseFixture() {
-1
View File
@@ -36,7 +36,6 @@
"src/data/modelRecord.js", "src/data/modelRecord.js",
"src/data/modelVisualAnchor.js", "src/data/modelVisualAnchor.js",
"src/data/motionModel.js", "src/data/motionModel.js",
"src/data/osmOverlays.js",
"src/data/trailRenderer.js", "src/data/trailRenderer.js",
"src/input/dispatcher.js", "src/input/dispatcher.js",
"src/input/sensorPick.js", "src/input/sensorPick.js",