worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 20439 bytes

import './_planet.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fakeRenderer } from './importmap.mjs';

const THREE = await import('three');
const { TileLayer, CONCURRENCY } = await import('../js/tilelayer.js');
const { GlobeView } = await import('../js/globe.js');
const { tileOf, tileKey, tilesForPoints } = await import('../js/tiles.js');

function setup() {
  const view = new GlobeView(fakeRenderer(), () => {}), pending = [];
  const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej }));
  const layer = new TileLayer(view, { url: '/tiles/v1/{layer}/{z}/{x}/{y}.jpg', load });
  layer.setLayers('relief', '');
  return { layer, pending };
}
const grid = (lat, lon, d) => Array.from({ length: 49 }, (_, k) => ({ lat: lat + (Math.floor(k / 7) - 3) * d, lon: lon + ((k % 7) - 3) * d }));
const flush = () => new Promise(r => setTimeout(r, 0));
async function settle(pending, rounds = 6) {
  for (let i = 0; i < rounds; i++) { pending.splice(0).forEach(p => p.res()); await flush(); await flush(); }
}

test('off below the minimum zoom; loading, capped, when zoomed in', () => {
  const { layer, pending } = setup();
  layer.update(50, grid(10, 20, 1));
  assert.equal(layer.active, false);
  assert.equal(pending.length, 0);
  layer.update(0.5, grid(10, 20, 0.05));
  assert.equal(layer.active, true);
  assert.equal(layer.z, 9);
  assert.equal(pending.length, CONCURRENCY);
  assert.match(pending[0].url, /^\/tiles\/v1\/relief\/9\/\d+\/\d+\.jpg$/);
});

test('moving away drops queued tiles that are no longer wanted', () => {
  const { layer, pending } = setup();
  layer.update(0.5, grid(10, 20, 0.05));
  assert.ok(layer.queue.length > 0);
  const pts = grid(-40, -100, 0.05);
  layer.update(0.5, pts);
  const want = new Set(tilesForPoints(pts, 9, 1).map(tileKey));
  assert.ok(layer.queue.every(e => want.has(e.key)));
  assert.equal(pending.length, CONCURRENCY);   // nothing beyond the cap started
});

test('loaded tiles show; an ancestor stands in while children load', async () => {
  const { layer, pending } = setup();
  const here = [{ lat: 10, lon: 20 }];
  layer.update(1.0, here);                       // z 8
  await settle(pending);
  layer.update(1.0, here);
  const parent = layer.tiles.get(tileKey(tileOf(10, 20, 8)));
  assert.equal(parent.state, 'ready');
  assert.equal(parent.mesh.visible, true);
  layer.update(0.5, here);                       // z 9: children still loading
  assert.equal(layer.z, 9);
  assert.equal(parent.mesh.visible, true);
  await settle(pending);
  layer.update(0.5, here);
  assert.equal(parent.mesh.visible, false);
  assert.equal(layer.tiles.get(tileKey(tileOf(10, 20, 9))).mesh.visible, true);
});

test('switching layers drops every tile', async () => {
  const { layer, pending } = setup();
  layer.update(1.0, [{ lat: 10, lon: 20 }]);
  await settle(pending);
  layer.setLayers('biomes', '');
  assert.equal(layer.tiles.size, 0);
  assert.equal(layer.group.children.length, 0);
});

test('coverage before sharpness: too many tiles for the budget → one zoom coarser', () => {
  const { layer, pending } = setup();
  const pts = Array.from({ length: 900 }, (_, k) => ({ lat: 10 + (Math.floor(k / 30) - 15) * 0.1, lon: 20 + ((k % 30) - 15) * 0.1 }));
  layer.update(0.5, pts, 24);                    // z 9 would need far more than 24 tiles
  assert.ok(layer.z < 9, `z ${layer.z}`);
  assert.ok(layer.queue.length + layer.inflight <= 24);
  assert.equal(pending.length, CONCURRENCY);
});

test('suspend (view hidden) stops loading and frees every tile', async () => {
  const { layer, pending } = setup();
  layer.update(0.5, grid(10, 20, 0.05));
  assert.ok(layer.queue.length > 0);
  layer.suspend();
  assert.equal(layer.queue.length, 0);
  assert.equal(layer.tiles.size, 0);
  const started = pending.length;
  pending.splice(0).forEach(p => p.res());
  await flush();
  await flush();
  assert.equal(layer.group.children.length, 0, 'late loads are dropped');
  assert.equal(pending.length, 0, `nothing new started (had ${started})`);
});

test('with an over-layer a tile costs two requests: still at most 6 in flight', () => {
  const { layer, pending } = setup();
  layer.setLayers('relief', 'biomes');
  layer.update(0.5, grid(10, 20, 0.05));
  assert.equal(pending.length, CONCURRENCY);
  assert.equal(layer.inflight, CONCURRENCY);
});

test('failed tiles are retried after a backoff', async () => {
  let clock = 0;
  const view = new GlobeView(fakeRenderer(), () => {}), pending = [];
  const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej }));
  const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, now: () => clock });
  layer.setLayers('relief', '');
  const here = [{ lat: 10, lon: 20 }], key = tileKey(tileOf(10, 20, 8)), url = `/t/relief/${key}.jpg`;
  layer.update(1.0, here);
  for (let i = 0; i < 3; i++) { pending.splice(0).forEach(p => (p.url === url ? p.rej(new Error('503')) : p.res())); await flush(); await flush(); }
  assert.equal(layer.tiles.get(key).state, 'error');
  layer.update(1.0, here);
  assert.ok(!pending.some(p => p.url === url), 'not yet');
  clock += 60000;
  layer.update(1.0, here);
  assert.ok(pending.some(p => p.url === url), 'retried after the backoff');
});

test('the queue follows the current view, its centre tile first', () => {
  const { layer } = setup();
  layer.update(0.5, grid(10, 20, 0.05));
  layer.update(0.5, [{ lat: 11.5, lon: 21.5 }, ...grid(10, 20, 0.05)]);
  assert.equal(layer.queue[0].key, tileKey(tileOf(11.5, 21.5, 9)));
});

test('a failed tile wakes the map when its backoff ends (no input needed)', async () => {
  const view = new GlobeView(fakeRenderer(), () => {}), pending = [], timers = [];
  let changes = 0;
  const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej }));
  const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, onChange: () => changes++,
    later: (fn, ms) => timers.push({ fn, ms }) });
  layer.setLayers('relief', '');
  layer.update(1.0, [{ lat: 10, lon: 20 }]);
  pending.splice(0).forEach(p => p.rej(new Error('503')));
  await flush(); await flush();
  assert.equal(timers.length, CONCURRENCY);
  assert.equal(timers[0].ms, 2000);
  const before = changes;
  timers[0].fn();
  assert.equal(changes, before + 1, 'redraw requested → update() retries');
  layer.setLayers('biomes', '');
  const after = changes;
  timers[1].fn();
  assert.equal(changes, after, 'stale wake-ups (older generation) do nothing');
});

test('a throw while building a tile marks it failed, frees its textures, rejects nothing', async () => {
  const view = new GlobeView(fakeRenderer(), () => {}), pending = [], unhandled = [];
  const onRej = e => unhandled.push(e);
  process.on('unhandledRejection', onRej);
  const texes = [];
  const load = url => new Promise(res => pending.push({ url, res: () => { const t = new THREE.Texture(); t.dispose = () => { t.gone = true; }; texes.push(t); res(t); } }));
  const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, later: () => {} });
  layer.setLayers('relief', '');
  Object.defineProperty(view, 'shade', { get() { throw new Error('boom'); } });
  layer.update(1.0, [{ lat: 10, lon: 20 }]);
  for (let i = 0; i < 6; i++) { pending.splice(0).forEach(p => p.res()); await flush(); await flush(); }
  process.off('unhandledRejection', onRej);
  assert.deepEqual(unhandled, []);
  const e = layer.tiles.get(tileKey(tileOf(10, 20, 8)));
  assert.equal(e.state, 'error');
  assert.ok(texes.every(t => t.gone));
  assert.equal(layer.inflight, 0);
});

function terrainSetup(meshes = () => Promise.resolve({ h: new Float32Array(129 * 129).fill(1000), water: new Uint8Array(129 * 129) })) {
  const view = new GlobeView(fakeRenderer(), () => {}), pending = [], urls = [];
  const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej }));
  const loadMesh = url => { urls.push(url); return meshes(url); };
  const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load, loadMesh });
  layer.setLayers('relief', '');
  return { layer, pending, urls, view };
}
const radius = e => { const a = e.mesh.geometry.attributes.position.array, c = e.mesh.position;
  return Math.hypot(a[0] + c.x, a[1] + c.y, a[2] + c.z); };

test('terrain: each tile also loads its heights and is displaced by height × exaggeration', async () => {
  const { R_KM } = await import('../js/geo.js');
  const { layer, pending, urls } = terrainSetup(), here = [{ lat: 10, lon: 20 }];
  layer.setTerrain(1);
  layer.update(1.0, here);
  assert.equal(pending.length, CONCURRENCY / 2, 'heights (≈ 66 KB) count as a full request');
  await settle(pending);
  const e = layer.tiles.get(tileKey(tileOf(10, 20, 8)));
  assert.equal(e.state, 'ready');
  assert.ok(urls.includes(`/t/mesh/${e.key}.bin`));
  assert.ok(Math.abs(radius(e) - (1 + 1000 / (R_KM * 1000))) < 1e-7);
  assert.ok(Math.abs(layer.heightAt(10, 20) - 1000) < 1e-9);
});

test('exaggeration reshapes in place; switching 3D on or off reloads every tile', async () => {
  const { R_KM } = await import('../js/geo.js');
  const { layer, pending } = terrainSetup(), here = [{ lat: 10, lon: 20 }];
  layer.setTerrain(1);
  layer.update(1.0, here);
  await settle(pending);
  const e = layer.tiles.get(tileKey(tileOf(10, 20, 8))), mesh = e.mesh;
  layer.setTerrain(2.5);
  assert.equal(e.mesh, mesh);
  assert.ok(Math.abs(radius(e) - (1 + 2500 / (R_KM * 1000))) < 1e-7);
  layer.setTerrain(0);
  assert.equal(layer.tiles.size, 0);
  assert.equal(layer.heightAt(10, 20), null);
});

test('a bad heights file fails the tile (retry later), never throws', async () => {
  const { layer, pending } = terrainSetup(() => Promise.reject(new Error('404')));
  layer.setTerrain(1);
  layer.update(1.0, [{ lat: 10, lon: 20 }]);
  await settle(pending);
  assert.equal(layer.tiles.get(tileKey(tileOf(10, 20, 8))).state, 'error');
});

test('flat views never go 3D', async () => {
  const { FlatView } = await import('../js/flatmap.js');
  const layer = new TileLayer(new FlatView(fakeRenderer(), 'equal_earth', () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/m' });
  layer.setTerrain(2);
  assert.equal(layer.terrain, 0);
});

test('tilted: near samples load fine tiles, far samples coarse ones; budget still drops everything a level', () => {
  const { layer } = setup();
  const near = grid(10, 20, 0.002).map(p => ({ ...p, km: 0.05 })), far = grid(10.5, 20, 0.05).map(p => ({ ...p, km: 2 }));
  layer.update(1, [...near, ...far]);
  const zs = new Set(layer.queue.map(e => e.t.z));
  assert.equal(layer.z, Math.max(...zs));
  assert.ok(zs.size >= 2 && Math.min(...zs) <= 8, [...zs].join());
  layer.update(1, [...near, ...far], 4);
  assert.ok(layer.queue.length + layer.inflight <= 4 + CONCURRENCY);
  assert.ok(layer.z < 13);
});

test('heightAt(…, shown) reads only drawn tiles; heightGen bumps when the drawn heights change', async () => {
  const { layer, pending } = terrainSetup(), here = [{ lat: 10, lon: 20 }];
  layer.setTerrain(1);
  layer.update(1.0, here);                  // z 8
  await settle(pending);
  layer.update(1.0, here);
  const g0 = layer.heightGen;
  assert.ok(g0 > 0);
  assert.equal(layer.heightAt(10, 20, true), 1000);
  layer.update(0.25, here);                 // z 10 wanted: the z 8 tile still stands in, drawn
  assert.equal(layer.heightAt(10, 20, true), 1000);
  await settle(pending);
  layer.update(0.25, here);                 // children drawn, the z 8 tile hidden (still cached)
  assert.ok(layer.heightGen > g0);
  const e8 = layer.tiles.get(tileKey(tileOf(10, 20, 8)));
  assert.equal(e8.mesh.visible, false);
  layer.update(50, here);                   // zoomed out: nothing drawn
  assert.equal(layer.heightAt(10, 20, true), null);
  assert.equal(layer.heightAt(10, 20), 1000, 'the camera floor still uses every loaded height');
});

test('3D: a stand-in ancestor never overlaps its loaded descendants (coarse ground would poke through)', async () => {
  const { parentTile } = await import('../js/tiles.js');
  const failing = tileKey(tileOf(10, 20, 10));
  const { layer, pending } = terrainSetup(url => (url.includes(`/${failing}.bin`) ? Promise.reject(new Error('500'))
    : Promise.resolve({ h: new Float32Array(129 * 129).fill(1000), water: new Uint8Array(129 * 129) })));
  layer.setTerrain(1);
  const here = [{ lat: 10, lon: 20 }];
  layer.update(1.0, here);                                    // z 8 loads
  await settle(pending);
  layer.update(0.25, here);                                   // z 10: one tile keeps failing
  await settle(pending);
  layer.update(0.25, here);
  const shown = [...layer.tiles.values()].filter(e => e.mesh?.visible).map(e => e.t);
  const keys = new Set(shown.map(tileKey));
  assert.ok(shown.some(t => t.z < 10), 'an ancestor stands in for the failing tile');
  for (const t of shown) for (let a = parentTile(t); a.z >= 5; a = parentTile(a)) assert.ok(!keys.has(tileKey(a)), `${tileKey(t)} under ${tileKey(a)}`);
});

test('a failed tile still in view keeps its place (and its backoff) when the cache trims', async () => {
  const view = new GlobeView(fakeRenderer(), () => {}), pending = [];
  const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej }));
  let clock = 0;
  const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, now: () => clock++, later: () => {} });
  layer.setLayers('relief', '');
  const here = { lat: 10, lon: 20 }, key = tileKey(tileOf(10, 20, 9)), url = `/t/relief/${key}.jpg`;
  layer.update(0.5, [here]);
  for (let i = 0; i < 4; i++) { pending.splice(0).forEach(p => (p.url === url ? p.rej(new Error('503')) : p.res())); await flush(); await flush(); }
  assert.equal(layer.tiles.get(key).state, 'error');
  for (let k = 0; k < 40; k++) layer.update(0.5, [here, ...grid(-30 + k, 100, 0.05)]);   // the view keeps the tile, the cache fills up
  const e = layer.tiles.get(key);
  assert.ok(layer.tiles.size <= layer.cacheSize, 'the cache did trim');
  assert.ok(e && e.fails >= 1, 'kept with its failure count (its backoff survives)');
});

test('a new tile URL (regions rebuilt) drops every tile and reloads', async () => {
  const { layer, pending } = setup();
  layer.update(1.0, [{ lat: 10, lon: 20 }]);
  await settle(pending);
  assert.ok(layer.tiles.size > 0);
  layer.setUrl('/tiles/v4-new/{layer}/{z}/{x}/{y}.jpg', '/tiles/v4-new/mesh/{z}/{x}/{y}.bin');
  assert.equal(layer.tiles.size, 0);
  layer.update(1.0, [{ lat: 10, lon: 20 }]);
  assert.match(pending.at(-1).url, /^\/tiles\/v4-new\//);
});

test('the default back-off timer works where setTimeout must be called on the window (Firefox)', async () => {
  const orig = globalThis.setTimeout, unhandled = [];
  const onRej = e => unhandled.push(e);
  globalThis.setTimeout = function (fn, ms) {   // as Firefox: a method call on another object throws
    if (this !== undefined && this !== globalThis) throw new TypeError("'setTimeout' called on an object that does not implement interface Window.");
    return orig(fn, ms);
  };
  process.on('unhandledRejection', onRej);
  try {
    const view = new GlobeView(fakeRenderer(), () => {}), pending = [];
    const load = url => new Promise((res, rej) => pending.push({ url, rej }));
    const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load });
    layer.setLayers('relief', '');
    layer.update(1.0, [{ lat: 10, lon: 20 }]);
    pending.splice(0).forEach(p => p.rej(new Error('404')));   // e.g. a tile of a region set the server just retired
    await flush(); await flush(); await flush();
    assert.deepEqual(unhandled, []);
    assert.ok([...layer.tiles.values()].some(e => e.state === 'error'), 'the tile backs off');
  } finally {
    globalThis.setTimeout = orig;
    process.off('unhandledRejection', onRej);
  }
});

test('heights: water on draws the surface at 0 m, water off the floor', async () => {
  const { MESH_SEGS } = await import('../js/tiles.js');
  const n = (MESH_SEGS + 1) ** 2, h = new Float32Array(n).fill(-3000), water = new Uint8Array(n).fill(1);
  h[0] = 200; water[0] = 0;
  const view = new GlobeView(fakeRenderer(), () => {});
  const tex = () => Promise.resolve(new THREE.Texture());
  const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: tex,
    loadMesh: () => Promise.resolve({ h, water }) });
  layer.setLayers('relief', '');
  layer.setTerrain(1);
  layer.update(0.05, [{ lat: 10, lon: 20 }]);
  for (let i = 0; i < 20; i++) await flush();
  assert.equal(layer.heightAt(10, 20), 0, 'sea: the water surface');
  layer.setWater(false);
  assert.ok(layer.heightAt(10, 20) < -2000, 'water off: the floor');
});

test('3D patches colour near-vertical faces as rock, not the texture at their foot', async () => {
  const { STEEP_GLSL } = await import('../js/tilelayer.js');
  assert.match(STEEP_GLSL, /dFdx\(rel\)/);   // slope from patch-relative positions: world ones (|p| ≈ 1) are float32 noise at street zoom
  assert.match(STEEP_GLSL, /smoothstep\(0\.42, 0\.71/);     // cos 65° … cos 45°
  const { MESH_SEGS } = await import('../js/tiles.js');
  const n = (MESH_SEGS + 1) ** 2;
  const view = new GlobeView(fakeRenderer(), () => {});
  const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin',
    load: () => Promise.resolve(new THREE.Texture()),
    loadMesh: () => Promise.resolve({ h: new Float32Array(n), water: new Uint8Array(n) }) });
  layer.setLayers('relief', '');
  layer.setTerrain(1);
  layer.update(0.05, [{ lat: 10, lon: 20 }]);
  for (let i = 0; i < 20; i++) await flush();
  const mesh = [...layer.tiles.values()].find(e => e.mesh)?.mesh;
  assert.match(mesh.material.fragmentShader, /steepColor\(c, vPos, vRel, limb\)/);
  assert.match(mesh.material.vertexShader, /vRel = mat3\(modelMatrix\) \* position;/);
});

test('3D patches: a constant depth offset per zoom, none scaled by slope (it pulled the edge-on skirts over the ground: a line along tile edges)', async () => {
  const { MESH_SEGS } = await import('../js/tiles.js');
  const n = (MESH_SEGS + 1) ** 2;
  const layer = new TileLayer(new GlobeView(fakeRenderer(), () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg',
    meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: () => Promise.resolve(new THREE.Texture()),
    loadMesh: () => Promise.resolve({ h: new Float32Array(n), water: new Uint8Array(n) }) });
  layer.setLayers('relief', '');
  layer.setTerrain(1);
  layer.update(0.05, [{ lat: 10, lon: 20 }]);
  for (let i = 0; i < 20; i++) await flush();
  const meshes = [...layer.tiles.values()].filter(e => e.mesh?.geometry.attributes.skirt).map(e => e.mesh);
  assert.ok(meshes.length > 0);
  for (const m of meshes) {
    assert.equal(m.material.polygonOffsetFactor, 0);
    assert.ok(m.material.polygonOffsetUnits < 0, 'finer zooms still win over a standing-in ancestor');
  }
});

test('3D patches: a fine grain fades in where the map texture is magnified, with seamless coordinates, never on water', async () => {
  const { MESH_SEGS, detailOrigin } = await import('../js/tiles.js');
  const n = (MESH_SEGS + 1) ** 2, water = new Uint8Array(n);
  water[0] = 1;
  const layer = new TileLayer(new GlobeView(fakeRenderer(), () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg',
    meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: () => Promise.resolve(new THREE.Texture()),
    loadMesh: () => Promise.resolve({ h: new Float32Array(n), water }) });
  layer.setLayers('relief', '');
  layer.setTerrain(1);
  layer.update(0.05, [{ lat: 10, lon: 20 }]);
  for (let i = 0; i < 20; i++) await flush();
  const e = [...layer.tiles.values()].find(t => t.mesh);
  const m = e.mesh;
  assert.deepEqual([...m.material.uniforms.detailOrg.value.toArray()], detailOrigin(e.t));
  assert.equal(m.geometry.attributes.wet.array[0], 1);
  assert.equal(m.geometry.attributes.wet.array[1], 0);
  assert.equal(m.material.uniforms.waterOn.value, 1);
  layer.setWater(false);
  assert.equal(m.material.uniforms.waterOn.value, 0, 'water off: the sea floor gets its grain too');
  assert.match(m.material.fragmentShader, /fineGrain\(/);
  assert.match(m.material.fragmentShader, /fwidth\(/, 'fades in by how magnified the texture is');
});