worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 18964 bytes

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