1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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();
});
}
|