diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
| commit | 3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch) | |
| tree | 4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/js/globe.js | |
| download | worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip | |
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/js/globe.js')
| -rw-r--r-- | viewer/js/globe.js | 297 |
1 files changed, 297 insertions, 0 deletions
diff --git a/viewer/js/globe.js b/viewer/js/globe.js new file mode 100644 index 0000000..0850e2f --- /dev/null +++ b/viewer/js/globe.js @@ -0,0 +1,297 @@ +// globe.js — 3D globe: textured unit sphere, orbit camera (north up), analytic picking +import * as THREE from 'three'; +import { GlobeControls } from './globecontrols.js'; +import { SkyDome, atmosphere, HAZE_MAX } from './atmosphere.js'; +import { clampPose, eyeOf, lift, kmPerPxAt, eyeAltitude, rangeForAltitude, R_M } from './camera.js'; +import { sphereMesh, toVec, toLatLon, R_KM } from './geo.js'; +import { globeSegments, drapedSegments } from './overlay_geom.js'; +import { LineSet } from './lines.js'; +import { SHADE_GLSL, shadeUniforms, applyOver, applySun } from './shading.js'; + +import { MIN_ALT, MAX_ALT } from './limits.js'; + +export { MIN_ALT, MAX_ALT }; +export const BACKGROUND = 0x10121a; +const DRAPE_ALT = 0.05; // below this eye altitude (planet radii) lines lie on the 3D ground +const TRENCH_KM = 12; // below the deepest sea floor (≈ −11 km) + +const VERT = /* glsl */ ` +varying vec2 vUv; +varying vec3 vNormal; +varying vec3 vPos; +void main() { + vUv = uv; + vPos = position; + vNormal = normalize(normalMatrix * position); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +}`; + +const FRAG = /* glsl */ `${SHADE_GLSL} +varying vec2 vUv; +varying vec3 vNormal; +varying vec3 vPos; +void main() { + gl_FragColor = vec4(shadeColor(vUv, vPos, 0.72 + 0.28 * clamp(vNormal.z, 0.0, 1.0)), 1.0); +}`; + +export class GlobeView { + constructor(renderer, onChange) { + this.kind = 'globe'; + this.renderer = renderer; + this.w = 1; + this.h = 1; + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(BACKGROUND); + this.camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10); + const m = sphereMesh(256, 512); + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(m.positions, 3)); + geom.setAttribute('uv', new THREE.BufferAttribute(m.uvs, 2)); + geom.setIndex(new THREE.BufferAttribute(m.indices, 1)); + this.shade = shadeUniforms(); + this.material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: this.shade }); + this.base = new THREE.Mesh(geom, this.material); + this.scene.add(this.base); + this.lines = new LineSet(this.scene); + this.raw = new Map(); // id → {lines, color}: kept so they can be laid on the 3D ground + this.drape = null; // the current draping frame, or null (lines at sea level) + this.horizon = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0); // keeps only the cap the camera can see + this.lines.setClip([this.horizon]); + renderer.localClippingEnabled = true; + this.sphere = new THREE.Sphere(new THREE.Vector3(), 1); + this.raycaster = new THREE.Raycaster(); + this.dome = new SkyDome(this.scene, BACKGROUND); + this.onChange = onChange; + this.terrain = 0; // 3D exaggeration; 0 = flat tiles (the app sets it) + this.groundU = 0; // ground under the eye, planet radii (for the near plane) + this.seaFloor = false; // Water off: the ground may lie below 0 m (the app sets it) + this.pose = clampPose({ lat: 20, lon: 0, range: 2 }); + this.controls = new GlobeControls(this, renderer.domElement); + this.#place(); + } + + activate() { this.controls.enabled = true; } + deactivate() { this.controls.enabled = false; } + + resize(w, h) { + this.w = w; + this.h = h; + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.#tune(); + } + + setTexture(tex) { + this.material.uniforms.map.value = tex; + this.material.uniforms.hasMap.value = tex ? 1 : 0; + } + + setOver(tex, op) { applyOver(this.shade, tex, op); } + setSun(v) { + applySun(this.shade, v); + this.#air(); + } + + setLines(id, lines, color) { + this.raw.set(id, { lines, color }); + this.#line(id); + } + + removeLines(id) { + this.raw.delete(id); + this.lines.remove(id); + } + + #line(id) { + const { lines, color } = this.raw.get(id); + if (this.drape) this.lines.set(id, drapedSegments(lines, this.drape), color, this.drape.origin); + else this.lines.set(id, globeSegments(lines, 1.0008, 1), color); + } + + updateDrape(now = performance.now()) { // each frame: re-lay lines on the 3D ground when heights or the view change + const p = this.camera.position, d = p.length(), alt = d - 1, dr = this.drape; + if (!(this.terrain > 0 && this.tiles && alt < DRAPE_ALT)) { + if (dr) this.#relay(null); + return; + } + const n = [p.x / d, p.y / d, p.z / d], gen = this.tiles.heightGen ?? 0; + const moved = !dr || n[0] * dr.n[0] + n[1] * dr.n[1] + n[2] * dr.n[2] < Math.cos(dr.capRad / 4) + || Math.abs(Math.log(alt / dr.alt)) > 0.3 || this.terrain !== dr.terrain; + if (!moved && (gen === dr.gen || now - dr.at < 250)) return; + const capRad = Math.acos(1 / d) + 0.0376 * Math.sqrt(this.terrain); // horizon + 9 km (× exaggeration) peaks beyond it + this.#relay({ eye: [p.x, p.y, p.z], n, origin: n, alt, gen, at: now, capRad, cosCap: Math.cos(capRad), + terrain: this.terrain, heightM: (la, lo) => this.#groundM(la, lo) }); + } + + #relay(drape) { + this.drape = drape; + this.lines.setClip(drape ? [] : [this.horizon]); + for (const id of this.raw.keys()) this.#line(id); + this.#tune(); + } + + #groundM(lat, lon, shown = true, unknown = 0) { // exaggerated height (m) of the drawn 3D ground; 0 without it + if (!(this.terrain > 0)) return 0; + const h = this.tiles?.heightAt?.(lat, lon, shown); // the drawn surface: sea at 0 m with water on, dry hollows below it + return h == null ? unknown : h * this.terrain; // tiles reloading: keep the last known ground + } + + #floor(p) { // the eye stays 20 m above the drawn ground under it (sea level without 3D terrain or loaded heights) + const known = this.groundU * R_M; + this.groundU = 0; + for (let i = 0; i < 2; i++) { // the ground under a tilted eye moves as the range grows: settle twice + const at = toLatLon(eyeOf(p).eye); + const h = Math.max(this.#groundM(at.lat, at.lon, false, known), // the finest heights loaded, and what is drawn: + this.#groundM(at.lat, at.lon, true, -Infinity)); // a coarser stand-in may stand higher + this.groundU = h / R_M; + p = lift(p, h, true); // h is the drawn ground, below 0 m only where something is drawn there + } + return p; + } + + #held = false; // set from state (URL, search, zoom buttons): the range holds from the ground as it loads + + setPose(p) { + this.#held = false; + this.#apply(p); + } + + #apply(p) { + p = clampPose(p); + p.ground = this.#groundM(p.lat, p.lon, false, this.pose?.ground ?? 0); // the target sits on the (loaded) ground: range counts from there + this.pose = this.#floor(p); + this.#place(); + this.onChange(); + } + + #place() { + const { eye, up, target } = eyeOf(this.pose); + this.camera.position.set(eye[0], eye[1], eye[2]); + this.camera.up.set(up[0], up[1], up[2]); + this.camera.lookAt(target[0], target[1], target[2]); + this.#tune(); + } + + keepAboveGround() { // finer heights loaded: the target follows the ground, the eye stays put unless swallowed + let p = this.pose; + const g = this.#groundM(p.lat, p.lon, false, p.ground); + if (this.#held) { // a restored view: its ground loads under it (unknown: wait for it) + if (this.terrain > 0 && this.tiles?.heightAt?.(p.lat, p.lon, false) != null && Math.abs(g - p.ground) > 0.01) + p = { ...p, ground: g }; + } else if (Math.abs(g - p.ground) > 0.01) { + const r = rangeForAltitude(eyeAltitude(p.range, p.pitch, p.ground), p.pitch, g); + p = { ...p, ground: g, range: Math.max(MIN_ALT, Number.isFinite(r) ? r : MIN_ALT) }; + } + p = this.#floor(p); + if (p === this.pose) return; + this.pose = p; + this.#place(); + this.onChange(); + } + + #tune() { // depth range and overlay lines follow the eye; the coarse sphere sinks under the tile patches + const p = this.camera.position, d = p.length(), alt = d - 1; + this.camera.near = Math.max(1e-7, (alt - this.groundU) * 0.3); + this.camera.far = Math.sqrt(Math.max(d * d - 1, 0)) + 0.1; // to the horizon and a bit beyond (far mountains) + this.camera.updateProjectionMatrix(); + this.camera.updateMatrixWorld(); + if (this.drape) this.lines.setTransform(1, true); // laid on the ground: depth hides what is behind hills + else { + const hidden = alt > 0.05 || (this.terrain > 0 && this.pose.pitch > 0); // tilted 3D: sea-level lines would float + this.lines.setTransform((1 + Math.max(2e-6, Math.min(0.0008, alt * 0.02))) / 1.0008, hidden); + } + this.horizon.normal.copy(p).divideScalar(d); // lines drawn on top when zoomed in: drop the far side explicitly + this.horizon.constant = -1 / d; + this.base.scale.setScalar(alt < 0.6 ? 1 - (this.terrain > 0 ? (this.seaFloor ? Math.max(2e-3, (TRENCH_KM * this.terrain) / R_KM) : 4e-4) : 3e-5) : 1); + this.#air(); + } + + #air() { // sky pass and haze follow the eye height and the sun + const p = this.camera.position, d = p.length(), up = p.clone().divideScalar(d); + const air = atmosphere((d - 1) * R_KM, this.shade.sky.value ? up.dot(this.shade.sunDir.value) : null); + this.dome.update(this.camera, up, air); + this.shade.camPos.value.copy(p); + this.shade.hazeCol.value.set(...air.horizon); + this.shade.hazeAmt.value = air.amount * HAZE_MAX * Math.min(1, this.pose.pitch / 45); // no horizon, no haze + } + + getState() { + const p = this.pose; + return { lat: p.lat, lon: p.lon, alt: p.range, hd: p.heading, pt: p.pitch }; + } + + setState({ lat, lon, alt, hd = 0, pt = 0 }) { + this.#apply({ lat, lon, range: alt, heading: hd, pitch: pt }); + this.#held = true; + } + + #ray(x, y) { + this.camera.updateMatrixWorld(); + this.raycaster.setFromCamera(new THREE.Vector2((x / this.w) * 2 - 1, 1 - (y / this.h) * 2), this.camera); + return this.raycaster.ray; + } + + #march(ray) { // where a ray first meets the drawn 3D ground: growing steps, then bisection; null if it doesn't + const { origin: o, direction: dir } = ray, top = (9000 * this.terrain) / R_M; + const at = t => [o.x + dir.x * t, o.y + dir.y * t, o.z + dir.z * t]; + const below = t => { + const v = at(t), ll = toLatLon(v); + return Math.hypot(v[0], v[1], v[2]) - 1 <= this.#groundM(ll.lat, ll.lon) / R_M; + }; + const ceil = ray.intersectSphere(new THREE.Sphere(new THREE.Vector3(), 1 + top), new THREE.Vector3()); + let lo = ceil && o.length() > 1 + top ? ceil.distanceTo(o) : 0; // nothing stands above the ceiling sphere + for (let t = Math.max(1e-7, lo * 1.01), i = 0; i < 120; i++, t *= 1.2) { + if (below(t)) { + let hi = t; + for (let k = 0; k < 16; k++) { + const m = (lo + hi) / 2; + if (below(m)) hi = m; + else lo = m; + } + return at(hi); + } + const v = at(t); + if (Math.hypot(v[0], v[1], v[2]) - 1 > top && v[0] * dir.x + v[1] * dir.y + v[2] * dir.z > 0) return null; // skyward + lo = t; + } + return null; + } + + pickVec(x, y, radius = null) { // the ground this pixel shows (3D ground if drawn), or the sphere of a given radius + const ray = this.#ray(x, y); + if (radius === null && this.terrain > 0 && this.tiles) { + const v = this.#march(ray); + if (v) return v; + } + const hit = ray.intersectSphere(radius === null ? this.sphere : new THREE.Sphere(new THREE.Vector3(), radius), new THREE.Vector3()); + return hit ? [hit.x, hit.y, hit.z] : null; + } + + pick(x, y, ground = true) { // ground = false: the sea-level sphere (cheap; tile sampling looking straight down) + const v = ground ? this.pickVec(x, y) : this.pickVec(x, y, 1); + return v ? toLatLon(v) : null; + } + + groundSample(x, y) { // tilted tile sampling: the ground this pixel sees and km per pixel there + const v = this.pickVec(x, y); + if (!v) return null; + const e = this.camera.position, d = Math.hypot(v[0] - e.x, v[1] - e.y, v[2] - e.z); + return { ...toLatLon(v), km: (d * R_KM * 2 * Math.tan((this.camera.fov * Math.PI) / 360)) / this.h }; + } + + kmPerPxAt(p) { + const e = this.camera.position; + return kmPerPxAt([e.x, e.y, e.z], p, this.camera.fov, this.h); + } + + toScreen(lat, lon) { + const v = new THREE.Vector3(...toVec(lat, lon)); + if (v.dot(this.camera.position) <= 1) return null; // beyond the horizon + v.multiplyScalar(1 + this.#groundM(lat, lon) / R_M); // on the drawn 3D ground + this.camera.updateMatrixWorld(); + v.project(this.camera); + return { x: ((v.x + 1) / 2) * this.w, y: ((1 - v.y) / 2) * this.h }; + } + + render() { this.renderer.render(this.scene, this.camera); } +} |
