diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 629480f0b..41856b54e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1108,5 +1108,6 @@ "src/app/layers/qaSources.js", "src/app/layers/qaSources.test.mjs", "scripts/qa-w1-layers.mjs", - "src/bench/w1Feeds.js" + "src/bench/w1Feeds.js", + "scripts/qa-map-switch.mjs" ] diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index f5dfc2c8b..ac223c598 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -57,15 +57,47 @@ function imageryFor(source) { } } +// After a map change Navara 0.2.1 can report idle and stop drawing before a +// raster tile it fetched arrives (measured at the whole-globe view: the +// stack switch drew a stale picture until something else asked for a +// frame). For a while after each change the engine asks for frames itself. +const SETTLE_MS = 6000; +const SETTLE_EVERY_MS = 250; + /** * @param {object} view A Navara ThreeView (addSource, addLayer, attribution, forceUpdate). - * @param {{ fetch?: typeof fetch }} [options] + * @param {{ fetch?: typeof fetch, setInterval?: Function, + * clearInterval?: Function, now?: () => number }} [options] timers: test seams. * @returns {{ maps: object, destroy: () => void }} */ export function createNavaraMaps( view, - { fetch: fetchJson = globalThis.fetch } = {}, + { + fetch: fetchJson = globalThis.fetch, + setInterval: every = globalThis.setInterval, + clearInterval: stopEvery = globalThis.clearInterval, + now = () => performance.now(), + } = {}, ) { + let settleTimer = null; + let settleUntil = 0; + const stopSettling = () => { + if (settleTimer !== null) stopEvery(settleTimer); + settleTimer = null; + }; + // A frame now, and frames for SETTLE_MS while tiles arrive. + const changed = () => { + view.forceUpdate(); + settleUntil = now() + SETTLE_MS; + if (settleTimer !== null || destroyed) return; + settleTimer = every(() => { + if (destroyed || now() >= settleUntil) { + stopSettling(); + return; + } + view.forceUpdate(); + }, SETTLE_EVERY_MS); + }; const slots = { imagery: null, terrain: null, photoreal: null }; // Base imagery and photoreal tiles share one switch order (display): the // call made last decides what shows. @@ -105,7 +137,7 @@ export function createNavaraMaps( credits: shown, hidden: false, }; - view.forceUpdate(); + changed(); } // Base imagery under photoreal is hidden rather than deleted (see show). @@ -120,7 +152,7 @@ export function createNavaraMaps( }); if (slot.credits.length) view.attribution?.[shown ? 'add' : 'remove'](slot.credits); - view.forceUpdate(); + changed(); } async function terrainFor(source) { @@ -182,7 +214,7 @@ export function createNavaraMaps( if (destroyed || generation !== generations.terrain) return; if (!description) { clear('terrain'); - view.forceUpdate(); + changed(); return; } if (slots.terrain?.key === description.url) return; @@ -198,7 +230,7 @@ export function createNavaraMaps( setImageryShown(true); currentBase = baseSource; } - view.forceUpdate(); + changed(); return; } const key = String(source.googleApiKey || '').trim(); @@ -235,7 +267,7 @@ export function createNavaraMaps( if (tilesSource !== source) return; tilesSource = null; clear('photoreal'); - view.forceUpdate(); + changed(); }, photorealActive: () => slots.photoreal != null, current: () => ({ baseImagery: currentBase, photoreal: tilesSource }), @@ -246,6 +278,7 @@ export function createNavaraMaps( destroy() { if (destroyed) return; destroyed = true; + stopSettling(); for (const name of Object.keys(slots)) clear(name); }, }; diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs index c2765b50e..3c16af355 100644 --- a/src/globe/navara/maps.test.mjs +++ b/src/globe/navara/maps.test.mjs @@ -295,3 +295,48 @@ test('releasePhotorealTiles forgets the source and takes its tiles away if shown assert.equal(maps.current().photoreal, null); assert.equal(layersOf(view, '3d-tiles').length, 0); }); + +test('after a map change Navara keeps redrawing while tiles arrive, then stops', async () => { + const view = fakeView(); + const timers = []; + let now = 0; + const { maps, destroy } = createNavaraMaps(view, { + setInterval: (fn, ms) => { + const timer = { fn, ms, cleared: false }; + timers.push(timer); + return timer; + }, + clearInterval: (timer) => { + if (timer) timer.cleared = true; + }, + now: () => now, + }); + const tick = (ms) => { + for (let t = 0; t < ms; t += 250) { + now += 250; + for (const timer of timers) if (!timer.cleared) timer.fn(); + } + }; + await maps.setBaseImagery({ kind: 'osm' }); + const afterChange = view.frames; + tick(1000); + // Navara 0.2.1 can report idle and stop drawing before a raster tile it + // fetched arrives: the engine asks for frames for a while after a change. + assert.ok(view.frames >= afterChange + 4, `frames ${view.frames}`); + tick(10_000); + const settled = view.frames; + tick(5_000); + assert.equal(view.frames, settled, 'idle again once the window ends'); + assert.ok( + timers.every((timer) => timer.cleared), + 'no timer left running', + ); + // Another change opens a new window; destroy ends it. + await maps.setBaseImagery({ kind: 'esri-imagery' }); + tick(500); + assert.ok(view.frames > settled + 1); + destroy(); + const atDestroy = view.frames; + tick(2000); + assert.equal(view.frames, atDestroy); +});