worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 7974 bytes

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