worldmap-viewer

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

master

raw · 5118 bytes

// camera.js — globe camera pose (pure): a target on the ground (lat, lon and the ground's height there, m), a range
// (planet radii), a heading (0 = north up) and a pitch (0 = straight down). Scene units: planet radius 1.
import { R_KM, toVec, toLatLon } from './geo.js';
import { MIN_ALT, MAX_ALT, MAX_PITCH, CLEARANCE_M } from './limits.js';

const D = Math.PI / 180;
export const R_M = R_KM * 1000;
export const TILT_FULL = 1000 / R_KM;   // full tilt below ≈ 1,000 km …
export const TILT_NONE = 2000 / R_KM;   // … none above 2,000 km: zooming out levels the view smoothly

const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const mul = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const norm = a => mul(a, 1 / (Math.hypot(a[0], a[1], a[2]) || 1));
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);

export function frame(lat, lon) {   // unit east, north and up at a point (defined at the poles too, by lon)
  const la = lat * D, lo = lon * D;
  return { east: [-Math.sin(lo), 0, -Math.cos(lo)],
    north: [-Math.sin(la) * Math.cos(lo), Math.cos(la), Math.sin(la) * Math.sin(lo)], up: toVec(lat, lon) };
}

export const maxPitch = range => MAX_PITCH * clamp((TILT_NONE - range) / (TILT_NONE - TILT_FULL), 0, 1);

export function clampPose(p) {
  const range = clamp(num(p.range, 2), MIN_ALT, MAX_ALT);
  return { lat: clamp(num(p.lat, 0), -90, 90), lon: ((((num(p.lon, 0) + 180) % 360) + 360) % 360) - 180, range,
    heading: ((num(p.heading, 0) % 360) + 360) % 360, pitch: clamp(num(p.pitch, 0), 0, maxPitch(range)),
    ground: Math.max(0, num(p.ground, 0)) };
}

const forward = (f, heading) => add(mul(f.north, Math.cos(heading * D)), mul(f.east, Math.sin(heading * D)));

export function eyeOf(p) {   // camera position, screen-up vector and look-at target
  const f = frame(p.lat, p.lon), fw = forward(f, p.heading), t = p.pitch * D;
  const back = add(mul(f.up, Math.cos(t)), mul(fw, -Math.sin(t))), target = mul(f.up, 1 + (p.ground ?? 0) / R_M);
  return { eye: add(target, mul(back, p.range)), up: add(mul(fw, Math.cos(t)), mul(f.up, Math.sin(t))), target };
}

export function eyeAltitude(range, pitch, ground = 0) {   // above sea level, for a target on ground (m)
  const b = 1 + ground / R_M;
  return Math.sqrt(b * b + 2 * b * range * Math.cos(pitch * D) + range * range) - 1;
}

export function rangeForAltitude(alt, pitch, ground = 0) {   // the range that puts the eye at this altitude
  const b = 1 + ground / R_M, c = Math.cos(pitch * D);
  return -b * c + Math.sqrt(b * b * c * c - b * b + (1 + alt) ** 2);
}

export function headingOf(lat, lon, dir) {   // compass bearing (deg) of a direction at a point
  const f = frame(lat, lon);
  return ((Math.atan2(dot(dir, f.east), dot(dir, f.north)) / D) % 360 + 360) % 360;
}

export function rotatePose(p, from, to) {   // turn the whole camera about the planet centre, taking `from` to `to`
  const a = norm(from), b = norm(to), axis = cross(a, b), s = Math.hypot(axis[0], axis[1], axis[2]), c = dot(a, b);
  if (s < 1e-15) return { ...p };
  const k = mul(axis, 1 / s);
  const rot = v => add(add(mul(v, c), mul(cross(k, v), s)), mul(k, dot(k, v) * (1 - c)));   // Rodrigues
  const f = frame(p.lat, p.lon), target = rot(f.up), fw = rot(forward(f, p.heading));
  const { lat, lon } = toLatLon(target);
  return { ...p, lat, lon, heading: headingOf(lat, lon, fw) };
}

export const nearPole = lat => Math.abs(lat) > 89;   // north is undefined here: pans turn rigidly

export function keepHeading(from, to) {   // a pan result that keeps the compass heading, except around a pole
  return nearPole(from.lat) || nearPole(to.lat) ? to : { ...to, heading: from.heading };
}

export function panPose(p, fx, fy) {   // keys: move the target by fractions of the range (right = fx, screen-up = −fy);
  // the compass heading stays put, like a map (except around a pole)
  const f = frame(p.lat, p.lon), fw = forward(f, p.heading), right = cross(fw, f.up);
  const ang = Math.min(0.5, Math.hypot(fx, fy) * p.range);
  if (!(ang > 0)) return { ...p };
  const dir = norm(add(mul(right, fx), mul(fw, -fy)));
  return keepHeading(p, rotatePose(p, f.up, add(mul(f.up, Math.cos(ang)), mul(dir, Math.sin(ang)))));
}

export function lift(p, groundM, below = false) {   // raise the range until the eye clears the ground (or sea level;
  const g = num(groundM, 0), need = ((below ? g : Math.max(0, g)) + CLEARANCE_M) / R_M;   // below: the floor under it)
  if (eyeAltitude(p.range, p.pitch, p.ground) >= need - 1e-15) return p;
  return { ...p, range: Math.min(MAX_ALT, rangeForAltitude(need, p.pitch, p.ground)) };
}

export function kmPerPxAt(eye, p, fovDeg, hPx) {   // ground size of one pixel at a point, by its distance only
  const v = toVec(p.lat, p.lon);
  return (Math.hypot(eye[0] - v[0], eye[1] - v[1], eye[2] - v[2]) * R_KM * 2 * Math.tan((fovDeg * D) / 2)) / hPx;
}