aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/flatmap.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/flatmap.js
downloadworldmap-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.js194
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 });
+ }
+}