raw · 18964 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 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}`); }); |