aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/tools/smoke
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/tools/smoke')
-rw-r--r--viewer/tools/smoke/blend.mjs20
-rw-r--r--viewer/tools/smoke/core.mjs18
-rw-r--r--viewer/tools/smoke/coverage.mjs17
-rw-r--r--viewer/tools/smoke/deepzoom.mjs33
-rw-r--r--viewer/tools/smoke/era-fallback.mjs7
-rw-r--r--viewer/tools/smoke/export.mjs18
-rw-r--r--viewer/tools/smoke/mobile.mjs48
-rw-r--r--viewer/tools/smoke/nav.mjs42
-rw-r--r--viewer/tools/smoke/pins.mjs21
-rw-r--r--viewer/tools/smoke/regions.mjs43
-rw-r--r--viewer/tools/smoke/search.mjs29
-rw-r--r--viewer/tools/smoke/sky.mjs26
-rw-r--r--viewer/tools/smoke/street.mjs64
-rw-r--r--viewer/tools/smoke/terrain.mjs22
-rw-r--r--viewer/tools/smoke/touch.mjs42
15 files changed, 450 insertions, 0 deletions
diff --git a/viewer/tools/smoke/blend.mjs b/viewer/tools/smoke/blend.mjs
new file mode 100644
index 0000000..16d0dc6
--- /dev/null
+++ b/viewer/tools/smoke/blend.mjs
@@ -0,0 +1,20 @@
+// blend.mjs — a second layer drawn over the first, opacity slider, URL state
+const px = `const a = window.mapViewer, gl = a.renderer.getContext(); a.view.render(); const p = new Uint8Array(4);
+ gl.readPixels(gl.drawingBufferWidth >> 1, gl.drawingBufferHeight >> 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, p); return [...p.slice(0, 3)];`;
+const set = (id, v, ev = 'change') => `const s = document.getElementById('${id}'); s.value = '${v}'; s.dispatchEvent(new Event('${ev}'));
+ await new Promise(r => setTimeout(r, 1500));`;
+const close = (a, b, tol = 4) => a.every((x, i) => Math.abs(x - b[i]) <= tol);
+export const steps = [
+ { name: 'relief pixel', wait: 4000, js: `window.mapViewer.view.setState({ lat: 40, lon: 10, alt: 1.5 }); await new Promise(r => setTimeout(r, 500)); window.__r = (() => { ${px} })(); return window.__r;` },
+ { name: 'biomes pixel', js: set('layer', 'biomes') + `window.__b = (() => { ${px} })(); return window.__b;` },
+ { name: 'back to relief', js: set('layer', 'relief') + `const p = (() => { ${px} })(); return [p, window.__r];`, expect: ([p, r]) => close(p, r) },
+ { name: 'over = biomes shows the slider', js: set('over', 'biomes') + `return !document.getElementById('opacity').hidden;`, expect: v => v === true },
+ { name: 'opacity 100 → biomes', js: set('opacity', '100', 'input') + `const p = (() => { ${px} })(); return [p, window.__b];`, expect: ([p, b]) => close(p, b) },
+ { name: 'opacity 0 → relief', js: set('opacity', '0', 'input') + `const p = (() => { ${px} })(); return [p, window.__r];`, expect: ([p, r]) => close(p, r) },
+ { name: 'URL keeps over and op', js: set('opacity', '40', 'input') + `await new Promise(r => setTimeout(r, 400)); return location.hash;`,
+ expect: v => v.includes('over=biomes') && v.includes('op=0.4') },
+ { name: 'flat view blends too', js: `window.mapViewer.setView('equal_earth'); await new Promise(r => setTimeout(r, 500)); return window.mapViewer.view.shade.mix2.value;`,
+ expect: v => Math.abs(v - 0.4) < 1e-6 },
+ { name: 'over = none hides the slider', js: set('over', '') + `return [document.getElementById('opacity').hidden, window.mapViewer.view.shade.mix2.value];`,
+ expect: ([h, m]) => h === true && m === 0 },
+];
diff --git a/viewer/tools/smoke/core.mjs b/viewer/tools/smoke/core.mjs
new file mode 100644
index 0000000..000215c
--- /dev/null
+++ b/viewer/tools/smoke/core.mjs
@@ -0,0 +1,18 @@
+// core.mjs — the viewer boots, renders the globe, switches views and layers without console errors.
+export const steps = [
+ { name: 'boots', wait: 4000, expect: v => v.ok && !v.toast,
+ js: `const a = window.mapViewer; return { ok: !!a && !!a.texture, toast: document.getElementById('toast').textContent };` },
+ { name: 'globe drawn (centre pixel is not background)', expect: v => Math.abs(v[0] - 16) + Math.abs(v[1] - 18) + Math.abs(v[2] - 26) > 20,
+ js: `const a = window.mapViewer, gl = a.renderer.getContext(); a.view.render(); const p = new Uint8Array(4);
+ gl.readPixels(gl.drawingBufferWidth >> 1, gl.drawingBufferHeight >> 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, p); return [...p.slice(0, 3)];` },
+ { name: 'views switch', expect: v => v.join() === 'equal_earth,plate_carree,globe',
+ js: `const a = window.mapViewer, out = []; for (const k of ['equal_earth', 'plate_carree', 'globe']) { a.setView(k); out.push(a.view.kind); } return out;` },
+ { name: 'flat views render (their shaders compile)', expect: v => v.every(p => Math.abs(p[0] - 16) + Math.abs(p[1] - 18) + Math.abs(p[2] - 26) > 20),
+ js: `const a = window.mapViewer, gl = a.renderer.getContext(), out = [];
+ for (const k of ['equal_earth', 'plate_carree']) { a.setView(k); a.view.setState({ lat: 0, lon: 0, zoom: 1 }); a.view.render();
+ const p = new Uint8Array(4); gl.readPixels(gl.drawingBufferWidth >> 1, gl.drawingBufferHeight >> 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, p); out.push([...p.slice(0, 3)]); }
+ a.setView('globe'); return out;` },
+ { name: 'layers load', expect: v => v.n >= 19 && !v.toast,
+ js: `const s = document.getElementById('layer'); for (const o of s.options) { s.value = o.value; s.dispatchEvent(new Event('change')); await new Promise(r => setTimeout(r, 400)); }
+ return { n: s.options.length, toast: document.getElementById('toast').textContent };` },
+];
diff --git a/viewer/tools/smoke/coverage.mjs b/viewer/tools/smoke/coverage.mjs
new file mode 100644
index 0000000..f244947
--- /dev/null
+++ b/viewer/tools/smoke/coverage.mjs
@@ -0,0 +1,17 @@
+// coverage.mjs — at a common screen size, full-detail tiles cover (nearly) the whole map area, also at high latitude
+export const viewport = { width: 1920, height: 1080 };
+const cover = `const a = window.mapViewer; for (let i = 0; i < 80; i++) { await new Promise(r => setTimeout(r, 500));
+ const t = a.view.tiles; if (i > 2 && t.inflight === 0 && t.queue.length === 0) break; }
+ const t = a.view.tiles, w = a.el.clientWidth - 360, h = a.el.clientHeight; let hit = 0, n = 0;
+ const S = Math.PI / 180, span = 180 / 2 ** t.z;
+ for (let i = 0; i < 30; i++) for (let j = 0; j < 40; j++) {
+ const p = a.view.pick((j + 0.5) / 40 * w, (i + 0.5) / 30 * h); if (!p) continue; n++;
+ const x = ((Math.floor((p.lon + 180) / span) % (2 * 2 ** t.z)) + 2 * 2 ** t.z) % (2 * 2 ** t.z), y = Math.min(2 ** t.z - 1, Math.floor((90 - p.lat) / span));
+ const e = t.tiles.get(t.z + '/' + x + '/' + y); if (e && e.state === 'ready' && e.mesh.visible) hit++; }
+ return { z: t.z, share: +(hit / n).toFixed(3), tiles: t.tiles.size };`;
+const at = (lat, lon, alt) => `window.mapViewer.view.setState({ lat: ${lat}, lon: ${lon}, alt: ${alt} });`;
+export const steps = [
+ { name: '2 km view at 41°N: ≥ 90% full detail', wait: 5000, js: at(41, 3.5, 0.0006) + cover, expect: v => v.share >= 0.9 },
+ { name: '50 km view at 70°N: ≥ 90% full detail', js: at(70, 20, 0.006) + cover, expect: v => v.share >= 0.9 },
+ { name: '20 km view at 85°N: ≥ 90% full detail', js: at(85, 20, 0.003) + cover, expect: v => v.share >= 0.9 },
+];
diff --git a/viewer/tools/smoke/deepzoom.mjs b/viewer/tools/smoke/deepzoom.mjs
new file mode 100644
index 0000000..cde74df
--- /dev/null
+++ b/viewer/tools/smoke/deepzoom.mjs
@@ -0,0 +1,33 @@
+// deepzoom.mjs — procedural detail tiles from 10 km/px down to street level, globe and flat, seams and poles
+const wait = `const a = window.mapViewer; for (let i = 0; i < 60; i++) { await new Promise(r => setTimeout(r, 500));
+ const t = a.view.tiles; if (i > 2 && t.inflight === 0 && t.queue.length === 0) break; }
+ const t = a.view.tiles; return { z: t.z, active: t.active, ready: [...t.tiles.values()].filter(e => e.state === 'ready').length,
+ errors: [...t.tiles.values()].filter(e => e.state === 'error').length, badge: !document.getElementById('detail-badge').hidden,
+ coast: a.view.lines.objects.has('coast'), rivers: a.view.lines.objects.has('rivers'), scale: document.getElementById('scale-text').textContent };`;
+const at = (lat, lon, alt) => `window.mapViewer.view.setState({ lat: ${lat}, lon: ${lon}, alt: ${alt} });`;
+const detail = `const a = window.mapViewer, gl = a.renderer.getContext(); a.view.render(); const n = 64, p = new Uint8Array(4 * n * n);
+ gl.readPixels((gl.drawingBufferWidth - n) >> 1, (gl.drawingBufferHeight - n) >> 1, n, n, gl.RGBA, gl.UNSIGNED_BYTE, p);
+ let s = 0, s2 = 0; for (let i = 0; i < p.length; i += 4) { const v = p[i] + p[i + 1] + p[i + 2]; s += v; s2 += v * v; }
+ const m = s / (n * n); return Math.sqrt(s2 / (n * n) - m * m);`;
+export const steps = [
+ { name: '20 km view: tiles on, badge shown, coast lines hidden', wait: 4000, js: at(41, 3.5, 0.003) + wait,
+ expect: v => v.active && v.z >= 10 && v.ready >= 4 && v.errors === 0 && v.badge && !v.coast && !v.rivers },
+ { name: 'the deep view has fine detail', js: detail, expect: v => v > 3 },
+ { name: 'on a data layer the tiles draw no rivers, so the overlay stays', expect: v => v.data && !v.relief,
+ js: `const a = window.mapViewer; await a.setLayer('biomes'); a.dirty = true; await new Promise(r => setTimeout(r, 400));
+ const data = a.view.lines.objects.has('rivers'); await a.setLayer('relief'); a.dirty = true; await new Promise(r => setTimeout(r, 400));
+ return { data, relief: a.view.lines.objects.has('rivers') };` },
+ { name: 'street-level limit (20 m above sea level with 3D off)', js: `const a = window.mapViewer; a.setTerrain(0); a.zoomBy(1e-6); return a.view.getState().alt * 12742000;`,
+ expect: v => Math.abs(v - 20) < 1e-3 },
+ { name: 'max-zoom tiles load', js: wait, expect: v => v.active && v.z === 16 && v.ready >= 4 && v.errors === 0 && / m · /.test(v.scale) },
+ { name: 'antimeridian', js: at(10, 179.999, 0.004) + wait, expect: v => v.active && v.ready >= 4 && v.errors === 0 },
+ { name: 'north pole', js: at(89.95, 0, 0.004) + wait, expect: v => v.active && v.ready >= 1 && v.errors === 0 },
+ { name: 'switching away frees the globe tiles', expect: v => v.size === 0 && v.queue === 0,
+ js: `const b = window.mapViewer; b.view.setState({ lat: 41, lon: 3.5, alt: 0.003 }); await new Promise(r => setTimeout(r, 1500));
+ const g = b.views.globe.tiles; b.setView('equal_earth'); await new Promise(r => setTimeout(r, 1000));
+ return { size: g.tiles.size, queue: g.queue.length, inflight: g.inflight };` },
+ { name: 'flat deep zoom', js: `const b = window.mapViewer; b.setView('equal_earth'); b.view.setState({ lat: 41, lon: 3.5, zoom: 2000 });` + wait,
+ expect: v => v.active && v.z >= 9 && v.ready >= 4 && v.errors === 0 },
+ { name: 'zoom out: tiles off, coast back', js: `const b = window.mapViewer; b.setView('globe'); b.home();` + wait,
+ expect: v => !v.active && v.coast && !v.badge },
+];
diff --git a/viewer/tools/smoke/era-fallback.mjs b/viewer/tools/smoke/era-fallback.mjs
new file mode 100644
index 0000000..443c1c9
--- /dev/null
+++ b/viewer/tools/smoke/era-fallback.mjs
@@ -0,0 +1,7 @@
+// era-fallback.mjs — run with '#era=nope': an unknown era in the URL falls back to the default with a toast
+export const steps = [
+ { name: 'unknown era: the default era shows, a toast says so', wait: 3000,
+ expect: v => v.era === v.def && /unknown era/i.test(v.toast) && !/era=nope/.test(v.hash),
+ js: `const a = window.mapViewer; a.saveUrl();
+ return { era: a.meta.era, def: a.meta.default_era, toast: document.getElementById('toast').textContent, hash: location.hash };` },
+];
diff --git a/viewer/tools/smoke/export.mjs b/viewer/tools/smoke/export.mjs
new file mode 100644
index 0000000..d6183cf
--- /dev/null
+++ b/viewer/tools/smoke/export.mjs
@@ -0,0 +1,18 @@
+// export.mjs — Export tab: the square around the map centre, export it, progress, the list, the square goes away
+const wait = `const a = window.mapViewer; for (let i = 0; i < 300; i++) { await new Promise(r => setTimeout(r, 500));
+ if (['done', 'failed'].includes(a.exports.status?.state)) break; }
+ return { state: a.exports.status?.state, n: a.exports.list.length, text: document.getElementById('tab-export').textContent };`;
+export const steps = [
+ { name: 'the Export tab shows the square and the sample count', wait: 4000,
+ expect: v => v.line && /257 × 257/.test(v.info),
+ js: `const a = window.mapViewer; a.view.setState({ lat: 46.5, lon: 9.8, alt: 0.02 }); a.showTab('export');
+ const box = document.getElementById('tab-export'), set = (n, v) => { const s = box.querySelector('select[name=' + n + ']');
+ s.value = v; s.dispatchEvent(new Event('change')); };
+ set('size', '5'); set('res', '20'); await new Promise(r => setTimeout(r, 300));
+ return { line: a.view.lines.objects.has('export-square'), info: box.textContent };` },
+ { name: 'export runs and lists the result', expect: v => v.state === 'done' && v.n === 1 && /n46\.500-e9\.800-5km-20m/.test(v.text),
+ js: `[...document.querySelectorAll('#tab-export button')].find(b => b.textContent.startsWith('Export here')).click();
+ await new Promise(r => setTimeout(r, 500));` + wait },
+ { name: 'leaving the tab hides the square', expect: v => v === false,
+ js: `window.mapViewer.showTab('inspector'); return window.mapViewer.view.lines.objects.has('export-square');` },
+];
diff --git a/viewer/tools/smoke/mobile.mjs b/viewer/tools/smoke/mobile.mjs
new file mode 100644
index 0000000..6c94dee
--- /dev/null
+++ b/viewer/tools/smoke/mobile.mjs
@@ -0,0 +1,48 @@
+// mobile.mjs — the viewer on a phone screen: controls fit without overlapping, a tap opens the inspector sheet,
+// the ☰ menu holds the layer picker, map buttons are finger-sized. SHOTS=<dir> (default /tmp): screenshots.
+// LANDSCAPE=1: the phone on its side.
+const dir = process.env.SHOTS || '/tmp';
+const land = !!process.env.LANDSCAPE;
+const tag = land ? 'landscape' : 'portrait';
+export const viewport = land ? { width: 844, height: 390 } : { width: 390, height: 844 };
+const overlap = `const vis = id => { const e = document.getElementById(id); if (!e || e.hidden) return null;
+ const b = e.getBoundingClientRect(); return b.width && b.height && getComputedStyle(e).display !== 'none' ? b : null; };
+ const ids = ['toolbar', 'panel', 'nav', 'status', 'legend', 'exag-box', 'skybar'].filter(vis);
+ const bad = [];
+ for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
+ const a = vis(ids[i]), b = vis(ids[j]);
+ if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) bad.push(ids[i] + 'x' + ids[j]);
+ }
+ const off = ids.filter(id => { const b = vis(id); return b.left < 0 || b.right > innerWidth + 1 || b.top < 0 || b.bottom > innerHeight + 1; });
+ const covered = ids.reduce((s, id) => { const b = vis(id); return s + b.width * b.height; }, 0) / (innerWidth * innerHeight);
+ return { bad, off, covered: +covered.toFixed(2) };`;
+const tap = `const c = document.querySelector('#map canvas'), b = c.getBoundingClientRect(), x = b.left + b.width * 0.3, y = b.top + b.height * 0.35;
+ for (const t of ['pointerdown', 'pointerup']) c.dispatchEvent(new PointerEvent(t, { clientX: x, clientY: y, bubbles: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0 }));
+ c.dispatchEvent(new MouseEvent('click', { clientX: x, clientY: y, bubbles: true }));
+ await new Promise(r => setTimeout(r, 1500));`;
+export const steps = [
+ { name: 'boots on a phone', wait: 5000, expect: v => v.ok && !v.toast && v.compact,
+ js: `return { ok: !!window.mapViewer?.texture, toast: document.getElementById('toast').textContent,
+ compact: document.body.classList.contains('compact') };` },
+ { name: `${tag}: controls do not overlap or leave the screen, the map stays visible`, shot: `${dir}/phone-${tag}.png`,
+ expect: v => !v.bad.length && !v.off.length && v.covered < 0.35, js: overlap },
+ { name: 'tap the map: the inspector shows', shot: `${dir}/phone-${tag}-inspect.png`, expect: v => v.visible,
+ js: `${tap}
+ const s = document.getElementById('tab-inspector'), r = s.getBoundingClientRect();
+ return { visible: !s.hidden && r.height > 40 && r.top < innerHeight - 40, text: s.textContent.slice(0, 40) };` },
+ { name: 'panel open: still no overlaps', expect: v => !v.bad.length && !v.off.length, js: overlap },
+ { name: 'the menu holds the layer picker and fits the screen', shot: `${dir}/phone-${tag}-menu.png`,
+ expect: v => v.layer && v.fits,
+ js: `document.getElementById('menu-toggle').click(); await new Promise(r => setTimeout(r, 300));
+ const l = document.getElementById('layer').getBoundingClientRect(), t = document.getElementById('toolbar').getBoundingClientRect();
+ return { layer: l.width > 150 && l.height >= 36, fits: t.bottom <= innerHeight };` },
+ { name: 'a tap on the map closes the menu', expect: v => v === false,
+ js: `const c = document.querySelector('#map canvas');
+ c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 200, clientY: 300, bubbles: true, pointerId: 2, pointerType: 'touch' }));
+ c.dispatchEvent(new PointerEvent('pointerup', { clientX: 200, clientY: 300, bubbles: true, pointerId: 2, pointerType: 'touch' }));
+ return document.body.classList.contains('menu-open');` },
+ { name: 'layer textures fit a phone GPU (≤ 4096 px wide)', expect: v => v > 0 && v <= 4096,
+ js: `return window.mapViewer.texture?.image?.width ?? 0;` },
+ { name: 'map buttons are finger-sized', expect: v => v.length >= 4 && v.every(b => b.w >= 40 && b.h >= 40),
+ js: `return [...document.querySelectorAll('#nav button')].filter(b => b.offsetParent).map(b => ({ w: b.offsetWidth, h: b.offsetHeight }));` },
+];
diff --git a/viewer/tools/smoke/nav.mjs b/viewer/tools/smoke/nav.mjs
new file mode 100644
index 0000000..0f11660
--- /dev/null
+++ b/viewer/tools/smoke/nav.mjs
@@ -0,0 +1,42 @@
+// nav.mjs — buttons, keyboard, export, copy link
+const key = k => [{ type: 'key', id: 'kb', actions: [{ type: 'keyDown', value: k }, { type: 'keyUp', value: k }] }];
+const st = `return window.mapViewer.view.getState();`;
+export const steps = [
+ { name: 'start at alt 2', wait: 4000, js: `window.mapViewer.view.setState({ lat: 20, lon: 0, alt: 2 });` + st, expect: v => Math.abs(v.alt - 2) < 1e-6 },
+ { name: 'zoom-in button (×0.6)', js: `document.getElementById('nav-in').click();` + st, expect: v => Math.abs(v.alt - 1.2) < 1e-6 },
+ { name: 'zoom-out button', js: `document.getElementById('nav-out').click();` + st, expect: v => Math.abs(v.alt - 2) < 1e-6 },
+ { name: 'ArrowRight pans east, north stays up', actions: key(''), js: st, expect: v => v.lon > 5 && v.hd === 0 },
+ { name: '+ key zooms in', actions: key('+'), js: st, expect: v => v.alt < 1.9 },
+ { name: 'Home key: whole planet', actions: key(''), js: `const s = window.mapViewer.view.getState(); window.__home = s; return s;`,
+ expect: v => Math.abs(v.alt - 2) < 1e-6 && Math.abs(v.lat - 20) < 1e-6 },
+ { name: 'focus a text field', js: `const i = document.createElement('input'); i.id = 'tmp-input'; document.body.append(i); i.focus(); return document.activeElement.id;`, expect: v => v === 'tmp-input' },
+ { name: 'arrows while typing do nothing', actions: key(''),
+ js: `const s = window.mapViewer.view.getState(); document.getElementById('tmp-input').remove(); return Math.abs(s.lon - window.__home.lon) < 1e-9;`, expect: v => v === true },
+ { name: 'export PNG', expect: v => v && v.name === 'world-relief-globe.png' && v.len > 20000,
+ js: `let got = null; const orig = HTMLAnchorElement.prototype.click;
+ HTMLAnchorElement.prototype.click = function () { got = { name: this.download, len: this.href.length }; };
+ document.getElementById('nav-export').click(); HTMLAnchorElement.prototype.click = orig; return got;` },
+ { name: 'copy link answers with a toast', expect: v => /^(Link copied|Copy failed)/.test(v),
+ js: `document.getElementById('nav-link').click(); await new Promise(r => setTimeout(r, 400)); return document.getElementById('toast').textContent;` },
+ { name: 'Shift+Up tilts, Shift+Right turns, the compass shows it, N resets', expect: v => v[0] > 5 && v[1] === 15 && /rotate\(-15deg\)/.test(v[2]) && v[3] === 0 && v[4] === 0,
+ js: `const a = window.mapViewer; a.view.setState({ lat: 40, lon: 5, alt: 0.01 });
+ const k = (key, shiftKey) => dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey }));
+ k('ArrowUp', true); k('ArrowRight', true); const s = a.view.getState(); await new Promise(r => setTimeout(r, 100));
+ const t = document.querySelector('#nav-north span').style.transform; document.getElementById('nav-north').click();
+ const u = a.view.getState(); return [s.pt, s.hd, t, u.hd, u.pt];` },
+ { name: 'tilt lands in the URL and comes back from it', expect: v => /pt=30/.test(v[0]) && /hd=45/.test(v[0]) && v[1] === 30,
+ js: `const a = window.mapViewer; a.view.setState({ lat: 40, lon: 5, alt: 0.01, hd: 45, pt: 30 }); a.saveUrl(); const h = location.hash;
+ a.view.setState({ lat: 40, lon: 5, alt: 0.01 }); a.saveUrl(); location.hash = h; await new Promise(r => setTimeout(r, 300));
+ return [h, a.view.getState().pt];` },
+ { name: 'flat map: Shift+arrows pan (no tilt there)', expect: v => v > 1,
+ js: `const a = window.mapViewer; a.setView('equal_earth'); a.view.setState({ lat: 0, lon: 0, zoom: 4 });
+ dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true })); const lon = a.view.getState().lon; a.setView('globe'); return lon;` },
+ { name: 'ground button from a flat map with 3D off: globe, 3D on, close and tilted, eye over the spot',
+ expect: v => v.kind === 'globe' && v.terrain > 0 && v.alt * 12742 <= 3.6 && v.pt === 78 && Math.abs(v.lat + 53.4) < 0.05,
+ js: `const a = window.mapViewer; a.setTerrain(0); a.setView('equal_earth'); a.view.setState({ lat: -53.4, lon: -43.9, zoom: 8 });
+ document.getElementById('nav-ground').click(); await new Promise(r => setTimeout(r, 200)); const s = a.view.getState();
+ return { kind: a.view.kind, terrain: a.state.terrain, alt: s.alt, pt: s.pt, lat: s.lat };` },
+ { name: '? shows the controls, Esc hides them', expect: v => v[0] === false && v[1] === true,
+ js: `const k = key => dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey: key === '?' }));
+ k('?'); const shown = document.getElementById('help').hidden; k('Escape'); return [shown, document.getElementById('help').hidden];` },
+];
diff --git a/viewer/tools/smoke/pins.mjs b/viewer/tools/smoke/pins.mjs
new file mode 100644
index 0000000..d6e7cf1
--- /dev/null
+++ b/viewer/tools/smoke/pins.mjs
@@ -0,0 +1,21 @@
+// pins.mjs — place a pin by clicking, give it an Epoch, move it by editing lat/lon, delete it
+const P = acts => [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: acts }];
+const click = (x, y) => P([{ type: 'pointerMove', x, y }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }]);
+const set = (sel, v) => `{ const i = document.querySelector('${sel}'); i.value = '${v}'; i.dispatchEvent(new Event('input')); i.dispatchEvent(new Event('change')); }`;
+const pins = `return window.mapViewer.pins.pins.map(p => [p.name, p.epoch, +p.lat.toFixed(2), +p.lon.toFixed(2)]);`;
+export const steps = [
+ { name: 'add pin → placing', wait: 4000, js: `window.mapViewer.showTab('pins'); [...document.querySelectorAll('#tab-pins button')].find(b => b.textContent.includes('Add pin')).click(); return window.mapViewer.mode;`, expect: v => v === 'pin' },
+ { name: 'click the map opens the form with epoch + coordinates', actions: click(600, 400), settle: 500,
+ js: `const f = document.querySelector('form.pin'); return [!!f, f.querySelectorAll('select option').length, f.querySelectorAll('input[type=number]').length];`,
+ expect: ([f, n, c]) => f && n >= 7 && c === 2 },
+ { name: 'save with name + epoch', js: set('form.pin input[required]', 'Smoke Isle') + set('form.pin select', 'epoch-high-fantasy') +
+ `document.querySelector('form.pin button[type=submit]').click(); await new Promise(r => setTimeout(r, 800));` + pins,
+ expect: v => v.length === 1 && v[0][0] === 'Smoke Isle' && v[0][1] === 'epoch-high-fantasy' },
+ { name: 'edit: move by lat/lon', js: `[...document.querySelectorAll('#tab-pins button')].find(b => b.textContent === 'Edit').click(); await new Promise(r => setTimeout(r, 200));
+ const n = document.querySelectorAll('form.pin input[type=number]');` + `n[0].value = '-15.5'; n[0].dispatchEvent(new Event('input')); n[1].value = '-34.2'; n[1].dispatchEvent(new Event('input'));
+ document.querySelector('form.pin button[type=submit]').click(); await new Promise(r => setTimeout(r, 800));` + pins,
+ expect: v => v[0][2] === -15.5 && v[0][3] === -34.2 && v[0][1] === 'epoch-high-fantasy' },
+ { name: 'list shows the epoch', js: `return document.getElementById('tab-pins').textContent;`, expect: v => /high-fantasy/.test(v) },
+ { name: 'delete', js: `window.confirm = () => true; [...document.querySelectorAll('#tab-pins button')].find(b => b.textContent === 'Delete').click();
+ await new Promise(r => setTimeout(r, 800));` + pins, expect: v => v.length === 0 },
+];
diff --git a/viewer/tools/smoke/regions.mjs b/viewer/tools/smoke/regions.mjs
new file mode 100644
index 0000000..52300ae
--- /dev/null
+++ b/viewer/tools/smoke/regions.mjs
@@ -0,0 +1,43 @@
+// regions.mjs — draw a region by clicking, name and save it, watch it refine, tiles switch, delete it
+const P = acts => [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: acts }];
+const click = (x, y) => P([{ type: 'pointerMove', x, y }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }]);
+const wait = `const a = window.mapViewer; for (let i = 0; i < 400; i++) { await new Promise(r => setTimeout(r, 500));
+ if (['ready', 'failed'].includes(a.regions.status?.state)) break; }
+ return { state: a.regions.status?.state, n: a.regions.regions.length, url: a.meta.tiles.url, url0: window.__url0,
+ line: a.view.lines.objects.has('regions'), text: document.getElementById('tab-regions').textContent };`;
+const corner = (x, y) => ({ name: `outline point ${x},${y}`, actions: click(x, y), settle: 300,
+ js: 'return window.mapViewer.regions.points?.length ?? -1;', expect: v => v >= 1 });
+export const steps = [
+ { name: 'draw mode', wait: 4000, expect: v => v === 'region',
+ js: `const a = window.mapViewer; window.__url0 = a.meta.tiles.url; a.view.setState({ lat: 46.5, lon: 9.8, alt: 0.08 });
+ await new Promise(r => setTimeout(r, 500)); a.showTab('regions');
+ [...document.querySelectorAll('#tab-regions button')].find(b => b.textContent.includes('Draw region')).click(); return a.mode;` },
+ corner(500, 350), corner(700, 350),
+ { name: 'Esc closing the controls card keeps the outline', expect: v => v[0] === true && v[1] === 2 && v[2] === 'region',
+ js: `const k = key => dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey: key === '?' }));
+ k('?'); k('Escape'); const a = window.mapViewer;
+ return [document.getElementById('help').hidden, a.regions.points?.length ?? -1, a.mode];` },
+ { name: 'clicking the Regions tab again keeps drawing', expect: v => v[0] === 'region' && v[1] === 2,
+ js: `const a = window.mapViewer; document.querySelector('#panel nav [data-tab=regions]').click();
+ return [a.mode, a.regions.points?.length ?? -1];` },
+ corner(700, 500), corner(500, 500),
+ { name: 'a double-click closes it too (no duplicate point, no re-centring)',
+ expect: v => v[0] === true && v[1] === 5 && v[2] < 1e-9,
+ actions: P([{ type: 'pointerMove', x: 560, y: 560 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 },
+ { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }]), settle: 300,
+ js: `const r = window.mapViewer.regions, s = window.mapViewer.view.getState();
+ const out = [r.closed, r.points.length, Math.abs(s.lat - 46.5) + Math.abs(s.lon - 9.8)]; r.closed = false; r.points.pop();
+ window.mapViewer.setMode('region'); return out;` },
+ { name: 'clicking the first point closes the outline', actions: click(503, 352), settle: 300,
+ js: `return [window.mapViewer.regions.closed, window.mapViewer.mode, !!document.querySelector('form.region input')];`,
+ expect: ([c, m, i]) => c && m === 'inspect' && i },
+ { name: 'name + save starts the refinement', expect: v => v.n === 1,
+ js: `const i = document.querySelector('form.region input'); i.value = 'Smoke region'; i.dispatchEvent(new Event('input'));
+ document.querySelector('form.region button[type=submit]').click(); await new Promise(r => setTimeout(r, 800));
+ return { n: window.mapViewer.regions.regions.length };` },
+ { name: 'refines, tiles switch, outline drawn', js: wait,
+ expect: v => v.state === 'ready' && v.url !== v.url0 && v.line && /up to date/.test(v.text) && /· refined/.test(v.text) },
+ { name: 'delete removes it and rebuilds', expect: v => v.n === 0 && v.state === 'ready',
+ js: `window.confirm = () => true; [...document.querySelectorAll('#tab-regions button')].find(b => b.textContent === 'Delete').click();
+ await new Promise(r => setTimeout(r, 800));` + wait },
+];
diff --git a/viewer/tools/smoke/search.mjs b/viewer/tools/smoke/search.mjs
new file mode 100644
index 0000000..bdc159c
--- /dev/null
+++ b/viewer/tools/smoke/search.mjs
@@ -0,0 +1,29 @@
+// search.mjs — search by coordinates, H3 cell id and pin name
+const type = s => [{ type: 'key', id: 'kb', actions: [...s].flatMap(c => [{ type: 'keyDown', value: c }, { type: 'keyUp', value: c }]) }];
+const ENTER = '', ESC = '';
+const items = `return [...document.querySelectorAll('#suggest .item')].map(e => e.textContent);`;
+export const steps = [
+ { name: '/ focuses search', wait: 4000, actions: type('/'), js: `return document.activeElement.id;`, expect: v => v === 'search' },
+ { name: 'coordinates suggest', actions: type('40N 10W'), js: items, expect: v => v.length === 1 && v[0].startsWith('40.000° N') },
+ { name: 'Enter flies there with a marker', actions: type(ENTER), settle: 700,
+ js: `const s = window.mapViewer.view.getState(); return { s, n: document.querySelectorAll('.marker.search').length };`,
+ expect: v => Math.abs(v.s.lat - 40) < 0.01 && Math.abs(v.s.lon + 10) < 0.01 && v.s.alt <= 0.6 + 1e-9 && v.n === 1 },
+ { name: 'Esc clears the marker', actions: type(ESC), js: `return document.querySelectorAll('.marker.search').length;`, expect: v => v === 0 },
+ { name: 'cell id suggests', js: `const c = await (await fetch('/api/cell?lat=-20&lon=100')).json(); const i = document.getElementById('search');
+ i.focus(); i.value = c.cell; i.dispatchEvent(new Event('input')); return document.querySelectorAll('#suggest .item').length;`, expect: v => v === 1 },
+ { name: 'Enter resolves the cell', actions: type(ENTER), settle: 900,
+ js: `const s = window.mapViewer.view.getState(); return Math.abs(s.lat + 20) < 0.6 && Math.abs(s.lon - 100) < 0.6;`, expect: v => v === true },
+ { name: 'pins are searchable', js: `await fetch('/api/pins', { method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'Searchable Ford', lat: 12, lon: 34 }) }); await window.mapViewer.pins.load();
+ const i = document.getElementById('search'); i.focus(); i.value = 'ford'; i.dispatchEvent(new Event('input'));` + items,
+ expect: v => v.some(t => t.startsWith('Searchable Ford')) },
+ { name: 'Enter on a pin flies without a marker', actions: type(ENTER), settle: 700,
+ js: `const s = window.mapViewer.view.getState(); return [s.lat, s.lon, document.querySelectorAll('.marker.search').length];`,
+ expect: ([la, lo, n]) => Math.abs(la - 12) < 0.01 && Math.abs(lo - 34) < 0.01 && n === 0 },
+ { name: 'measure two points, focus search', js: `const a = window.mapViewer; a.showTab('measure'); a.measure.add({ lat: 1, lon: 2 });
+ a.measure.add({ lat: 3, lon: 4 }); a.measureChanged(); document.getElementById('search').focus(); return a.measure.points.length;`, expect: v => v === 2 },
+ { name: 'Esc in the search box keeps the measure path', actions: type(ESC), expect: v => v === 2,
+ js: `return window.mapViewer.measure.points.length;` },
+ { name: 'junk suggests nothing', js: `const i = document.getElementById('search'); i.value = '95 10'; i.dispatchEvent(new Event('input')); return document.getElementById('suggest').hidden;`,
+ expect: v => v === true },
+];
diff --git a/viewer/tools/smoke/sky.mjs b/viewer/tools/smoke/sky.mjs
new file mode 100644
index 0000000..5becf59
--- /dev/null
+++ b/viewer/tools/smoke/sky.mjs
@@ -0,0 +1,26 @@
+// sky.mjs — day/night lighting, sun marker, URL state, play
+const bright = `const a = window.mapViewer, gl = a.renderer.getContext(); a.view.render();
+ const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight, p = new Uint8Array(4 * 400);
+ gl.readPixels((w >> 1) - 10, (h >> 1) - 10, 20, 20, gl.RGBA, gl.UNSIGNED_BYTE, p);
+ let s = 0; for (let i = 0; i < p.length; i += 4) s += p[i] + p[i + 1] + p[i + 2]; return s / 400;`;
+const at = lon => `window.mapViewer.view.setState({ lat: 20, lon: ${lon}, alt: 1.5 }); await new Promise(r => setTimeout(r, 400));`;
+export const steps = [
+ { name: 'sky off at 0°E and 180°', wait: 4000, js: at(0) + `window.__off0 = (() => { ${bright} })();` + at(180) + `window.__off180 = (() => { ${bright} })(); return [window.__off0, window.__off180];` },
+ { name: 'sky on: bar, label, sun marker', expect: ([h, n, t]) => h === false && n === 1 && t === 'day 54 of 216 · northern midsummer',
+ js: `document.getElementById('sky-toggle').click(); const d = document.getElementById('sky-day'), m = document.getElementById('sky-mo');
+ d.value = 54; d.dispatchEvent(new Event('input')); m.value = 23328; m.dispatchEvent(new Event('input')); await new Promise(r => setTimeout(r, 300));
+ return [document.getElementById('skybar').hidden, document.querySelectorAll('.marker.sun').length, document.getElementById('sky-day-label').textContent];` },
+ { name: 'noon at 0°E stays lit', js: at(0) + `return [(() => { ${bright} })(), window.__off0];`, expect: ([on, off]) => on > off * 0.8 },
+ { name: 'midnight at 180° goes dark', js: at(180) + `return [(() => { ${bright} })(), window.__off180];`, expect: ([on, off]) => on < off * 0.5 },
+ { name: 'URL carries the sky', js: `await new Promise(r => setTimeout(r, 400)); return location.hash;`,
+ expect: v => v.includes('sky=1') && v.includes('day=54') && v.includes('mo=23328') },
+ { name: 'flat view is lit too', js: `const a = window.mapViewer; a.setView('equal_earth'); return a.view.shade.sky.value;`, expect: v => v === 1 },
+ { name: 'playing rebuilds no sky lines', expect: v => v.n === 0 && v.moved,
+ js: `const a = window.mapViewer, o = a.overlays, f = o.setExtra.bind(o); let n = 0; o.setExtra = (...x) => { n++; return f(...x); };
+ const m0 = a.state.mo, b = document.getElementById('sky-play'); b.click(); await new Promise(r => setTimeout(r, 600)); b.click();
+ o.setExtra = f; return { n, moved: a.state.mo !== m0 };` },
+ { name: 'play advances time', expect: v => v === true,
+ js: `const b = document.getElementById('sky-play'); const m0 = window.mapViewer.state.mo; b.click(); await new Promise(r => setTimeout(r, 600)); b.click(); return window.mapViewer.state.mo !== m0;` },
+ { name: 'sky off restores', js: `document.getElementById('sky-toggle').click(); return [window.mapViewer.view.shade.sky.value, document.querySelectorAll('.marker.sun').length];`,
+ expect: ([s, n]) => s === 0 && n === 0 },
+];
diff --git a/viewer/tools/smoke/street.mjs b/viewer/tools/smoke/street.mjs
new file mode 100644
index 0000000..82c4361
--- /dev/null
+++ b/viewer/tools/smoke/street.mjs
@@ -0,0 +1,64 @@
+// street.mjs — 3D terrain + tilt: toggle, heights, floor above the ground, URL round trip, sky toward the horizon
+const R_M = 12742000;
+const settle = `for (let i = 0; i < 80; i++) { await new Promise(r => setTimeout(r, 400));
+ const l = window.mapViewer.view.tiles; if (i > 2 && l.inflight === 0 && l.queue.length === 0) break; }
+ const t = window.mapViewer.view.tiles, ready = [...t.tiles.values()].filter(e => e.state === 'ready');`; // declares t, ready
+export const steps = [
+ { name: '3D is on by default: tiles carry heights', wait: 4000, expect: v => v.terrain === 1 && v.withH > 0 && v.withH === v.ready && v.on,
+ js: `window.mapViewer.view.setState({ lat: 46.5, lon: 9.8, alt: 0.0008 });` + settle +
+ `return { terrain: t.terrain, ready: ready.length, withH: ready.filter(e => e.h).length, on: document.getElementById('nav-3d').classList.contains('on') };` },
+ { name: 'the camera stays 20 m above the loaded ground', expect: v => v.clear >= 19.9 && v.ground > 0,
+ js: `const a = window.mapViewer; a.zoomBy(1e-6);` + settle + `const p = a.view.camera.position, e = p.length() - 1;
+ const at = { lat: a.view.pose.lat, lon: a.view.pose.lon }, g = a.view.tiles.heightAt(at.lat, at.lon) ?? 0;
+ return { clear: e * ${R_M} - g * a.state.terrain, ground: g };` },
+ { name: 'exaggeration slider reshapes and lands in the URL', expect: v => v.t === 3 && /3d=3/.test(v.h) && v.text === '3×',
+ js: `const a = window.mapViewer, s = document.getElementById('exag'); s.value = '3'; s.dispatchEvent(new Event('input'));
+ a.saveUrl(); return { t: a.view.tiles.terrain, h: location.hash, text: document.getElementById('exag-text').textContent };` },
+ { name: '3D off: flat tiles again, the slider hides, URL says 3d=0', expect: v => v.t === 0 && v.hidden && /3d=0/.test(v.h) && v.withH === 0,
+ js: `const a = window.mapViewer; document.getElementById('nav-3d').click();` + settle +
+ `a.saveUrl(); return { t: t.terrain, hidden: document.getElementById('exag-box').hidden, h: location.hash, withH: ready.filter(e => e.h).length };` },
+ { name: 'URL brings 3D back at its exaggeration', expect: v => v === 2,
+ js: `location.hash = location.hash.replace(/3d=0/, '3d=2'); await new Promise(r => setTimeout(r, 300)); return window.mapViewer.view.tiles.terrain;` },
+ { name: 'tilted near the ground: sky at the top, ground at the bottom',
+ js: `const a = window.mapViewer; a.view.setState({ lat: 46.5, lon: 9.8, alt: 0.0003, hd: 20, pt: 78 });` + settle + `
+ const gl = a.renderer.getContext(); a.view.render(); const W = gl.drawingBufferWidth, H = gl.drawingBufferHeight, px = new Uint8Array(4);
+ gl.readPixels(W >> 1, H - 4, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); const top = [...px];
+ gl.readPixels(W >> 1, 4, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); const bottom = [...px];
+ return { top, bottom };`,
+ expect: v => v.top[2] > 150 && v.top[2] > v.top[0] && Math.abs(v.bottom[2] - v.top[2]) + Math.abs(v.bottom[0] - v.top[0]) > 30 },
+ { name: 'screenshot for a human look', js: 'return 1', shot: process.env.SHOT || '/dev/null' },
+ { name: 'a click picks the 3D ground it shows; a marker there lands on the same pixel', expect: v => v.moved > 0.001 && v.dx < 1 && v.dy < 1,
+ js: `const a = window.mapViewer, W = a.el.clientWidth, H = a.el.clientHeight, x = W / 2, y = H * 0.6;
+ const p = a.view.pick(x, y), sea = a.view.pick(x, y, false), s = a.view.toScreen(p.lat, p.lon);
+ return { moved: Math.hypot(p.lat - sea.lat, p.lon - sea.lon), dx: Math.abs(s.x - x), dy: Math.abs(s.y - y) };` },
+ { name: 'deep zoom: rivers are water in the tiles (overlay hidden); other lines lie on the 3D ground, depth-tested',
+ expect: v => !v.riverLine && v.drape && v.depth && v.onGround,
+ js: `const a = window.mapViewer; a.toggleOverlay('rivers', true);
+ const R = await a.overlays.cache.get('rivers'), line = R.big.find(l => l.length > 6 && l.some(q => Math.abs(q.lat) < 50));
+ const i = Math.floor(line.length / 2), p = line[i], q = line[i + 1];
+ const hd = Math.atan2((q.lon - p.lon) * Math.cos(p.lat * Math.PI / 180), q.lat - p.lat) * 180 / Math.PI;
+ a.view.setState({ lat: p.lat, lon: p.lon, alt: 0.0006, hd, pt: 72 });` + settle + `
+ a.showTab('measure'); a.measure.clear(); a.measure.add(p); a.measure.add({ lat: p.lat + 0.02, lon: p.lon + 0.02 }); a.measureChanged();
+ await new Promise(r => setTimeout(r, 800));
+ const o = a.view.lines.objects.get('measure'), arr = o.geometry.attributes.position.array;
+ let best = null; // the draped vertex nearest the first measure point: compare with the drawn ground under it
+ for (let k = 0; k < arr.length; k += 3) {
+ const v = [arr[k] + o.position.x, arr[k + 1] + o.position.y, arr[k + 2] + o.position.z], r = Math.hypot(...v);
+ const la = Math.asin(v[1] / r) * 180 / Math.PI, lo = Math.atan2(-v[2], v[0]) * 180 / Math.PI, d = Math.hypot(la - p.lat, lo - p.lon);
+ if (!best || d < best.d) best = { d, h: (r - 1) * ${R_M}, g: (a.view.tiles.heightAt(la, lo, true) ?? 0) * a.state.terrain };
+ }
+ a.measure.clear(); a.measureChanged();
+ return { riverLine: a.view.lines.objects.has('rivers'), drape: !!a.view.drape, depth: o.material.depthTest,
+ onGround: best.d < 0.05 && Math.abs(best.h - best.g) < 60, ...best };` },
+ { name: 'screenshot: rivers on the ground', js: 'return 1', shot: process.env.SHOT2 || '/dev/null' },
+ { name: 'tilted 3D across the antimeridian: tiles with heights, finite picks', expect: v => v.withH >= 4 && v.errors === 0 && v.pick,
+ js: `window.mapViewer.view.setState({ lat: 10, lon: 179.99, alt: 0.002, hd: 90, pt: 60 });` + settle + `const a = window.mapViewer;
+ const p = a.view.pick(a.el.clientWidth / 2, a.el.clientHeight * 0.7);
+ return { withH: ready.filter(e => e.h).length, errors: [...t.tiles.values()].filter(e => e.state === 'error').length,
+ pick: !!p && Number.isFinite(p.lat) && Number.isFinite(p.lon) };` },
+ { name: 'tilted 3D near the north pole', expect: v => v.withH >= 1 && v.errors === 0 && v.pick,
+ js: `window.mapViewer.view.setState({ lat: 89.9, lon: 0, alt: 0.002, hd: 0, pt: 60 });` + settle + `const a = window.mapViewer;
+ const p = a.view.pick(a.el.clientWidth / 2, a.el.clientHeight * 0.7);
+ return { withH: ready.filter(e => e.h).length, errors: [...t.tiles.values()].filter(e => e.state === 'error').length,
+ pick: !!p && Number.isFinite(p.lat) && Number.isFinite(p.lon) };` },
+];
diff --git a/viewer/tools/smoke/terrain.mjs b/viewer/tools/smoke/terrain.mjs
new file mode 100644
index 0000000..fb04e47
--- /dev/null
+++ b/viewer/tools/smoke/terrain.mjs
@@ -0,0 +1,22 @@
+// terrain.mjs — elevation profile along a measure path, probe marker, hover quick-info
+const P = acts => [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: acts }];
+export const steps = [
+ { name: 'two measure points → chart', wait: 4000, expect: v => v.chart && v.len > 100 && /Highest/.test(v.text),
+ js: `const a = window.mapViewer; a.showTab('measure'); a.measure.add({ lat: 40, lon: 0 }); a.measure.add({ lat: 45, lon: 20 });
+ a.measureChanged(); let c = null; for (let i = 0; i < 40 && !c; i++) { await new Promise(r => setTimeout(r, 250)); c = document.querySelector('#tab-measure svg.profile'); }
+ return { chart: !!c, len: c?.querySelector('path.line')?.getAttribute('d').length, text: document.querySelector('.profile-box').textContent };` },
+ { name: 'hovering the chart drops a probe marker', expect: ([n, t]) => n === 1 && / km · /.test(t),
+ js: `const c = document.querySelector('#tab-measure svg.profile'), r = c.getBoundingClientRect();
+ c.dispatchEvent(new PointerEvent('pointermove', { clientX: r.left + r.width / 2, clientY: r.top + 30, bubbles: true }));
+ return [document.querySelectorAll('.marker.probe').length, document.querySelector('.readout').textContent];` },
+ { name: 'an antipodal leg shows a warning, not a chart', expect: v => /antipodal/.test(v),
+ js: `const a = window.mapViewer; a.measure.clear(); a.measure.add({ lat: 0, lon: 0 }); a.measure.add({ lat: 0, lon: 180 }); a.measureChanged();
+ await new Promise(r => setTimeout(r, 800)); return document.querySelector('.profile-box').textContent;` },
+ { name: 'hover quick-info in the status bar', wait: 300,
+ actions: P([{ type: 'pointerMove', x: 600, y: 400 }, { type: 'pointerMove', x: 602, y: 401, duration: 50 }]), settle: 1000,
+ js: `window.mapViewer.measure.clear(); window.mapViewer.measureChanged(); return document.getElementById('hover').textContent;`,
+ expect: v => /^· .* m/.test(v) },
+ { name: 'hover text comes back after the pointer leaves the map and returns', expect: v => /^· .* m/.test(v),
+ js: `const a = window.mapViewer, p = { lat: 20, lon: 0 }; await a.hover(p); await a.hover(null); await a.hover(p);
+ return document.getElementById('hover').textContent;` },
+];
diff --git a/viewer/tools/smoke/touch.mjs b/viewer/tools/smoke/touch.mjs
new file mode 100644
index 0000000..55405fd
--- /dev/null
+++ b/viewer/tools/smoke/touch.mjs
@@ -0,0 +1,42 @@
+// touch.mjs — two-finger gestures on a phone: the map follows the fingers (twist turns it with them, a spread zooms
+// in about them, both fingers up tilt the globe); flat maps pinch about the fingers.
+export const viewport = { width: 390, height: 844 };
+const helpers = `const a = window.mapViewer, c = document.querySelector('#map canvas'), r = c.getBoundingClientRect();
+ const ev = (t, id, x, y) => c.dispatchEvent(new PointerEvent(t, { clientX: r.left + x, clientY: r.top + y, bubbles: true,
+ pointerId: id, pointerType: 'touch', isPrimary: id === 11, button: 0, buttons: t === 'pointerup' ? 0 : 1 }));
+ const gesture = (from, to, steps = 10) => { // from/to: [[x, y], [x, y]] for fingers 11 and 12
+ ev('pointerdown', 11, ...from[0]); ev('pointerdown', 12, ...from[1]);
+ for (let s = 1; s <= steps; s++) for (const [k, id] of [[0, 11], [1, 12]])
+ ev('pointermove', id, from[k][0] + (to[k][0] - from[k][0]) * s / steps, from[k][1] + (to[k][1] - from[k][1]) * s / steps);
+ ev('pointerup', 11, ...to[0]); ev('pointerup', 12, ...to[1]);
+ };
+ const W = r.width, H = r.height, cx = W / 2, cy = H / 2;`;
+export const steps = [
+ { name: 'boots', wait: 5000, expect: v => v, js: `window.mapViewer.setView('globe'); return !!window.mapViewer.view.pose;` },
+ { name: 'twist clockwise 30° (about the view centre): a ground point turns ~30° clockwise with the fingers', expect: v => Math.abs(v - 30) < 6,
+ js: `${helpers}
+ a.view.setPose({ ...a.view.pose, alt: undefined, pitch: 0, range: a.view.pose.range });
+ const p = a.view.pick(cx + 60, cy, false); const s0 = a.view.toScreen(p.lat, p.lon);
+ const R = 80, rad = d => d * Math.PI / 180, at = d => [cx + R * Math.cos(rad(d)), cy + R * Math.sin(rad(d))];
+ gesture([at(180), at(0)], [at(210), at(30)]);
+ const s1 = a.view.toScreen(p.lat, p.lon);
+ return (Math.atan2(s1.y - cy, s1.x - cx) - Math.atan2(s0.y - cy, s0.x - cx)) * 180 / Math.PI;` },
+ { name: 'spread to twice the distance: zoomed in ~2× (range halves)', expect: v => Math.abs(v - 0.5) < 0.08,
+ js: `${helpers} const r0 = a.view.pose.range; gesture([[cx - 40, cy], [cx + 40, cy]], [[cx - 80, cy], [cx + 80, cy]]); return a.view.pose.range / r0;` },
+ { name: 'both fingers up: the globe tilts toward the horizon', expect: v => v > 5,
+ js: `${helpers} a.view.setPose({ ...a.view.pose, range: a.view.pose.range * 0.02, pitch: 0 }); const p0 = a.view.pose.pitch; gesture([[cx - 60, cy + 100], [cx + 60, cy + 100]], [[cx - 60, cy], [cx + 60, cy]]); return a.view.pose.pitch - p0;` },
+ { name: 'one finger after a pinch keeps panning (no jump, no stuck drag)', expect: v => v.moved && !v.jump,
+ js: `${helpers} a.view.setPose({ ...a.view.pose, pitch: 0 });
+ const before = { ...a.view.pose };
+ ev('pointerdown', 11, cx - 40, cy); ev('pointerdown', 12, cx + 40, cy); ev('pointermove', 12, cx + 45, cy); ev('pointerup', 12, cx + 45, cy);
+ const mid = { ...a.view.pose };
+ ev('pointermove', 11, cx - 40, cy + 30); ev('pointerup', 11, cx - 40, cy + 30);
+ const after = a.view.pose;
+ return { moved: Math.abs(after.lat - mid.lat) > 1e-4, jump: Math.abs(after.lat - mid.lat) > 20 };` },
+ { name: 'flat map: spread zooms 2× about the fingers (the point between them stays put)', expect: v => Math.abs(v.z - 2) < 0.1 && v.drift < 3,
+ js: `${helpers} a.setView('equal_earth'); a.view.setState({ lat: 0, lon: 0, zoom: 2 });
+ const z0 = a.view.getState().zoom, mx = cx + 30, my = cy + 20, p = a.view.pick(mx, my, false);
+ gesture([[mx - 40, my], [mx + 40, my]], [[mx - 80, my], [mx + 80, my]]);
+ const s = a.view.toScreen(p.lat, p.lon), z = a.view.getState().zoom / z0; a.setView('globe');
+ return { z, drift: Math.hypot(s.x - mx, s.y - my) };` },
+];