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/flatmap.js | |
| download | worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip | |
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/js/flatmap.js')
| -rw-r--r-- | viewer/js/flatmap.js | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/viewer/js/flatmap.js b/viewer/js/flatmap.js new file mode 100644 index 0000000..63d09d1 --- /dev/null +++ b/viewer/js/flatmap.js @@ -0,0 +1,194 @@ +// flatmap.js — flat world maps (Equal Earth, plate carrée): GPU-reprojected texture, pan/zoom, picking +import * as THREE from 'three'; +import { project, unproject, EE_XMAX, EE_YMAX, PC_XMAX, PC_YMAX } from './geo.js'; +import { densify, flatSegments } from './overlay_geom.js'; +import { LineSet } from './lines.js'; +import { BACKGROUND } from './globe.js'; +import { SHADE_GLSL, shadeUniforms, applyOver, applySun } from './shading.js'; + +import { MAX_ZOOM } from './limits.js'; +import { Touches } from './gestures.js'; + +export { MAX_ZOOM }; +const EXTENT = { equal_earth: [EE_XMAX, EE_YMAX, 1], plate_carree: [PC_XMAX, PC_YMAX, 0] }; + +const VERT = /* glsl */ ` +varying vec2 vXY; +void main() { + vXY = position.xy; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +}`; + +// Inverse projection per pixel, same maths as geo.js / mapgen/projections.py (Equal Earth: Newton on t). +const FRAG = /* glsl */ `${SHADE_GLSL} +uniform int proj; +varying vec2 vXY; +const float A1 = 1.340264, A2 = -0.081106, A3 = 0.000893, A4 = 0.003796; +const float M = 0.8660254037844386, PI = 3.141592653589793; +void main() { + float lat = vXY.y, lon = vXY.x; + if (proj == 1) { + float t = vXY.y / A1; + for (int i = 0; i < 12; i++) { + float t2 = t * t, t6 = t2 * t2 * t2; + t -= (t * (A1 + A2 * t2 + A3 * t6 + A4 * t6 * t2) - vXY.y) / (A1 + 3.0 * A2 * t2 + 7.0 * A3 * t6 + 9.0 * A4 * t6 * t2); + } + float t2 = t * t, t6 = t2 * t2 * t2; + float s = sin(t) / M; + lon = 3.0 * vXY.x * (A1 + 3.0 * A2 * t2 + 7.0 * A3 * t6 + 9.0 * A4 * t6 * t2) / (2.0 * sqrt(3.0) * cos(t)); + if (abs(s) > 1.000001 || abs(lon) > PI) discard; + lat = asin(clamp(s, -1.0, 1.0)); + } + vec3 n = vec3(cos(lat) * cos(lon), sin(lat), -cos(lat) * sin(lon)); // scene convention, as geo.toVec + gl_FragColor = vec4(shadeColor(vec2(lon / (2.0 * PI) + 0.5, lat / PI + 0.5), n, 1.0), 1.0); +}`; + +export class FlatView { + constructor(renderer, kind, onChange) { + const [xmax, ymax, proj] = EXTENT[kind]; + Object.assign(this, { renderer, kind, onChange, xmax, ymax, cx: 0, cy: 0, zoom: 1, w: 1, h: 1, active: false }); + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(BACKGROUND); + this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); + this.camera.position.z = 5; + this.shade = shadeUniforms(); + this.material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, + uniforms: { ...this.shade, proj: { value: proj } } }); + this.scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2 * xmax, 2 * ymax), this.material)); + this.lines = new LineSet(this.scene); + this.#bind(renderer.domElement); + } + + activate() { this.active = true; } + deactivate() { this.active = false; } + + resize(w, h) { + this.w = w; + this.h = h; + this.#apply(); + } + + #half() { // half width/height of the visible area in projection units; zoom 1 fits the whole map + const hh = (Math.max(this.ymax, (this.xmax * this.h) / this.w) * 1.04) / this.zoom; + return [(hh * this.w) / this.h, hh]; + } + + #apply() { + this.cy = Math.max(-this.ymax, Math.min(this.ymax, this.cy)); + const edge = this.kind === 'equal_earth' // keep the centre on the map, so its lat/lon is exact + ? project(this.kind, unproject(this.kind, 0, this.cy).lat, 180).x : this.xmax; + this.cx = Math.max(-edge, Math.min(edge, this.cx)); + const [hw, hh] = this.#half(); + Object.assign(this.camera, { left: -hw, right: hw, top: hh, bottom: -hh }); // camera follows the centre: + this.camera.position.set(this.cx, this.cy, 5); // small offsets keep float precision + this.camera.updateProjectionMatrix(); + } + + toWorld(x, y) { + const [hw, hh] = this.#half(); + return { X: this.cx + ((x / this.w) * 2 - 1) * hw, Y: this.cy + (1 - (y / this.h) * 2) * hh }; + } + + getState() { + const c = unproject(this.kind, this.cx, this.cy) // centre may sit outside the outline + ?? { lat: unproject(this.kind, 0, this.cy).lat, lon: Math.sign(this.cx) * 180 }; + return { lat: c.lat, lon: c.lon, zoom: this.zoom }; + } + + setState({ lat, lon, zoom }) { + const p = project(this.kind, lat, lon); + this.cx = p.x; + this.cy = p.y; + this.zoom = Math.min(MAX_ZOOM, Math.max(1, Number.isFinite(zoom) ? zoom : this.zoom)); + this.#apply(); + this.onChange(); + } + + 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); } + + setLines(id, lines, color) { // densify: long legs (measure paths) follow the great circle, as on the globe + this.lines.set(id, flatSegments(densify(lines, 1), this.kind, 0.001), color); + } + removeLines(id) { this.lines.remove(id); } + + pick(x, y) { + const { X, Y } = this.toWorld(x, y); + return unproject(this.kind, X, Y); + } + + toScreen(lat, lon) { + const p = project(this.kind, lat, lon), [hw, hh] = this.#half(); + return { x: (((p.x - this.cx) / hw + 1) / 2) * this.w, y: ((1 - (p.y - this.cy) / hh) / 2) * this.h }; + } + + render() { this.renderer.render(this.scene, this.camera); } + + #zoomAt(x, y, f) { // zoom by f keeping the point under (x, y) in place + const before = this.toWorld(x, y); + this.zoom = Math.min(MAX_ZOOM, Math.max(1, this.zoom * f)); + const after = this.toWorld(x, y); + this.cx += before.X - after.X; + this.cy += before.Y - after.Y; + } + + #bind(canvas) { + let drag = null; + const touches = new Touches(), at = e => { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; + canvas.addEventListener('pointerdown', e => { + if (!this.active || e.button !== 0) return; + drag = { x: e.clientX, y: e.clientY, cx: this.cx, cy: this.cy, id: e.pointerId }; + try { canvas.setPointerCapture(e.pointerId); } catch { /* a synthetic or already-ended pointer */ } + if (e.pointerType === 'touch') { + touches.down(e.pointerId, at(e)); + if (touches.size >= 2) drag = null; // two fingers: pinch and pan together + } + }); + canvas.addEventListener('pointermove', e => { + if (!this.active) return; + const g = e.pointerType === 'touch' ? touches.move(e.pointerId, at(e)) : null; + if (g) { + const k = (2 * this.#half()[1]) / this.h; + this.cx -= g.move.x * k; + this.cy += g.move.y * k; + this.#zoomAt(g.mid.x, g.mid.y, g.scale); + this.#apply(); + this.onChange(); + return; + } + if (!drag || e.pointerId !== drag.id) return; + const k = (2 * this.#half()[1]) / this.h; // projection units per CSS px + this.cx = drag.cx - (e.clientX - drag.x) * k; + this.cy = drag.cy + (e.clientY - drag.y) * k; + this.#apply(); + this.onChange(); + }); + const end = e => { + drag = null; + if (e.pointerType !== 'touch') return; + const wasPair = touches.size >= 2; + touches.up(e.pointerId); + const rest = wasPair && touches.size === 1 ? touches.other(e.pointerId) : null; + if (rest) { // one finger left: it pans on from where it is + const r = canvas.getBoundingClientRect(); + drag = { x: rest.at.x + r.left, y: rest.at.y + r.top, cx: this.cx, cy: this.cy, id: rest.id }; + } + }; + canvas.addEventListener('pointerup', end); + canvas.addEventListener('pointercancel', end); + canvas.addEventListener('wheel', e => { + if (!this.active) return; + e.preventDefault(); + const r = canvas.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top; + const px = e.deltaY * (e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 400 : 1); // Firefox may send lines + this.#zoomAt(x, y, Math.exp(-px * 0.0015)); + this.#apply(); + this.onChange(); + }, { passive: false }); + } +} |
