Hide the Navara globe under photoreal tiles and keep credits on screen

Under Google 3D tiles Navara hid only the base imagery and kept drawing
the terrain globe, which showed through the tiles wherever the terrain
mesh lies above them (grey patches over the Nepal mountains). As on
Cesium, the globe now stops drawing under the tiles while terrain
sampling continues, and the tileset is kept across map switches instead
of being fetched again.

Sea Marks and Ski Pistes drew as native globe rasters and would vanish
with it; they now go through engine.imagery, which drapes onto the tiles
on both engines, and the native overlay path is gone.

Navara opened its attribution card to keep on-screen credits visible,
covering the scene; it now shows them in a persistent strip like
Cesium's, with the card behind its button.
This commit is contained in:
German Khokhlov committed 2026-11-12 00:42:17 +04:00
1 parent 14b2adb7af
commit 8a00049b85
15 files changed
+979 -192

No files matched your search

+7 -1
View File
@@ -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) => {
+17 -32
View File
@@ -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 {
+45 -29
View File
@@ -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(), []);
});
}
+43
View File
@@ -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({
+6 -4
View File
@@ -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.
+305
View File
@@ -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 =
'<a href="https://example.test/credit?a=1;b=2">Publisher</a><img src=x onerror="alert(1)"><a href="javascript:alert(1)">Unsafe</a>';
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/,
);
});
+107 -16
View File
@@ -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) => `<span class="cesium-credit-wrapper">${entry}</span>`)
.join('<span class="cesium-credit-delimiter"> • </span>');
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();
},
};
}
+2 -2
View File
@@ -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);
});
+31
View File
@@ -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]: {} });
}
+77
View File
@@ -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() });
+103 -7
View File
@@ -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();
}
});
+49 -52
View File
@@ -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;
},
};
}
+148 -39
View File
@@ -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, {
+3 -1
View File
@@ -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,
+36 -9
View File
@@ -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;