fix(globe): Navara redraws after a map change while its tiles arrive

Phase-4 QA: after a map-stack switch at the whole-globe view Navara
sometimes showed the new map only seconds later. Navara 0.2.1 reports idle
and stops drawing before a raster tile it fetched arrives, so the last
frame stays stale until something else asks for one. Every engine.maps
change (base imagery, terrain, photoreal tiles, their release) now asks for
a frame and keeps asking every 250 ms for 6 s, then the engine idles again.

scripts/qa-map-switch.mjs switches the stack (photoreal / Esri / OSM) at
18 000 km on each engine, waits for the tiles, hashes the picture, forces
frames and hashes again: a change means a stale picture. Before: Navara 3/8
switches drew the new picture by themselves; after: Navara 8/8, Cesium 8/8.
This commit is contained in:
German Khokhlov committed 2026-10-16 23:00:15 +04:00
1 parent ba039f7974
commit 907cb2092d
3 files changed
+87 -8

No files matched your search

+2 -1
View File
@@ -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"
]
+40 -7
View File
@@ -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);
},
};
+45
View File
@@ -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);
});