aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/nav.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/nav.js
downloadworldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz
worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/js/nav.js')
-rw-r--r--viewer/js/nav.js126
1 files changed, 126 insertions, 0 deletions
diff --git a/viewer/js/nav.js b/viewer/js/nav.js
new file mode 100644
index 0000000..1e15adc
--- /dev/null
+++ b/viewer/js/nav.js
@@ -0,0 +1,126 @@
+// 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();
+ });
+}