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}`); });