diff --git a/scripts/qa-tiles-fixture.test.mjs b/scripts/qa-tiles-fixture.test.mjs
index c7e1c9adb..156890da6 100644
--- a/scripts/qa-tiles-fixture.test.mjs
+++ b/scripts/qa-tiles-fixture.test.mjs
@@ -307,12 +307,13 @@ test('Navara accepts the local 3D source without a key or Google credit, and rej
globals(t, { location: new URL('http://localhost:4303/') });
const sources = [],
credits = [];
+ const update = t.mock.fn();
const view = {
addSource: (desc) => {
sources.push(desc);
return { delete() {} };
},
- addLayer: () => ({ id: 'fixture', delete() {} }),
+ addLayer: () => ({ id: 'fixture', update, delete() {} }),
forceUpdate() {},
attribution: {
add: (items) => credits.push(...items),
@@ -342,6 +343,11 @@ test('Navara accepts the local 3D source without a key or Google credit, and rej
assert.equal(sources.length, 1);
await owner.maps.setPhotorealTiles(null);
assert.equal(owner.maps.photorealActive(), false);
+ assert.deepEqual(update.mock.calls[0].arguments[0].model, {
+ maxSse: 16,
+ height: 0,
+ show: false,
+ });
});
test('strict keyless assertion permits only the opted-in fixture and its real tileset URL', async (t) => {
diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js
index e706c5282..cb79331d4 100644
--- a/src/data/osmOverlays.js
+++ b/src/data/osmOverlays.js
@@ -16,12 +16,10 @@ import { NO_IMAGERY_HOST } from '../layers/shared/imageryHost.js';
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
*
* The overlays drape through the globe engine on whatever surface the
- * active map stack offers: `engine.imagery` (on Cesium the globe on the OSM
- * and Bing stacks, the Google Photorealistic tileset's own imagery on Google
- * 3D; the engine follows stack switches), or Navara's own raster overlay
- * (`engine.maps.addOverlay`, per-tile levels on its terrain) where the
- * engine has one. Only a surface with neither leaves them hidden, and the
- * row says so.
+ * active map stack offers through `engine.imagery`: the globe on imagery
+ * stacks and the tiles' surface on Google 3D. The engine follows stack
+ * switches; native globe rasters would disappear when tiles hide the globe.
+ * Only a surface with neither leaves them hidden, and the row says so.
* @module data/osmOverlays
*/
@@ -69,22 +67,20 @@ export function createOsmOverlayLayer(overlay) {
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
let engine = null;
let enabled = false;
- /** The drape this instance owns while enabled: an imagery layer or a removal. */
+ /** The imagery layer this instance owns while enabled. */
let owned = null;
/** Where the drape lies now: 'globe', 'tileset' or 'none'. */
function hostKind() {
if (!owned) return 'none';
- if (owned.remove) return 'globe';
return HOST_KIND[engine?.imagery?.surface?.()] ?? 'none';
}
function release() {
if (!owned) return;
- const { layer, remove } = owned;
+ const layer = owned;
owned = null;
- if (remove) remove();
- else layer.destroy();
+ layer.destroy();
}
/** Create or drop the owned drape to match the state. */
@@ -94,28 +90,17 @@ export function createOsmOverlayLayer(overlay) {
return;
}
if (owned) return;
- const remove = engine.maps?.addOverlay?.({
- url: overlay.urlTemplate,
- maxZoom: overlay.maximumLevel,
- credit: overlay.credit,
- });
- if (typeof remove === 'function') {
- owned = { remove };
- return;
- }
if (!engine.imagery) return;
- owned = {
- layer: engine.imagery.createLayer({
- id: `osm-overlay-${overlay.id}`,
- source: {
- kind: 'tiles',
- url: overlay.urlTemplate,
- maxLevel: overlay.maximumLevel,
- credit: overlay.credit,
- },
- order: OVERLAY_ORDER,
- }),
- };
+ owned = engine.imagery.createLayer({
+ id: `osm-overlay-${overlay.id}`,
+ source: {
+ kind: 'tiles',
+ url: overlay.urlTemplate,
+ maxLevel: overlay.maximumLevel,
+ credit: overlay.credit,
+ },
+ order: OVERLAY_ORDER,
+ });
}
return {
diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs
index 0df40972f..718e43669 100644
--- a/src/data/osmOverlays.test.mjs
+++ b/src/data/osmOverlays.test.mjs
@@ -115,32 +115,48 @@ test('destroy and a renderer detach remove the owned imagery', () => {
assert.equal(layers().length, 0);
});
-test('on Navara an overlay drapes through engine.maps and follows the globe', () => {
- const added = [];
- const engineWith = () => {
- const engine = createFakeEngine({ kind: 'navara' });
- engine.maps.addOverlay = (options) => {
- const entry = { options, removed: false };
- added.push(entry);
- return () => (entry.removed = true);
- };
- return engine;
- };
- const [seaMarks] = createOsmOverlayLayers();
- seaMarks.init(null, { engine: engineWith() });
- assert.equal(added.length, 0, 'nothing until enabled');
- seaMarks.enable();
- assert.equal(added.length, 1);
- assert.equal(added[0].options.url, OSM_OVERLAYS[0].urlTemplate);
- assert.equal(added[0].options.credit, OSM_OVERLAYS[0].credit);
- assert.equal(seaMarks.getStats().error, null);
- // An engine switch: the drape leaves with the old globe, comes back on the next.
- seaMarks.detachRenderer();
- assert.equal(added[0].removed, true);
- seaMarks.attachRenderer({ engine: engineWith() });
- assert.equal(added.length, 2);
- assert.equal(added[1].removed, false);
- seaMarks.disable();
- assert.equal(added[1].removed, true);
- seaMarks.destroy();
-});
+for (const kind of ['cesium', 'navara']) {
+ test(`${kind}: both overlays follow imagery surfaces and survive an engine switch`, () => {
+ let surface = 'tiles';
+ const engine = createFakeEngine({ kind });
+ engine.imagery.surface = () => surface;
+ const nextEngine = createFakeEngine({
+ kind: kind === 'navara' ? 'cesium' : 'navara',
+ });
+ const layers = createOsmOverlayLayers();
+ for (const layer of layers) {
+ layer.enable();
+ layer.attachRenderer({ engine });
+ layer.enable();
+ }
+ const owned = engine.imagery.list();
+ assert.deepEqual(
+ owned.map(({ id }) => id),
+ OSM_OVERLAYS.map(({ id }) => `osm-overlay-${id}`),
+ );
+ for (const host of ['tiles', 'globe', 'none', 'tiles']) {
+ surface = host;
+ for (const layer of layers) {
+ assert.deepEqual(layer.diagnostics(), {
+ host: host === 'tiles' ? 'tileset' : host,
+ ownedCount: 1,
+ });
+ assert.equal(
+ layer.getStats().error,
+ host === 'none' ? NO_IMAGERY_HOST : null,
+ );
+ }
+ assert.deepEqual(engine.imagery.list(), owned);
+ }
+ for (const layer of layers) {
+ layer.detachRenderer();
+ layer.attachRenderer({ engine: nextEngine });
+ }
+ assert.deepEqual(engine.imagery.list(), []);
+ assert.deepEqual(nextEngine.imagery.list(), owned);
+ layers[0].disable();
+ assert.equal(nextEngine.imagery.list().length, 1);
+ for (const layer of layers) layer.destroy();
+ assert.deepEqual(nextEngine.imagery.list(), []);
+ });
+}
diff --git a/src/globe/cesium/imagery.test.mjs b/src/globe/cesium/imagery.test.mjs
index ab35924eb..63c7c5ace 100644
--- a/src/globe/cesium/imagery.test.mjs
+++ b/src/globe/cesium/imagery.test.mjs
@@ -2,6 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js';
+import { createFakeEngine } from '../testing/fakeEngine.js';
+import {
+ OSM_OVERLAYS,
+ createOsmOverlayLayers,
+} from '../../data/osmOverlays.js';
const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
const tiles = (extra = {}) => ({
@@ -126,6 +131,44 @@ const layersOf = (collection) =>
/** The layers on the globe over the fixture's base imagery. */
const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base);
+test('map overlays preserve transparent tile sources on the globe and tileset', (t) => {
+ const f = fixture();
+ t.after(f.destroy);
+ const layers = createOsmOverlayLayers();
+ for (const layer of layers) {
+ // The fake engine with this adapter's imagery.
+ const engine = Object.assign(createFakeEngine({ kind: 'cesium' }), {
+ imagery: f.imagery,
+ });
+ layer.init(null, { engine });
+ layer.enable();
+ }
+ for (const active of [false, true, false]) {
+ f.viewer.scene.globe.show = !active;
+ f.tileset.show = active;
+ f.frame();
+ const drawn = active ? layersOf(f.tileset.imageryLayers) : drawnOn(f);
+ assert.equal(drawn.length, 2);
+ assert.equal(
+ active ? drawnOn(f).length : f.tileset.imageryLayers.length,
+ 0,
+ );
+ for (const [i, overlay] of OSM_OVERLAYS.entries()) {
+ const { options } = drawn[i].imageryProvider;
+ assert.equal(options.url, overlay.urlTemplate);
+ assert.equal(options.maximumLevel, overlay.maximumLevel);
+ assert.equal(options.credit, overlay.credit);
+ assert.equal(options.hasAlphaChannel, true);
+ assert.equal(drawn[i].alpha, 1);
+ assert.equal(drawn[i].show, true);
+ assert.equal(layers[i].diagnostics().host, active ? 'tileset' : 'globe');
+ }
+ }
+ for (const layer of layers) layer.destroy();
+ assert.deepEqual(drawnOn(f), []);
+ assert.equal(f.tileset.imageryLayers.length, 0);
+});
+
test('a tiles layer is one Web Mercator template provider bounded to its rectangle', () => {
const f = fixture();
const layer = f.imagery.createLayer({
diff --git a/src/globe/navara/WORKAROUNDS.md b/src/globe/navara/WORKAROUNDS.md
index 3c5f748c8..2acf49cf4 100644
--- a/src/globe/navara/WORKAROUNDS.md
+++ b/src/globe/navara/WORKAROUNDS.md
@@ -48,7 +48,8 @@ is fixed. Neither excluded check is a signal to remove a workaround.
| `src/globe/navara/ground/index.js:145` | The original native-vector path lost the first `show: false` while a new layer was being built. Current ground rendering uses its own pass, so the hide retry is already absent. | 0.2.1 (original path) | No canary: `initial-vector-hide-dropped` timed out before a verdict; the shared readiness error does not identify the failed wait. Requires browser diagnosis. | No current hide retry to delete. Do not remove the ground pass: it also implements draping and materials. |
| `src/globe/navara/maps.js:76` | Idle can be reported before a fetched raster tile arrives after a map switch; the map may keep a stale frame. | 0.2.1 | `idle-before-raster-arrives` (browser, confirmed) holds real raster requests across the idle event, then releases local PNG responses. | Remove the `SETTLE_MS` / `SETTLE_EVERY_MS` redraw timer from `changed`. |
| `src/globe/navara/maps.js` (`setPhotorealTiles`) | The default `model.height=1` displaces transformed 3D Tiles by −1 m in ECEF X on the synthetic ENU fixture; both rendered meshes and CPU queries inherit it. Set `model.height: 0` to preserve the tileset transform. | 0.2.1 | `scripts/qa-tiles-fixture.test.mjs` loads the real WASM package: default transform reproduces the measured flat-top +0.118 m / roof −0.676 m errors; the adapter restores all probes and 40 roof points within 1 µm against independent triangle rays. `qa-tiles-fixture --report` prints browser samples without asserting them. | Remove the explicit zero only when the package's default preserves authored tile positions; retain the authored-geometry regression test. |
-| `src/globe/navara/maps.js:145` | Removing the final raster layer interrupts terrain loading at the current view. | 0.2.1 | No canary: needs terrain-loading continuity across final-layer removal; the raster idle fixture does not test terrain. | Remove the add-before-delete constraint and hidden base-raster retention under 3D tiles. |
+| `src/globe/navara/maps.js` (`setImageryShown`, `show`), `src/globe/navara/ground/globe.js` | Hiding raster imagery leaves the terrain surface writing colour, depth and shadows over 3D tiles. Removing the final raster layer interrupts terrain loading. Keep the raster/source alive and hide the globe scene through a descriptor's public context; restore its visibility on clear, release or destruction. | 0.2.1 | Source inspection: `CustomRenderPass` renders the globe before tiles and copies its depth; `TileMesh` still draws without shown imagery. `maps.test.mjs` covers visibility, source/sampling continuity, replacement and teardown. No browser canary: confirm that the cleared globe depth is 1 and ground/imagery classify on tile depth at all camera heights. | Replace the descriptor with a native globe-visibility API that preserves terrain traversal and sampling. Remove raster retention only when terrain loads independently of raster layers. |
+| `src/globe/navara/maps.js` (`setPhotorealShown`), `src/globe/navara/surface.js` | Deleting a photoreal layer/source on a base-map switch discards its reusable tile cache. Retain one hidden tileset with `model.show: false`, keep SSE 16 and height 0, and exclude it from surface queries until shown. Explicit release, replacement and destruction still dispose it. | 0.2.1 | `maps-release.test.mjs` runs the installed WASM fixture: five initial payload requests, none while hidden or after restore, the same four models and restored native visibility; fake-view tests cover repeated switches and parked-source release. This proves switch-related refetching, not the cause of excess requests during uninterrupted flights. | Keep the tileset lifetime aligned with Cesium. Replace the visibility bridge if upstream exposes active-surface queries. Diagnose flight requests with URL repetition, eviction and SSE measurements before changing cache or LOD settings. |
| `src/globe/navara/maps.js:37`, `src/globe/navara/maps.js:209` | Quantized-mesh root URLs without the tile template can panic. | 0.2.1 | No canary: deliberately panicking WASM would invalidate the remaining view checks; no isolated terrain fixture here. | Use the upstream root-URL API instead of manually appending `{z}/{x}/{y}.terrain`. |
| `src/globe/navara/maps.js:65` | No ion imagery path. | 0.2.1 | No canary: explicit unsupported source; requires a real ion imagery endpoint. | Replace the ion-imagery rejection with native source support. |
| `src/globe/navara/maps.js:209` | No terrain-request authentication retry hook; an expired ion endpoint token cannot be refreshed after a tile 401. | 0.2.1 | No canary: no authenticated fixture or exposed request callback. This is an unresolved gap, not a working retry. | Add retry through the upstream hook; there is no compensating retry code to delete. |
@@ -72,12 +73,13 @@ is fixed. Neither excluded check is a signal to remove a workaround.
| `src/globe/navara/viewOptions.js:175` | G-buffer normals for globe/3D tiles are zero, breaking atmosphere lighting. | 0.2.1 | No canary: needs actual G-buffer and lighting pixel checks; Google tiles are not used by these canaries. | Remove `useNormalBuffer: false` when native normals support the lighting path. |
| `src/globe/navara/viewOptions.js:191` | Cloud defaults override the low preset's shadow map size and cascade count. | 0.2.1 | No canary: requires inspecting the real cloud preset result; option tests only verify the override. | Remove explicit `shadowMapSize` / `shadowCascadeCount` once the low preset survives native defaults. |
| `src/globe/navara/viewOptions.js:93`, `src/globe/navara/look.js:172` | Cloud bases are ellipsoid-relative and do not clear local terrain. | 0.2.1 | No canary: needs sampled relief and cloud-base inspection at local scale. | Remove terrain-dependent cloud offsets if native cloud altitude can be terrain-relative. |
-| `src/globe/navara/credits.js:13` | Content-based deduplication has no holder reference count. | 0.2.1 | No canary: current credit tests stub attribution; they do not test the plugin. | Remove local counts when native removal retains a credit until its last holder releases it. |
-| `src/globe/navara/credits.js:28` | No separate persistent on-screen credit strip. | 0.2.1 | No canary: UI/API gap; opening the attribution card is only a partial substitute. | Replace forced `attribution.show()` with native on-screen credits. |
+| `src/globe/navara/credits.js` (`createNavaraCredits`) | Content-based deduplication has no holder reference count or on-screen flag. Count total and on-screen holders separately so a quiet holder keeps the card entry without keeping it in the strip. | 0.2.1 | `credits-plugin.test.mjs` exercises the installed attribution plugin's deduplication and mixed-holder removal over a DOM; the unused Plugin base is stubbed. | Remove local counts when native removal and on-screen visibility follow the remaining holders. |
+| `src/globe/navara/credits.js` (`createCreditStrip`), `src/ui/styles/status.css` | No persistent on-screen strip or public credit snapshot/change event. Closed cards defer tile and zoom-band rendering. Reuse the application's Cesium credit layout, moving native logos and the button/card into the renderer; mirror sanitized list entries for map and on-screen static credits. Private accesses: `populateList`, `requestRender`, `displayedItems`, `currentZoomLevel`, `hasZoomBands`, `lastZoomLevel`, `dock`, `listEl`, `logosEl`. | 0.2.1 | `credits-plugin.test.mjs` reproduces deferred closed-card updates with the installed plugin and checks tile visibility/removal, map replacement, zoom bands, sanitization, card controls and teardown. Method/DOM startup guards detect API changes. No browser pixel canary. | Replace the strip bridge and private hooks with native persistent credits or a public snapshot subscription; retain shared layout and lifecycle tests. |
| `src/globe/navara/missingTiles.js:28`, `src/globe/navara/maps.js:108` | Raster sources have no tile discard policy, so Esri's grey "Map data not yet available" tiles (200 responses) draw over the parent level. | 0.2.1 | No canary: needs Esri's live placeholder; `missingTiles.test.mjs` covers the fetch wrapper only. | Remove the fetch wrapper when a raster source can reject a loaded tile (discard callback or missing-tile image). |
| `src/globe/navara/bloom.js:59` | Native selective bloom uses emissive/effect masks before the transparent pass; the contract needs whole-frame bloom after overlays. | 0.2.1 | No canary: capability gap; custom effect tests verify our implementation. | Replace the custom bloom descriptor with a native effect only if ordering and settings match. |
Private-member checks run through `navaraHooks(view)` before engine construction
-and fail with the member path and pinned version. The current recursive source
+and fail with the member path and pinned version. Attribution methods and DOM
+are checked locally in `createCreditStrip` after plugin initialization. The current recursive source
scan found no private access in `ground/`, `imagery/`, `lines/`, `movers/`, or
other adapter subfolders. No private-access moves were necessary.
diff --git a/src/globe/navara/credits-plugin.test.mjs b/src/globe/navara/credits-plugin.test.mjs
new file mode 100644
index 000000000..0c6cb7b85
--- /dev/null
+++ b/src/globe/navara/credits-plugin.test.mjs
@@ -0,0 +1,305 @@
+import '../../testSupport/registerDom.mjs';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { build } from 'esbuild';
+import { createNavaraCredits } from './credits.js';
+import { createNavaraMaps } from './maps.js';
+
+// Exercise the installed plugin's DOM, sanitizer and credit tracking. Only
+// its unused Plugin base is stubbed, to avoid starting the WASM/WebGL engine.
+const bundle = await build({
+ entryPoints: [
+ new URL(
+ '../../../node_modules/@navaramap/three/src/plugins/AttributionPlugin/index.ts',
+ import.meta.url,
+ ).pathname,
+ ],
+ bundle: true,
+ write: false,
+ format: 'esm',
+ plugins: [
+ {
+ name: 'plugin-base',
+ setup(build) {
+ build.onResolve({ filter: /^@navaramap\/core$/ }, () => ({
+ path: 'plugin-base',
+ namespace: 'stub',
+ }));
+ build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({
+ contents: 'export class Plugin {}',
+ }));
+ },
+ },
+ ],
+});
+const { AttributionPlugin } = await import(
+ `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`
+);
+
+function events() {
+ const listeners = new Map();
+ return {
+ on(name, listener) {
+ if (!listeners.has(name)) listeners.set(name, new Set());
+ listeners.get(name).add(listener);
+ },
+ off(name, listener) {
+ listeners.get(name)?.delete(listener);
+ },
+ emit(name, value) {
+ for (const listener of listeners.get(name) ?? []) listener(value);
+ },
+ count(name) {
+ return listeners.get(name)?.size ?? 0;
+ },
+ };
+}
+
+async function setup(t, { strip = true } = {}) {
+ const host = document.createElement('div');
+ const canvas = document.createElement('canvas');
+ host.appendChild(canvas);
+ document.body.appendChild(host);
+ const layers = new Map();
+ let nextId = 0;
+ const view = {
+ ...events(),
+ canvas,
+ camera: { zoom: 10 },
+ findLayerById: (id) => layers.get(id),
+ forceUpdate() {},
+ addSource(description) {
+ return { description, delete() {} };
+ },
+ addLayer(description) {
+ const layer = {
+ ...events(),
+ id: `layer-${nextId++}`,
+ description,
+ update() {},
+ delete() {
+ layers.delete(layer.id);
+ layer.emit('deleted');
+ },
+ };
+ layers.set(layer.id, layer);
+ return layer;
+ },
+ };
+ const attribution = new AttributionPlugin();
+ view.attribution = attribution;
+ await attribution.init(view, {});
+ const originalPopulateList = attribution.populateList;
+ const originalRequestRender = attribution.requestRender;
+ const adapter = strip ? createNavaraCredits(view) : null;
+ t.after(() => {
+ adapter?.destroy();
+ attribution.dispose();
+ host.remove();
+ });
+ const row = host.querySelector('#cesium-credits');
+ return {
+ host,
+ view,
+ attribution,
+ layers,
+ row,
+ inline: () => row.querySelector('.cesium-credit-textContainer'),
+ card: document.querySelector('.navara-attr-card'),
+ originalPopulateList,
+ originalRequestRender,
+ ...adapter,
+ };
+}
+
+test('the native plugin defers closed-card tile updates and has no credit strip', async (t) => {
+ const { view, attribution, card, host } = await setup(t, { strip: false });
+ const layer = view.addLayer({});
+ attribution.add([{ attribution: 'Tiles', creditLayerId: layer.id }]);
+ layer.emit('featureCreated', { featureSetId: 1n, credit: 'Tile copyright' });
+ assert.equal(card.hidden, true);
+ assert.doesNotMatch(card.textContent, /Tile copyright/);
+ assert.equal(host.querySelector('#cesium-credits'), null);
+ attribution.show();
+ assert.match(card.textContent, /Tile copyright/);
+});
+
+test('the native plugin deduplicates content without retaining separate holders', async (t) => {
+ const { attribution, card } = await setup(t, { strip: false });
+ const credit = { attributionHtml: 'Shared credit' };
+ attribution.add([credit]);
+ attribution.add([credit]);
+ assert.equal(card.textContent.match(/Shared credit/g).length, 1);
+ attribution.remove([credit]);
+ assert.doesNotMatch(card.textContent, /Shared credit/);
+});
+
+test('on-screen holders share one strip entry while quiet credits stay in the card', async (t) => {
+ const { credits, inline, card } = await setup(t);
+ const quiet = credits.addStatic('Shared credit');
+ const first = credits.addStatic('Shared credit', { onScreen: true });
+ const second = credits.addStatic('Shared credit', { onScreen: true });
+ credits.addStatic('Card only');
+ assert.equal(card.hidden, true);
+ assert.equal(inline().textContent.match(/Shared credit/g).length, 1);
+ assert.doesNotMatch(inline().textContent, /Card only/);
+ assert.match(card.textContent, /Card only/);
+ first();
+ first();
+ assert.match(inline().textContent, /Shared credit/);
+ second();
+ assert.doesNotMatch(inline().textContent, /Shared credit/);
+ assert.match(card.textContent, /Shared credit/);
+ quiet();
+ assert.doesNotMatch(card.textContent, /Shared credit/);
+});
+
+test('map credits, Google logos and tile copyrights follow source and visibility changes', async (t) => {
+ const { view, row, inline, card, layers } = await setup(t);
+ const { maps, destroy } = createNavaraMaps(view, {
+ setInterval: () => 1,
+ clearInterval() {},
+ });
+ t.after(destroy);
+ await maps.setBaseImagery({ kind: 'osm' });
+ assert.match(inline().textContent, /OpenStreetMap/);
+ await maps.setPhotorealTiles({ googleApiKey: 'test-key' });
+ assert.equal(card.hidden, true);
+ assert.doesNotMatch(inline().textContent, /OpenStreetMap/);
+ const logo = row.querySelector('.cesium-credit-logoContainer img');
+ assert.equal(logo.alt, 'Google');
+ assert.equal(logo.src, 'https://assets.ion.cesium.com/google-credit.png');
+ const tiles = [...layers.values()].find(
+ (layer) => layer.description.type === '3d-tiles',
+ );
+ tiles.emit('featureCreated', { featureSetId: 1n, credit: '© A; © Shared' });
+ tiles.emit('featureCreated', { featureSetId: 2n, credit: '© B; © Shared' });
+ assert.match(inline().textContent, /© A.*© B/);
+ assert.equal(inline().textContent.match(/© Shared/g).length, 1);
+ tiles.emit('featureVisibilityChanged', { featureSetId: 1n, visible: false });
+ assert.doesNotMatch(inline().textContent, /© A/);
+ assert.match(inline().textContent, /© B/);
+ tiles.emit('featureVisibilityChanged', { featureSetId: 1n, visible: true });
+ tiles.emit('featureRemoved', { featureSetId: 2n });
+ assert.match(inline().textContent, /© A/);
+ assert.doesNotMatch(inline().textContent, /© B/);
+ await maps.setPhotorealTiles({ googleApiKey: 'replacement-key' });
+ assert.doesNotMatch(inline().textContent, /© A|© Shared/);
+ assert.equal(tiles.count('featureCreated'), 0);
+ tiles.emit('featureCreated', { featureSetId: 3n, credit: 'Late credit' });
+ assert.doesNotMatch(inline().textContent, /Late credit/);
+ assert.equal(
+ row.querySelectorAll('.cesium-credit-logoContainer img').length,
+ 1,
+ );
+ await maps.setPhotorealTiles(null);
+ assert.match(inline().textContent, /OpenStreetMap/);
+ assert.equal(
+ row.querySelectorAll('.cesium-credit-logoContainer img').length,
+ 0,
+ );
+ assert.equal(card.hidden, true);
+});
+
+test('native clear removes strip entries', async (t) => {
+ const { attribution, inline, card } = await setup(t);
+ attribution.add([{ attribution: 'Additional source' }]);
+ assert.match(inline().textContent, /Additional source/);
+ attribution.clear();
+ assert.equal(inline().textContent, 'Navara');
+ assert.equal(card.hidden, true);
+});
+
+test('closed-card credits follow zoom bands without churning an unchanged frame', async (t) => {
+ const { view, attribution, inline, card } = await setup(t);
+ attribution.add([
+ {
+ attribution: 'Imagery',
+ children: [
+ { attribution: 'Overview', maxZoom: 10 },
+ { attribution: 'Detailed', minZoom: 11 },
+ ],
+ },
+ ]);
+ assert.match(inline().textContent, /Overview/);
+ assert.doesNotMatch(inline().textContent, /Detailed/);
+ const content = inline().firstChild;
+ view.emit('preRender');
+ assert.equal(inline().firstChild, content);
+ view.camera.zoom = 12;
+ view.emit('preRender');
+ assert.match(inline().textContent, /Detailed/);
+ assert.doesNotMatch(inline().textContent, /Overview/);
+ assert.equal(card.hidden, true);
+});
+
+test('the strip preserves sanitized links from static and tile credits', async (t) => {
+ const { view, attribution, credits, inline } = await setup(t);
+ const markup =
+ 'Publisher
Unsafe';
+ credits.addStatic(markup, { onScreen: true });
+ const layer = view.addLayer({});
+ attribution.add([{ attribution: 'Tiles', creditLayerId: layer.id }]);
+ layer.emit('featureCreated', { featureSetId: 1n, credit: markup });
+ const links = inline().querySelectorAll('a[href*="example.test"]');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].target, '_blank');
+ assert.equal(links[0].rel, 'noopener noreferrer');
+ assert.equal(
+ inline().querySelectorAll('img, [onerror], [href^="javascript:"]').length,
+ 0,
+ );
+ assert.match(inline().textContent, /Unsafe/);
+});
+
+test('the native button, close button and Escape still control only the card', async (t) => {
+ const { credits, row, inline, card, view } = await setup(t);
+ credits.addStatic('On screen', { onScreen: true });
+ const toggle = row.querySelector('.navara-attr-toggle');
+ toggle.click();
+ assert.equal(card.hidden, false);
+ assert.equal(toggle.getAttribute('aria-expanded'), 'true');
+ row.querySelector('.navara-attr-close').click();
+ assert.equal(card.hidden, true);
+ toggle.click();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ assert.equal(card.hidden, true);
+ assert.equal(toggle.getAttribute('aria-expanded'), 'false');
+ view.emit('preRender');
+ credits.addStatic('Another on screen', { onScreen: true });
+ assert.equal(card.hidden, true);
+ assert.match(inline().textContent, /On screen.*Another on screen/);
+});
+
+test('parking hides the complete credit UI; destroy restores the plugin and releases the strip', async (t) => {
+ const {
+ host,
+ view,
+ attribution,
+ credits,
+ row,
+ destroy,
+ originalPopulateList,
+ originalRequestRender,
+ } = await setup(t);
+ const remove = credits.addStatic('Owned', { onScreen: true });
+ host.remove();
+ assert.equal(document.querySelector('.navara-attr-dock'), null);
+ assert.equal(document.querySelector('.navara-attr-logoframe'), null);
+ assert.equal(document.querySelector('#cesium-credits'), null);
+ document.body.appendChild(host);
+ assert.equal(document.querySelector('#cesium-credits'), row);
+ assert.equal(view.count('preRender'), 2);
+ destroy();
+ destroy();
+ remove();
+ credits.addStatic('After destroy', { onScreen: true });
+ assert.equal(view.count('preRender'), 1);
+ assert.equal(attribution.populateList, originalPopulateList);
+ assert.equal(attribution.requestRender, originalRequestRender);
+ assert.equal(host.querySelector('#cesium-credits'), null);
+ assert.doesNotMatch(
+ document.querySelector('.navara-attr-list').textContent,
+ /Owned|After destroy/,
+ );
+});
diff --git a/src/globe/navara/credits.js b/src/globe/navara/credits.js
index 411e100e6..f5f1a45c2 100644
--- a/src/globe/navara/credits.js
+++ b/src/globe/navara/credits.js
@@ -1,10 +1,89 @@
// Static credits on Navara's attribution UI (view.attribution, the
// AttributionPlugin). Navara matches entries by content and drops duplicates,
// so one HTML string is shown once and counted here, staying until its last
-// holder removes it. Navara has no separate on-screen strip: an `onScreen`
-// credit opens the attribution card (collapsed by default) so the list is on
-// screen; logos that must stay visible (Google) are sources with a `logo`,
-// added by maps.js.
+// holder removes it. Map credits and on-screen static credits share the
+// application's credit strip; the full list stays behind Navara's button.
+
+function createCreditStrip(view, counts) {
+ const parent = view.canvas?.parentElement;
+ const document = parent?.ownerDocument;
+ const attribution = view.attribution;
+ if (!document || !attribution) return { update() {}, destroy() {} };
+ // Navara 0.2.1 exposes neither a credit snapshot nor a change event. Keep
+ // its sanitizer and visible-tile tracking, refreshing the list while closed.
+ for (const name of [
+ 'populateList',
+ 'requestRender',
+ 'displayedItems',
+ 'currentZoomLevel',
+ ]) {
+ if (typeof attribution[name] !== 'function')
+ throw new Error(`Navara attribution API changed: ${name}`);
+ }
+ const { dock, listEl, logosEl } = attribution;
+ if (!dock || !listEl || !logosEl)
+ throw new Error('Navara attribution DOM is not ready');
+ const dockParent = dock.parentElement;
+ const logosParent = logosEl.parentElement;
+ const strip = document.createElement('div');
+ // Reuse Cesium's credit layout, including dock/mobile/recording clearance
+ // and the panel obstacle measurements. The renderer owns its lifetime.
+ strip.id = 'cesium-credits';
+ strip.className = 'navara-credit-strip';
+ const logos = document.createElement('span');
+ logos.className = 'cesium-credit-logoContainer';
+ const text = document.createElement('span');
+ text.className = 'cesium-credit-textContainer';
+ logos.appendChild(logosEl);
+ strip.append(logos, text, dock);
+ parent.appendChild(strip);
+
+ function update() {
+ const entries = new Set();
+ attribution.displayedItems().forEach((item, index) => {
+ const holders = counts.get(item.attributionHtml);
+ if (holders && !holders.onScreen) return;
+ const row = listEl.children[index];
+ if (!item.logo) {
+ const name = row.querySelector('.navara-attr-name').lastElementChild;
+ entries.add(name.innerHTML);
+ }
+ for (const credit of row.querySelectorAll('.navara-attr-related > li'))
+ entries.add(credit.innerHTML);
+ });
+ const html = [...entries]
+ .map((entry) => `${entry}`)
+ .join(' • ');
+ if (text.innerHTML !== html) text.innerHTML = html;
+ }
+
+ const { populateList, requestRender } = attribution;
+ attribution.populateList = function () {
+ populateList.call(this);
+ update();
+ };
+ attribution.requestRender = () => attribution.populateList();
+ const updateZoom = () => {
+ if (!attribution.hasZoomBands) return;
+ const zoom = attribution.currentZoomLevel();
+ if (zoom === attribution.lastZoomLevel) return;
+ attribution.lastZoomLevel = zoom;
+ attribution.requestRender();
+ };
+ view.on('preRender', updateZoom);
+ attribution.populateList();
+ return {
+ update,
+ destroy() {
+ view.off('preRender', updateZoom);
+ attribution.populateList = populateList;
+ attribution.requestRender = requestRender;
+ dockParent.appendChild(dock);
+ logosParent.appendChild(logosEl);
+ strip.remove();
+ },
+ };
+}
/**
* @param {{ attribution?: { add: Function, remove: Function } }} view
@@ -12,35 +91,47 @@
*/
export function createNavaraCredits(view) {
const counts = new Map();
- const release = (html) => {
- if (!counts.has(html)) return;
+ const strip = createCreditStrip(view, counts);
+ let destroyed = false;
+ const release = (html, onScreen) => {
const count = counts.get(html);
- if (count > 1) return counts.set(html, count - 1);
- counts.delete(html);
- view.attribution?.remove([{ attributionHtml: html }]);
+ if (!count) return;
+ count.total--;
+ if (onScreen) count.onScreen--;
+ if (count.total === 0) {
+ counts.delete(html);
+ view.attribution?.remove([{ attributionHtml: html }]);
+ }
+ strip.update();
};
const credits = {
addStatic(html, { onScreen = false } = {}) {
+ if (destroyed) return () => {};
const text = String(html);
- const count = counts.get(text) ?? 0;
- counts.set(text, count + 1);
- if (count === 0) view.attribution?.add([{ attributionHtml: text }]);
- if (onScreen) view.attribution?.show();
+ const count = counts.get(text) ?? { total: 0, onScreen: 0 };
+ count.total++;
+ if (onScreen) count.onScreen++;
+ counts.set(text, count);
+ if (count.total === 1) view.attribution?.add([{ attributionHtml: text }]);
+ strip.update();
let removed = false;
return () => {
if (removed) return;
removed = true;
- release(text);
+ release(text, onScreen);
};
},
};
return {
credits,
destroy() {
+ if (destroyed) return;
+ destroyed = true;
for (const html of [...counts.keys()]) {
- counts.set(html, 1);
- release(html);
+ counts.delete(html);
+ view.attribution?.remove([{ attributionHtml: html }]);
}
+ strip.destroy();
},
};
}
diff --git a/src/globe/navara/credits.test.mjs b/src/globe/navara/credits.test.mjs
index 251592784..d542c1e21 100644
--- a/src/globe/navara/credits.test.mjs
+++ b/src/globe/navara/credits.test.mjs
@@ -65,11 +65,11 @@ test('destroy removes every credit; a view without attribution UI is a no-op', (
remove();
});
-test('onScreen credits open the attribution list on screen; others do not', () => {
+test('static credits never force the attribution card open', () => {
const attribution = fakeAttribution();
const { credits } = createNavaraCredits({ attribution });
credits.addStatic('Quiet');
assert.equal(attribution.opened, 0);
credits.addStatic('Loud', { onScreen: true });
- assert.equal(attribution.opened, 1);
+ assert.equal(attribution.opened, 0);
});
diff --git a/src/globe/navara/ground/globe.js b/src/globe/navara/ground/globe.js
new file mode 100644
index 000000000..8aebc7ca8
--- /dev/null
+++ b/src/globe/navara/ground/globe.js
@@ -0,0 +1,31 @@
+import { EffectDesc } from '@navaramap/three';
+
+// Raster visibility only hides imagery. The globe scene must stop writing
+// colour, depth and shadows while 3D tiles supply the rendered surface.
+// A descriptor gives us the public scene context without stopping terrain
+// traversal or changing the meshes used by CPU height sampling.
+class HiddenGlobeDesc extends EffectDesc {
+ static key = 'gevHiddenGlobe';
+
+ createPass() {
+ this.wasVisible = this.ctx.scenes.globe.visible;
+ this.ctx.scenes.globe.visible = false;
+ // No screen pass: only the scene's visibility changes.
+ }
+
+ onDestroy() {
+ if (this.wasVisible !== undefined)
+ this.ctx.scenes.globe.visible = this.wasVisible;
+ super.onDestroy();
+ }
+}
+
+const registered = new WeakSet();
+
+export function hideGlobe(view) {
+ if (!registered.has(view)) {
+ view.registerEffect(HiddenGlobeDesc.key, HiddenGlobeDesc);
+ registered.add(view);
+ }
+ return view.addEffect({ [HiddenGlobeDesc.key]: {} });
+}
diff --git a/src/globe/navara/imagery/index.test.mjs b/src/globe/navara/imagery/index.test.mjs
index 69a68e6a0..4d1e8538f 100644
--- a/src/globe/navara/imagery/index.test.mjs
+++ b/src/globe/navara/imagery/index.test.mjs
@@ -2,6 +2,12 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createEngineEvent } from '../../contract.js';
import { MAX_IMAGERY_SLOTS, createNavaraImagery } from './index.js';
+import { createNavaraMaps } from '../maps.js';
+import { createFakeEngine } from '../../testing/fakeEngine.js';
+import {
+ OSM_OVERLAYS,
+ createOsmOverlayLayers,
+} from '../../../data/osmOverlays.js';
const BOX = { west: -97.77, south: 30.25, east: -97.71, north: 30.29 };
const tick = () => new Promise((resolve) => setImmediate(resolve));
@@ -83,6 +89,77 @@ const tiles = (extra = {}) => ({
...extra,
});
+for (const photoreal of [false, true]) {
+ test(`map overlays keep their draped atlases across surface switches (tiles initially ${photoreal})`, async (t) => {
+ const r = Math.PI / 180;
+ const f = fixture(
+ Object.fromEntries(
+ Object.entries(BOX).map(([key, value]) => [key, value * r]),
+ ),
+ );
+ t.after(f.destroy);
+ const mapOwner = createNavaraMaps({
+ addSource() {
+ assert.fail('Map overlays must not create native globe rasters');
+ },
+ });
+ t.after(mapOwner.destroy);
+ f.setPhotoreal(photoreal);
+ const layers = createOsmOverlayLayers();
+ for (const layer of layers) {
+ // The fake engine with this adapter's maps and imagery.
+ const engine = Object.assign(createFakeEngine({ kind: 'navara' }), {
+ maps: mapOwner.maps,
+ imagery: f.imagery,
+ });
+ layer.init(null, { engine });
+ layer.enable();
+ }
+ await tick();
+ const slots = f.last().slots;
+ assert.equal(slots.length, 2);
+ for (const [i, overlay] of OSM_OVERLAYS.entries()) {
+ const source = f.imagery.list()[i];
+ assert.equal(source.id, `osm-overlay-${overlay.id}`);
+ assert.equal(source.order, 10);
+ assert.equal(f.credits.get(overlay.credit), 1);
+ assert.deepEqual(slots[i].look, [1, 0, 1, 0]);
+ assert.equal(slots[i].raise, 0, 'draped on the visible surface');
+ assert.ok(
+ f.jobs.some(({ url }) =>
+ url.startsWith(overlay.urlTemplate.split('{z}')[0]),
+ ),
+ );
+ }
+ const decoded = { close() {} };
+ f.jobs[0].resolve(decoded);
+ await tick();
+ assert.deepEqual(f.draws, [decoded]);
+ const jobs = f.jobs.length;
+ for (const active of [!photoreal, photoreal]) {
+ f.setPhotoreal(active);
+ f.changed.raiseEvent();
+ assert.equal(f.imagery.surface(), active ? 'tiles' : 'globe');
+ assert.deepEqual(
+ f.last().slots,
+ slots,
+ 'the same textures and blend settings',
+ );
+ assert.equal(f.jobs.length, jobs, 'surface switches do not reload tiles');
+ for (const layer of layers)
+ assert.equal(layer.diagnostics().host, active ? 'tileset' : 'globe');
+ }
+ for (const layer of layers) layer.destroy();
+ assert.deepEqual(f.last().slots, []);
+ assert.ok(f.jobs.slice(1).every(({ signal }) => signal.aborted));
+ assert.ok(
+ f.canvases.every(({ width, height }) => width === 0 && height === 0),
+ );
+ for (const overlay of OSM_OVERLAYS)
+ assert.equal(f.credits.get(overlay.credit), 0);
+ });
+}
+
test('a tiles layer mosaics the visible tiles into one atlas; a new source drops it and its loads', async () => {
const f = fixture();
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
diff --git a/src/globe/navara/maps-release.test.mjs b/src/globe/navara/maps-release.test.mjs
index 2d69a715d..7ac510b10 100644
--- a/src/globe/navara/maps-release.test.mjs
+++ b/src/globe/navara/maps-release.test.mjs
@@ -65,6 +65,7 @@ function setup(core) {
String(sequence++),
update(next) {
Object.assign(layer, next);
+ core?.updateLayer(layer.id, { ...next, source: layer.source.id });
},
delete() {
core?.deleteLayer(layer.id);
@@ -119,7 +120,7 @@ for (const [name, source] of [
['Google', google],
]) {
for (const action of ['clear', 'release', 'stack', 'destroy']) {
- test(`${name} photoreal ${action} removes visible and hidden roots, geometry, source and credit`, async (t) => {
+ test(`${name} photoreal ${action} releases or parks tiles without stale surface answers or credits`, async (t) => {
globalThis.location = { origin: 'http://localhost' };
t.after(() => {
delete globalThis.location;
@@ -154,10 +155,15 @@ for (const [name, source] of [
maps.releasePhotorealTiles(maps.current().photoreal);
if (action === 'stack') await controller.setStack('osm');
if (action === 'destroy') state.destroy();
- assert.deepEqual(hooks.tileRoots(), []);
- assert.equal(state.view._meshes.size, 0);
- assert.ok(!sources.has(layer.source));
- assert.ok(!layers.has(layer.id));
+ const parked = action === 'clear' || action === 'stack';
+ assert.deepEqual(
+ hooks.tileRoots(),
+ parked ? tiles.map((t) => t.root) : [],
+ );
+ assert.equal(state.view._meshes.size, parked ? 2 : 0);
+ assert.equal(sources.has(layer.source), parked);
+ assert.equal(layers.has(layer.id), parked);
+ if (parked) assert.equal(layer.model.show, false);
assert.ok(
![...credits].some((credit) => credit.creditLayerId === layer.id),
);
@@ -182,6 +188,12 @@ for (const [name, source] of [
undefined,
);
for (const { root, mesh, disposed } of tiles) {
+ if (parked) {
+ assert.ok(root.parent);
+ assert.ok(mesh.geometry.attributes.position);
+ assert.equal(disposed(), 0);
+ continue;
+ }
assert.equal(root.parent, null);
assert.equal(root.children.length, 0);
assert.equal(mesh.geometry.attributes.position, undefined);
@@ -226,7 +238,7 @@ test('native tile eviction removes the root from layer cleanup', async (t) => {
root.dispatchEvent({ type: 'removedFromWorld' });
root.removeFromParent();
state.view._meshes.delete(root.id);
- await state.maps.setPhotorealTiles(null);
+ state.maps.releasePhotorealTiles(google);
assert.equal(layer.listenerCount('deleted'), 0);
assert.equal(disposed(), 0, 'native eviction already owns disposal');
});
@@ -260,7 +272,7 @@ test('native budgeted source deletion leaves no adapter tile roots', async (t) =
for (let frame = 0; frame < 120; frame++) {
if (frame === 60) {
assert.equal(added, 4, 'all native fixture models loaded');
- await state.maps.setPhotorealTiles(null);
+ state.maps.releasePhotorealTiles(fixture);
assert.equal(core.getSourceDescription(layer.source.id), undefined);
assert.deepEqual(state.hooks.tileRoots(), []);
assert.equal(state.view._meshes.size, 0);
@@ -309,3 +321,87 @@ test('native budgeted source deletion leaves no adapter tile roots', async (t) =
}
}
});
+
+test('native budgeted photoreal hide and restore reuse all tile payloads', async (t) => {
+ const { Core, initSync } = await import('@navaramap/engine');
+ initSync({
+ module: readFileSync(
+ new URL(
+ '../../../node_modules/@navaramap/engine/navara_wasm_bg.wasm',
+ import.meta.url,
+ ),
+ ),
+ });
+ globalThis.location = { origin: 'http://localhost' };
+ t.after(() => {
+ delete globalThis.location;
+ });
+ const core = new Core('tiles-retention-test');
+ core.start();
+ core.resize(1280, 800, 1);
+ core.setCacheBytes(768 * 1024 * 1024);
+ core.changeCamera(new Float64Array([-97.7431, 30.2672, 1100]), -90, 0, 0);
+ const state = setup(core);
+ const requested = [[], [], []];
+ let added = 0;
+ const shown = new Map();
+ try {
+ await state.maps.setPhotorealTiles(fixture);
+ const layer = [...state.layers.values()][0];
+ for (let frame = 0; frame < 180; frame++) {
+ if (frame === 60) {
+ assert.equal(added, 4);
+ await state.maps.setPhotorealTiles(null);
+ }
+ if (frame === 120) {
+ assert.deepEqual([...shown.values()], [false, false, false, false]);
+ await state.maps.setPhotorealTiles(fixture);
+ }
+ core.update(frame * 16);
+ const events = core.readEvents();
+ if (!events) continue;
+ for (const event of events.take_data_requested()) {
+ const name = new URL(event.url).pathname.split('/').at(-1);
+ requested[Math.floor(frame / 60)].push(name);
+ const bytes = readFileSync(
+ new URL(`../../../scripts/fixtures/tiles3d/${name}`, import.meta.url),
+ );
+ core.setBufferU8(event.handle, event.bits, bytes.length, (buffer) =>
+ buffer.set(bytes),
+ );
+ event.free();
+ }
+ for (const event of events.take_renderable_feature_added()) {
+ state.load(layer);
+ added++;
+ core.markFeatureIsRendered('model', event.bits);
+ event.free();
+ }
+ for (const event of events.take_renderable_feature_changed()) {
+ const feature = event.feature;
+ const model = feature.model;
+ const material = model.material;
+ shown.set(event.bits, model.active && material.show !== false);
+ material.free();
+ model.free();
+ feature.free();
+ event.free();
+ }
+ for (const event of events.take_mesh_added()) {
+ core.setTileMeshPrepared(event.tile_handle);
+ event.free();
+ }
+ events.free();
+ }
+ assert.equal(requested[0].length, 5);
+ assert.deepEqual(requested.slice(1), [[], []]);
+ assert.equal(added, 4, 'the same four decoded models are reused');
+ assert.deepEqual([...shown.values()], [true, true, true, true]);
+ assert.deepEqual(layer.model, { maxSse: 16, height: 0, show: true });
+ state.maps.releasePhotorealTiles(fixture);
+ assert.deepEqual(state.hooks.tileRoots(), []);
+ } finally {
+ state.destroy();
+ core.free();
+ }
+});
diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js
index ae0b1317d..60e74602e 100644
--- a/src/globe/navara/maps.js
+++ b/src/globe/navara/maps.js
@@ -13,8 +13,8 @@
// URL without the {z}/{x}/{y}.terrain template, so URLs always carry it.
// - photoreal: Google 3D tiles with a browser key only (no server proxy),
// with Google's logo and the tiles' own
-// copyright lines (creditLayerId) on screen. Showing them hides the base
-// imagery; clearing them restores the last base imagery and its credit.
+// copyright lines (creditLayerId) on screen. Showing them hides the
+// globe surface; clearing them restores the last base imagery and its credit.
// Hidden, not deleted: deleting the last raster layer stops Navara 0.2.1
// loading terrain at the current view.
@@ -54,6 +54,11 @@ const GOOGLE_LOGO = 'https://assets.ion.cesium.com/google-credit.png';
// Cesium3DTileset's default maximumScreenSpaceError.
const PHOTOREAL_MAX_SSE = 16;
+// Hidden tiles remain cached but must not answer rendered-surface queries.
+const photorealVisibility = new WeakMap();
+export const photorealTilesVisible = (view) =>
+ photorealVisibility.get(view) !== false;
+
const kindOf = (source) => (source?.descriptor ?? source)?.kind;
function imageryFor(source) {
@@ -126,15 +131,8 @@ export function createNavaraMaps(
}, SETTLE_EVERY_MS);
};
const slots = { imagery: null, terrain: null, photoreal: null };
- // Transparent tile overlays (sea marks, ski pistes) above the base
- // imagery: Navara draws raster layers in the order they were added.
- const overlays = new Set();
- function restackOverlays() {
- for (const overlay of overlays) {
- overlay.layer.delete();
- overlay.layer = view.addLayer({ type: 'raster', source: overlay.source });
- }
- }
+ let hiddenGlobe = null;
+ photorealVisibility.set(view, false);
// Base imagery and photoreal tiles share one switch order (display): the
// call made last decides what shows.
const generations = { display: 0, terrain: 0 };
@@ -163,7 +161,6 @@ export function createNavaraMaps(
const previous = slots[name];
const source = view.addSource(sourceDesc);
const layer = view.addLayer({ ...layerDesc, source });
- if (name === 'imagery') restackOverlays();
const shown = credits.map((credit) =>
typeof credit === 'function' ? credit(layer) : credit,
);
@@ -196,6 +193,21 @@ export function createNavaraMaps(
changed();
}
+ function setPhotorealShown(shown) {
+ const slot = slots.photoreal;
+ photorealVisibility.set(view, shown);
+ if (!slot || slot.hidden === !shown) return;
+ slot.hidden = !shown;
+ slot.layer.update({
+ ...slot.layerDesc,
+ source: slot.source,
+ model: { ...slot.layerDesc.model, show: shown },
+ });
+ if (slot.credits.length)
+ view.attribution?.[shown ? 'add' : 'remove'](slot.credits);
+ changed();
+ }
+
async function terrainFor(source) {
const kind = source?.kind ?? source?.id;
switch (kind) {
@@ -259,7 +271,7 @@ export function createNavaraMaps(
[credit],
);
// Under tiles the base imagery waits hidden, credit included.
- setImageryShown(slots.photoreal == null);
+ setImageryShown(!maps.photorealActive());
},
async setTerrain(source) {
const generation = ++generations.terrain;
@@ -285,7 +297,11 @@ export function createNavaraMaps(
async setPhotorealTiles(source) {
const generation = ++generations.display;
if (source == null) {
- clear('photoreal');
+ // Keep the tileset warm across base-map switches, as Cesium does.
+ // Deleting and recreating it refetches even the cached tile contents.
+ setPhotorealShown(false);
+ hiddenGlobe?.delete();
+ hiddenGlobe = null;
tilesSource = null;
// The globe comes back with the last base imagery and its credit.
if (slots.imagery) {
@@ -301,15 +317,24 @@ export function createNavaraMaps(
throw new Error(
'Google 3D tiles on Navara need a browser key (googleApiKey)',
);
+ // A source-only view (terrain sampling without a renderer) has no globe.
+ const hideGlobe = view.globe
+ ? (await import('./ground/globe.js')).hideGlobe
+ : null;
if (destroyed || generation !== generations.display) return;
const url = tilesetUrl
? new URL(tilesetUrl, globalThis.location.origin).href
: `${GOOGLE_TILES_ROOT}?key=${encodeURIComponent(key)}`;
setImageryShown(false);
+ if (!hiddenGlobe && hideGlobe) hiddenGlobe = hideGlobe(view);
currentBase = null;
tilesSource = source;
knownTiles.add(source);
- if (slots.photoreal?.key === url) return;
+ if (slots.photoreal?.key === url) {
+ slots.photoreal.owner = source;
+ setPhotorealShown(true);
+ return;
+ }
show(
'photoreal',
url,
@@ -326,43 +351,17 @@ export function createNavaraMaps(
}),
],
);
- // Google's terms want its copyright lines on screen, as Cesium shows
- // them; Navara lists them in its attribution card, collapsed by default.
- if (!tilesetUrl) view.attribution?.show();
+ slots.photoreal.owner = source;
+ setPhotorealShown(true);
},
releasePhotorealTiles(source) {
if (!knownTiles.delete(source)) return;
- if (tilesSource !== source) return;
- void maps.setPhotorealTiles(null);
- },
- photorealActive: () => slots.photoreal != null,
- /**
- * Navara-only extension: a transparent XYZ tile overlay ({z}/{x}/{y}
- * `url`) above the base imagery, with its credit. Returns its removal.
- */
- addOverlay({ url, maxZoom, credit }) {
- if (destroyed) return () => {};
- const source = view.addSource({
- type: 'raster-tile',
- url,
- ...(maxZoom && { maxZoom }),
- });
- const overlay = {
- source,
- layer: view.addLayer({ type: 'raster', source }),
- credits: credit ? [{ attributionHtml: String(credit) }] : [],
- };
- overlays.add(overlay);
- if (overlay.credits.length) view.attribution?.add(overlay.credits);
+ if (slots.photoreal?.owner !== source) return;
+ if (tilesSource === source) void maps.setPhotorealTiles(null);
+ clear('photoreal');
changed();
- return () => {
- if (!overlays.delete(overlay)) return;
- overlay.layer.delete();
- overlay.source.delete();
- if (overlay.credits.length) view.attribution?.remove(overlay.credits);
- changed();
- };
},
+ photorealActive: () => slots.photoreal != null && !slots.photoreal.hidden,
current: () => ({ baseImagery: currentBase, photoreal: tilesSource }),
terrainRevision: () => terrainRevision,
};
@@ -392,12 +391,10 @@ export function createNavaraMaps(
destroyed = true;
stopSettling();
stopDiscardingTiles();
- for (const overlay of overlays) {
- overlay.layer.delete();
- overlay.source.delete();
- }
- overlays.clear();
for (const name of Object.keys(slots)) clear(name);
+ photorealVisibility.set(view, false);
+ hiddenGlobe?.delete();
+ hiddenGlobe = null;
},
};
}
diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs
index b4d692eab..57b78a657 100644
--- a/src/globe/navara/maps.test.mjs
+++ b/src/globe/navara/maps.test.mjs
@@ -1,9 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import { registerHooks } from 'node:module';
+import { Scene } from 'three';
import { createNavaraMaps } from './maps.js';
+const loader = registerHooks({
+ resolve(specifier, context, next) {
+ return specifier === '@navaramap/three'
+ ? {
+ url: `data:text/javascript,${encodeURIComponent(`
+ export class EffectDesc {
+ constructor(view, ctx) { this.ctx = ctx; }
+ onCreate() { this.createPass(); }
+ onDestroy() {}
+ }
+ `)}`,
+ shortCircuit: true,
+ }
+ : next(specifier, context);
+ },
+});
+await import('./ground/globe.js');
+loader.deregister();
+
function fakeView() {
const view = {
+ globe: {},
+ scenes: { globe: new Scene(), mrt: new Scene() },
+ effects: new Set(),
+ effectTypes: new Map(),
+ registerEffect(key, type) {
+ view.effectTypes.set(key, type);
+ },
+ addEffect(desc) {
+ const key = Object.keys(desc)[0];
+ const Type = view.effectTypes.get(key);
+ const effect = new Type(view, { scenes: view.scenes }, desc);
+ effect.onCreate();
+ const handle = {
+ delete() {
+ if (!view.effects.delete(handle)) return;
+ effect.onDestroy();
+ },
+ };
+ view.effects.add(handle);
+ return handle;
+ },
sources: [],
layers: [],
shown: [],
@@ -205,11 +247,16 @@ test('setPhotorealTiles shows Google 3D tiles with a browser key and the Google
assert.deepEqual(view.rasterCounts, []);
const google = view.shown.find((item) => item.creditLayerId === tiles.id);
assert.ok(google?.logo, 'no Google logo');
- assert.ok(view.opened > 0, 'the copyright lines are not opened on screen');
+ assert.equal(
+ view.opened,
+ 0,
+ 'tiles must not force the attribution card open',
+ );
assert.equal(maps.photorealActive(), true);
await maps.setPhotorealTiles(null);
assert.equal(maps.photorealActive(), false);
- assert.equal(layersOf(view, '3d-tiles').length, 0);
+ assert.equal(layersOf(view, '3d-tiles').length, 1);
+ assert.equal(tiles.desc.model.show, false);
assert.ok(!view.shown.some((item) => item.creditLayerId));
});
@@ -231,6 +278,105 @@ test('base imagery hidden by photoreal comes back; a new one replaces it without
assert.deepEqual(view.rasterCounts, [1], 'a raster gap while replacing');
});
+test('photoreal hides the whole globe while terrain sources and sampling stay live', async (t) => {
+ const view = fakeView();
+ const adapter = createNavaraMaps(view);
+ const { maps } = adapter;
+ t.after(adapter.destroy);
+ const point = { longitude: 1, latitude: 0.5 };
+ await maps.setBaseImagery({ kind: 'osm' });
+ await maps.setTerrain({ kind: 'keyless' });
+ const [terrain] = layersOf(view, 'terrain');
+ view.sampleTerrainMostDetailed = async (source) => {
+ assert.equal(source, terrain.desc.source);
+ return [{ height: 4200 }];
+ };
+ const revision = maps.terrainRevision();
+ const tiles = { googleApiKey: 'k' };
+ await maps.setPhotorealTiles(tiles);
+ assert.equal(view.scenes.globe.visible, false);
+ assert.equal(view.scenes.mrt.visible, true, 'tiles still write scene depth');
+ assert.deepEqual(await adapter.sampleTerrain([point]), [4200]);
+ assert.equal(maps.terrainRevision(), revision);
+ assert.equal(layersOf(view, 'terrain')[0], terrain);
+ assert.equal(layersOf(view, 'raster').length, 1);
+ await maps.setBaseImagery({ kind: 'esri-imagery' });
+ assert.equal(view.scenes.globe.visible, false);
+ await maps.setPhotorealTiles(tiles);
+ assert.equal(view.effects.size, 1, 'same tiles do not stack globe owners');
+ await maps.setPhotorealTiles({ googleApiKey: 'replacement' });
+ assert.equal(view.effects.size, 1);
+ assert.equal(view.scenes.globe.visible, false);
+ await maps.setPhotorealTiles(null);
+ assert.equal(view.scenes.globe.visible, true);
+ assert.equal(view.effects.size, 0);
+ assert.equal(layersOf(view, 'raster')[0].desc.raster.show, true);
+ assert.deepEqual(await adapter.sampleTerrain([point]), [4200]);
+});
+
+test('globe visibility is restored on release and destruction, including an already hidden globe', async () => {
+ for (const visible of [true, false]) {
+ for (const action of ['release', 'destroy']) {
+ const view = fakeView();
+ view.scenes.globe.visible = visible;
+ const { maps, destroy } = createNavaraMaps(view);
+ const tiles = { googleApiKey: 'k' };
+ await maps.setPhotorealTiles(tiles);
+ assert.equal(view.scenes.globe.visible, false);
+ if (action === 'release') maps.releasePhotorealTiles(tiles);
+ else destroy();
+ assert.equal(view.scenes.globe.visible, visible);
+ assert.equal(view.effects.size, 0);
+ destroy();
+ }
+ }
+});
+
+test('a photoreal request superseded during renderer loading cannot hide the globe', async () => {
+ const view = fakeView();
+ const { maps, destroy } = createNavaraMaps(view);
+ const pending = maps.setPhotorealTiles({ googleApiKey: 'k' });
+ await maps.setPhotorealTiles(null);
+ await pending;
+ assert.equal(view.scenes.globe.visible, true);
+ assert.equal(maps.photorealActive(), false);
+ assert.equal(view.effects.size, 0);
+ const next = maps.setPhotorealTiles({ googleApiKey: 'k' });
+ destroy();
+ await next;
+ assert.equal(view.scenes.globe.visible, true);
+ assert.equal(view.layers.length, 0);
+});
+
+test('map switches reuse the tile cache at the same detail and release a parked source', async (t) => {
+ const view = fakeView();
+ const { maps, destroy } = createNavaraMaps(view);
+ t.after(destroy);
+ const tiles = { googleApiKey: 'k' };
+ await maps.setBaseImagery({ kind: 'osm' });
+ await maps.setPhotorealTiles(tiles);
+ const [layer] = layersOf(view, '3d-tiles');
+ const source = layer.desc.source;
+ for (let i = 0; i < 3; i++) {
+ await maps.setPhotorealTiles(null);
+ assert.equal(maps.photorealActive(), false);
+ assert.equal(layer.desc.model.show, false);
+ await maps.setBaseImagery({ kind: 'osm' });
+ assert.equal(layersOf(view, 'raster')[0].desc.raster.show, true);
+ await maps.setPhotorealTiles(tiles);
+ assert.equal(maps.photorealActive(), true);
+ assert.equal(layersOf(view, '3d-tiles')[0], layer);
+ assert.equal(layer.desc.source, source);
+ assert.deepEqual(layer.desc.model, { maxSse: 16, height: 0, show: true });
+ assert.equal(view.sources.length, 2);
+ }
+ await maps.setPhotorealTiles(null);
+ maps.releasePhotorealTiles(tiles);
+ assert.equal(layersOf(view, '3d-tiles').length, 0);
+ assert.ok(!view.sources.includes(source));
+ assert.equal(view.scenes.globe.visible, true);
+});
+
test('photoreal without a browser key rejects and changes nothing', async () => {
const view = fakeView();
const { maps } = createNavaraMaps(view);
@@ -360,43 +506,6 @@ test('after a map change Navara keeps redrawing while tiles arrive, then stops',
assert.equal(view.frames, atDestroy);
});
-test('a tile overlay stays above the base imagery across map switches and credits its source', async () => {
- const view = fakeView();
- const { maps, destroy } = createNavaraMaps(view, {
- setInterval: () => 1,
- clearInterval() {},
- });
- await maps.setBaseImagery({ kind: 'osm' });
- const remove = maps.addOverlay({
- url: 'https://tiles.example/seamark/{z}/{x}/{y}.png',
- maxZoom: 18,
- credit: 'Sea marks © example',
- });
- const order = () =>
- view.layers.map(
- (layer) =>
- view.sources.find((source) => source === layer.desc.source)?.desc.url,
- );
- assert.deepEqual(order(), [
- 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
- 'https://tiles.example/seamark/{z}/{x}/{y}.png',
- ]);
- assert.ok(
- view.shown.some((item) => item.attributionHtml === 'Sea marks © example'),
- );
- // A new base layer goes in on top; the overlay is put back above it.
- await maps.setBaseImagery({ kind: 'esri-imagery' });
- assert.equal(order().at(-1), 'https://tiles.example/seamark/{z}/{x}/{y}.png');
- assert.equal(order().length, 2);
- remove();
- assert.equal(order().length, 1);
- assert.ok(
- !view.shown.some((item) => item.attributionHtml === 'Sea marks © example'),
- );
- remove();
- destroy();
-});
-
test('world terrain publishes endpoint credits and removes them on replacement', async () => {
const view = fakeView();
const { maps, destroy } = createNavaraMaps(view, {
diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js
index cd8c9a77b..3bc3410f8 100644
--- a/src/globe/navara/surface.js
+++ b/src/globe/navara/surface.js
@@ -21,6 +21,7 @@ import {
} from 'three';
import * as geo from '../../geo/index.js';
import { normalizeSurfaceOptions } from '../surfaceDetail.js';
+import { photorealTilesVisible } from './maps.js';
const { cartesian3, cartographic, math } = geo;
const WGS84 = geo.ellipsoid.WGS84;
@@ -266,7 +267,7 @@ export function createNavaraSurface(
};
const sampleHeightDetailed = (place, options) => {
const { exclude } = normalizeSurfaceOptions(options);
- if (!supported) return terrainDetail(place);
+ if (!supported || !photorealTilesVisible(view)) return terrainDetail(place);
const ray = surfaceRay(place);
const roots = hooks.tileRoots();
const excluded = exclude.length ? new Set(exclude) : NO_EXCLUDE;
@@ -299,6 +300,7 @@ export function createNavaraSurface(
const raycast = ({ origin, direction, far, exclude = [] }) => {
if (
!supported ||
+ !photorealTilesVisible(view) ||
![
origin?.x,
origin?.y,
diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css
index 1b67d2f21..3ed506d4a 100644
--- a/src/ui/styles/status.css
+++ b/src/ui/styles/status.css
@@ -439,15 +439,42 @@ html:not([lang='en']) #global-loading-detail {
transform: translateX(-50%) translateY(0);
}
-/* The experimental Navara engine (?engine=navara) shows its attributions in
- its own card, fixed bottom-right over the location tray. Keep it on the
- left, above the HUD's coordinate lines and clear of the left panel rail:
- the list is capped (Google's lines come first; the rest scrolls). */
-body .navara-attr-dock {
- right: auto;
- left: 16px;
- bottom: 176px;
- align-items: flex-start;
+/* Navara uses the shared credit strip's position and typography. Its native
+ card and logos stay owned by the plugin, inside the renderer's container. */
+#cesium-credits .navara-attr-logoframe:not([hidden]) {
+ position: static;
+ display: inline-flex;
+ vertical-align: middle;
+ margin-right: 5px;
+}
+#cesium-credits .navara-attr-logo {
+ height: auto;
+}
+#cesium-credits .navara-attr-dock {
+ position: static;
+ display: inline-block;
+ inset: auto;
+ margin-left: 5px;
+ vertical-align: middle;
+}
+#cesium-credits .navara-attr-toggle {
+ width: 16px;
+ height: 16px;
+ min-width: 16px;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ color: inherit;
+}
+#cesium-credits .navara-attr-card {
+ position: absolute;
+ left: 0;
+ bottom: calc(100% + 8px);
+ white-space: normal;
+ text-shadow: none;
+}
+#cesium-credits .navara-attr-card a {
+ color: var(--nvr-attr-link, #3a6595);
}
body .navara-attr-list {
max-height: 120px;