1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
|
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');
});
|