aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/globe.js
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
committergodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
commit3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch)
tree4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/js/globe.js
downloadworldmap-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.js297
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); }
+}