aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/tests
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/tests')
-rw-r--r--viewer/tests/_planet.mjs3
-rw-r--r--viewer/tests/atmosphere.test.mjs24
-rw-r--r--viewer/tests/camera.test.mjs139
-rw-r--r--viewer/tests/exports.test.mjs46
-rw-r--r--viewer/tests/fakedom.mjs29
-rw-r--r--viewer/tests/fixtures/projections.json42
-rw-r--r--viewer/tests/flatmap.test.mjs83
-rw-r--r--viewer/tests/geo.test.mjs77
-rw-r--r--viewer/tests/gestures.test.mjs61
-rw-r--r--viewer/tests/globe.test.mjs377
-rw-r--r--viewer/tests/hover.test.mjs30
-rw-r--r--viewer/tests/importmap-hooks.mjs11
-rw-r--r--viewer/tests/importmap.mjs15
-rw-r--r--viewer/tests/inspector.test.mjs30
-rw-r--r--viewer/tests/latest.test.mjs24
-rw-r--r--viewer/tests/layers.test.mjs50
-rw-r--r--viewer/tests/layout.test.mjs40
-rw-r--r--viewer/tests/measure.test.mjs82
-rw-r--r--viewer/tests/modules.test.mjs23
-rw-r--r--viewer/tests/nav.test.mjs64
-rw-r--r--viewer/tests/overlay_geom.test.mjs69
-rw-r--r--viewer/tests/overlays.test.mjs147
-rw-r--r--viewer/tests/pins.test.mjs51
-rw-r--r--viewer/tests/profile.test.mjs36
-rw-r--r--viewer/tests/public.test.mjs18
-rw-r--r--viewer/tests/regions.test.mjs82
-rw-r--r--viewer/tests/riverlod.test.mjs37
-rw-r--r--viewer/tests/search.test.mjs55
-rw-r--r--viewer/tests/searchbox.test.mjs65
-rw-r--r--viewer/tests/shading.test.mjs57
-rw-r--r--viewer/tests/sky.test.mjs38
-rw-r--r--viewer/tests/skypanel.test.mjs16
-rw-r--r--viewer/tests/tilelayer.test.mjs402
-rw-r--r--viewer/tests/tiles.test.mjs126
-rw-r--r--viewer/tests/units.test.mjs38
-rw-r--r--viewer/tests/urlstate.test.mjs77
-rw-r--r--viewer/tests/vents.test.mjs27
37 files changed, 2591 insertions, 0 deletions
diff --git a/viewer/tests/_planet.mjs b/viewer/tests/_planet.mjs
new file mode 100644
index 0000000..a1f9822
--- /dev/null
+++ b/viewer/tests/_planet.mjs
@@ -0,0 +1,3 @@
+// _planet.mjs — the test world (import first): 12,742 km, 20° tilt, 216-day year, 31.149 h days of 46,656 moments
+import { PLANET } from '../js/planet.js';
+Object.assign(PLANET, { radius_km: 12742, tilt_deg: 20, year_days: 216, day_hours: 31.149, moment_s: 2.403473 });
diff --git a/viewer/tests/atmosphere.test.mjs b/viewer/tests/atmosphere.test.mjs
new file mode 100644
index 0000000..79f183a
--- /dev/null
+++ b/viewer/tests/atmosphere.test.mjs
@@ -0,0 +1,24 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import './importmap.mjs';
+
+const { atmosphere, SKY_FULL_KM, SKY_NONE_KM } = await import('../js/atmosphere.js');
+const lum = c => c[0] + c[1] + c[2];
+
+test('full sky near the ground, none in space, a smooth fade between', () => {
+ assert.equal(atmosphere(5).amount, 1);
+ assert.equal(atmosphere(SKY_FULL_KM).amount, 1);
+ assert.equal(atmosphere(SKY_NONE_KM).amount, 0);
+ assert.equal(atmosphere(5000).amount, 0);
+ const m = atmosphere(60).amount;
+ assert.ok(m > 0.2 && m < 0.8);
+});
+
+test('day sky is blue, lighter at the horizon; night is dark; Sky off = day', () => {
+ const d = atmosphere(1, null);
+ assert.ok(d.zenith[2] > d.zenith[0] && lum(d.horizon) > lum(d.zenith));
+ const n = atmosphere(1, -1);
+ assert.ok(lum(n.horizon) < 0.3 * lum(d.horizon));
+ assert.deepEqual(atmosphere(1, 1), d);
+});
diff --git a/viewer/tests/camera.test.mjs b/viewer/tests/camera.test.mjs
new file mode 100644
index 0000000..f2ed115
--- /dev/null
+++ b/viewer/tests/camera.test.mjs
@@ -0,0 +1,139 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { toVec, R_KM } from '../js/geo.js';
+import { MIN_ALT, MAX_ALT, CLEARANCE_M } from '../js/limits.js';
+import { R_M, TILT_FULL, TILT_NONE, frame, maxPitch, clampPose, eyeOf, eyeAltitude, rangeForAltitude, headingOf,
+ rotatePose, panPose, lift, kmPerPxAt } from '../js/camera.js';
+
+const near = (a, b, eps, m = '') => assert.ok(Math.abs(a - b) <= eps, `${m} ${a} vs ${b}`);
+const nearV = (a, b, eps) => a.forEach((v, i) => near(v, b[i], eps, `[${i}]`));
+const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+const sub = (a, b) => a.map((v, i) => v - b[i]);
+const len = a => Math.hypot(...a);
+const pose = o => clampPose({ lat: 40, lon: -10, range: 0.01, heading: 0, pitch: 0, ...o });
+
+test('frame: east, north, up are orthonormal and right-handed (east × north = up)', () => {
+ for (const [la, lo] of [[0, 0], [40, -10], [-65, 170], [90, 30], [-90, -120]]) {
+ const { east: e, north: n, up: u } = frame(la, lo);
+ for (const v of [e, n, u]) near(len(v), 1, 1e-12);
+ near(dot(e, n), 0, 1e-12); near(dot(e, u), 0, 1e-12); near(dot(n, u), 0, 1e-12);
+ nearV([e[1] * n[2] - e[2] * n[1], e[2] * n[0] - e[0] * n[2], e[0] * n[1] - e[1] * n[0]], u, 1e-12);
+ }
+ nearV(frame(0, 0).north, [0, 1, 0], 1e-12);
+});
+
+test('straight down: the eye is above the target, screen-up is the heading', () => {
+ const p = pose({ heading: 0 }), { eye, up, target } = eyeOf(p);
+ nearV(target, toVec(40, -10), 1e-12);
+ nearV(eye, toVec(40, -10).map(v => v * 1.01), 1e-12);
+ nearV(up, frame(40, -10).north, 1e-12);
+ nearV(eyeOf(pose({ heading: 90 })).up, frame(40, -10).east, 1e-12);
+});
+
+test('tilted: range is the eye–target distance, pitch the angle from vertical, eye behind the heading', () => {
+ const p = pose({ heading: 30, pitch: 60 }), { eye, up, target } = eyeOf(p), back = sub(eye, target);
+ near(len(back), 0.01, 1e-12);
+ near(Math.acos(dot(back, target) / len(back)) * 180 / Math.PI, 60, 1e-9);
+ near(headingOf(40, -10, sub(target, eye)), 30, 1e-9);
+ near(dot(up, sub(target, eye)), 0, 1e-12);
+ near(len(up), 1, 1e-12);
+});
+
+test('clampPose: range limits, tilt only when low (ramp 1,000 → 2,000 km), heading wraps, junk → defaults', () => {
+ assert.equal(clampPose({ range: 99 }).range, MAX_ALT);
+ assert.equal(clampPose({ range: 0 }).range, MIN_ALT);
+ near(TILT_FULL * R_KM, 1000, 1e-9); near(TILT_NONE * R_KM, 2000, 1e-9);
+ assert.equal(pose({ range: 500 / R_KM, pitch: 70 }).pitch, 70);
+ assert.equal(pose({ range: 3000 / R_KM, pitch: 70 }).pitch, 0);
+ near(maxPitch(1500 / R_KM), 40, 1e-9);
+ assert.equal(pose({ range: 500 / R_KM, pitch: 95 }).pitch, 80);
+ assert.equal(pose({ heading: -30 }).heading, 330);
+ assert.equal(pose({ heading: 720 }).heading, 0);
+ const j = clampPose({ lat: NaN, lon: 'x', range: undefined, heading: null, pitch: Infinity });
+ assert.deepEqual([j.lat, j.lon, j.range, j.heading], [0, 0, 2, 0]);
+ assert.equal(pose({ lon: 190 }).lon, -170);
+});
+
+test('eye altitude ↔ range round trip', () => {
+ for (const pt of [0, 30, 80]) for (const a of [1e-6, 3e-4, 0.05]) near(eyeAltitude(rangeForAltitude(a, pt), pt), a, 1e-15);
+ near(eyeAltitude(0.01, 0), 0.01, 1e-15);
+});
+
+test('rotatePose moves the target exactly; a meridian move keeps the heading; poles and the antimeridian stay finite', () => {
+ const p = pose({ heading: 45, pitch: 30 }), q = rotatePose(p, toVec(40, -10), toVec(41, -10));
+ near(q.lat, 41, 1e-9); near(q.lon, -10, 1e-9); near(q.heading, 45, 1e-9);
+ assert.equal(q.range, p.range); assert.equal(q.pitch, p.pitch);
+ const r = rotatePose(pose({ lon: 179.5 }), toVec(40, 179.5), toVec(40, -179.5));
+ near(r.lon, -179.5, 1e-9);
+ const s = rotatePose(pose({ lat: 89.9, heading: 10 }), toVec(89.9, -10), toVec(90, 0));
+ assert.ok([s.lat, s.lon, s.heading].every(Number.isFinite));
+ assert.deepEqual(rotatePose(p, toVec(1, 2), toVec(1, 2)), p);
+});
+
+test('rotatePose is a rigid turn: the eye keeps its distance to the moved point (the grab invariant)', () => {
+ const p = pose({ heading: 200, pitch: 50 }), a = toVec(40.2, -9.9), b = toVec(40.05, -10.3);
+ const e1 = eyeOf(p).eye, e2 = eyeOf(rotatePose(p, a, b)).eye;
+ near(len(sub(e2, b)), len(sub(e1, a)), 1e-12);
+});
+
+test('panPose: screen-up moves forward along the heading, right moves right, by a fraction of the range', () => {
+ const up = panPose(pose({ heading: 0 }), 0, -0.2);
+ near(up.lat - 40, (0.2 * 0.01 * 180) / Math.PI, 1e-9); near(up.lon, -10, 1e-9);
+ const right = panPose(pose({ lat: 0, lon: 0, heading: 0 }), 0.2, 0);
+ near(right.lat, 0, 1e-9); near(right.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9);
+ const east = panPose(pose({ lat: 0, lon: 0, heading: 90 }), 0, -0.2);
+ near(east.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9);
+ assert.ok(panPose(pose({ range: 5 }), 0, -1).lat - 40 <= 0.5 * 180 / Math.PI + 1e-9, 'big steps are capped');
+});
+
+test('lift: never below the ground + 20 m (sea level when the ground is lower or unknown)', () => {
+ const low = pose({ range: 1e-9, pitch: 0 }), g = 1500;
+ near(eyeAltitude(lift(low, g).range, 0) * R_M, g + CLEARANCE_M, 1e-6);
+ near(eyeAltitude(lift(low, -300).range, 0) * R_M, CLEARANCE_M, 1e-6);
+ const tilted = pose({ range: 2e-6, pitch: 80 });
+ near(eyeAltitude(lift(tilted, 0).range, 80) * R_M, CLEARANCE_M, 1e-6);
+ const high = pose({ range: 0.01 });
+ assert.equal(lift(high, 1500), high);
+});
+
+test('kmPerPxAt: distance × the pixel angle', () => {
+ const { eye } = eyeOf(pose()), fov = 35, h = 800;
+ near(kmPerPxAt(eye, { lat: 40, lon: -10 }, fov, h), (0.01 * R_KM * 2 * Math.tan((fov * Math.PI) / 360)) / h, 1e-9);
+});
+
+test('panning keeps the compass heading (north stays where it was)', () => {
+ const p = pose({ lat: 20, lon: 0, range: 2, heading: 0 });
+ assert.equal(panPose(p, 0.2, 0).heading, 0);
+ assert.equal(panPose(pose({ heading: 30, pitch: 40 }), 0, -0.2).heading, 30);
+});
+
+test('the target sits on the ground: range, altitude and the floor measure from it', () => {
+ const g = 3000, p = pose({ range: 20 / R_M, ground: g });
+ const { eye, target } = eyeOf(p);
+ near(len(target), 1 + g / R_M, 1e-15);
+ near(len(sub(eye, target)), 20 / R_M, 1e-15);
+ near(eyeAltitude(p.range, 0, g) * R_M, g + 20, 1e-6);
+ for (const pt of [0, 45, 80]) near(eyeAltitude(rangeForAltitude(0.001, pt, g), pt, g), 0.001, 1e-15);
+ near(eyeAltitude(lift(pose({ range: 1e-9, ground: g }), g).range, 0, g) * R_M, g + CLEARANCE_M, 1e-6);
+ assert.equal(clampPose({ ground: -5 }).ground, 0);
+ assert.equal(clampPose({}).ground, 0);
+});
+
+test('holding the forward key across a pole keeps going (no bouncing back and forth)', async () => {
+ const { angleRad } = await import('../js/geo.js');
+ let p = pose({ lat: 89.5, lon: 0, range: 0.01, heading: 0 }), last = 0;
+ const start = { lat: 89.5, lon: 0 };
+ for (let i = 0; i < 12; i++) {
+ p = panPose(p, 0, -0.2);
+ const d = angleRad(start, p);
+ assert.ok(d > last, `press ${i}: ${d} after ${last} (lat ${p.lat}, lon ${p.lon})`);
+ last = d;
+ }
+});
+
+test('lift: a floor below sea level only when asked', () => {
+ const p = { lat: 0, lon: 0, range: MIN_ALT, heading: 0, pitch: 0, ground: -3000 };
+ near(eyeAltitude(lift(p, -3000).range, 0, -3000) * R_M, CLEARANCE_M, 1, 'default: 20 m above sea level');
+ near(eyeAltitude(lift(p, -3000, true).range, 0, -3000) * R_M, -3000 + CLEARANCE_M, 1, 'below: 20 m above the floor');
+});
diff --git a/viewer/tests/exports.test.mjs b/viewer/tests/exports.test.mjs
new file mode 100644
index 0000000..c3735fb
--- /dev/null
+++ b/viewer/tests/exports.test.mjs
@@ -0,0 +1,46 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { samplesFor, squareOutline, exportSizeMB, exportStatusText } from '../js/exports.js';
+
+test('samples: the smallest 2^n + 1 covering the size (as the server)', () => {
+ assert.equal(samplesFor(40, 10), 4097);
+ assert.equal(samplesFor(100, 2000), 65);
+ assert.equal(samplesFor(5, 20), 257);
+});
+
+test('the square outline: corners size/√2 from the centre, closed', () => {
+ const R = 12742, pts = squareOutline(0, 0, 100);
+ assert.deepEqual(pts[0], pts[pts.length - 1]);
+ const d = p => R * Math.acos(Math.cos(p.lat * Math.PI / 180) * Math.cos(p.lon * Math.PI / 180));
+ assert.ok(Math.abs(d(pts[0]) - 50 * Math.SQRT2) < 0.05);
+ const north = pts.reduce((a, b) => (b.lat > a.lat ? b : a));
+ assert.ok(Math.abs(north.lat * Math.PI / 180 * R - 50) < 0.1, 'the north edge 50 km up');
+});
+
+test('size estimate and status', () => {
+ assert.ok(exportSizeMB(4097) > 100 && exportSizeMB(4097) < 200, "the island export was 128 MB");
+ assert.equal(exportStatusText({ state: 'running', name: 'x', fraction: 0.5 }), 'Exporting x… 50%');
+ assert.equal(exportStatusText({ state: 'failed', name: 'x', error: 'boom' }), 'Export x failed: boom');
+ assert.equal(exportStatusText({ state: 'idle' }), '');
+});
+
+test('polling while an export runs keeps what is being typed', async () => {
+ const { installFakeDom, FakeEl } = await import('./fakedom.mjs');
+ installFakeDom();
+ let calls = 0;
+ globalThis.fetch = async () => ({ ok: true, status: 200, headers: { get: () => 'application/json' },
+ json: async () => ({ exports: [], status: { state: 'running', name: 'x', fraction: 0.1 * ++calls } }) });
+ const { ExportPanel } = await import('../js/exportpanel.js');
+ const box = new FakeEl('section');
+ const p = new ExportPanel(box, { getCenter: () => ({ lat: 1, lon: 2 }), onSquare: () => {} });
+ p.visible = true;
+ await p.load();
+ const input = box.find(e => e.tag === 'input');
+ input.value = 'my-area';
+ await p.load(); // a poll
+ clearTimeout(p.timer);
+ assert.equal(box.find(e => e.tag === 'input'), input, 'the same input, not rebuilt');
+ assert.equal(input.value, 'my-area');
+ assert.match(box.textContent, /Exporting x… 20%/);
+});
diff --git a/viewer/tests/fakedom.mjs b/viewer/tests/fakedom.mjs
new file mode 100644
index 0000000..5b2fadd
--- /dev/null
+++ b/viewer/tests/fakedom.mjs
@@ -0,0 +1,29 @@
+// Minimal DOM stand-in for node tests of the panels: just what ui.el() and the panels touch.
+export class FakeEl {
+ constructor(tag) { Object.assign(this, { tag, kids: [], attrs: {}, on: {}, style: {}, value: '', hidden: false }); }
+ setAttribute(k, v) { this.attrs[k] = v; }
+ get className() { return this.attrs.class ?? ''; }
+ addEventListener(t, f) { (this.on[t] ??= []).push(f); }
+ fire(t, e = {}) { for (const f of this.on[t] ?? []) f({ preventDefault() {}, stopPropagation() {}, target: this, ...e }); }
+ append(...k) { this.kids.push(...k); }
+ replaceChildren(...k) { this.kids = k; }
+ focus() {}
+ blur() {}
+ getBoundingClientRect() { return { left: 0, top: 0, width: 320, height: 130 }; }
+ get textContent() { return this.kids.map(k => (typeof k === 'string' ? k : k.textContent)).join(''); }
+ set textContent(v) { this.kids = [String(v)]; }
+ find(pred) {
+ for (const k of this.kids) {
+ if (typeof k === 'string') continue;
+ if (pred(k)) return k;
+ const r = k.find(pred);
+ if (r) return r;
+ }
+ return null;
+ }
+}
+
+export function installFakeDom() {
+ globalThis.document = { createElement: t => new FakeEl(t), createElementNS: (_, t) => new FakeEl(t),
+ querySelector: () => null, querySelectorAll: () => [] };
+}
diff --git a/viewer/tests/fixtures/projections.json b/viewer/tests/fixtures/projections.json
new file mode 100644
index 0000000..cb29f74
--- /dev/null
+++ b/viewer/tests/fixtures/projections.json
@@ -0,0 +1,42 @@
+{
+ "ee_xmax": 2.7066299836960743,
+ "ee_ymax": 1.317362759157413,
+ "equal_earth": [
+ {
+ "lat": 0.0,
+ "lon": 0.0,
+ "x": 0.0,
+ "y": 0.0
+ },
+ {
+ "lat": 30.0,
+ "lon": 120.0,
+ "x": 1.6878011172181764,
+ "y": 0.592935119848017
+ },
+ {
+ "lat": -60.0,
+ "lon": -170.0,
+ "x": -1.9257789715953615,
+ "y": -1.0883008355053196
+ },
+ {
+ "lat": 89.0,
+ "lon": 45.0,
+ "x": 0.40107231234847257,
+ "y": 1.3170644437079506
+ },
+ {
+ "lat": 10.0,
+ "lon": -179.0,
+ "x": -2.672037641978515,
+ "y": 0.20204243960029805
+ },
+ {
+ "lat": -45.5,
+ "lon": 33.3,
+ "x": 0.42759882515649705,
+ "y": -0.8685664740830532
+ }
+ ]
+} \ No newline at end of file
diff --git a/viewer/tests/flatmap.test.mjs b/viewer/tests/flatmap.test.mjs
new file mode 100644
index 0000000..2cafb36
--- /dev/null
+++ b/viewer/tests/flatmap.test.mjs
@@ -0,0 +1,83 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { fakeRenderer } from './importmap.mjs';
+
+const { FlatView, MAX_ZOOM } = await import('../js/flatmap.js');
+const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);
+const ev = (type, props) => Object.assign(new Event(type, { cancelable: true }), { button: 0, pointerId: 1, deltaMode: 0, ...props });
+
+function flat(kind, st = { lat: 20, lon: 30, zoom: 3 }) {
+ const r = fakeRenderer();
+ const v = new FlatView(r, kind, () => {});
+ v.resize(800, 400);
+ v.setState(st);
+ v.activate();
+ return { v, canvas: r.domElement };
+}
+
+for (const kind of ['equal_earth', 'plate_carree']) {
+ test(`${kind}: state round trip, zoom clamped`, () => {
+ const s = flat(kind).v.getState();
+ near(s.lat, 20, 1e-9); near(s.lon, 30, 1e-9);
+ assert.equal(s.zoom, 3);
+ assert.equal(flat(kind, { lat: 0, lon: 0, zoom: 1e9 }).v.getState().zoom, MAX_ZOOM);
+ assert.equal(MAX_ZOOM, 20000);
+ assert.equal(flat(kind, { lat: 0, lon: 0, zoom: 0 }).v.getState().zoom, 1);
+ });
+
+ test(`${kind}: pick and toScreen agree, incl. near the seam and poles`, () => {
+ const { v } = flat(kind, { lat: 0, lon: 0, zoom: 1 });
+ for (const [lat, lon] of [[0, 0], [45, 179.9], [-89, -120], [60, -179.9]]) {
+ const q = v.toScreen(lat, lon), p = v.pick(q.x, q.y);
+ near(p.lat, lat, 1e-6); near(p.lon, lon, 1e-6);
+ }
+ });
+
+ test(`${kind}: wheel zoom keeps the point under the cursor`, () => {
+ const { v, canvas } = flat(kind, { lat: 10, lon: 10, zoom: 2 });
+ const before = v.pick(600, 120);
+ canvas.dispatchEvent(ev('wheel', { deltaY: -300, clientX: 600, clientY: 120 }));
+ assert.ok(v.getState().zoom > 2);
+ const after = v.pick(600, 120);
+ near(after.lat, before.lat, 1e-6); near(after.lon, before.lon, 1e-6);
+ });
+
+ test(`${kind}: drag pans with the cursor; an inactive view ignores input`, () => {
+ const { v, canvas } = flat(kind, { lat: 0, lon: 0, zoom: 4 });
+ const p = v.pick(400, 200);
+ canvas.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 200 }));
+ canvas.dispatchEvent(ev('pointermove', { clientX: 300, clientY: 200 }));
+ canvas.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 200 }));
+ const q = v.pick(300, 200);
+ near(q.lat, p.lat, 1e-6); near(q.lon, p.lon, 1e-6);
+ v.deactivate();
+ const s = v.getState();
+ canvas.dispatchEvent(ev('wheel', { deltaY: -300, clientX: 10, clientY: 10 }));
+ assert.deepEqual(v.getState(), s);
+ });
+}
+
+test('equal earth: outside the outline picks null', () => {
+ assert.equal(flat('equal_earth', { lat: 0, lon: 0, zoom: 1 }).v.pick(2, 2), null);
+});
+
+test('long lines are drawn along the great circle (bowing poleward), not straight in the projection', () => {
+ const { v } = flat('plate_carree', { lat: 0, lon: 0, zoom: 1 });
+ v.setLines('m', [[{ lat: 40, lon: -60 }, { lat: 40, lon: 60 }]], 0xffffff);
+ const pos = v.lines.objects.get('m').geometry.attributes.position.array;
+ assert.ok(pos.length / 6 > 10, `${pos.length / 6} segments`);
+ const maxLat = Math.max(...pos.filter((_, i) => i % 3 === 1)) * 180 / Math.PI;
+ assert.ok(maxLat > 50, `peak latitude ${maxLat}`);
+});
+
+test('equal earth: the centre cannot leave the outline, so the saved state reproduces the view', () => {
+ const { v, canvas } = flat('equal_earth', { lat: 60, lon: 170, zoom: 8 });
+ canvas.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 200 }));
+ canvas.dispatchEvent(ev('pointermove', { clientX: 100, clientY: 200 })); // pan east past lon 180
+ canvas.dispatchEvent(ev('pointerup', { clientX: 100, clientY: 200 }));
+ const s = v.getState(), before = v.toWorld(400, 200);
+ v.setState(s);
+ const after = v.toWorld(400, 200);
+ near(after.X, before.X, 1e-9); near(after.Y, before.Y, 1e-9);
+});
diff --git a/viewer/tests/geo.test.mjs b/viewer/tests/geo.test.mjs
new file mode 100644
index 0000000..4ebb2ea
--- /dev/null
+++ b/viewer/tests/geo.test.mjs
@@ -0,0 +1,77 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import * as G from '../js/geo.js';
+
+const near = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);
+
+test('lat/lon <-> vector round trip incl. poles and seam', () => {
+ for (const [lat, lon] of [[0, 0], [45, 120], [-60, -170], [89.999, 10], [-89.999, -100], [10, 179.999], [10, -179.999]]) {
+ const p = G.toLatLon(G.toVec(lat, lon));
+ near(p.lat, lat, 1e-9);
+ near(p.lon, lon, 1e-7);
+ }
+ const pole = G.toLatLon(G.toVec(90, 0));
+ near(pole.lat, 90, 1e-9);
+ assert.ok(Number.isFinite(pole.lon));
+});
+
+test('distances on the 12,742 km sphere', () => {
+ near(G.distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 90 }), (Math.PI / 2) * 12742, 1e-6);
+ near(G.distanceKm({ lat: 90, lon: 0 }, { lat: -90, lon: 0 }), Math.PI * 12742, 1e-6);
+ near(G.distanceKm({ lat: 10, lon: 179 }, { lat: 10, lon: -179 }), G.distanceKm({ lat: 10, lon: 0 }, { lat: 10, lon: 2 }), 1e-6);
+});
+
+test('interpolate follows the great circle', () => {
+ const pts = G.interpolate({ lat: 0, lon: 0 }, { lat: 0, lon: 90 }, 10);
+ assert.equal(pts.length, 10);
+ near(pts[5].lon, 50, 1e-9);
+ near(pts[5].lat, 0, 1e-9);
+});
+
+test('spherical polygon area', () => {
+ near(G.polygonAreaKm2([{ lat: 0, lon: 0 }, { lat: 0, lon: 90 }, { lat: 90, lon: 0 }]), (4 * Math.PI * 12742 ** 2) / 8, 1);
+ const cell = ((12742 * Math.PI) / 180) ** 2;
+ near(G.polygonAreaKm2([{ lat: 0, lon: 0 }, { lat: 0, lon: 1 }, { lat: 1, lon: 1 }, { lat: 1, lon: 0 }]), cell, cell * 0.01);
+});
+
+test('equal earth matches mapgen/projections.py', () => {
+ const fx = JSON.parse(readFileSync(new URL('./fixtures/projections.json', import.meta.url)));
+ near(G.EE_XMAX, fx.ee_xmax);
+ near(G.EE_YMAX, fx.ee_ymax);
+ for (const p of fx.equal_earth) {
+ const q = G.project('equal_earth', p.lat, p.lon);
+ near(q.x, p.x);
+ near(q.y, p.y);
+ const r = G.unproject('equal_earth', p.x, p.y);
+ near(r.lat, p.lat, 1e-6);
+ near(r.lon, p.lon, 1e-6);
+ }
+ assert.equal(G.unproject('equal_earth', G.EE_XMAX, G.EE_YMAX), null);
+ near(G.unproject('equal_earth', 0, G.EE_YMAX).lat, 90, 1e-6); // pole survives rounding
+});
+
+test('plate carree projection', () => {
+ const q = G.project('plate_carree', 45, -90);
+ near(q.x, -Math.PI / 2);
+ near(q.y, Math.PI / 4);
+ assert.equal(G.unproject('plate_carree', 4, 0), null);
+});
+
+test('sphere mesh: uv follows lat/lon and faces point outward', () => {
+ const m = G.sphereMesh(8, 16);
+ for (let i = 0; i < m.uvs.length / 2; i++) {
+ const ll = G.toLatLon([m.positions[3 * i], m.positions[3 * i + 1], m.positions[3 * i + 2]]);
+ const u = m.uvs[2 * i], v = m.uvs[2 * i + 1];
+ near((ll.lat + 90) / 180, v, 1e-6);
+ if (Math.abs(ll.lat) < 89.9 && u > 1e-6 && u < 1 - 1e-6) near((ll.lon + 180) / 360, u, 1e-6);
+ }
+ assert.equal(m.indices.length, 8 * 16 * 6);
+ const q = 3 * 16 * 6 + 5 * 6; // a quad just north of the equator
+ const P = k => [m.positions[3 * k], m.positions[3 * k + 1], m.positions[3 * k + 2]];
+ const [a, b, c] = [P(m.indices[q]), P(m.indices[q + 1]), P(m.indices[q + 2])];
+ const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
+ const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
+ assert.ok(n[0] * a[0] + n[1] * a[1] + n[2] * a[2] > 0, 'triangles wind counter-clockwise seen from outside');
+});
diff --git a/viewer/tests/gestures.test.mjs b/viewer/tests/gestures.test.mjs
new file mode 100644
index 0000000..3236f44
--- /dev/null
+++ b/viewer/tests/gestures.test.mjs
@@ -0,0 +1,61 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const { twoFinger, Touches } = await import('../js/gestures.js');
+const close = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) < eps, `${a} ≉ ${b}`);
+
+test('spreading the fingers to twice the distance is scale 2, no turn, no tilt', () => {
+ const g = twoFinger({ x: 90, y: 100 }, { x: 110, y: 100 }, { x: 80, y: 100 }, { x: 120, y: 100 });
+ close(g.scale, 2); close(g.rot, 0); close(g.tilt, 0);
+ assert.deepEqual(g.mid, { x: 100, y: 100 }); assert.deepEqual(g.move, { x: 0, y: 0 });
+});
+
+test('turning the pair a quarter clockwise on screen (y down) is +90°', () => {
+ const g = twoFinger({ x: -10, y: 0 }, { x: 10, y: 0 }, { x: 0, y: -10 }, { x: 0, y: 10 });
+ close(g.rot, 90); close(g.scale, 1);
+});
+
+test('turns take the short way across ±180°', () => {
+ const g = twoFinger({ x: 0, y: 0 }, { x: -10, y: 1 }, { x: 0, y: 0 }, { x: -10, y: -1 });
+ assert.ok(Math.abs(g.rot) < 12, String(g.rot));
+});
+
+test('both fingers moving up together tilt by the smaller move; a vertical pinch does not', () => {
+ close(twoFinger({ x: 0, y: 100 }, { x: 50, y: 100 }, { x: 0, y: 80 }, { x: 50, y: 70 }).tilt, -20);
+ close(twoFinger({ x: 0, y: 100 }, { x: 0, y: 150 }, { x: 0, y: 90 }, { x: 0, y: 160 }).tilt, 0);
+});
+
+test('Touches reports a gesture only while two fingers are down', () => {
+ const t = new Touches();
+ t.down(1, { x: 0, y: 0 });
+ assert.equal(t.move(1, { x: 5, y: 0 }), null);
+ t.down(2, { x: 20, y: 0 });
+ const g = t.move(2, { x: 35, y: 0 }); // 15 → 30 apart
+ close(g.scale, 2);
+ assert.equal(t.move(9, { x: 0, y: 0 }), null, 'unknown pointer');
+ t.up(1);
+ assert.equal(t.size, 1);
+ assert.deepEqual(t.other(1), { id: 2, at: { x: 35, y: 0 } });
+});
+
+test('fingers moving up one event at a time still tilt (browsers send one move per finger)', () => {
+ const t = new Touches();
+ t.down(1, { x: 0, y: 100 }); t.down(2, { x: 50, y: 100 });
+ let tilt = 0;
+ for (let s = 1; s <= 5; s++) {
+ tilt += t.move(1, { x: 0, y: 100 - 10 * s }).tilt;
+ tilt += t.move(2, { x: 50, y: 100 - 10 * s }).tilt;
+ }
+ close(tilt, -50);
+});
+
+test('a vertical pinch, one finger at a time, does not tilt', () => {
+ const t = new Touches();
+ t.down(1, { x: 0, y: 100 }); t.down(2, { x: 0, y: 200 });
+ let tilt = 0;
+ for (let s = 1; s <= 5; s++) {
+ tilt += t.move(1, { x: 0, y: 100 - 10 * s }).tilt;
+ tilt += t.move(2, { x: 0, y: 200 + 10 * s }).tilt;
+ }
+ close(tilt, 0);
+});
diff --git a/viewer/tests/globe.test.mjs b/viewer/tests/globe.test.mjs
new file mode 100644
index 0000000..147a16a
--- /dev/null
+++ b/viewer/tests/globe.test.mjs
@@ -0,0 +1,377 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { fakeRenderer } from './importmap.mjs';
+
+const { GlobeView, MIN_ALT, MAX_ALT } = await import('../js/globe.js');
+const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);
+
+function globe(st = { lat: 40, lon: -10, alt: 0.5 }) {
+ const g = new GlobeView(fakeRenderer(), () => {});
+ g.resize(800, 400);
+ g.setState(st);
+ return g;
+}
+
+test('state round trip; altitude clamped', () => {
+ const s = globe().getState();
+ near(s.lat, 40, 1e-6); near(s.lon, -10, 1e-6); near(s.alt, 0.5, 1e-9);
+ assert.equal(globe({ lat: 0, lon: 0, alt: 99 }).getState().alt.toFixed(6), MAX_ALT.toFixed(6));
+ assert.equal(globe({ lat: 0, lon: 0, alt: 0 }).getState().alt.toFixed(6), MIN_ALT.toFixed(6));
+});
+
+test('screen centre picks the sub-camera point; off-globe picks null', () => {
+ const p = globe().pick(400, 200);
+ near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6);
+ assert.equal(globe({ lat: 0, lon: 0, alt: 4 }).pick(2, 2), null);
+});
+
+test('toScreen inverts pick; far side hidden', () => {
+ const g = globe({ lat: 10, lon: 179, alt: 0.3 }); // straddles the antimeridian
+ for (const [x, y] of [[100, 50], [400, 200], [700, 350], [620, 90]]) {
+ const p = g.pick(x, y);
+ assert.ok(p && Math.abs(p.lon) <= 180, 'finite pick');
+ const q = g.toScreen(p.lat, p.lon);
+ near(q.x, x, 1e-6); near(q.y, y, 1e-6);
+ }
+ assert.equal(g.toScreen(-10, 1), null);
+});
+
+test('poles stay finite', () => {
+ for (const lat of [90, -90]) {
+ const s = globe({ lat, lon: 0, alt: 1 }).getState();
+ assert.ok(Number.isFinite(s.lat) && Number.isFinite(s.lon) && Math.abs(Math.abs(s.lat) - 90) < 1e-3, JSON.stringify(s));
+ }
+});
+
+test('overlay lines are clipped to the visible cap (far-side lines never show through when drawn on top)', async () => {
+ const THREE = await import('three');
+ const { toVec } = await import('../js/geo.js');
+ const g = globe({ lat: 10, lon: 20, alt: 0.01 });
+ const plane = g.lines.clip?.[0];
+ assert.ok(plane, 'lines carry a clipping plane');
+ assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 20))) >= 0);
+ assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-10, -160))) < 0);
+ assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 60))) < 0, '40° away is beyond the horizon at 64 km up');
+ g.setState({ lat: -30, lon: 100, alt: 2 });
+ assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-30, 100))) >= 0);
+});
+
+const ev = (type, o = {}) => Object.assign(new Event(type, { cancelable: true }),
+ { clientX: 0, clientY: 0, button: 0, buttons: 1, shiftKey: false, pointerId: 1, deltaY: 0, deltaMode: 0, ...o });
+
+test('tilted state round trip; the pitch levels out when high', async () => {
+ const { R_KM } = await import('../js/geo.js');
+ const s = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 }).getState();
+ near(s.lat, 40, 1e-9); near(s.lon, -10, 1e-9); near(s.alt, 0.01, 1e-12); near(s.hd, 30, 1e-9); near(s.pt, 60, 1e-9);
+ assert.equal(globe({ lat: 0, lon: 0, alt: 0.5, hd: 30, pt: 60 }).getState().pt, 0);
+ assert.equal(globe({ lat: 0, lon: 0, alt: 3000 / R_KM, pt: 60 }).getState().pt, 0);
+ const h = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 });
+ h.setState({ lat: 1, lon: 2, alt: 2 });
+ assert.deepEqual([h.getState().hd, h.getState().pt], [0, 0], 'no hd/pt = north up, straight down');
+});
+
+test('tilted: the centre still picks the target, the top of the screen is sky', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.002, hd: 0, pt: 80 });
+ const p = g.pick(400, 200);
+ near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6);
+ assert.equal(g.pick(400, 3), null);
+ assert.ok(g.pick(400, 390).lat < 40, 'the bottom of the screen is nearer ground, behind the target (south)');
+});
+
+test('the eye never goes below 20 m; with 3D terrain, 20 m above the loaded ground × exaggeration', async () => {
+ const { eyeAltitude, R_M } = await import('../js/camera.js');
+ const g = globe({ lat: 40, lon: -10, alt: 1e-9, pt: 0 });
+ near(eyeAltitude(g.pose.range, g.pose.pitch) * R_M, 20, 1e-6);
+ g.terrain = 2;
+ g.tiles = { heightAt: () => 1000 };
+ g.setState({ lat: 40, lon: -10, alt: 1e-9 });
+ near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 2020, 1e-6);
+ g.tiles = { heightAt: () => 3000 }; // finer ground arrived under the camera
+ g.keepAboveGround();
+ near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6);
+ g.tiles = { heightAt: () => null };
+ g.keepAboveGround();
+ near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6, 'unknown ground never pulls the camera down');
+});
+
+test('a restored view keeps its range from the ground that loads after it (a reload lands where it was)', async () => {
+ const { R_M } = await import('../js/camera.js');
+ const g = new GlobeView(fakeRenderer(), () => {});
+ g.resize(800, 400);
+ g.terrain = 1;
+ let h = null;
+ g.tiles = { heightAt: () => h };
+ g.setState({ lat: -53, lon: -44, alt: 120 / R_M, pt: 50 }); // the URL's view, heights not loaded yet
+ g.keepAboveGround();
+ h = 560; // coarse heights arrive
+ g.keepAboveGround();
+ near(g.pose.range * R_M, 120, 1e-6);
+ h = null; // tiles reloading: nothing moves
+ g.keepAboveGround();
+ near(g.pose.range * R_M, 120, 1e-6);
+ h = 575; // finer heights
+ g.keepAboveGround();
+ near(g.pose.range * R_M, 120, 1e-6);
+ assert.equal(g.getState().alt, 120 / R_M, 'the URL round-trips');
+ g.setPose({ ...g.pose, heading: 10 }); // the user moves: the eye stays put from here on
+ h = 700;
+ g.keepAboveGround();
+ assert.ok(g.pose.range * R_M < 120 - 50, 'finer ground after a gesture keeps the eye, not the range');
+});
+
+test('drag pans: the grabbed ground stays under the pointer', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement;
+ g.activate();
+ const grabbed = g.pick(300, 150);
+ c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150 }));
+ c.dispatchEvent(ev('pointermove', { clientX: 350, clientY: 230 }));
+ c.dispatchEvent(ev('pointermove', { clientX: 420, clientY: 260 }));
+ c.dispatchEvent(ev('pointerup', { clientX: 420, clientY: 260 }));
+ const now = g.pick(420, 260);
+ near(now.lat, grabbed.lat, 1e-6); near(now.lon, grabbed.lon, 1e-6);
+});
+
+test('Shift+drag or right-drag: up tilts toward the horizon, sideways turns; the wheel zooms; off when inactive', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement;
+ c.dispatchEvent(ev('wheel', { deltaY: 100 }));
+ near(g.getState().alt, 0.01, 1e-15, 'inactive: ignored');
+ g.activate();
+ c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, shiftKey: true }));
+ c.dispatchEvent(ev('pointermove', { clientX: 400, clientY: 100, shiftKey: true }));
+ c.dispatchEvent(ev('pointerup', { clientX: 400, clientY: 100 }));
+ assert.ok(g.getState().pt > 30, `pitch ${g.getState().pt}`);
+ c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, button: 2, buttons: 2 }));
+ c.dispatchEvent(ev('pointermove', { clientX: 300, clientY: 300, buttons: 2 }));
+ c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 300, button: 2 }));
+ assert.ok(Math.abs(g.getState().hd - 25) < 1e-9, `heading ${g.getState().hd}`);
+ const e = ev('wheel', { deltaY: 100 });
+ c.dispatchEvent(e);
+ assert.ok(g.getState().alt > 0.0105 && e.defaultPrevented);
+ const ctx = ev('contextmenu');
+ c.dispatchEvent(ctx);
+ assert.ok(ctx.defaultPrevented, 'right button belongs to the map');
+});
+
+test('pixel size grows with distance when tilted', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 75 });
+ assert.ok(g.kmPerPxAt(g.pick(400, 60)) > 3 * g.kmPerPxAt(g.pick(400, 390)));
+});
+
+test('a long diagonal drag keeps north up and the grabbed ground under the pointer', () => {
+ const g = globe({ lat: 50, lon: 10, alt: 0.3 }), c = g.renderer.domElement;
+ g.activate();
+ const grabbed = g.pick(200, 100);
+ c.dispatchEvent(ev('pointerdown', { clientX: 200, clientY: 100 }));
+ for (let i = 1; i <= 10; i++) c.dispatchEvent(ev('pointermove', { clientX: 200 + 40 * i, clientY: 100 + 25 * i }));
+ c.dispatchEvent(ev('pointerup', { clientX: 600, clientY: 350 }));
+ assert.equal(g.getState().hd, 0);
+ const q = g.toScreen(grabbed.lat, grabbed.lon);
+ near(q.x, 600, 0.05); near(q.y, 350, 0.05); // within a twentieth of a pixel
+});
+
+test('tilted over 3D terrain, overlay lines are depth-tested (no rivers floating through mountains)', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.001 });
+ assert.equal(g.lines.depthTest, false, 'straight down: lines drawn on top of the tiles');
+ g.terrain = 1;
+ g.setState({ lat: 40, lon: -10, alt: 0.001, pt: 60 });
+ assert.equal(g.lines.depthTest, true);
+});
+
+test('sky dome and haze near the ground, gone from orbit', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.0005, pt: 70 });
+ assert.equal(g.dome.mesh.visible, true);
+ assert.ok(g.shade.hazeAmt.value > 0);
+ g.setState({ lat: 40, lon: -10, alt: 2 });
+ assert.equal(g.dome.mesh.visible, false);
+ assert.equal(g.shade.hazeAmt.value, 0);
+});
+
+test('tilted over high 3D ground: tile samples hit the ground, nearer and finer than the sea-level pick', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
+ g.terrain = 1;
+ g.tiles = { heightAt: () => 3000 };
+ g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
+ const sea = g.pick(400, 200, false), s = g.groundSample(400, 200);
+ assert.ok(s.km < 0.95 * g.kmPerPxAt(sea), `${s.km} vs ${g.kmPerPxAt(sea)}`);
+ assert.ok(s.lat < sea.lat, 'the ground hit is nearer the camera (south of the sea-level point)');
+ g.terrain = 0;
+ const f = g.groundSample(400, 200);
+ near(f.lat, sea.lat, 1e-9); near(f.km, g.kmPerPxAt(sea), 1e-9);
+ g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 80 });
+ assert.equal(g.pick(400, 3), null);
+ assert.equal(g.groundSample(400, 3), null, 'sky');
+});
+
+test('ground sampling finds a slope rising above the eye (camera below the far ground level)', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 });
+ g.terrain = 1;
+ g.tiles = { heightAt: lat => (lat > 40.02 ? 6000 : 500) }; // a wall of mountains just north of the target
+ g.setState({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 });
+ const s = g.groundSample(400, 100); // this ray meets the wall face before the 500 m ground
+ assert.ok(s, 'hits the wall');
+ assert.ok(s.lat > 40.02 && s.lat < 40.1, `wall face at ${s.lat}`);
+});
+
+function hillGlobe(h = 3000) { // tilted over flat 3D ground at h metres
+ const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
+ g.terrain = 1;
+ g.tiles = { heightAt: () => h, heightGen: 1 };
+ g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 });
+ return g;
+}
+
+test('with 3D terrain a click picks the ground it sees; markers sit on that ground (toScreen inverts pick)', () => {
+ const g = hillGlobe(), sea = g.pick(400, 200, false), p = g.pick(400, 200);
+ assert.ok(p.lat < sea.lat - 0.001, 'the ground hit is nearer than the sea-level point behind it');
+ for (const [x, y] of [[400, 200], [120, 330], [700, 90]]) {
+ const q = g.pick(x, y), s = g.toScreen(q.lat, q.lon);
+ near(s.x, x, 0.5); near(s.y, y, 0.5);
+ }
+});
+
+test('grabbing a hill in 3D keeps that hill under the pointer', () => {
+ const g = hillGlobe(), c = g.renderer.domElement;
+ g.activate();
+ const grabbed = g.pick(400, 250);
+ c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 250 }));
+ c.dispatchEvent(ev('pointermove', { clientX: 450, clientY: 290 }));
+ c.dispatchEvent(ev('pointerup', { clientX: 450, clientY: 290 }));
+ const s = g.toScreen(grabbed.lat, grabbed.lon);
+ near(s.x, 450, 0.5); near(s.y, 290, 0.5);
+});
+
+test('with 3D terrain close up, lines lie on the drawn ground (depth-tested); flat again without it', async () => {
+ const { R_M } = await import('../js/camera.js');
+ const g = hillGlobe(1000);
+ g.setLines('rivers', [[{ lat: 40.01, lon: -10 }, { lat: 40.05, lon: -10.02 }]], 0x3399ff);
+ g.updateDrape(0);
+ let o = g.lines.objects.get('rivers');
+ const a = o.geometry.attributes.position.array, r = Math.hypot(a[0] + o.position.x, a[1] + o.position.y, a[2] + o.position.z);
+ assert.ok(r > 1 + 1000 / R_M && r < 1 + 1100 / R_M, `${(r - 1) * R_M} m`);
+ assert.equal(o.material.depthTest, true);
+ assert.equal(o.material.clippingPlanes?.length ?? 0, 0, 'no horizon clip: depth hides what is behind hills');
+ g.tiles.heightGen = 2;
+ g.tiles.heightAt = () => 2000;
+ g.updateDrape(1000); // heights changed: re-laid
+ o = g.lines.objects.get('rivers');
+ const b = o.geometry.attributes.position.array;
+ assert.ok(Math.hypot(b[0] + o.position.x, b[1] + o.position.y, b[2] + o.position.z) > 1 + 2000 / R_M);
+ g.terrain = 0;
+ g.updateDrape(2000);
+ o = g.lines.objects.get('rivers');
+ const c = o.geometry.attributes.position.array;
+ assert.ok(Math.hypot(c[0], c[1], c[2]) * o.scale.x < 1 + 600 / R_M, 'back near sea level (lifted alt × 0.02, as before)');
+ assert.equal(o.position.length(), 0);
+});
+
+test('20 m above high ground: a wheel notch and an arrow key move by the height above the ground, not sea level', async () => {
+ const { R_M, eyeAltitude } = await import('../js/camera.js');
+ const { panPose } = await import('../js/camera.js');
+ const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement;
+ g.terrain = 1;
+ g.tiles = { heightAt: () => 3000, heightGen: 1 };
+ g.setState({ lat: 40, lon: -10, alt: 1e-9 });
+ const clear = () => eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M - 3000;
+ near(clear(), 20, 1e-6);
+ g.activate();
+ c.dispatchEvent(ev('wheel', { deltaY: 100 }));
+ assert.ok(clear() > 21 && clear() < 25, `one notch: ${clear()} m above the ground`);
+ const before = g.pick(400, 200);
+ g.setPose(panPose(g.pose, 0.2, 0));
+ const after = g.pick(400, 200);
+ const movedM = Math.hypot(after.lat - before.lat, (after.lon - before.lon) * Math.cos(40 * Math.PI / 180)) * Math.PI / 180 * R_M;
+ assert.ok(movedM < 20, `ArrowRight moved ${movedM} m with a ~40 m wide view`);
+});
+
+test('dragging over a pole keeps the grabbed ground under the pointer', () => {
+ const g = globe({ lat: 89.8, lon: 30, alt: 0.01 }), c = g.renderer.domElement;
+ g.activate();
+ for (const x0 of [400, 250]) {
+ const grabbed = g.pick(x0, 20);
+ c.dispatchEvent(ev('pointerdown', { clientX: x0, clientY: 20 }));
+ for (let i = 1; i <= 37; i++) c.dispatchEvent(ev('pointermove', { clientX: x0, clientY: 20 + 10 * i }));
+ c.dispatchEvent(ev('pointerup', { clientX: x0, clientY: 390 }));
+ const s = g.toScreen(grabbed.lat, grabbed.lon);
+ near(s.x, x0, 1); near(s.y, 390, 1);
+ }
+});
+
+test('looking straight down there is no haze (data colours match their legend); tilting brings it in', () => {
+ const g = globe({ lat: 40, lon: -10, alt: 40 / 12742 });
+ assert.equal(g.shade.hazeAmt.value, 0);
+ g.setState({ lat: 40, lon: -10, alt: 40 / 12742, pt: 60 });
+ assert.ok(g.shade.hazeAmt.value > 0);
+});
+
+test('controls follow one pointer; the right-click menu is only taken while the globe is active; no touch scrolling', async () => {
+ const { readFileSync } = await import('node:fs');
+ const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement;
+ const menu = ev('contextmenu');
+ c.dispatchEvent(menu);
+ assert.equal(menu.defaultPrevented, false, 'inactive: the browser keeps its menu');
+ g.activate();
+ const before = g.getState();
+ c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150, pointerId: 1 }));
+ c.dispatchEvent(ev('pointermove', { clientX: 500, clientY: 300, pointerId: 2 })); // a second finger
+ near(g.getState().lat, before.lat, 1e-12);
+ c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 150, pointerId: 1 }));
+ assert.match(readFileSync(new URL('../style.css', import.meta.url), 'utf8'), /#map canvas\s*\{[^}]*touch-action:\s*none/);
+});
+
+test('dry land below sea level (water on): the eye comes down to 20 m above it, not above 0 m', async () => {
+ const { eyeAltitude, R_M } = await import('../js/camera.js');
+ const g = new GlobeView(fakeRenderer(), () => {});
+ g.terrain = 1;
+ g.tiles = { heightAt: () => -14 }; // a dry hollow: the tiles draw it at −14 m (sea cells they draw at 0 m)
+ g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 60 });
+ near(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M, -14 + 20, 1e-6);
+ near(g.pose.ground, -14, 1e-9);
+});
+
+test('the eye clears the drawn ground too, not only finer heights loaded but hidden (a coarser stand-in drawn higher)', async () => {
+ const { eyeAltitude, R_M } = await import('../js/camera.js');
+ const g = new GlobeView(fakeRenderer(), () => {});
+ g.terrain = 1;
+ g.tiles = { heightAt: (lat, lon, shown) => (shown ? 30 : 0) }; // drawn: a stand-in 30 m up; loaded finer: 0 m
+ g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 0 });
+ assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6, 'not inside the drawn ground');
+ g.keepAboveGround();
+ assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6);
+});
+
+test('camera floor follows the water mode', () => {
+ const g = new GlobeView(fakeRenderer(), () => {});
+ g.terrain = 1;
+ g.tiles = { heightAt: () => (g.seaFloor ? -4000 : 0) }; // what the tiles draw: the water surface, or the floor
+ g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 });
+ const above = g.camera.position.length();
+ assert.ok(above > 1, 'water on: above sea level');
+ g.seaFloor = true;
+ g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 });
+ assert.ok(g.camera.position.length() < 1, 'water off: may dive below 0 m');
+});
+
+test('water off: tiles reloading (no heights yet) keep the camera where it was, not at sea level', () => {
+ const g = new GlobeView(fakeRenderer(), () => {});
+ g.terrain = 1;
+ g.seaFloor = true;
+ let h = -4000;
+ g.tiles = { heightAt: () => h };
+ g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 });
+ const below = g.camera.position.length();
+ assert.ok(below < 1);
+ h = null; // a layer switch dropped every tile
+ g.keepAboveGround();
+ assert.ok(Math.abs(g.camera.position.length() - below) < 1e-9, 'not lifted to sea level');
+});
+
+test('water off: the coarse sphere sinks below the deepest trench at any exaggeration', async () => {
+ const { R_KM } = await import('../js/geo.js');
+ const g = new GlobeView(fakeRenderer(), () => {});
+ g.terrain = 4;
+ g.seaFloor = true;
+ g.tiles = { heightAt: () => -2000 };
+ g.setPose({ lat: 0, lon: 0, range: 0.01, heading: 0, pitch: 0 });
+ assert.ok(g.base.scale.x < 1 - (11 * 4) / R_KM, `sphere at ${g.base.scale.x}`);
+});
diff --git a/viewer/tests/hover.test.mjs b/viewer/tests/hover.test.mjs
new file mode 100644
index 0000000..7b26be5
--- /dev/null
+++ b/viewer/tests/hover.test.mjs
@@ -0,0 +1,30 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { hoverText } from '../js/hover.js';
+
+const cell = fields => ({ cell: 'x', lat: 0, lon: 0, fields });
+
+test('land shows elevation and life zone', () => {
+ assert.equal(hoverText(cell([{ key: 'z_surface_m', value: 2344.4 }, { key: 'ocean', value: false, text: 'no' },
+ { key: 'lake', value: false, text: 'no' }, { key: 'holdridge', value: 3, text: 'cool temperate desert scrub' }])),
+ '2,344 m · cool temperate desert scrub');
+});
+
+test('sea shows depth; lakes are named; procedural detail only when tiles show', () => {
+ assert.equal(hoverText(cell([{ key: 'z_surface_m', value: -3300 }, { key: 'ocean', value: true, text: 'yes' }])), 'sea, 3,300 m deep');
+ assert.equal(hoverText(cell([{ key: 'z_surface_m', value: 120 }, { key: 'ocean', value: false }, { key: 'lake', value: true },
+ { key: 'holdridge', value: 1, text: 'boreal moist forest' }])), '120 m · lake · boreal moist forest');
+ const both = cell([{ key: 'z_surface_m', value: 120 }, { key: 'z_detail_m', value: 131.6 }, { key: 'ocean', value: false }]);
+ assert.equal(hoverText(both), '120 m', 'zoomed out: the build value');
+ assert.equal(hoverText(both, true), '132 m', 'procedural tiles showing: the detail value');
+});
+
+test('a cell answer is reused near its centre, never under procedural detail', async () => {
+ const { canReuse } = await import('../js/hover.js');
+ const c = { center: [10, 20], inner_km: 50 };
+ assert.equal(canReuse(c, { lat: 10.1, lon: 20 }, false), true);
+ assert.equal(canReuse(c, { lat: 11, lon: 20 }, false), false);
+ assert.equal(canReuse(c, { lat: 10.1, lon: 20 }, true), false);
+ assert.equal(canReuse(null, { lat: 10, lon: 20 }, false), false);
+});
diff --git a/viewer/tests/importmap-hooks.mjs b/viewer/tests/importmap-hooks.mjs
new file mode 100644
index 0000000..3769c21
--- /dev/null
+++ b/viewer/tests/importmap-hooks.mjs
@@ -0,0 +1,11 @@
+// Node module-resolution hook: resolve bare specifiers with the page's import map.
+let map;
+export function initialize(data) { map = data; }
+export async function resolve(spec, ctx, next) {
+ for (const [k, v] of Object.entries(map.imports)) {
+ if (spec === k || (k.endsWith('/') && spec.startsWith(k))) {
+ return { url: new URL(v + spec.slice(k.length), map.base).href, shortCircuit: true };
+ }
+ }
+ return next(spec, ctx);
+}
diff --git a/viewer/tests/importmap.mjs b/viewer/tests/importmap.mjs
new file mode 100644
index 0000000..301d2bc
--- /dev/null
+++ b/viewer/tests/importmap.mjs
@@ -0,0 +1,15 @@
+// Import first, then load viewer modules with dynamic import(): bare 'three' resolves as in index.html.
+import { register } from 'node:module';
+import { readFileSync } from 'node:fs';
+
+export const HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+export const IMPORTS = JSON.parse(HTML.match(/<script type="importmap">([\s\S]*?)<\/script>/)[1]).imports;
+register('./importmap-hooks.mjs', import.meta.url, { data: { imports: IMPORTS, base: new URL('../', import.meta.url).href } });
+
+export function fakeCanvas(width = 800, height = 400) {
+ const c = new EventTarget();
+ Object.assign(c, { style: {}, ownerDocument: new EventTarget(), setPointerCapture() {}, releasePointerCapture() {},
+ getRootNode: () => new EventTarget(), getBoundingClientRect: () => ({ left: 0, top: 0, width, height }) });
+ return c;
+}
+export const fakeRenderer = () => ({ domElement: fakeCanvas(), render() {} });
diff --git a/viewer/tests/inspector.test.mjs b/viewer/tests/inspector.test.mjs
new file mode 100644
index 0000000..ea9c456
--- /dev/null
+++ b/viewer/tests/inspector.test.mjs
@@ -0,0 +1,30 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { groupFields, fmtValue } from '../js/inspector.js';
+
+test('fields grouped in order of first appearance', () => {
+ const g = groupFields([{ key: 'a', group: 'Terrain' }, { key: 'b', group: 'Climate' }, { key: 'c', group: 'Terrain' }]);
+ assert.deepEqual([...g.keys()], ['Terrain', 'Climate']);
+ assert.deepEqual(g.get('Terrain').map(f => f.key), ['a', 'c']);
+});
+
+test('values: text wins; large numbers rounded with separators; small ones to 2 decimals', () => {
+ assert.equal(fmtValue({ value: 3, text: 'Boreal moist forest', unit: '' }), 'Boreal moist forest');
+ assert.equal(fmtValue({ value: 1234.5, unit: 'm' }), '1,235 m');
+ assert.equal(fmtValue({ value: -3.456, unit: '°C' }), '-3.46 °C');
+ assert.equal(fmtValue({ value: 0.2, unit: '' }), '0.2');
+});
+
+test('inspect asks for the era and shows its label', async () => {
+ const { installFakeDom, FakeEl } = await import('./fakedom.mjs');
+ installFakeDom();
+ const { inspect } = await import('../js/inspector.js');
+ const box = new FakeEl('div');
+ let asked = null;
+ globalThis.fetch = async url => { asked = url; return { ok: true, status: 200, json: async () => ({
+ lat: 1, lon: 2, cell: '84', era: { name: 'high-fantasy', label: 'High Fantasy' }, fields: [] }) }; };
+ await inspect(box, { lat: 1, lon: 2 }, 'high-fantasy');
+ assert.match(asked, /&era=high-fantasy$/);
+ assert.match(box.textContent, /High Fantasy/);
+});
diff --git a/viewer/tests/latest.test.mjs b/viewer/tests/latest.test.mjs
new file mode 100644
index 0000000..e257ebf
--- /dev/null
+++ b/viewer/tests/latest.test.mjs
@@ -0,0 +1,24 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const { Latest } = await import('../js/latest.js');
+const { metaFor } = await import('../js/boot.js');
+
+test('only the newest call is current: an older answer arriving late is dropped', () => {
+ const l = new Latest();
+ const a = l.next(), b = l.next();
+ assert.equal(l.is(a), false);
+ assert.equal(l.is(b), true);
+});
+
+test('startup meta: an unknown era falls back to the default with a note', async () => {
+ const get = async u => { if (u.includes('era=')) throw new Error('unknown era'); return { era: 'hf' }; };
+ assert.deepEqual(await metaFor(get, 'x'), { meta: { era: 'hf' }, note: 'Era “x”: unknown era — showing hf' });
+ assert.deepEqual(await metaFor(get, ''), { meta: { era: 'hf' }, note: '' });
+});
+
+test('startup meta: without an era a failure is not called an unknown era', async () => {
+ const get = async () => { throw new Error('502 Bad Gateway'); };
+ await assert.rejects(metaFor(get, ''), /502/);
+});
diff --git a/viewer/tests/layers.test.mjs b/viewer/tests/layers.test.mjs
new file mode 100644
index 0000000..229606b
--- /dev/null
+++ b/viewer/tests/layers.test.mjs
@@ -0,0 +1,50 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import './importmap.mjs';
+import { FakeEl, installFakeDom } from './fakedom.mjs';
+
+installFakeDom();
+const { renderLegend } = await import('../js/layers.js');
+
+test('legend shows readable units (degC → °C)', () => {
+ const box = new FakeEl('div');
+ renderLegend(box, { name: 'T', legend: { type: 'continuous', unit: 'degC', stops: [[-40, '#000000'], [40, '#ffffff']] } });
+ assert.match(box.textContent, /°C/);
+ assert.doesNotMatch(box.textContent, /degC/);
+});
+
+test('the base and over-layer textures in use are never evicted', async () => {
+ const { LayerStore } = await import('../js/layers.js');
+ const ids = ['relief', 'biomes', 'a', 'b', 'c', 'd'], disposed = [];
+ const store = new LayerStore(ids.map(id => ({ id, file: id })), 8192, 1, async l => ({ id: l.id, dispose() { disposed.push(l.id); } }));
+ store.pin(['relief', 'biomes']);
+ for (const id of ids) await store.texture(id);
+ await new Promise(r => setTimeout(r, 0));
+ assert.ok(!disposed.includes('relief') && !disposed.includes('biomes'), disposed.join());
+ assert.ok(disposed.includes('a'));
+});
+
+test('textures still on screen are never evicted while newer ones load', async () => {
+ const { LayerStore } = await import('../js/layers.js');
+ const ids = ['relief', 'a', 'b', 'c', 'd', 'e'], disposed = [];
+ const store = new LayerStore(ids.map(id => ({ id, file: id })), 8192, 1, async l => ({ id: l.id, dispose() { disposed.push(l.id); } }));
+ await store.texture('relief');
+ store.showing(['relief']); // drawn now
+ for (const id of ids.slice(1)) { store.pin([id]); store.texture(id); } // rapid layer switches, none shown yet
+ await new Promise(r => setTimeout(r, 0));
+ assert.ok(!disposed.includes('relief'), disposed.join());
+});
+
+test('dispose frees every cached texture except those still drawn (another era took over)', async () => {
+ const { LayerStore } = await import('../js/layers.js');
+ const ids = ['relief', 'a', 'b'], disposed = [];
+ const store = new LayerStore(ids.map(id => ({ id, file: id })), 8192, 1, async l => ({ id: l.id, dispose() { disposed.push(l.id); } }));
+ const drawn = await store.texture('relief');
+ await store.texture('a');
+ await store.texture('b');
+ store.dispose([drawn]);
+ await new Promise(r => setTimeout(r, 0));
+ assert.deepEqual(disposed.sort(), ['a', 'b']);
+ assert.equal(store.cache.size, 0);
+});
diff --git a/viewer/tests/layout.test.mjs b/viewer/tests/layout.test.mjs
new file mode 100644
index 0000000..14fb8d6
--- /dev/null
+++ b/viewer/tests/layout.test.mjs
@@ -0,0 +1,40 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const { compactMode, freeArea, applyMode, textureCap } = await import('../js/layout.js');
+
+test('phones get a compact layout; tablets and desktops keep theirs', () => {
+ assert.equal(compactMode(390, 844), 'portrait'); // phone, upright
+ assert.equal(compactMode(700, 1000), 'portrait');
+ assert.equal(compactMode(844, 390), 'landscape'); // phone on its side
+ assert.equal(compactMode(1000, 500), 'landscape');
+ assert.equal(compactMode(1024, 768), ''); // tablet
+ assert.equal(compactMode(1400, 900), '');
+ assert.equal(compactMode(1200, 450), '', 'a wide short window is a desktop');
+});
+
+test('the free map area leaves out what the panel covers', () => {
+ assert.deepEqual(freeArea(1400, 900, { left: 1050, top: 10, width: 340, height: 880 }), { w: 1050, h: 900 });
+ assert.deepEqual(freeArea(390, 844, { left: 0, top: 464, width: 390, height: 380 }), { w: 390, h: 464 });
+ assert.deepEqual(freeArea(390, 844, null), { w: 390, h: 844 });
+ assert.deepEqual(freeArea(390, 844, { left: 0, top: 0, width: 0, height: 0 }), { w: 390, h: 844 });
+ assert.deepEqual(freeArea(390, 844, { left: 0, top: 10, width: 390, height: 834 }), { w: 390, h: 64 }, 'never below a tile row');
+});
+
+test('applyMode sets the body classes and says when a phone layout begins', () => {
+ const cls = new Set(['menu-open']);
+ const body = { classList: { toggle: (c, on) => (on ? cls.add(c) : cls.delete(c)), remove: c => cls.delete(c) } };
+ assert.equal(applyMode(body, 'portrait', ''), true);
+ assert.deepEqual([...cls].sort(), ['compact', 'menu-open', 'portrait']);
+ assert.equal(applyMode(body, 'landscape', 'portrait'), false, 'already compact');
+ assert.deepEqual([...cls].sort(), ['compact', 'landscape', 'menu-open']);
+ assert.equal(applyMode(body, '', 'landscape'), false);
+ assert.deepEqual([...cls], [], 'desktop: no phone classes, menu closed');
+});
+
+test('touch devices and phone screens get 4096-px layer textures; desktops their GPU maximum', () => {
+ assert.equal(textureCap(16384, false, 1400, 900), 16384);
+ assert.equal(textureCap(16384, true, 1400, 900), 4096, 'a tablet');
+ assert.equal(textureCap(16384, false, 390, 844), 4096, 'a phone-sized window');
+ assert.equal(textureCap(2048, true, 390, 844), 2048, 'never above the GPU limit');
+});
diff --git a/viewer/tests/measure.test.mjs b/viewer/tests/measure.test.mjs
new file mode 100644
index 0000000..f8c2110
--- /dev/null
+++ b/viewer/tests/measure.test.mjs
@@ -0,0 +1,82 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { MeasurePath } from '../js/measure.js';
+import { R_KM } from '../js/geo.js';
+import { FakeEl, installFakeDom } from './fakedom.mjs';
+
+const A = { lat: 0, lon: 0 }, B = { lat: 0, lon: 90 }, C = { lat: 90, lon: 0 };
+const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);
+
+test('open path: segments and total', () => {
+ const m = new MeasurePath();
+ [A, B, C].forEach(p => m.add(p));
+ assert.equal(m.segmentsKm().length, 2);
+ near(m.totalKm(), R_KM * Math.PI, 1e-6); // two quarter circles
+ assert.equal(m.areaKm2(), null);
+ assert.equal(m.lines()[0].length, 3);
+});
+
+test('closing needs three points; a closed path adds the return leg and an area', () => {
+ const m = new MeasurePath();
+ m.add(A);
+ m.add(B);
+ assert.equal(m.close(), false);
+ m.add(C);
+ assert.equal(m.close(), true);
+ assert.equal(m.segmentsKm().length, 3);
+ near(m.areaKm2(), (4 * Math.PI * R_KM ** 2) / 8, 1);
+ assert.deepEqual(m.lines()[0].at(-1), A);
+});
+
+test('undo reopens then removes; repeats ignored; adding after a close starts over', () => {
+ const m = new MeasurePath();
+ [A, B, C].forEach(p => m.add(p));
+ m.close();
+ m.undo();
+ assert.equal(m.closed, false);
+ assert.equal(m.points.length, 3);
+ m.undo();
+ assert.equal(m.points.length, 2);
+ m.add(B); // a double-click lands twice on the same spot
+ assert.equal(m.points.length, 2);
+ m.add(C);
+ m.close();
+ m.add(A);
+ assert.deepEqual(m.points, [A]);
+});
+
+test('panel never renders null/undefined children (DOM would print "null")', async () => {
+ installFakeDom();
+ const { renderMeasure } = await import('../js/measure.js');
+ const box = new FakeEl('section'), noop = () => {};
+ const m = new MeasurePath();
+ for (const pts of [[], [A], [A, B]]) {
+ m.clear();
+ pts.forEach(p => m.add(p));
+ renderMeasure(box, m, { undo: noop, close: noop, clear: noop });
+ assert.ok(box.kids.every(k => k !== null && k !== undefined), `${pts.length} points: ${box.kids.map(String)}`);
+ }
+});
+
+test('elevation chart: an svg profile; hovering reports the nearest sample', async () => {
+ installFakeDom();
+ const { renderProfile, renderMeasure } = await import('../js/measure.js');
+ const box = new FakeEl('div'), seen = [];
+ const prof = { samples: [{ d_km: 0, lat: 0, lon: 0, z_m: -100, water: true }, { d_km: 10, lat: 0, lon: 0.1, z_m: 500, water: false }],
+ stats: { min_m: -100, max_m: 500, ascent_m: 600, descent_m: 0, water_km: 5, length_km: 10 } };
+ renderProfile(box, prof, p => seen.push(p));
+ const chart = box.kids[0];
+ assert.equal(chart.tag, 'svg');
+ assert.ok(chart.find(e => e.tag === 'path'));
+ chart.fire('pointermove', { clientX: 300 });
+ assert.equal(seen.at(-1).d_km, 10);
+ assert.match(box.textContent, /Highest 500 m/);
+ chart.fire('pointerleave');
+ assert.equal(seen.at(-1), null);
+ const mbox = new FakeEl('section'), m = new MeasurePath();
+ m.add(A);
+ m.add(B);
+ const pbox = renderMeasure(mbox, m, { undo() {}, close() {}, clear() {} });
+ assert.ok(mbox.kids.includes(pbox));
+});
diff --git a/viewer/tests/modules.test.mjs b/viewer/tests/modules.test.mjs
new file mode 100644
index 0000000..8565085
--- /dev/null
+++ b/viewer/tests/modules.test.mjs
@@ -0,0 +1,23 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync, existsSync } from 'node:fs';
+import { HTML, IMPORTS } from './importmap.mjs';
+
+const JS = new URL('../js/', import.meta.url);
+const files = readdirSync(JS).filter(f => f.endsWith('.js'));
+
+test('import map targets exist', () => {
+ for (const v of Object.values(IMPORTS)) assert.ok(existsSync(new URL(v, new URL('../', import.meta.url))), v);
+});
+
+test('every viewer module links (imports resolve, named exports exist)', async () => {
+ for (const f of files) await import(new URL(f, JS).href);
+});
+
+test('every #id the scripts look up exists in index.html', () => {
+ const ids = new Set([...HTML.matchAll(/\bid="([\w-]+)"/g)].map(m => m[1]));
+ for (const f of files) {
+ for (const m of readFileSync(new URL(f, JS), 'utf8').matchAll(/\$\('#([\w-]+)'\)/g)) assert.ok(ids.has(m[1]), `${f}: #${m[1]}`);
+ }
+});
diff --git a/viewer/tests/nav.test.mjs b/viewer/tests/nav.test.mjs
new file mode 100644
index 0000000..4efde7f
--- /dev/null
+++ b/viewer/tests/nav.test.mjs
@@ -0,0 +1,64 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { keyAction, exportName } from '../js/nav.js';
+
+const k = (key, extra = {}) => keyAction({ key, target: { tagName: 'CANVAS' }, ...extra });
+
+test('keys map to actions', () => {
+ assert.deepEqual(k('ArrowLeft'), ['pan', -0.2, 0]);
+ assert.deepEqual(k('ArrowDown'), ['pan', 0, 0.2]);
+ assert.deepEqual(k('+'), ['zoom', 0.6]);
+ assert.deepEqual(k('='), ['zoom', 0.6]);
+ assert.deepEqual(k('-'), ['zoom', 1 / 0.6]);
+ assert.deepEqual(k('Home'), ['home']);
+ assert.deepEqual(k('f'), ['fullscreen']);
+ assert.deepEqual(k('/'), ['search']);
+ assert.equal(k('x'), null);
+});
+
+test('typing and modifier chords are left alone', () => {
+ for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT']) assert.equal(keyAction({ key: 'ArrowLeft', target: { tagName } }), null);
+ assert.equal(k('f', { ctrlKey: true }), null);
+ assert.equal(k('ArrowUp', { metaKey: true }), null);
+ assert.equal(k('-', { altKey: true }), null);
+});
+
+test('export file names', () => {
+ assert.equal(exportName({ layer: 'relief', over: '', view: 'globe' }), 'world-relief-globe.png');
+ assert.equal(exportName({ layer: 'relief', over: 'biomes', view: 'equal_earth' }), 'world-relief+biomes-equal_earth.png');
+});
+
+test('exports note procedural detail when tiles are showing', async () => {
+ const { exportNotes } = await import('../js/nav.js');
+ assert.deepEqual(exportNotes({ view: { tiles: { active: true } } }), ['procedural detail — not data']);
+ assert.deepEqual(exportNotes({ view: { tiles: { active: false } } }), []);
+ assert.deepEqual(exportNotes({ view: {} }), []);
+});
+
+test('Shift+arrows turn and tilt; plain arrows still pan; N points north', () => {
+ const k = (key, o = {}) => keyAction({ key, target: { tagName: 'CANVAS' }, ...o });
+ assert.deepEqual(k('ArrowLeft', { shiftKey: true }), ['turn', -15, 0]);
+ assert.deepEqual(k('ArrowUp', { shiftKey: true }), ['turn', 0, 10]);
+ assert.deepEqual(k('ArrowLeft'), ['pan', -0.2, 0]);
+ assert.deepEqual(k('n'), ['north']);
+ assert.equal(k('n', { target: { tagName: 'INPUT' } }), null);
+});
+
+test('G goes to ground view, ? shows the controls', () => {
+ assert.deepEqual(k('g'), ['ground']);
+ assert.deepEqual(k('?', { shiftKey: true }), ['help']);
+ assert.equal(k('g', { target: { tagName: 'INPUT' } }), null);
+});
+
+test('ground view: the eye above the spot looked at, close and tilted, heading kept', async () => {
+ const { groundPose, GROUND_RANGE, GROUND_PITCH } = await import('../js/nav.js');
+ const ahead = GROUND_RANGE * Math.sin(GROUND_PITCH * Math.PI / 180) * 180 / Math.PI; // degrees of arc
+ const n = groundPose({ lat: 0, lon: 10, range: 0.5, heading: 0, pitch: 0 });
+ assert.ok(Math.abs(n.lat - ahead) < 1e-9 && Math.abs(n.lon - 10) < 1e-9, 'looking north: the target lies ahead');
+ assert.equal(n.range, GROUND_RANGE);
+ assert.equal(n.pitch, GROUND_PITCH);
+ const e = groundPose({ lat: 0, lon: 10, range: 0.5, heading: 90, pitch: 0 });
+ assert.ok(Math.abs(e.lat) < 1e-9 && Math.abs(e.lon - 10 - ahead) < 1e-9 && e.heading === 90);
+ assert.ok(GROUND_RANGE * 12742 < 5 && GROUND_PITCH >= 70);
+});
diff --git a/viewer/tests/overlay_geom.test.mjs b/viewer/tests/overlay_geom.test.mjs
new file mode 100644
index 0000000..c4c6c6e
--- /dev/null
+++ b/viewer/tests/overlay_geom.test.mjs
@@ -0,0 +1,69 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import * as O from '../js/overlay_geom.js';
+import { isClick } from '../js/input.js';
+
+const fc = { type: 'FeatureCollection', features: [
+ { type: 'Feature', properties: { order: 4 }, geometry: { type: 'LineString', coordinates: [[0, 0], [10, 0]] } },
+ { type: 'Feature', properties: { order: 1 }, geometry: { type: 'LineString', coordinates: [[0, 5], [1, 5]] } },
+ { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]], [[0.2, 0.2], [0.4, 0.2], [0.2, 0.4], [0.2, 0.2]]] } },
+ { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: [[[[5, 5], [6, 5], [6, 6], [5, 5]]]] } },
+] };
+
+test('linesFromGeoJSON reads all geometry kinds and filters', () => {
+ assert.equal(O.linesFromGeoJSON(fc).length, 5);
+ const big = O.linesFromGeoJSON(fc, p => (p.order ?? 0) >= 3);
+ assert.equal(big.length, 1);
+ assert.deepEqual(big[0][1], { lat: 0, lon: 10 });
+});
+
+test('polygon rings are split where they run along the antimeridian cut', () => {
+ const cut = { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Polygon',
+ coordinates: [[[170, 0], [180, 0], [180, 10], [170, 10], [170, 0]]] } }] };
+ const lines = O.linesFromGeoJSON(cut);
+ assert.equal(lines.length, 2);
+ for (const l of lines) for (let i = 0; i + 1 < l.length; i++)
+ assert.ok(!(Math.abs(l[i].lon) === 180 && Math.abs(l[i + 1].lon) === 180), 'no segment along lon ±180');
+});
+
+test('globe segments lie just above the unit sphere and follow great circles', () => {
+ const pos = O.globeSegments([[{ lat: 0, lon: 0 }, { lat: 0, lon: 10 }]], 1.002, 1);
+ assert.equal(pos.length, 10 * 6);
+ for (let i = 0; i < pos.length; i += 3) assert.ok(Math.abs(Math.hypot(pos[i], pos[i + 1], pos[i + 2]) - 1.002) < 1e-6);
+});
+
+test('flat segments drop antimeridian jumps', () => {
+ for (const view of ['equal_earth', 'plate_carree']) {
+ assert.equal(O.flatSegments([[{ lat: 10, lon: 179 }, { lat: 10, lon: -179 }]], view).length, 0);
+ assert.equal(O.flatSegments([[{ lat: 10, lon: 170 }, { lat: 10, lon: 179 }]], view).length, 6);
+ }
+});
+
+test('densify and graticule', () => {
+ const d = O.densify([[{ lat: 0, lon: 0 }, { lat: 0, lon: 3 }, { lat: 0, lon: 5 }]], 1);
+ assert.equal(d[0].length, 6);
+ assert.equal(O.graticuleLines(30).length, 13 + 5);
+});
+
+test('clicks vs drags', () => {
+ assert.equal(isClick({ x: 10, y: 10, t: 0 }, { x: 12, y: 11, t: 200 }), true);
+ assert.equal(isClick({ x: 10, y: 10, t: 0 }, { x: 40, y: 10, t: 200 }), false);
+ assert.equal(isClick({ x: 10, y: 10, t: 0 }, { x: 10, y: 10, t: 2000 }), false);
+});
+
+test('drapedSegments: lines near the camera lie on the ground, finer close up; far ones are skipped', async () => {
+ const { drapedSegments } = await import('../js/overlay_geom.js');
+ const { toVec, R_KM } = await import('../js/geo.js');
+ const R_M = R_KM * 1000, eyeDir = toVec(40, -10), eye = eyeDir.map(v => v * (1 + 1500 / R_M)); // above the ground
+ const opts = { eye, n: eyeDir, cosCap: Math.cos(0.01), origin: eyeDir, heightM: lat => (lat > 40 ? 1000 : 200) };
+ const near = [[{ lat: 40.001, lon: -10 }, { lat: 40.03, lon: -10 }]]; // ≈ 200 m … 4 km from the camera
+ const far = [[{ lat: 60, lon: 50 }, { lat: 61, lon: 50 }]];
+ const p = drapedSegments([...near, ...far], opts);
+ assert.ok(p.length / 6 > 10, `near segment cut into pieces: ${p.length / 6}`);
+ for (let i = 0; i < p.length; i += 3) {
+ const r = Math.hypot(p[i] + eyeDir[0], p[i + 1] + eyeDir[1], p[i + 2] + eyeDir[2]);
+ assert.ok(r > 1 + 1000 / R_M && r < 1 + 1020 / R_M, `vertex ${i / 3} at ${(r - 1) * R_M} m`); // ground + 1 m, pulled ≤ 1% up
+ }
+ assert.equal(drapedSegments(far, opts).length, 0);
+});
diff --git a/viewer/tests/overlays.test.mjs b/viewer/tests/overlays.test.mjs
new file mode 100644
index 0000000..d6f3d7c
--- /dev/null
+++ b/viewer/tests/overlays.test.mjs
@@ -0,0 +1,147 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const fc = { type: 'FeatureCollection', features: [
+ { type: 'Feature', properties: { order: 1 }, geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] } },
+ { type: 'Feature', properties: { order: 4 }, geometry: { type: 'LineString', coordinates: [[5, 5], [6, 6]] } },
+] };
+let fetches = 0;
+globalThis.fetch = async () => { fetches++; return new Response(JSON.stringify(fc), { status: 200 }); };
+const { Overlays } = await import('../js/overlays.js');
+
+const flush = () => new Promise(r => setTimeout(r, 0));
+const box = { replaceChildren() {} };
+const defs = [{ id: 'coast', name: 'Coastline', file: '/data/geo/coast.geojson' },
+ { id: 'rivers', name: 'Rivers', file: '/data/geo/rivers.geojson' }];
+const fakeView = () => ({ lines: new Map(), setLines(id, l) { this.lines.set(id, l); },
+ removeLines(id) { this.lines.delete(id); }, toScreen: () => null });
+
+test('enabled overlays reach the view; rivers show the orders that stay apart at the scale', async () => {
+ const o = new Overlays(defs, box), v = fakeView();
+ o.setEnabled(['coast', 'rivers', 'graticule']);
+ o.attach(v);
+ await flush();
+ assert.deepEqual([...v.lines.keys()].sort(), ['coast', 'graticule', 'rivers']);
+ assert.equal(v.lines.get('rivers').length, 1); // no scale yet: the largest order only
+ o.setScale(0.01); // a first-order segment (≈ 157 km) spans far more than 8 px
+ await flush();
+ assert.equal(v.lines.get('rivers').length, 2);
+ o.setScale(1000);
+ await flush();
+ assert.equal(v.lines.get('rivers').length, 1);
+ assert.equal(v.lines.get('graticule').length, 18);
+ o.setEnabled(['coast']);
+ await flush();
+ assert.deepEqual([...v.lines.keys()], ['coast']);
+});
+
+test('GeoJSON fetched once; a newly attached view gets everything; extras always shown', async () => {
+ fetches = 0;
+ const o = new Overlays(defs, box), a = fakeView(), b = fakeView();
+ o.setEnabled(['coast']);
+ o.attach(a);
+ await flush();
+ o.setExtra('measure', [[{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }]]);
+ o.attach(b);
+ await flush();
+ assert.equal(fetches, 1);
+ assert.deepEqual([...b.lines.keys()].sort(), ['coast', 'measure']);
+ o.setExtra('measure', []);
+ assert.deepEqual(b.lines.get('measure'), []);
+});
+
+test('list offers meta overlays plus graticule, refined regions, vents and pins', () => {
+ assert.deepEqual(new Overlays(defs, box).list().map(o => o.id), ['coast', 'rivers', 'graticule', 'regions', 'vents', 'pins']);
+});
+
+test('vent markers carry a title and class, show only with their overlay, and restyle when re-clustered', async () => {
+ const { FakeEl, installFakeDom } = await import('./fakedom.mjs');
+ installFakeDom();
+ const mbox = new FakeEl('div'), o = new Overlays(defs, mbox);
+ o.attach({ ...fakeView(), toScreen: () => ({ x: 1, y: 2 }) });
+ o.setMarkers('vent', [{ lat: 1, lon: 2, label: '', cls: 't0', title: 'black smoker' }]);
+ const [m] = mbox.kids;
+ assert.match(m.className, /marker vent t0/);
+ assert.equal(m.attrs.title, 'black smoker');
+ assert.equal(m.style.display, 'none', 'vents overlay off');
+ o.setEnabled(['vents']);
+ assert.equal(m.style.display, '');
+ o.setMarkers('vent', [{ lat: 1, lon: 2, label: '', cls: 't2', title: 'diffuse' }]);
+ assert.match(mbox.kids[0].className, /t2/);
+});
+
+test('refined-region outlines draw while their overlay is on', () => {
+ const o = new Overlays(defs, box), v = fakeView();
+ o.attach(v);
+ const line = [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 1, lon: 2 }];
+ o.setRegions([line]);
+ assert.ok(!v.lines.has('regions'), 'off: nothing drawn');
+ o.setEnabled(['regions']);
+ assert.deepEqual(v.lines.get('regions'), [line]);
+ o.setEnabled([]);
+ assert.ok(!v.lines.has('regions'));
+});
+
+test('asks the app to redraw after lines change, including after async loads', async () => {
+ let n = 0;
+ const o = new Overlays(defs, box, () => n++), v = fakeView();
+ o.setEnabled(['coast']);
+ o.attach(v);
+ const before = n;
+ await flush();
+ assert.ok(v.lines.has('coast'));
+ assert.ok(n > before, 'redraw requested after the GeoJSON arrived');
+ const k = n;
+ o.setEnabled([]);
+ assert.ok(n > k, 'redraw requested after removal');
+});
+
+test('markers with onClick are clickable; the rest let the pointer through', async () => {
+ const { FakeEl, installFakeDom } = await import('./fakedom.mjs');
+ installFakeDom();
+ let hit = null;
+ const mbox = new FakeEl('div'), o = new Overlays(defs, mbox);
+ o.attach(fakeView());
+ o.setMarkers('pin', [{ lat: 1, lon: 2, label: 'A', onClick: () => { hit = 'A'; } }]);
+ o.setMarkers('measure', [{ lat: 3, lon: 4, label: '1' }]);
+ const [pin, meas] = mbox.kids;
+ assert.match(pin.className, /clickable/);
+ assert.doesNotMatch(meas.className, /clickable/);
+ pin.fire('click');
+ assert.equal(hit, 'A');
+});
+
+test('suppressed overlays leave the view and come back', async () => {
+ const o = new Overlays(defs, box), v = fakeView();
+ o.setEnabled(['coast']);
+ o.attach(v);
+ await flush();
+ o.setSuppressed(['coast', 'lakes']);
+ assert.equal(v.lines.has('coast'), false);
+ o.setSuppressed([]);
+ await flush();
+ assert.equal(v.lines.has('coast'), true);
+});
+
+test('moving a marker keeps its DOM node (sky play moves the sun 20× a second)', async () => {
+ const { FakeEl, installFakeDom } = await import('./fakedom.mjs');
+ installFakeDom();
+ const mbox = new FakeEl('div'), o = new Overlays(defs, mbox);
+ o.attach(fakeView());
+ o.setMarkers('sun', [{ lat: 1, lon: 2, label: '☉' }]);
+ const node = mbox.kids[0];
+ o.setMarkers('sun', [{ lat: 3, lon: 4, label: '☉' }]);
+ assert.equal(mbox.kids[0], node);
+ assert.deepEqual([o.markers[0].lat, o.markers[0].lon], [3, 4]);
+});
+
+test('new definitions drop the loaded overlays (another era)', async () => {
+ const { FakeEl, installFakeDom } = await import('./fakedom.mjs');
+ installFakeDom();
+ const o = new Overlays([{ id: 'coast', name: 'Coast', file: '/data/geo/coast.geojson?era=a' }], new FakeEl('div'));
+ o.cache.set('coast', Promise.resolve([]));
+ o.setDefs([{ id: 'coast', name: 'Coast', file: '/data/geo/coast.geojson?era=b' }]);
+ assert.equal(o.cache.size, 0);
+ assert.match(o.defs[0].file, /era=b$/);
+});
diff --git a/viewer/tests/pins.test.mjs b/viewer/tests/pins.test.mjs
new file mode 100644
index 0000000..1d69015
--- /dev/null
+++ b/viewer/tests/pins.test.mjs
@@ -0,0 +1,51 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { filterPins, loreWarning, pinPayload, epochIds } from '../js/pins.js';
+
+const pins = [
+ { id: 'pin-1', name: 'Zeta ford', lat: 1, lon: 2, lore: null, note: 'shallow crossing' },
+ { id: 'pin-2', name: 'alpha peak', lat: 3, lon: 4, lore: 'physical-world', note: '' },
+ { id: 'pin-3', name: 'Mid Lake', lat: 5, lon: 6, lore: null, note: '' },
+];
+
+test('search: name, lore id and note, case-insensitive; sorted by name', () => {
+ assert.deepEqual(filterPins(pins, '').map(p => p.id), ['pin-2', 'pin-3', 'pin-1']);
+ assert.deepEqual(filterPins(pins, 'PEAK').map(p => p.id), ['pin-2']);
+ assert.deepEqual(filterPins(pins, 'physical').map(p => p.id), ['pin-2']);
+ assert.deepEqual(filterPins(pins, 'crossing').map(p => p.id), ['pin-1']);
+ assert.deepEqual(filterPins(pins, 'nothing'), []);
+ assert.equal(pins[0].id, 'pin-1', 'input not reordered');
+});
+
+test('lore warning only for unknown ids', () => {
+ assert.equal(loreWarning('', ['a']), null);
+ assert.equal(loreWarning('a', ['a']), null);
+ assert.match(loreWarning('b-c', ['a']), /not a known lore id/);
+});
+
+test('payload trims and turns an empty lore id into null', () => {
+ assert.deepEqual(pinPayload({ name: ' Ford ', lat: 1, lon: 2, lore: ' ', note: ' x ', id: 'pin-9', cell: 'c' }),
+ { name: 'Ford', lat: 1, lon: 2, lore: null, note: 'x', epoch: null });
+});
+
+test('a draft marker shows while the new-pin form is open', async () => {
+ const { FakeEl, installFakeDom } = await import('./fakedom.mjs');
+ installFakeDom();
+ const { PinsPanel } = await import('../js/pins.js');
+ const drafts = [];
+ const p = new PinsPanel(new FakeEl('section'), [], { onPins() {}, onFly() {}, onPlacing() {}, onDraft: d => drafts.push(d) });
+ p.placeAt({ lat: 1, lon: 2 });
+ assert.deepEqual(drafts.at(-1), { lat: 1, lon: 2 });
+ p.box.find(e => e.tag === 'button' && e.textContent === 'Cancel').fire('click');
+ assert.equal(drafts.at(-1), null);
+});
+
+test('payload carries the epoch and numeric coordinates', () => {
+ assert.deepEqual(pinPayload({ name: 'X', lat: '-15.5', lon: '-34.2', lore: 'cell-am-zee', note: '', epoch: 'epoch-high-fantasy' }),
+ { name: 'X', lat: -15.5, lon: -34.2, lore: 'cell-am-zee', note: '', epoch: 'epoch-high-fantasy' });
+});
+
+test('epoch choices come from the lore ids', () => {
+ assert.deepEqual(epochIds(['a', 'epoch-high-fantasy', 'epochs', 'epoch-space-age']), ['epoch-high-fantasy', 'epoch-space-age']);
+});
diff --git a/viewer/tests/profile.test.mjs b/viewer/tests/profile.test.mjs
new file mode 100644
index 0000000..9418565
--- /dev/null
+++ b/viewer/tests/profile.test.mjs
@@ -0,0 +1,36 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { profileModel, sampleAt } from '../js/profile.js';
+
+const S = [{ d_km: 0, z_m: -200, water: true }, { d_km: 50, z_m: 300, water: false }, { d_km: 100, z_m: 1200, water: false },
+ { d_km: 150, z_m: -50, water: true }];
+
+test('model spans distance and elevation incl. sea level', () => {
+ const m = profileModel(S, 320, 130);
+ assert.ok(m.line.startsWith('M'));
+ assert.equal(m.L, 150);
+ assert.ok(m.lo <= -200 && m.hi >= 1200);
+ assert.ok(m.Y(0) > m.Y(1200) && m.Y(0) < m.Y(-200));
+ assert.ok(Math.abs(m.xAt(m.X(75)) - 75) < 1e-9);
+ assert.equal(m.seaY, m.Y(0));
+});
+
+test('water runs cover the water samples, half a step either side', () => {
+ const m = profileModel(S, 320, 130);
+ assert.equal(m.water.length, 2);
+ assert.ok(Math.abs(m.water[0][0] - m.X(0)) < 1e-9 && Math.abs(m.water[0][1] - m.X(25)) < 1e-9);
+ assert.ok(Math.abs(m.water[1][0] - m.X(125)) < 1e-9 && Math.abs(m.water[1][1] - m.X(150)) < 1e-9);
+});
+
+test('flat land still gets a usable vertical range', () => {
+ const m = profileModel([{ d_km: 0, z_m: 10, water: false }, { d_km: 5, z_m: 10, water: false }], 320, 130);
+ assert.ok(m.hi - m.lo >= 100);
+});
+
+test('nearest sample by distance', () => {
+ assert.equal(sampleAt(S, -5), 0);
+ assert.equal(sampleAt(S, 74), 1);
+ assert.equal(sampleAt(S, 76), 2);
+ assert.equal(sampleAt(S, 999), 3);
+});
diff --git a/viewer/tests/public.test.mjs b/viewer/tests/public.test.mjs
new file mode 100644
index 0000000..04ded8c
--- /dev/null
+++ b/viewer/tests/public.test.mjs
@@ -0,0 +1,18 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const { hiddenTabs, hideTabs } = await import('../js/public.js');
+
+test('a read-only map hides the editing tabs; a private one hides none', () => {
+ assert.deepEqual(hiddenTabs({ read_only: true }), ['pins', 'regions', 'export']);
+ assert.deepEqual(hiddenTabs({ read_only: false }), []);
+ assert.deepEqual(hiddenTabs({}), []);
+ assert.deepEqual(hiddenTabs(null), []);
+});
+
+test('hideTabs removes the buttons and sections it names', () => {
+ const removed = [];
+ const doc = { querySelector: sel => ({ remove: () => removed.push(sel) }) };
+ hideTabs(doc, ['pins']);
+ assert.deepEqual(removed, ['#panel nav [data-tab="pins"]', '#tab-pins']);
+});
diff --git a/viewer/tests/regions.test.mjs b/viewer/tests/regions.test.mjs
new file mode 100644
index 0000000..d7a9d65
--- /dev/null
+++ b/viewer/tests/regions.test.mjs
@@ -0,0 +1,82 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { closesOutline, crossesItself, regionPayload, statusText, outlineLines } from '../js/regions.js';
+
+test('clicking near the first point closes the outline', () => {
+ assert.equal(closesOutline({ x: 100, y: 100 }, { x: 108, y: 105 }), true);
+ assert.equal(closesOutline({ x: 100, y: 100 }, { x: 130, y: 100 }), false);
+ assert.equal(closesOutline(null, { x: 1, y: 1 }), false);
+});
+
+test('self-crossing outlines are refused; unwraps across ±180°', () => {
+ const sq = [[0, 0], [0, 2], [2, 2], [2, 0]].map(([lat, lon]) => ({ lat, lon }));
+ const bow = [[0, 0], [2, 2], [0, 2], [2, 0]].map(([lat, lon]) => ({ lat, lon }));
+ assert.equal(crossesItself(sq), false);
+ assert.equal(crossesItself(bow), true);
+ const dateline = [[0, 179], [0, -179], [2, -179], [2, 179]].map(([lat, lon]) => ({ lat, lon }));
+ assert.equal(crossesItself(dateline), false);
+});
+
+test('payload, status text, outline lines', () => {
+ assert.deepEqual(regionPayload(' Gorge ', [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }]),
+ { name: 'Gorge', outline: [[1, 2], [3, 4], [5, 6]] });
+ assert.equal(statusText({ state: 'building', stage: 'erosion', fraction: 0.456 }), 'Refining… 46% (erosion)');
+ assert.equal(statusText({ state: 'ready' }), 'Refined areas are up to date');
+ assert.match(statusText({ state: 'failed', error: 'boom' }), /failed.*boom/i);
+ assert.equal(statusText({ state: 'queued' }), 'Queued…');
+ const lines = outlineLines([{ outline: [[1, 2], [3, 4], [5, 6]] }]);
+ assert.equal(lines.length, 1);
+ assert.deepEqual(lines[0].at(-1), { lat: 1, lon: 2 }, 'closed');
+});
+
+test('status shows the tiles being saved ahead after a build', async () => {
+ const { statusText } = await import('../js/regions.js');
+ assert.equal(statusText({ state: 'ready', tiles: { done: 45, total: 100 } }), 'Refined areas are up to date · saving tiles 45%');
+ assert.equal(statusText({ state: 'ready', tiles: { done: 100, total: 100 } }), 'Refined areas are up to date');
+ assert.equal(statusText({ state: 'idle', tiles: { done: 3, total: 10 } }), 'Saving region tiles 30%');
+});
+
+test('outline area on a 2× Earth-radius world, also across ±180°', async () => {
+ const { outlineAreaKm2 } = await import('../js/regions.js');
+ const box = (la0, lo0, d) => [{ lat: la0, lon: lo0 }, { lat: la0, lon: lo0 + d }, { lat: la0 + d, lon: lo0 + d }, { lat: la0 + d, lon: lo0 }];
+ const want = 12742 ** 2 * (10 * Math.PI / 180) * Math.sin(10 * Math.PI / 180);
+ assert.ok(Math.abs(outlineAreaKm2(box(0, 20, 10)) / want - 1) < 1e-9);
+ assert.ok(Math.abs(outlineAreaKm2(box(0, 175, 10).map(p => ({ ...p, lon: p.lon > 180 ? p.lon - 360 : p.lon }))) / want - 1) < 1e-9);
+ assert.ok(Math.abs(outlineAreaKm2([...box(0, 20, 10)].reverse()) / want - 1) < 1e-9, 'either winding');
+});
+
+test('tiles switch whenever the server names a different URL', async () => {
+ const { needsTileSwitch } = await import('../js/regions.js');
+ assert.equal(needsTileSwitch({ url: '/tiles/a/x' }, { url: '/tiles/b/x' }), true);
+ assert.equal(needsTileSwitch({ url: '/tiles/a/x' }, { url: '/tiles/a/x' }), false);
+ assert.equal(needsTileSwitch({ url: '/tiles/a/x' }, undefined), false);
+});
+
+test('outline clicks: add, close on the first point, and a double-click (a second click on the last point) closes', async () => {
+ const { outlineClick } = await import('../js/regions.js');
+ const first = { x: 100, y: 100 }, last = { x: 300, y: 200 };
+ assert.equal(outlineClick(null, null, { x: 5, y: 5 }, 0), 'add');
+ assert.equal(outlineClick(first, last, { x: 400, y: 400 }, 3), 'add');
+ assert.equal(outlineClick(first, last, { x: 103, y: 99 }, 3), 'close');
+ assert.equal(outlineClick(first, last, { x: 301, y: 202 }, 3), 'close', 'double-click on the last point');
+ assert.equal(outlineClick(first, last, { x: 301, y: 202 }, 2), 'ignore', 'too few points: no duplicate either');
+});
+
+test('a poll tells which era it asked about: an answer for the era before a switch is not taken for this one', async () => {
+ const { FakeEl, installFakeDom } = await import('./fakedom.mjs');
+ installFakeDom();
+ const { RegionsPanel } = await import('../js/regionspanel.js');
+ let q = '?era=a', release;
+ const seen = [];
+ globalThis.fetch = () => new Promise(r => { release = () => r({ ok: true, status: 200,
+ json: async () => ({ regions: [], status: { state: 'ready' }, tiles: { url: 'a' } }) }); });
+ const p = new RegionsPanel(new FakeEl('section'), { onDraft() {}, onDrawing() {}, onRegions() {},
+ onStatus: (s, t, was, asked) => seen.push(asked), eraQuery: () => q });
+ const done = p.poll();
+ q = '?era=b';
+ release();
+ await done;
+ clearTimeout(p.timer);
+ assert.deepEqual(seen, ['?era=a']);
+});
diff --git a/viewer/tests/riverlod.test.mjs b/viewer/tests/riverlod.test.mjs
new file mode 100644
index 0000000..6bc58cc
--- /dev/null
+++ b/viewer/tests/riverlod.test.mjs
@@ -0,0 +1,37 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+const { riverStats, riverMinOrder, cellSpacingKm } = await import('../js/riverlod.js');
+// r5 reference world: total river length per Strahler order (km, from its rivers.geojson) and its cell spacing
+const r5 = { length: { 1: 3177232, 2: 430077, 3: 141166, 4: 38607, 5: 8174, 6: 1273 } };
+const cell = cellSpacingKm(5, 6371);
+
+test('H3 res-5 cells on an Earth-sized planet lie ≈ 16 km apart', () => {
+ assert.ok(Math.abs(cell - 15.9) < 0.2, String(cell));
+});
+
+test('a phone at 26 km/px (the view that merged into blue) draws orders ≥ 3', () => {
+ assert.equal(riverMinOrder(26, r5, cell), 3); // order 3 ≈ 358 km apart ≥ 208; order 2 ≈ 117 km is not
+});
+
+test('closer in, smaller rivers join; first-order ones only when a cell spans ≥ 8 px', () => {
+ assert.equal(riverMinOrder(14, r5, cell), 2);
+ assert.equal(riverMinOrder(cell / 8, r5, cell), 1);
+ assert.equal(riverMinOrder(cell / 7, r5, cell), 2);
+});
+
+test('far out: only the largest rivers; no scale yet: the largest; no data: all', () => {
+ assert.equal(riverMinOrder(1e4, r5, cell), 6);
+ assert.equal(riverMinOrder(null, r5, cell), 6);
+ assert.equal(riverMinOrder(5, { length: {} }, cell), 1);
+});
+
+test('riverStats sums each order\'s length', async () => {
+ const line = (o, ...lons) => ({ properties: { order: o }, geometry: { coordinates: lons.map(x => [x, 0]) } });
+ const s = riverStats({ features: [line(1, 0, 1), line(1, 2, 3, 4), line(2, 0, 0.5)] });
+ const { distanceKm } = await import('../js/geo.js');
+ const deg = distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }); // km per degree on this planet's equator
+ assert.ok(Math.abs(s.length[1] - 3 * deg) < 1, String(s.length[1]));
+ assert.ok(Math.abs(s.length[2] - 0.5 * deg) < 1, String(s.length[2]));
+});
diff --git a/viewer/tests/search.test.mjs b/viewer/tests/search.test.mjs
new file mode 100644
index 0000000..12b5e83
--- /dev/null
+++ b/viewer/tests/search.test.mjs
@@ -0,0 +1,55 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { parseCoords, isCellId, suggest } from '../js/search.js';
+
+const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);
+
+test('coordinate formats', () => {
+ const cases = [['40N 10W', 40, -10], ['40.5, -10.25', 40.5, -10.25], ['-12 170', -12, 170], ["40°30'N 10°15'W", 40.5, -10.25],
+ ['10W 40N', 40, -10], ['N40 E10', 40, 10], ['40.5 N, 10.25 E', 40.5, 10.25], ['12°30\'15"S 45°E', -(12 + 30 / 60 + 15 / 3600), 45]];
+ for (const [q, lat, lon] of cases) {
+ const c = parseCoords(q);
+ assert.ok(c, q);
+ near(c.lat, lat, 1e-9);
+ near(c.lon, lon, 1e-9);
+ }
+});
+
+test('junk is rejected', () => {
+ for (const q of ['', 'hello', '40', '95 10', '10 200', '40N 10N', "40°75'N 10W", '40N 10W 5', 'Cell Am Zee', 'NEWS', '4O 10']) {
+ assert.equal(parseCoords(q), null, q);
+ }
+});
+
+test('H3 cell ids', () => {
+ assert.equal(isCellId('8528308ffffffff'), true);
+ assert.equal(isCellId(' 8528308FFFFFFFF '), true);
+ assert.equal(isCellId('0528308ffffffff'), false);
+ assert.equal(isCellId('8528308fffffff'), false);
+});
+
+test('suggestions: coordinates, cells, then pins', () => {
+ const pins = [{ id: 'pin-1', name: 'Zeta ford', lat: 1, lon: 2, lore: null, note: '' },
+ { id: 'pin-2', name: 'Alpha peak', lat: 3, lon: 4, lore: 'physical-world', note: '' }];
+ assert.deepEqual(suggest('40N 10W', pins).map(s => [s.kind, s.lat, s.lon]), [['coords', 40, -10]]);
+ assert.deepEqual(suggest('8528308ffffffff', pins).map(s => [s.kind, s.cell]), [['cell', '8528308ffffffff']]);
+ assert.deepEqual(suggest('a', pins).map(s => s.label), ['Alpha peak', 'Zeta ford']);
+ assert.deepEqual(suggest(' ', pins), []);
+});
+
+test('decimal commas in a two-number entry', () => {
+ const a = parseCoords('40,5 10,25'), b = parseCoords('40,5N 10,25W'), c = parseCoords('40,5');
+ assert.deepEqual([a.lat, a.lon], [40.5, 10.25]);
+ assert.deepEqual([b.lat, b.lon], [40.5, -10.25]);
+ assert.deepEqual([c.lat, c.lon], [40, 5]); // no space: still "lat,lon"
+});
+
+test('decimal commas anywhere, and "lat,lon" still wins when it parses', () => {
+ for (const [q, lat, lon] of [['40 10,5', 40, 10.5], ['40.5 10,25', 40.5, 10.25], ['40N 10,5E', 40, 10.5],
+ ['40,5 10', 40.5, 10], ['40,5 E', 40, 5]]) {
+ const c = parseCoords(q);
+ assert.ok(c, q);
+ assert.deepEqual([c.lat, c.lon], [lat, lon], q);
+ }
+});
diff --git a/viewer/tests/searchbox.test.mjs b/viewer/tests/searchbox.test.mjs
new file mode 100644
index 0000000..bdf79b1
--- /dev/null
+++ b/viewer/tests/searchbox.test.mjs
@@ -0,0 +1,65 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { FakeEl, installFakeDom } from './fakedom.mjs';
+
+installFakeDom();
+const { SearchBox } = await import('../js/searchbox.js');
+const tick = () => new Promise(r => setTimeout(r, 0));
+
+function box(pins = [], locate = async () => ({ lat: 1, lon: 2 })) {
+ const input = new FakeEl('input'), list = new FakeEl('div'), went = [];
+ new SearchBox(input, list, { pins: () => pins, locate, go: r => went.push(r) });
+ return { input, list, went };
+}
+
+test('typing suggests; Enter flies to coordinates with a marker', async () => {
+ const { input, list, went } = box();
+ input.value = '40N 10W';
+ input.fire('input');
+ assert.equal(list.hidden, false);
+ assert.equal(list.kids.length, 1);
+ input.fire('keydown', { key: 'Enter' });
+ await tick();
+ assert.deepEqual([went[0].lat, went[0].lon, went[0].marker], [40, -10, true]);
+ assert.equal(list.hidden, true);
+});
+
+test('arrows move the selection; pins fly without a marker', async () => {
+ const pins = [{ name: 'Alpha', lat: 3, lon: 4, lore: null, note: '' }, { name: 'Alpine', lat: 5, lon: 6, lore: null, note: '' }];
+ const { input, list, went } = box(pins);
+ input.value = 'alp';
+ input.fire('input');
+ assert.equal(list.kids.length, 2);
+ input.fire('keydown', { key: 'ArrowDown' });
+ assert.match(list.kids[1].className, /sel/);
+ input.fire('keydown', { key: 'Enter' });
+ await tick();
+ assert.deepEqual([went[0].lat, went[0].lon, went[0].marker], [5, 6, false]);
+});
+
+test('cell ids go through the server; failures are reported', async () => {
+ const ok = box([], async cell => ({ cell, lat: -20, lon: 100 }));
+ ok.input.value = '8528308ffffffff';
+ ok.input.fire('input');
+ ok.input.fire('keydown', { key: 'Enter' });
+ await tick();
+ assert.deepEqual([ok.went[0].lat, ok.went[0].lon], [-20, 100]);
+ const bad = box([], async () => { throw new Error('nope'); });
+ bad.input.value = '8528308ffffffff';
+ bad.input.fire('input');
+ bad.input.fire('keydown', { key: 'Enter' });
+ await tick();
+ assert.equal(bad.went[0].error, 'nope');
+});
+
+test('Esc closes the list; junk shows nothing', () => {
+ const { input, list } = box();
+ input.value = '95 10';
+ input.fire('input');
+ assert.equal(list.hidden, true);
+ input.value = '40N 10W';
+ input.fire('input');
+ input.fire('keydown', { key: 'Escape' });
+ assert.equal(list.hidden, true);
+});
diff --git a/viewer/tests/shading.test.mjs b/viewer/tests/shading.test.mjs
new file mode 100644
index 0000000..65f4162
--- /dev/null
+++ b/viewer/tests/shading.test.mjs
@@ -0,0 +1,57 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { fakeRenderer } from './importmap.mjs';
+
+const { GlobeView } = await import('../js/globe.js');
+const { FlatView } = await import('../js/flatmap.js');
+const { SHADE_GLSL } = await import('../js/shading.js');
+
+const makers = [['globe', r => new GlobeView(r, () => {})], ['equal_earth', r => new FlatView(r, 'equal_earth', () => {})],
+ ['plate_carree', r => new FlatView(r, 'plate_carree', () => {})]];
+
+for (const [name, make] of makers) {
+ test(`${name}: over-layer blend and sun lighting drive the shared uniforms`, () => {
+ const v = make(fakeRenderer()), u = v.shade, tex = { isTexture: true };
+ v.setOver(tex, 0.4);
+ assert.equal(u.map2.value, tex);
+ assert.equal(u.mix2.value, 0.4);
+ v.setOver(null, 0.4);
+ assert.equal(u.mix2.value, 0);
+ v.setSun([0, 1, 0]);
+ assert.equal(u.sky.value, 1);
+ assert.deepEqual(u.sunDir.value.toArray(), [0, 1, 0]);
+ v.setSun(null);
+ assert.equal(u.sky.value, 0);
+ assert.ok(v.material.fragmentShader.includes('shadeColor('));
+ assert.equal(v.material.uniforms.mix2, u.mix2, 'material and view share uniform objects');
+ });
+}
+
+test('GLSL chunk declares the shared uniforms and the shading function', () => {
+ for (const n of ['uniform sampler2D map2', 'uniform float mix2', 'uniform vec3 sunDir', 'uniform float sky', 'vec3 shadeColor(']) {
+ assert.ok(SHADE_GLSL.includes(n), n);
+ }
+});
+
+test('haze uniforms exist and default to none', async () => {
+ const { shadeUniforms, SHADE_GLSL } = await import('../js/shading.js');
+ const u = shadeUniforms();
+ assert.equal(u.hazeAmt.value, 0);
+ assert.ok(u.camPos.value && u.hazeCol.value && u.hazeK.value > 0);
+ assert.match(SHADE_GLSL, /hazeAmt/);
+});
+
+test('over-layer keeps only the base\'s light and dark: its colour reads true, base colour (river blue) fades', async () => {
+ const { overMix, SHADE_GLSL } = await import('../js/shading.js');
+ const white = [0.94, 0.94, 0.94], river = [0.2, 0.4, 0.8], grass = [0.3, 0.45, 0.25];
+ const a = overMix(river, white, 0.5), b = overMix(grass, white, 0.5);
+ const spread = c => Math.max(...c) - Math.min(...c);
+ assert.ok(spread(a) < 0.12, `river under a white over-layer stays nearly grey: ${a}`);
+ assert.ok(Math.abs(a[2] - b[2]) < 0.12, 'river no longer stands out as blue');
+ assert.deepEqual(overMix(river, white, 0).map(v => +v.toFixed(6)), river);
+ const full = overMix(grass, [0.08, 0.47, 0.7], 1);
+ assert.ok(full[2] > full[0] + 0.3, 'at full opacity the over colour shows, lit by the base');
+ assert.match(SHADE_GLSL, /vec3 overColor\(vec3 c, vec3 o, float k\)/);
+ assert.match(SHADE_GLSL, /overColor\(c, texture2D\(map2, uv\)\.rgb, mix2\)/);
+});
diff --git a/viewer/tests/sky.test.mjs b/viewer/tests/sky.test.mjs
new file mode 100644
index 0000000..836508b
--- /dev/null
+++ b/viewer/tests/sky.test.mjs
@@ -0,0 +1,38 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { subsolar, sunVec, dayFraction, seasonName, DAY_MOMENTS } from '../js/sky.js';
+import { toLatLon } from '../js/geo.js';
+
+const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);
+
+test('sub-solar latitude follows the 20° tilt over the 216-day year', () => {
+ near(subsolar(0, 0).lat, 0, 1e-9);
+ near(subsolar(54, 0).lat, 20, 1e-9);
+ near(subsolar(108, 0).lat, 0, 1e-9);
+ near(subsolar(162, 0).lat, -20, 1e-9);
+});
+
+test('sub-solar longitude: noon at 0° at moment 23,328, midnight at 0° at moment 0', () => {
+ near(subsolar(0, DAY_MOMENTS / 2).lon, 0, 1e-9);
+ near(Math.abs(subsolar(0, 0).lon), 180, 1e-9);
+ near(subsolar(0, DAY_MOMENTS / 4).lon, 90, 1e-9);
+ const v = toLatLon(sunVec(54, DAY_MOMENTS / 2));
+ near(v.lat, subsolar(54, DAY_MOMENTS / 2).lat, 1e-6);
+});
+
+test('day length', () => {
+ near(dayFraction(0, 30), 0.5, 1e-9);
+ assert.equal(dayFraction(80, 54), 1);
+ assert.equal(dayFraction(80, 162), 0);
+ assert.equal(dayFraction(-80, 54), 0);
+ assert.equal(dayFraction(90, 0), 0.5);
+ assert.ok(dayFraction(45, 54) > 0.5 && dayFraction(45, 162) < 0.5);
+});
+
+test('season names near the equinoxes and solstices only', () => {
+ assert.equal(seasonName(0), 'northern spring equinox');
+ assert.equal(seasonName(55), 'northern midsummer');
+ assert.equal(seasonName(215), 'northern spring equinox');
+ assert.equal(seasonName(30), '');
+});
diff --git a/viewer/tests/skypanel.test.mjs b/viewer/tests/skypanel.test.mjs
new file mode 100644
index 0000000..c746bd8
--- /dev/null
+++ b/viewer/tests/skypanel.test.mjs
@@ -0,0 +1,16 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { dayLabel, momentLabel, daylightLabel, SKY_LINES } from '../js/skypanel.js';
+
+test('labels', () => {
+ assert.equal(dayLabel(54), 'day 54 of 216 · northern midsummer');
+ assert.equal(dayLabel(30), 'day 30 of 216');
+ assert.equal(momentLabel(23328), 'moment 23,328 of 46,656');
+ assert.equal(daylightLabel(0, 30), 'daylight 50% (15.6 h)');
+});
+
+test('tropics and polar circles', () => {
+ assert.deepEqual(SKY_LINES.map(l => l[0].lat), [20, -20, 70, -70]);
+ assert.ok(SKY_LINES.every(l => l.length === 181 && l[0].lon === -180 && l.at(-1).lon === 180));
+});
diff --git a/viewer/tests/tilelayer.test.mjs b/viewer/tests/tilelayer.test.mjs
new file mode 100644
index 0000000..10e2159
--- /dev/null
+++ b/viewer/tests/tilelayer.test.mjs
@@ -0,0 +1,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');
+});
diff --git a/viewer/tests/tiles.test.mjs b/viewer/tests/tiles.test.mjs
new file mode 100644
index 0000000..da3bf2a
--- /dev/null
+++ b/viewer/tests/tiles.test.mjs
@@ -0,0 +1,126 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { tileOf, tileBounds, targetZoom, pxKm, tilesForPoints, parentTile, tileKey, globePatch, flatPatch, MAX_TILE_Z } from '../js/tiles.js';
+import { toLatLon, project } from '../js/geo.js';
+
+const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`);
+
+test('tile of a point: wraps east–west, clamps at the poles', () => {
+ assert.deepEqual(tileOf(90, -180, 3), { z: 3, x: 0, y: 0 });
+ assert.deepEqual(tileOf(-90, 180, 3), { z: 3, x: 0, y: 7 });
+ assert.deepEqual(tileOf(0.1, 179.99, 3), { z: 3, x: 15, y: 3 });
+ const b = tileBounds(tileOf(12.3, 45.6, 9));
+ assert.ok(b.west <= 45.6 && 45.6 < b.east && b.south < 12.3 && 12.3 <= b.north);
+});
+
+test('target zoom: tile pixels just finer than screen pixels', () => {
+ for (const k of [50, 9.8, 1, 0.1, 0.004]) {
+ const z = targetZoom(k);
+ assert.ok(pxKm(z) <= k * (1 + 1e-9), `${k}: z ${z}`);
+ if (z > 0) assert.ok(pxKm(z - 1) > k);
+ }
+ assert.equal(targetZoom(1e-6), MAX_TILE_Z);
+ assert.equal(targetZoom(1e6), 0);
+ assert.equal(targetZoom(Infinity), 0);
+});
+
+test('tiles for points: neighbours, dedupe, antimeridian wrap, pole edge', () => {
+ const t = tilesForPoints([{ lat: 0.1, lon: 179.99 }], 3, 1).map(tileKey);
+ assert.equal(t.length, 9);
+ assert.ok(t.includes('3/0/3') && t.includes('3/14/2'));
+ assert.equal(tilesForPoints([{ lat: 0.1, lon: 179.99 }, { lat: 0.2, lon: 179.98 }], 3, 0).length, 1);
+ assert.equal(tilesForPoints([{ lat: 89.9, lon: 0 }], 3, 1).length, 6);
+});
+
+test('parent tiles', () => {
+ assert.deepEqual(parentTile({ z: 6, x: 45, y: 13 }), { z: 5, x: 22, y: 6 });
+});
+
+test('patches: globe vertices on the sphere with north-west first; flat vertices on the projection', () => {
+ const t = { z: 7, x: 130, y: 40 }, b = tileBounds(t), g = globePatch(t, 4);
+ for (let k = 0; k < g.positions.length / 3; k++) {
+ const v = [0, 1, 2].map(i => g.positions[3 * k + i] + g.center[i]);
+ near(Math.hypot(...v), 1, 1e-6);
+ }
+ const nw = toLatLon([0, 1, 2].map(i => g.positions[i] + g.center[i]));
+ near(nw.lat, b.north, 1e-4);
+ near(nw.lon, b.west, 1e-4);
+ assert.deepEqual([g.uvs[0], g.uvs[1]], [0, 1]);
+ assert.equal(g.indices.length, 4 * 4 * 6);
+ const f = flatPatch(t, 'equal_earth', 4), p = project('equal_earth', b.north, b.west);
+ near(f.positions[0] + f.center[0], p.x, 1e-6);
+ near(f.positions[1] + f.center[1], p.y, 1e-6);
+ near(f.ll[0], (b.north * Math.PI) / 180, 1e-6);
+});
+
+test('3D patch: vertices lifted by height × exaggeration, skirts hang below the border, flat patch unchanged', async () => {
+ const { globePatch, gridHeight, MESH_SEGS } = await import('../js/tiles.js');
+ const { R_KM } = await import('../js/geo.js');
+ const t = { z: 10, x: 600, y: 300 }, n = MESH_SEGS + 1, R_M = R_KM * 1000;
+ const h = Float32Array.from({ length: n * n }, (_, k) => 100 + (k % n)); // rises eastward
+ const flat = globePatch(t, 16), p = globePatch(t, MESH_SEGS, h, 3);
+ assert.equal(flat.positions.length, 17 * 17 * 3);
+ assert.equal(p.positions.length, (n * n + 4 * MESH_SEGS) * 3);
+ assert.equal(p.uvs.length, (n * n + 4 * MESH_SEGS) * 2);
+ const r = k => Math.hypot(...[0, 1, 2].map(i => p.positions[3 * k + i] + p.center[i]));
+ assert.ok(Math.abs(r(0) - (1 + (3 * 100) / R_M)) < 1e-9);
+ assert.ok(Math.abs(r(n - 1) - (1 + (3 * (100 + n - 1)) / R_M)) < 1e-9);
+ assert.ok(r(n * n) < r(0) - 1e-9, 'the first skirt vertex hangs below its border vertex');
+ const spacingM = (180 / 1024 / MESH_SEGS) * Math.PI / 180 * R_M;
+ assert.ok((r(0) - r(n * n)) * R_M >= 2 * spacingM * 3 - 0.05, 'skirts reach 2 vertex spacings × exaggeration (two zoom levels of mismatch)');
+ assert.ok(p.indices.every(i => i < n * n + 4 * MESH_SEGS));
+ assert.equal(p.indices.length, MESH_SEGS * MESH_SEGS * 6 + 4 * MESH_SEGS * 6);
+ const b = { west: -180 + 600 * 180 / 1024, north: 90 - 300 * 180 / 1024 }, s = 180 / 1024;
+ assert.equal(gridHeight(t, h, b.north, b.west), 100);
+ assert.ok(Math.abs(gridHeight(t, h, b.north - s / 2, b.west + s / 2) - (100 + MESH_SEGS / 2)) < 1e-9, 'centre: bilinear');
+ assert.equal(gridHeight(t, h, b.north - s, b.west + s), 100 + n - 1);
+});
+
+test('3D patch: skirt vertices are flagged (shaded as the ground above them, not as cliffs)', async () => {
+ const { globePatch, MESH_SEGS } = await import('../js/tiles.js');
+ const t = { z: 10, x: 600, y: 300 }, n = MESH_SEGS + 1;
+ const p = globePatch(t, MESH_SEGS, new Float32Array(n * n).fill(50), 1);
+ assert.equal(p.skirt.length, n * n + 4 * MESH_SEGS);
+ assert.ok(p.skirt.subarray(0, n * n).every(v => v === 0), 'the grid: no skirt');
+ assert.ok(p.skirt.subarray(n * n).every(v => v === 1), 'the skirt');
+ assert.equal(globePatch(t, 16).skirt, undefined, 'a flat patch has none');
+});
+
+test('lodTiles: uniform zoom = tilesForPoints; mixed zooms never overlap and cover every point', async () => {
+ const { lodTiles, tilesForPoints, tileOf, tileKey, parentTile } = await import('../js/tiles.js');
+ const pts = Array.from({ length: 30 }, (_, k) => ({ lat: 40 + (k % 6) * 0.02, lon: 10 + Math.floor(k / 6) * 0.03 }));
+ const u = lodTiles(pts, pts.map(() => 11));
+ assert.deepEqual(new Set(u.map(tileKey)), new Set(tilesForPoints(pts, 11, 1).map(tileKey)));
+ assert.equal(tileKey(u[0]), tileKey(tileOf(40, 10, 11)), 'first point first');
+ const zs = pts.map((_, k) => (k < 6 ? 14 : k < 18 ? 12 : 9)); // near → far
+ const m = lodTiles(pts, zs), keys = new Set(m.map(tileKey));
+ for (const t of m) for (let a = parentTile(t); a.z >= 5; a = parentTile(a)) assert.ok(!keys.has(tileKey(a)), `overlap ${tileKey(t)}`);
+ pts.forEach((p, k) => {
+ const cover = m.filter(t => { const o = tileOf(p.lat, p.lon, t.z); return o.x === t.x && o.y === t.y; });
+ assert.equal(cover.length, 1, `point ${k}`);
+ assert.ok(cover[0].z >= zs[k], `point ${k} never coarser than it asked`);
+ });
+ assert.ok(m.some(t => t.z === 14) && m.some(t => t.z < 12));
+ assert.deepEqual(lodTiles(pts, pts.map(() => 3)), [], 'below the tile zooms: nothing');
+});
+
+test('3D patches use the 129 × 129 server heights (128 segments)', async () => {
+ const { MESH_SEGS } = await import('../js/tiles.js');
+ assert.equal(MESH_SEGS, 128);
+});
+
+test('fine grain: its texel coordinates run on across tile edges (wrapping every DETAIL_TILES tiles and the antimeridian)', async () => {
+ const { detailOrigin, DETAIL_TILES, TILE } = await import('../js/tiles.js');
+ const P = DETAIL_TILES * TILE, mod = v => ((v % P) + P) % P;
+ for (const z of [5, 12, 16]) {
+ const nx = 2 ** (z + 1), ny = 2 ** z;
+ for (const [x, y] of [[0, 0], [7, 3], [DETAIL_TILES - 1, DETAIL_TILES - 1], [nx - 1, ny - 2], [54367, 37971 % ny]]) {
+ const o = detailOrigin({ z, x, y });
+ const e = detailOrigin({ z, x: (x + 1) % nx, y }), s = detailOrigin({ z, x, y: y + 1 });
+ assert.equal(mod(o[0] + TILE), mod(e[0]), `east of ${z}/${x}/${y}`);
+ assert.equal(mod(o[1] + TILE), mod(s[1]), `south of ${z}/${x}/${y}`);
+ assert.ok(o.every(v => v >= 0 && v < P), 'small numbers: float32 keeps sub-texel precision');
+ }
+ }
+});
diff --git a/viewer/tests/units.test.mjs b/viewer/tests/units.test.mjs
new file mode 100644
index 0000000..d4af9d8
--- /dev/null
+++ b/viewer/tests/units.test.mjs
@@ -0,0 +1,38 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import * as U from '../js/units.js';
+
+U.setUnits({ span_m: 0.331013, league_km: 15.4437 });
+
+test('conversions', () => {
+ assert.ok(Math.abs(U.kmToLeagues(15.4437) - 1) < 1e-12);
+ assert.ok(Math.abs(U.mToSpans(0.331013) - 1) < 1e-12);
+ assert.ok(Math.abs(U.km2ToSqLeagues(15.4437 ** 2) - 1) < 1e-12);
+ assert.equal(U.walkDays(90), 3);
+ assert.equal(U.fmtDistance(1000), '1,000 km · 64.8 leagues');
+ assert.equal(U.fmtDistance(12.34), '12.3 km · 0.80 leagues');
+ assert.equal(U.fmtWalk(45), '1.5 days on foot');
+ assert.equal(U.fmtLatLon(12.5, -3.25), '12.500° N, 3.250° W');
+ assert.equal(U.fmtLatLon(-0.5, 179), '0.500° S, 179.000° E');
+ assert.equal(U.fmtDistance(0.5), '500 m · 1,511 spans');
+ assert.equal(U.fmtElev(2344), '2,344 m · 7,081 spans');
+ assert.equal(U.fmtElev(-12.4), '-12 m · -37 spans');
+});
+
+test('niceKm picks 1/2/5 steps', () => {
+ assert.deepEqual([123, 260, 0.7, 5, 999].map(U.niceKm), [100, 200, 0.5, 5, 500]);
+});
+
+test('setUnits overrides', () => {
+ U.setUnits({ league_km: 10 });
+ assert.equal(U.kmToLeagues(25), 2.5);
+ U.setUnits({ league_km: 15.4437 });
+});
+
+test('SI only without in-world units', () => {
+ U.setUnits({ span_m: null, league_km: null });
+ assert.equal(U.fmtDistance(12.34), '12.3 km');
+ assert.equal(U.fmtElev(2344), '2,344 m');
+ U.setUnits({ span_m: 0.331013, league_km: 15.4437 });
+});
diff --git a/viewer/tests/urlstate.test.mjs b/viewer/tests/urlstate.test.mjs
new file mode 100644
index 0000000..f32656e
--- /dev/null
+++ b/viewer/tests/urlstate.test.mjs
@@ -0,0 +1,77 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { parse, serialize, DEFAULT, carryHash } from '../js/urlstate.js';
+
+test('round trip', () => {
+ const s = { ...DEFAULT, layer: 'biomes', view: 'equal_earth', lat: 40.5, lon: -10.25, alt: 0.5, zoom: 3, overlays: ['coast', 'pins'] };
+ assert.deepEqual(parse(serialize(s)), s);
+ const t = { ...s, over: 'biomes', op: 0.35, sky: true, day: 100, mo: 12345, alt: 0.0004, zoom: 20000 };
+ assert.deepEqual(parse(serialize(t)), t);
+});
+
+test('new keys clamp and default', () => {
+ const s = parse('#op=7&day=300&mo=-5&sky=maybe&alt=0.00001&zoom=1e9');
+ assert.equal(s.op, 1);
+ assert.equal(s.day, 215);
+ assert.equal(s.mo, 0);
+ assert.equal(s.sky, false);
+ assert.equal(s.alt, 0.00001); // above the 20 m floor now
+ assert.equal(s.zoom, 20000);
+ assert.equal(s.over, '');
+});
+
+test('garbage and out-of-range values fall back or clamp', () => {
+ const s = parse('#view=nope&lat=abc&lon=200&alt=99&zoom=0');
+ assert.equal(s.view, DEFAULT.view);
+ assert.equal(s.lat, DEFAULT.lat);
+ assert.equal(s.lon, -160);
+ assert.equal(s.alt, 5);
+ assert.equal(s.zoom, 1);
+ assert.deepEqual(parse(''), DEFAULT);
+});
+
+test('clamps come from the shared view limits', async () => {
+ const { MIN_ALT, MAX_ALT, MAX_ZOOM } = await import('../js/limits.js');
+ const s = parse('#alt=1e-9&zoom=1e12');
+ assert.equal(s.alt, MIN_ALT);
+ assert.equal(s.zoom, MAX_ZOOM);
+ assert.equal(parse('#alt=1e9').alt, MAX_ALT);
+});
+
+test('tilt and 3D: round trip, clamps, only written when they differ from the default', () => {
+ const s = { ...DEFAULT, alt: 0.001, hd: 30.5, pt: 45, terrain: 2.5 };
+ assert.deepEqual(parse(serialize(s)), s);
+ assert.deepEqual(parse(serialize({ ...DEFAULT, terrain: 0 })), { ...DEFAULT, terrain: 0 });
+ assert.ok(!/hd=|pt=|3d=/.test(serialize(DEFAULT)));
+ assert.ok(!/hd=|pt=/.test(serialize({ ...DEFAULT, view: 'equal_earth', hd: 30, pt: 40 })), 'flat views have no tilt');
+ const c = parse('#hd=-90&pt=95&3d=9');
+ assert.deepEqual([c.hd, c.pt, c.terrain], [270, 80, 5]);
+ assert.equal(parse('#3d=0.4').terrain, 1);
+ assert.equal(parse('#3d=-2').terrain, 0);
+ assert.equal(parse('#3d=abc').terrain, 1);
+});
+
+test('era and water round-trip; defaults stay out of the URL', () => {
+ const s = { ...DEFAULT, era: 'age-of-legends', water: false };
+ assert.deepEqual(parse(serialize(s)), s);
+ assert.doesNotMatch(serialize(DEFAULT), /era=|water=/);
+ assert.equal(parse('#water=1').water, true);
+ assert.equal(parse('#era=').era, '');
+});
+
+test('the sea floor replaces relief only on the 3D globe with the water off', async () => {
+ const { drawnLayer } = await import('../js/urlstate.js');
+ const s = { ...DEFAULT, water: false };
+ assert.equal(drawnLayer(s, 'globe'), 'seafloor');
+ assert.equal(drawnLayer({ ...s, terrain: 0 }, 'globe'), 'relief', '3D off: the water toggle is hidden');
+ assert.equal(drawnLayer(s, 'equal_earth'), 'relief');
+ assert.equal(drawnLayer({ ...s, layer: 'biomes' }, 'globe'), 'biomes');
+ assert.equal(drawnLayer(DEFAULT, 'globe'), 'relief');
+});
+
+test('carryHash keeps the view, drops era and time', () => {
+ assert.equal(carryHash('#layer=ground&view=globe&lat=10&era=high-fantasy&sky=1&day=50&mo=9'), '#layer=ground&view=globe&lat=10&sky=1');
+ assert.equal(carryHash('#era=x'), '');
+ assert.equal(carryHash(''), '');
+});
diff --git a/viewer/tests/vents.test.mjs b/viewer/tests/vents.test.mjs
new file mode 100644
index 0000000..78d3386
--- /dev/null
+++ b/viewer/tests/vents.test.mjs
@@ -0,0 +1,27 @@
+import './_planet.mjs';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { clusterVents, ventText, zoomBucket } from '../js/vents.js';
+
+const V = (lat, lon, type = 0) => ({ lat, lon, type, temp_c: 350, flow: 2, mineral: type });
+
+test('far apart: one marker each; close together: one cluster with a count', () => {
+ const vents = [V(0, 0), V(0.01, 0.01), V(10, 10)];
+ const fine = clusterVents(vents, 0.01); // 40 px ≈ 0.4 km: nothing merges
+ assert.equal(fine.length, 3);
+ assert.ok(fine.every(c => c.n === 1 && c.v));
+ const coarse = clusterVents(vents, 10); // 40 px ≈ 400 km
+ assert.equal(coarse.length, 2);
+ const big = coarse.find(c => c.n === 2);
+ assert.ok(big && Math.abs(big.lat - 0.005) < 1e-9);
+});
+
+test('hover text names type, temperature and mineral', () => {
+ const t = ventText(V(0, 0, 0), ['black smoker', 'white smoker'], ['copper-iron sulfides', 'zinc sulfides and barite']);
+ assert.equal(t, 'black smoker · 350 °C · copper-iron sulfides · strong flow');
+});
+
+test('zoom buckets change every ×1.5', () => {
+ assert.equal(zoomBucket(1), zoomBucket(1.2));
+ assert.notEqual(zoomBucket(1), zoomBucket(1.6));
+});