From 3443c1c65e9f1753e1e656b35d08416c1fa298f2 Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 00:14:38 +0200 Subject: worldmap-viewer: initial public history --- viewer/tests/_planet.mjs | 3 + viewer/tests/atmosphere.test.mjs | 24 ++ viewer/tests/camera.test.mjs | 139 ++++++++++++ viewer/tests/exports.test.mjs | 46 ++++ viewer/tests/fakedom.mjs | 29 +++ viewer/tests/fixtures/projections.json | 42 ++++ viewer/tests/flatmap.test.mjs | 83 +++++++ viewer/tests/geo.test.mjs | 77 +++++++ viewer/tests/gestures.test.mjs | 61 +++++ viewer/tests/globe.test.mjs | 377 +++++++++++++++++++++++++++++++ viewer/tests/hover.test.mjs | 30 +++ viewer/tests/importmap-hooks.mjs | 11 + viewer/tests/importmap.mjs | 15 ++ viewer/tests/inspector.test.mjs | 30 +++ viewer/tests/latest.test.mjs | 24 ++ viewer/tests/layers.test.mjs | 50 ++++ viewer/tests/layout.test.mjs | 40 ++++ viewer/tests/measure.test.mjs | 82 +++++++ viewer/tests/modules.test.mjs | 23 ++ viewer/tests/nav.test.mjs | 64 ++++++ viewer/tests/overlay_geom.test.mjs | 69 ++++++ viewer/tests/overlays.test.mjs | 147 ++++++++++++ viewer/tests/pins.test.mjs | 51 +++++ viewer/tests/profile.test.mjs | 36 +++ viewer/tests/public.test.mjs | 18 ++ viewer/tests/regions.test.mjs | 82 +++++++ viewer/tests/riverlod.test.mjs | 37 +++ viewer/tests/search.test.mjs | 55 +++++ viewer/tests/searchbox.test.mjs | 65 ++++++ viewer/tests/shading.test.mjs | 57 +++++ viewer/tests/sky.test.mjs | 38 ++++ viewer/tests/skypanel.test.mjs | 16 ++ viewer/tests/tilelayer.test.mjs | 402 +++++++++++++++++++++++++++++++++ viewer/tests/tiles.test.mjs | 126 +++++++++++ viewer/tests/units.test.mjs | 38 ++++ viewer/tests/urlstate.test.mjs | 77 +++++++ viewer/tests/vents.test.mjs | 27 +++ 37 files changed, 2591 insertions(+) create mode 100644 viewer/tests/_planet.mjs create mode 100644 viewer/tests/atmosphere.test.mjs create mode 100644 viewer/tests/camera.test.mjs create mode 100644 viewer/tests/exports.test.mjs create mode 100644 viewer/tests/fakedom.mjs create mode 100644 viewer/tests/fixtures/projections.json create mode 100644 viewer/tests/flatmap.test.mjs create mode 100644 viewer/tests/geo.test.mjs create mode 100644 viewer/tests/gestures.test.mjs create mode 100644 viewer/tests/globe.test.mjs create mode 100644 viewer/tests/hover.test.mjs create mode 100644 viewer/tests/importmap-hooks.mjs create mode 100644 viewer/tests/importmap.mjs create mode 100644 viewer/tests/inspector.test.mjs create mode 100644 viewer/tests/latest.test.mjs create mode 100644 viewer/tests/layers.test.mjs create mode 100644 viewer/tests/layout.test.mjs create mode 100644 viewer/tests/measure.test.mjs create mode 100644 viewer/tests/modules.test.mjs create mode 100644 viewer/tests/nav.test.mjs create mode 100644 viewer/tests/overlay_geom.test.mjs create mode 100644 viewer/tests/overlays.test.mjs create mode 100644 viewer/tests/pins.test.mjs create mode 100644 viewer/tests/profile.test.mjs create mode 100644 viewer/tests/public.test.mjs create mode 100644 viewer/tests/regions.test.mjs create mode 100644 viewer/tests/riverlod.test.mjs create mode 100644 viewer/tests/search.test.mjs create mode 100644 viewer/tests/searchbox.test.mjs create mode 100644 viewer/tests/shading.test.mjs create mode 100644 viewer/tests/sky.test.mjs create mode 100644 viewer/tests/skypanel.test.mjs create mode 100644 viewer/tests/tilelayer.test.mjs create mode 100644 viewer/tests/tiles.test.mjs create mode 100644 viewer/tests/units.test.mjs create mode 100644 viewer/tests/urlstate.test.mjs create mode 100644 viewer/tests/vents.test.mjs (limited to 'viewer/tests') 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(/