worldmap-viewer

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

master

raw · 5632 bytes

// nav.js — zoom, home, fullscreen, link and export buttons and keyboard navigation
import { PLANET } from './planet.js';
import { $, toast } from './ui.js';

export const KEYS = { ArrowLeft: ['pan', -0.2, 0], ArrowRight: ['pan', 0.2, 0], ArrowUp: ['pan', 0, -0.2],
  ArrowDown: ['pan', 0, 0.2], '+': ['zoom', 0.6], '=': ['zoom', 0.6], '-': ['zoom', 1 / 0.6], Home: ['home'],
  f: ['fullscreen'], '/': ['search'], n: ['north'], g: ['ground'], '?': ['help'] };
export const TURN_KEYS = { ArrowLeft: ['turn', -15, 0], ArrowRight: ['turn', 15, 0], ArrowUp: ['turn', 0, 10], ArrowDown: ['turn', 0, -10] };
const MARKER_COLOR = { pin: '#e0b44c', measure: '#7fd4ff', search: '#ff6a4d', draft: '#ffffff', sun: '#ffd166', probe: '#ffffff' };

export const GROUND_RANGE = 3.5 / PLANET.radius_km;   // ground view: 3.5 km from the ground point (planet radii) …
export const GROUND_PITCH = 78;            // … looking out toward the horizon

export function groundPose(p) {   // the eye comes down over the spot looked at; the target lies ahead along the heading
  const d = GROUND_RANGE * Math.sin(GROUND_PITCH * Math.PI / 180), r = Math.PI / 180;   // arc from eye to target
  const la = p.lat * r, h = p.heading * r;
  const lat = Math.asin(Math.sin(la) * Math.cos(d) + Math.cos(la) * Math.sin(d) * Math.cos(h));
  const lon = p.lon * r + Math.atan2(Math.sin(h) * Math.sin(d) * Math.cos(la), Math.cos(d) - Math.sin(la) * Math.sin(lat));
  return { lat: lat / r, lon: lon / r, range: GROUND_RANGE, heading: p.heading, pitch: GROUND_PITCH };
}

export function keyAction(e) {
  const tag = e.target?.tagName;
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.ctrlKey || e.metaKey || e.altKey) return null;
  return (e.shiftKey && TURN_KEYS[e.key]) || KEYS[e.key] || null;
}

export const exportNotes = app => (app.view?.tiles?.active ? ['procedural detail — not data'] : []);

const slug = n => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'world';
export const exportName = s => `${slug(PLANET.name)}-${s.layer}${s.over ? `+${s.over}` : ''}-${s.view}.png`;

function toggleHelp(show) {
  const h = $('#help');
  h.hidden = !(show ?? h.hidden);
}

function toggleFullscreen() {
  if (document.fullscreenElement) document.exitFullscreen();
  else document.documentElement.requestFullscreen().catch(e => toast(`Fullscreen: ${e.message}`));
}

async function copyLink(app) {
  app.saveUrl();
  try {
    await navigator.clipboard.writeText(location.href);
    toast('Link copied', 'info');
  } catch (e) {
    toast(`Copy failed: ${e.message}`);
  }
}

export function exportPng(app) {
  app.view.render();   // same task as drawImage: the WebGL buffer is still valid
  const src = app.renderer.domElement, c = document.createElement('canvas');
  c.width = src.width;
  c.height = src.height;
  const g = c.getContext('2d'), k = src.width / src.clientWidth;
  g.drawImage(src, 0, 0);
  g.font = `${12 * k}px system-ui, sans-serif`;
  g.textBaseline = 'middle';
  for (const m of app.overlays.markers) {
    if (m.el?.style.display === 'none') continue;
    const p = app.view.toScreen(m.lat, m.lon);
    if (!p) continue;
    const x = p.x * k, y = p.y * k;
    g.fillStyle = MARKER_COLOR[m.kind] ?? '#e0b44c';
    g.strokeStyle = '#000';
    g.lineWidth = k;
    g.beginPath();
    g.arc(x, y, 4 * k, 0, 2 * Math.PI);
    g.fill();
    g.stroke();
    if (m.label) {
      g.lineWidth = 3 * k;
      g.strokeText(m.label, x + 9 * k, y);
      g.fillStyle = '#e6e8ee';
      g.fillText(m.label, x + 9 * k, y);
    }
  }
  let y = c.height - 10 * k;
  for (const note of exportNotes(app)) {   // the same caveat the status bar shows
    g.font = `${13 * k}px system-ui, sans-serif`;
    g.lineWidth = 3 * k;
    g.strokeStyle = '#000';
    g.strokeText(note, 10 * k, y);
    g.fillStyle = '#f0a050';
    g.fillText(note, 10 * k, y);
    y -= 18 * k;
  }
  const a = document.createElement('a');
  a.download = exportName(app.state);
  a.href = c.toDataURL('image/png');
  a.click();
}

export function installNav(app) {
  $('#nav-in').addEventListener('click', () => app.zoomBy(0.6));
  $('#nav-out').addEventListener('click', () => app.zoomBy(1 / 0.6));
  $('#nav-home').addEventListener('click', () => app.home());
  $('#nav-north').addEventListener('click', () => app.resetNorth());
  $('#nav-ground').addEventListener('click', () => app.groundView());
  $('#nav-help').addEventListener('click', () => toggleHelp());
  $('#help').addEventListener('click', () => toggleHelp(false));
  $('#nav-3d').addEventListener('click', () => app.setTerrain(app.state.terrain > 0 ? 0 : app.lastExag ?? 1));
  $('#nav-water').addEventListener('click', () => app.setWater(!app.state.water));
  $('#exag').addEventListener('input', e => app.setTerrain(Number(e.target.value)));
  $('#nav-full').addEventListener('click', toggleFullscreen);
  $('#nav-link').addEventListener('click', () => copyLink(app));
  $('#nav-export').addEventListener('click', () => exportPng(app));
  addEventListener('keydown', e => {
    if (e.key === 'Escape' && !$('#help').hidden) return toggleHelp(false);
    const a = keyAction(e);
    if (!a) return;
    e.preventDefault();
    if (a[0] === 'pan') app.panBy(a[1], a[2]);
    else if (a[0] === 'zoom') app.zoomBy(a[1]);
    else if (a[0] === 'home') app.home();
    else if (a[0] === 'fullscreen') toggleFullscreen();
    else if (a[0] === 'turn') app.turnBy(a[1], a[2]);
    else if (a[0] === 'north') app.resetNorth();
    else if (a[0] === 'ground') app.groundView();
    else if (a[0] === 'help') toggleHelp();
    else document.getElementById('search')?.focus();
  });
}