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