aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/js')
-rw-r--r--viewer/js/app.js654
-rw-r--r--viewer/js/atmosphere.js65
-rw-r--r--viewer/js/boot.js10
-rw-r--r--viewer/js/camera.js91
-rw-r--r--viewer/js/exportpanel.js103
-rw-r--r--viewer/js/exports.js40
-rw-r--r--viewer/js/flatmap.js194
-rw-r--r--viewer/js/geo.js100
-rw-r--r--viewer/js/gestures.js42
-rw-r--r--viewer/js/globe.js297
-rw-r--r--viewer/js/globecontrols.js88
-rw-r--r--viewer/js/hover.js16
-rw-r--r--viewer/js/input.js4
-rw-r--r--viewer/js/inspector.js39
-rw-r--r--viewer/js/latest.js6
-rw-r--r--viewer/js/layers.js84
-rw-r--r--viewer/js/layout.js33
-rw-r--r--viewer/js/limits.js9
-rw-r--r--viewer/js/lines.js45
-rw-r--r--viewer/js/measure.js99
-rw-r--r--viewer/js/nav.js126
-rw-r--r--viewer/js/overlay_geom.js106
-rw-r--r--viewer/js/overlays.js159
-rw-r--r--viewer/js/pins.js143
-rw-r--r--viewer/js/planet.js10
-rw-r--r--viewer/js/profile.js37
-rw-r--r--viewer/js/public.js13
-rw-r--r--viewer/js/regions.js58
-rw-r--r--viewer/js/regionspanel.js125
-rw-r--r--viewer/js/riverlod.js32
-rw-r--r--viewer/js/search.js68
-rw-r--r--viewer/js/searchbox.js59
-rw-r--r--viewer/js/shading.js59
-rw-r--r--viewer/js/sky.js41
-rw-r--r--viewer/js/skypanel.js80
-rw-r--r--viewer/js/tilelayer.js401
-rw-r--r--viewer/js/tiles.js162
-rw-r--r--viewer/js/ui.js33
-rw-r--r--viewer/js/units.js22
-rw-r--r--viewer/js/urlstate.js59
-rw-r--r--viewer/js/vents.js25
41 files changed, 3837 insertions, 0 deletions
diff --git a/viewer/js/app.js b/viewer/js/app.js
new file mode 100644
index 0000000..66889af
--- /dev/null
+++ b/viewer/js/app.js
@@ -0,0 +1,654 @@
+// app.js — World Maps: bootstrap and wiring
+import * as THREE from 'three';
+import { GlobeView } from './globe.js';
+import { FlatView } from './flatmap.js';
+import { LayerStore, renderLegend } from './layers.js';
+import { Overlays } from './overlays.js';
+import { distanceKm } from './geo.js';
+import { isClick } from './input.js';
+import { inspect } from './inspector.js';
+import { MeasurePath, renderMeasure, renderProfile } from './measure.js';
+import { hoverText, canReuse } from './hover.js';
+import { PinsPanel } from './pins.js';
+import { RegionsPanel } from './regionspanel.js';
+import { ExportPanel } from './exportpanel.js';
+import { hiddenTabs, hideTabs } from './public.js';
+import { applyMode, compactMode, freeArea, textureCap } from './layout.js';
+import { cellSpacingKm } from './riverlod.js';
+import { needsTileSwitch, outlineClick, outlineLines } from './regions.js';
+import { groundPose, installNav } from './nav.js';
+import { SearchBox } from './searchbox.js';
+import { SkyPanel } from './skypanel.js';
+import { TileLayer } from './tilelayer.js';
+import { targetZoom } from './tiles.js';
+import { clusterVents, ventText, zoomBucket } from './vents.js';
+import { panPose } from './camera.js';
+import { parse, serialize, drawnLayer, carryHash } from './urlstate.js';
+import { setUnits, fmtLatLon, fmtDistance, niceKm } from './units.js';
+import { $, api, el, toast } from './ui.js';
+import { metaFor } from './boot.js';
+import { Latest } from './latest.js';
+
+export async function main() {
+ const { meta, note } = await metaFor(api, parse(location.hash).era);
+ if (note) toast(note);
+ setUnits(meta.units);
+ window.mapViewer = await new App(meta).start(); // handle for poking around in the console
+}
+
+class App {
+ constructor(meta) {
+ this.meta = meta;
+ this.eraCall = new Latest();
+ this.ventsCall = new Latest();
+ this.state = parse(location.hash);
+ this.state.era = meta.era === meta.default_era ? '' : meta.era;
+ if (!meta.layers.some(l => l.id === this.state.layer)) this.state.layer = meta.layers[0].id;
+ this.el = $('#map');
+ this.renderer = new THREE.WebGLRenderer({ antialias: true });
+ this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
+ this.el.append(this.renderer.domElement);
+ const caps = this.renderer.capabilities;
+ this.layers = new LayerStore(meta.layers, textureCap(caps.maxTextureSize), caps.getMaxAnisotropy());
+ this.overlays = new Overlays(meta.overlays, $('#markers'), () => { this.dirty = true; });
+ this.overlays.setCellKm(cellSpacingKm(meta.res, meta.radius_km)); // how far apart first-order rivers lie
+ this.views = {};
+ this.view = null;
+ this.texture = null;
+ this.overTexture = null;
+ this.sunVec = null;
+ this.mode = 'inspect';
+ this.measure = new MeasurePath();
+ this.profileSeq = 0;
+ this.hoverSeq = 0;
+ this.dirty = true;
+ }
+
+ async start() {
+ this.resize();
+ addEventListener('resize', () => this.resize());
+ addEventListener('hashchange', () => this.applyHash());
+ this.bindPointer();
+ this.bindViews();
+ this.buildToolbar();
+ this.bindPanel();
+ this.bindClicks();
+ const hidden = hiddenTabs(this.meta); // a public (read-only) map: no editing tabs
+ hideTabs(document, hidden);
+ if (!hidden.includes('pins')) this.bindPins();
+ if (!hidden.includes('regions')) this.bindRegions();
+ if (!hidden.includes('export')) this.bindExports();
+ this.loadVents();
+ installNav(this);
+ this.bindSearch();
+ this.sky = new SkyPanel(this);
+ this.setView(this.state.view, this.state);
+ this.setTerrain(this.state.terrain);
+ this.setWater(this.state.water);
+ await this.setLayer(this.state.layer);
+ if (this.state.over) await this.setOver(this.state.over);
+ this.loop();
+ return this;
+ }
+
+ changed() {
+ this.dirty = true;
+ this.exports?.update(); // the export square follows the map centre
+ clearTimeout(this.urlTimer);
+ this.urlTimer = setTimeout(() => this.saveUrl(), 300);
+ }
+
+ saveUrl() {
+ if (!this.view) return;
+ Object.assign(this.state, this.view.getState());
+ const hash = serialize(this.state);
+ if (hash !== location.hash) history.replaceState(null, '', hash);
+ }
+
+ viewFor(kind) {
+ if (!this.views[kind]) {
+ const v = kind === 'globe' ? new GlobeView(this.renderer, () => this.changed())
+ : new FlatView(this.renderer, kind, () => this.changed());
+ if (this.meta.tiles) {
+ v.tiles = new TileLayer(v, { url: this.meta.tiles.url, meshUrl: this.meta.tiles.mesh_url, onChange: () => { this.dirty = true; } });
+ }
+ this.applyTerrain(v);
+ v.seaFloor = kind === 'globe' && !this.state.water; // a view made after the water was turned off
+ v.tiles?.setWater(this.state.water);
+ this.views[kind] = v;
+ }
+ return this.views[kind];
+ }
+
+ applyTerrain(v) { // globe only; flat maps stay flat
+ v.terrain = v.kind === 'globe' ? this.state.terrain : 0;
+ v.tiles?.setTerrain(v.terrain);
+ }
+
+ setTerrain(x) { // 0 = flat tiles; 1–5 = 3D with that exaggeration
+ this.state.terrain = x;
+ if (x > 0) this.lastExag = x;
+ for (const v of Object.values(this.views)) this.applyTerrain(v);
+ this.syncTiles();
+ this.view?.keepAboveGround?.();
+ this.syncTerrainUi();
+ this.changed();
+ }
+
+ syncTerrainUi() {
+ const x = this.state.terrain, globe = this.view?.kind === 'globe';
+ $('#nav-3d').classList.toggle('on', x > 0);
+ $('#nav-3d').hidden = !globe;
+ $('#exag-box').hidden = !(x > 0 && globe);
+ $('#exag').value = String(x || this.lastExag || 1);
+ $('#exag-text').textContent = `${x || this.lastExag || 1}×`;
+ $('#nav-water').hidden = !(x > 0 && globe);
+ }
+
+ syncTiles() { // Water off: the 3D globe shows the sea floor in place of relief
+ for (const v of Object.values(this.views)) v.tiles?.setLayers(drawnLayer(this.state, v.kind), this.state.over);
+ }
+
+ setWater(on) { // 3D: water surface (on) or the sea floor (off)
+ this.state.water = on;
+ for (const v of Object.values(this.views)) {
+ v.seaFloor = v.kind === 'globe' && !on;
+ v.tiles?.setWater(on);
+ }
+ this.syncTiles();
+ this.view?.keepAboveGround?.();
+ $('#nav-water').classList.toggle('on', !on);
+ this.changed();
+ }
+
+ async setEra(name) {
+ const k = this.eraCall.next();
+ let meta;
+ try {
+ meta = await api(`api/meta?era=${encodeURIComponent(name)}`);
+ } catch (e) {
+ if (!this.eraCall.is(k)) return;
+ toast(`Era “${name}”: ${e.message}`);
+ $('#era').value = this.meta.era;
+ return;
+ }
+ if (!this.eraCall.is(k)) return;
+ this.meta = { ...this.meta, ...meta };
+ this.state.era = meta.era === meta.default_era ? '' : meta.era;
+ $('#era').value = meta.era;
+ const caps = this.renderer.capabilities, old = this.layers;
+ this.layers = new LayerStore(meta.layers, textureCap(caps.maxTextureSize), caps.getMaxAnisotropy());
+ this.overlays.setDefs(meta.overlays);
+ for (const v of Object.values(this.views)) v.tiles?.setUrl(meta.tiles.url, meta.tiles.mesh_url);
+ this.hoverCell = null;
+ await this.setLayer(this.state.layer);
+ if (this.state.over) await this.setOver(this.state.over);
+ old.dispose([this.texture, this.overTexture]);
+ this.loadVents();
+ this.changed();
+ }
+
+ updateTiles() { // sample the screen centre-first, hand the visible surface to the tile layer
+ const t = this.view.tiles;
+ if (!t) return;
+ // sample every 64 px (tiles are ≥ 128 px on screen, narrower toward the poles: the ring covers those), skip the panel
+ const p = $('#panel'), open = !p.classList.contains('collapsed');
+ const { w, h } = freeArea(this.el.clientWidth, this.el.clientHeight, open ? p.getBoundingClientRect() : null);
+ const nx = Math.ceil(w / 64), ny = Math.ceil(h / 64), cells = [], pts = [];
+ for (let i = 0; i < ny; i++) for (let j = 0; j < nx; j++) cells.push([((j + 0.5) * w) / nx, ((i + 0.5) * h) / ny]);
+ cells.sort((a, b) => Math.hypot(a[0] - w / 2, a[1] - h / 2) - Math.hypot(b[0] - w / 2, b[1] - h / 2));
+ const tilted = this.view.pose?.pitch > 0;
+ for (const [x, y] of cells) {
+ const p = tilted ? this.view.groundSample(x, y) : this.view.pick(x, y, false); // tilted: the ground this pixel sees
+ if (p) pts.push(p);
+ }
+ t.update(this.kmPerPx ?? Infinity, pts, Math.ceil((w / 128 + 2) * (h / 128 + 2)));
+ const deep = targetZoom(this.kmPerPx ?? Infinity) >= 7; // the tiles draw these themselves
+ const tileRivers = this.meta.tiles?.rivers && this.state.layer === 'relief'; // only relief tiles draw river water
+ this.overlays.setSuppressed([...(deep ? ['coast', 'lakes'] : []), ...(t.active && tileRivers ? ['rivers'] : [])]);
+ $('#detail-badge').hidden = !t.active;
+ }
+
+ bindViews() {
+ for (const b of document.querySelectorAll('#views button')) b.addEventListener('click', () => this.setView(b.dataset.view));
+ }
+
+ setView(kind, st) {
+ if (this.view) Object.assign(this.state, this.view.getState()); // keep each view's alt/zoom across switches
+ st ??= this.state;
+ if (this.view && this.view.kind !== kind) this.view.tiles?.suspend(); // hidden views neither load nor hold tiles
+ this.view?.deactivate();
+ this.view = this.viewFor(kind);
+ this.state.view = this.view.kind;
+ this.view.resize(this.el.clientWidth, this.el.clientHeight);
+ this.view.setState(st);
+ this.view.activate();
+ this.view.setTexture(this.texture);
+ this.syncTiles();
+ this.view.setOver(this.overTexture, this.state.op);
+ this.view.setSun(this.sunVec);
+ this.overlays.attach(this.view);
+ this.syncTerrainUi();
+ for (const b of document.querySelectorAll('#views button')) b.classList.toggle('on', b.dataset.view === this.view.kind);
+ this.changed();
+ }
+
+ async setLayer(id) {
+ const layer = this.layers.get(id) ?? this.meta.layers[0];
+ this.state.layer = layer.id;
+ this.layers.pin([this.state.layer, this.state.over]);
+ this.syncTiles();
+ $('#layer').value = layer.id;
+ renderLegend($('#legend'), layer);
+ try {
+ const tex = await this.layers.texture(layer.id);
+ if (this.state.layer !== layer.id) return; // another layer was picked meanwhile
+ this.texture = tex;
+ this.textureId = layer.id;
+ this.layers.showing([this.textureId, this.overTextureId]);
+ this.view.setTexture(tex);
+ this.changed();
+ } catch (e) {
+ toast(`Layer “${layer.name}”: ${e.message}`);
+ }
+ }
+
+ resize() {
+ const mode = compactMode(innerWidth, innerHeight); // phones: compact layout; the panel starts folded
+ if (applyMode(document.body, mode, this.compact)) $('#panel').classList.add('collapsed');
+ this.compact = mode;
+ const w = this.el.clientWidth, h = this.el.clientHeight;
+ this.renderer.setSize(w, h);
+ this.view?.resize(w, h);
+ this.dirty = true;
+ }
+
+ loop() {
+ requestAnimationFrame(() => this.loop());
+ if (!this.dirty || !this.view) return;
+ this.dirty = false;
+ this.updateScale();
+ this.updateVents();
+ this.updateTiles();
+ this.view.keepAboveGround?.();
+ this.view.updateDrape?.();
+ this.view.render();
+ this.overlays.updateMarkers();
+ this.overlays.setScale(this.kmPerPx);
+ this.sky?.refresh();
+ this.updateCompass();
+ }
+
+ setSun(v) {
+ this.sunVec = v;
+ for (const view of Object.values(this.views)) view.setSun(v);
+ this.changed();
+ }
+
+ eraQuery(sep = '&') {
+ return this.state.era ? `${sep}era=${encodeURIComponent(this.state.era)}` : '';
+ }
+
+ async loadVents() {
+ const k = this.ventsCall.next();
+ let v;
+ try {
+ v = await api(`api/vents${this.eraQuery('?')}`);
+ } catch (e) {
+ v = { vents: [], types: [], minerals: [] };
+ if (this.ventsCall.is(k)) toast(`Vents: ${e.message}`);
+ }
+ if (!this.ventsCall.is(k)) return;
+ this.vents = v;
+ this.ventBucket = null;
+ this.dirty = true;
+ }
+
+ updateVents() { // re-cluster when the zoom moved by ×1.5
+ const b = zoomBucket(this.kmPerPx ?? 1000);
+ if (!this.vents || b === this.ventBucket) return;
+ this.ventBucket = b;
+ const { types, minerals } = this.vents;
+ this.overlays.setMarkers('vent', clusterVents(this.vents.vents, this.kmPerPx ?? 1000).map(c => ({
+ lat: c.lat, lon: c.lon, label: c.n > 1 ? String(c.n) : '', cls: c.v ? `t${c.v.type}` : 'cluster',
+ title: c.v ? ventText(c.v, types, minerals) : `${c.n} vents — zoom in`,
+ })));
+ }
+
+ updateScale() {
+ const w = this.el.clientWidth, h = this.el.clientHeight;
+ const a = this.view.pick(w / 2 - 50, h / 2), b = this.view.pick(w / 2 + 50, h / 2);
+ $('#scale').hidden = !(a && b);
+ this.kmPerPx = a && b ? distanceKm(a, b) / 100 : null;
+ if (!(a && b)) return;
+ const kmPerPx = this.kmPerPx, km = niceKm(kmPerPx * 120);
+ $('#scale-bar').style.width = `${km / kmPerPx}px`;
+ $('#scale-text').textContent = fmtDistance(km);
+ }
+
+ buildToolbar() {
+ $('#menu-toggle').addEventListener('click', () => document.body.classList.toggle('menu-open'));
+ this.el.addEventListener('pointerdown', () => document.body.classList.remove('menu-open'), true); // the map closes it
+ const sel = $('#layer');
+ sel.replaceChildren(...this.meta.layers.map(l => el('option', { value: l.id }, l.name)));
+ sel.value = this.state.layer;
+ sel.addEventListener('change', () => this.setLayer(sel.value));
+ api('api/worlds').then(r => {
+ if (r.worlds.length < 2) return;
+ const w = $('#world');
+ w.replaceChildren(...r.worlds.map(x => el('option', { value: x.url }, x.name)));
+ w.value = r.worlds.find(x => x.id === r.current)?.url ?? '';
+ w.hidden = false;
+ w.addEventListener('change', () => { this.saveUrl(); location.href = w.value + carryHash(location.hash); });
+ }).catch(() => { });
+ const era = $('#era');
+ era.hidden = this.meta.eras.length < 2;
+ era.replaceChildren(...this.meta.eras.map(e => el('option', { value: e.name }, e.label)));
+ era.value = this.meta.era;
+ era.addEventListener('change', () => this.setEra(era.value));
+ const over = $('#over'), op = $('#opacity');
+ over.replaceChildren(el('option', { value: '' }, 'Over: none'),
+ ...this.meta.layers.map(l => el('option', { value: l.id }, `Over: ${l.name}`)));
+ over.value = this.state.over;
+ op.value = String(Math.round(this.state.op * 100));
+ over.addEventListener('change', () => this.setOver(over.value));
+ op.addEventListener('input', () => { this.state.op = Number(op.value) / 100; this.applyOver(); });
+ $('#overlay-list').replaceChildren(...this.overlays.list().map(o => el('label', {},
+ el('input', { type: 'checkbox', 'data-id': o.id, checked: this.state.overlays.includes(o.id),
+ onchange: e => this.toggleOverlay(o.id, e.target.checked) }), ' ', o.name)));
+ this.overlays.setEnabled(this.state.overlays);
+ }
+
+ applyHash() { // the user edited or pasted the URL (our own replaceState fires no hashchange)
+ const s = parse(location.hash);
+ if (s.terrain !== this.state.terrain) this.setTerrain(s.terrain);
+ if (s.water !== this.state.water) this.setWater(s.water);
+ if ((s.era || this.meta.default_era) !== this.meta.era) this.setEra(s.era || this.meta.default_era);
+ this.state.overlays = s.overlays;
+ this.overlays.setEnabled(s.overlays);
+ for (const i of document.querySelectorAll('#overlay-list input')) i.checked = s.overlays.includes(i.dataset.id);
+ if (s.view !== this.view.kind) this.setView(s.view, s);
+ else this.view.setState(s);
+ if (s.layer !== this.state.layer) this.setLayer(s.layer);
+ this.state.op = s.op;
+ $('#opacity').value = String(Math.round(s.op * 100));
+ if (s.over !== this.state.over) this.setOver(s.over);
+ else this.applyOver();
+ if (s.sky !== this.state.sky || s.day !== this.state.day || s.mo !== this.state.mo) {
+ this.state.day = s.day;
+ this.state.mo = s.mo;
+ this.sky.set(s.sky);
+ }
+ }
+
+ async setOver(id) {
+ this.state.over = id || '';
+ this.layers.pin([this.state.layer, this.state.over]);
+ this.syncTiles();
+ $('#over').value = this.state.over;
+ $('#opacity').hidden = !this.state.over;
+ if (!this.state.over) {
+ this.overTexture = null;
+ this.overTextureId = null;
+ this.layers.showing([this.textureId]);
+ return this.applyOver();
+ }
+ try {
+ const tex = await this.layers.texture(this.state.over);
+ if (this.state.over !== id) return; // changed meanwhile
+ this.overTexture = tex;
+ this.overTextureId = this.state.over;
+ this.layers.showing([this.textureId, this.overTextureId]);
+ this.applyOver();
+ } catch (e) {
+ toast(`Layer “${id}”: ${e.message}`);
+ }
+ }
+
+ applyOver() {
+ for (const v of Object.values(this.views)) v.setOver(this.overTexture, this.state.op);
+ this.changed();
+ }
+
+ toggleOverlay(id, on) {
+ const s = new Set(this.state.overlays);
+ if (on) s.add(id);
+ else s.delete(id);
+ this.state.overlays = [...s];
+ this.overlays.setEnabled(this.state.overlays);
+ this.changed();
+ }
+
+ bindPanel() {
+ for (const b of document.querySelectorAll('#panel nav [data-tab]')) b.addEventListener('click', () => this.showTab(b.dataset.tab));
+ $('#panel-toggle').addEventListener('click', () => $('#panel').classList.toggle('collapsed'));
+ addEventListener('keydown', e => { if (e.key === 'Escape') this.onEscape(e); });
+ this.measureChanged();
+ }
+
+ showTab(name) {
+ for (const b of document.querySelectorAll('#panel nav [data-tab]')) b.classList.toggle('on', b.dataset.tab === name);
+ for (const s of document.querySelectorAll('#panel section')) s.hidden = s.id !== `tab-${name}`;
+ $('#panel').classList.remove('collapsed');
+ if (this.pins?.placing) this.pins.setPlacing(false);
+ if (this.regions?.points && name !== 'regions') this.regions.cancel(); // leaving the tab drops a draft
+ if (name === 'export') this.exports?.show();
+ else this.exports?.hide();
+ const drawing = name === 'regions' && this.regions?.points && !this.regions.closed;
+ this.setMode(name === 'measure' ? 'measure' : drawing ? 'region' : 'inspect');
+ }
+
+ setMode(mode) {
+ this.mode = mode;
+ document.body.classList.toggle('measuring', mode === 'measure');
+ document.body.classList.toggle('placing', mode === 'pin' || mode === 'region');
+ }
+
+ bindClicks() { // a click = press + release without dragging; drags belong to the view
+ const c = this.renderer.domElement;
+ let down = null;
+ c.addEventListener('pointerdown', e => { down = e.button === 0 ? { ...this.at(e), t: performance.now() } : null; });
+ c.addEventListener('pointerup', e => {
+ const up = { ...this.at(e), t: performance.now() }, d = down;
+ down = null;
+ if (!d || e.button !== 0 || !isClick(d, up)) {
+ this.clicks = 0;
+ return;
+ }
+ this.clicks = up.t - (this.lastClick ?? -1e9) < 600 ? (this.clicks ?? 0) + 1 : 1; // for dblclick gating
+ this.lastClick = up.t;
+ const p = this.view.pick(up.x, up.y);
+ if (p) this.onMapClick(p);
+ });
+ }
+
+ onMapClick(p) {
+ if (this.mode === 'region') {
+ const pts = this.regions.points ?? [], scr = q => (q ? this.view.toScreen(q.lat, q.lon) : null);
+ const at = scr(p), action = at ? outlineClick(scr(pts[0]), scr(pts[pts.length - 1]), at, pts.length) : 'add';
+ if (action === 'close') this.clicks = 0; // a closing double-click must not also re-centre
+ this.regions.addPoint(p, action);
+ return;
+ }
+ if (this.mode === 'pin') {
+ this.pins.placeAt(p);
+ return;
+ }
+ if (this.mode === 'measure') {
+ this.measure.add(p);
+ this.measureChanged();
+ return;
+ }
+ this.showTab('inspector');
+ inspect($('#tab-inspector'), p, this.state.era || this.meta.era).catch(e => toast(`Inspector: ${e.message}`));
+ }
+
+ bindSearch() {
+ this.search = new SearchBox($('#search'), $('#suggest'), {
+ pins: () => this.pins?.pins ?? [],
+ locate: cell => api(`api/locate?cell=${encodeURIComponent(cell)}`),
+ go: r => this.go(r),
+ });
+ }
+
+ go(r) {
+ if (r.error) return toast(`Search: ${r.error}`);
+ const s = this.view.getState();
+ this.view.setState(s.alt !== undefined ? { lat: r.lat, lon: r.lon, alt: Math.min(s.alt, 0.6) }
+ : { lat: r.lat, lon: r.lon, zoom: Math.max(s.zoom, 3) });
+ this.overlays.setMarkers('search', r.marker ? [{ lat: r.lat, lon: r.lon, label: r.label }] : []);
+ }
+
+ onEscape(e) {
+ if ($('#help') && !$('#help').hidden) return; // this Esc closes the controls card (nav.js), nothing else
+ this.overlays.setMarkers('search', []);
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e?.target?.tagName)) return; // Esc in a field only leaves the field
+ if (this.mode === 'pin') this.pins.setPlacing(false);
+ if (this.regions?.points) this.regions.cancel();
+ if (this.mode === 'measure') {
+ this.measure.clear();
+ this.measureChanged();
+ }
+ }
+
+ measureChanged() {
+ const then = f => () => { f(); this.measureChanged(); };
+ this.profileBox = renderMeasure($('#tab-measure'), this.measure, { undo: then(() => this.measure.undo()),
+ close: then(() => this.measure.close()), clear: then(() => this.measure.clear()) });
+ this.overlays.setExtra('measure', this.measure.lines());
+ this.overlays.setMarkers('measure', this.measure.points.map((p, i) => ({ ...p, label: String(i + 1) })));
+ this.dirty = true;
+ this.loadProfile();
+ }
+
+ async loadProfile() { // latest path wins; the chart's hover drives the 'probe' marker
+ const mine = ++this.profileSeq, box = this.profileBox;
+ this.overlays.setMarkers('probe', []);
+ const pts = this.measure.lines()[0];
+ if (!pts) return;
+ box.replaceChildren(el('p', { class: 'muted' }, 'Loading elevation profile…'));
+ try {
+ const q = pts.map(p => `${p.lat.toFixed(5)},${p.lon.toFixed(5)}`).join(';');
+ const prof = await api(`api/profile?path=${encodeURIComponent(q)}&n=256${this.eraQuery()}`);
+ if (mine !== this.profileSeq) return;
+ renderProfile(box, prof, s => this.overlays.setMarkers('probe', s ? [{ lat: s.lat, lon: s.lon }] : []));
+ } catch (e) {
+ if (mine === this.profileSeq) box.replaceChildren(el('p', { class: 'warn' }, `Profile: ${e.message}`));
+ }
+ }
+
+ async hover(p) { // status-bar quick-info for the cell under the resting pointer
+ const mine = ++this.hoverSeq;
+ if (!p) {
+ $('#hover').textContent = '';
+ this.hoverCell = null; // the text is gone: coming back must ask again
+ return;
+ }
+ const detail = !!this.view.tiles?.active;
+ if (canReuse(this.hoverCell, p, detail)) return; // still inside the last cell: text unchanged
+ try {
+ const c = await api(`api/cell?lat=${p.lat.toFixed(5)}&lon=${p.lon.toFixed(5)}${this.eraQuery()}`);
+ if (mine !== this.hoverSeq) return;
+ this.hoverCell = detail ? null : c; // procedural heights vary inside a cell: never reuse those
+ $('#hover').textContent = `· ${hoverText(c, detail)}`;
+ } catch { /* keep the last text */ }
+ }
+
+ bindRegions() {
+ this.regions = new RegionsPanel($('#tab-regions'), {
+ onDraft: d => this.overlays.setExtra('region-draft', d ? [d.closed ? [...d.points, d.points[0]] : d.points] : [], 0xffd166),
+ onDrawing: on => this.setMode(on ? 'region' : 'inspect'),
+ onRegions: rs => this.overlays.setRegions(outlineLines(rs)),
+ onStatus: (s, tiles, was, q) => {
+ if (q === this.eraQuery('?') && needsTileSwitch(this.meta.tiles, tiles)) this.refreshTiles(tiles);
+ },
+ eraQuery: () => this.eraQuery('?'),
+ });
+ this.regions.load();
+ }
+
+ bindExports() {
+ this.exports = new ExportPanel($('#tab-export'), {
+ getCenter: () => { const s = this.view.getState(); return { lat: s.lat, lon: s.lon }; },
+ onSquare: pts => (pts ? this.overlays.setExtra('export-square', [pts], 0xff9f43) : this.overlays.removeExtra('export-square')),
+ getEra: () => this.meta.era,
+ });
+ }
+
+ refreshTiles(tiles) { // a refinement finished: the server serves tiles under a new URL
+ this.meta.tiles = tiles;
+ for (const v of Object.values(this.views)) v.tiles?.setUrl(tiles.url, tiles.mesh_url);
+ this.loadVents();
+ this.dirty = true;
+ }
+
+ bindPins() {
+ this.pins = new PinsPanel($('#tab-pins'), this.meta.lore_ids, {
+ onPins: pins => this.overlays.setMarkers('pin', pins.map(p => ({ lat: p.lat, lon: p.lon, label: p.name,
+ onClick: () => { this.showTab('pins'); this.pins.edit(p); } }))),
+ onDraft: d => this.overlays.setMarkers('draft', d ? [{ ...d, label: 'new pin' }] : []),
+ onFly: p => this.view.setState({ ...this.view.getState(), lat: p.lat, lon: p.lon }),
+ onPlacing: on => this.setMode(on ? 'pin' : 'inspect'),
+ });
+ this.pins.load();
+ }
+
+ zoomBy(f) {
+ const s = this.view.getState();
+ this.view.setState(s.alt !== undefined ? { ...s, alt: s.alt * f } : { ...s, zoom: s.zoom / f });
+ }
+
+ home() {
+ this.view.setState(this.view.kind === 'globe' ? { lat: 20, lon: this.view.getState().lon, alt: 2 } : { lat: 0, lon: 0, zoom: 1 });
+ }
+
+ panBy(fx, fy) { // keys: globe moves along the heading by a fraction of the range; flat maps by a screen fraction
+ if (this.view.pose) return this.view.setPose(panPose(this.view.pose, fx, fy));
+ const w = this.el.clientWidth, h = this.el.clientHeight, s = this.view.getState();
+ const p = this.view.pick(w / 2 + fx * w, h / 2 + fy * h);
+ this.view.setState(p ? { ...s, lat: p.lat, lon: p.lon }
+ : { ...s, lat: Math.max(-89, Math.min(89, s.lat - fy * 40)), lon: s.lon + fx * 40 });
+ }
+
+ turnBy(dh, dp) { // Shift+arrows: turn and tilt the globe; flat maps just pan
+ const p = this.view.pose;
+ if (p) this.view.setPose({ ...p, heading: p.heading + dh, pitch: p.pitch + dp });
+ else this.panBy(Math.sign(dh) * 0.2, -Math.sign(dp) * 0.2);
+ }
+
+ groundView() { // one click to street view: the ground at the view centre, close and tilted, 3D on
+ if (this.view.kind !== 'globe') this.setView('globe');
+ if (!(this.state.terrain > 0)) this.setTerrain(this.lastExag ?? 1);
+ this.view.setPose(groundPose(this.view.pose));
+ }
+
+ resetNorth() {
+ const p = this.view.pose;
+ if (p) this.view.setPose({ ...p, heading: 0, pitch: 0 });
+ }
+
+ updateCompass() {
+ const b = $('#nav-north'), p = this.view.pose;
+ b.hidden = !p;
+ if (!p) return;
+ b.firstElementChild.style.transform = `rotate(${-Math.round(p.heading)}deg)`;
+ b.classList.toggle('on', p.heading !== 0 || p.pitch !== 0);
+ }
+
+ bindPointer() {
+ const c = this.renderer.domElement;
+ this.at = e => { const r = c.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
+ c.addEventListener('pointermove', e => {
+ if (e.pointerType === 'touch') return; // a finger has no hover: no cursor readout
+ const { x, y } = this.at(e), p = this.view.pick(x, y);
+ $('#cursor').textContent = p ? fmtLatLon(p.lat, p.lon) : '–';
+ clearTimeout(this.hoverTimer);
+ if (!e.buttons) this.hoverTimer = setTimeout(() => this.hover(p), 200);
+ });
+ c.addEventListener('dblclick', e => {
+ if (this.mode !== 'inspect' || !(this.clicks >= 2)) return; // both presses must be clicks, not drags
+ const { x, y } = this.at(e), p = this.view.pick(x, y);
+ if (p) this.view.setState({ ...this.view.getState(), lat: p.lat, lon: p.lon });
+ });
+ }
+}
diff --git a/viewer/js/atmosphere.js b/viewer/js/atmosphere.js
new file mode 100644
index 0000000..98e5947
--- /dev/null
+++ b/viewer/js/atmosphere.js
@@ -0,0 +1,65 @@
+// atmosphere.js — sky and distance haze near the ground: colours from camera height and sun (pure) + the sky pass
+import * as THREE from 'three';
+
+export const SKY_FULL_KM = 20; // full sky below this eye altitude …
+export const SKY_NONE_KM = 100; // … fading to space above this
+export const HAZE_KM = 80; // e-folding distance of the haze
+export const HAZE_MAX = 0.85;
+const DAY = { horizon: [0.74, 0.83, 0.93], zenith: [0.2, 0.4, 0.74] };
+const NIGHT = { horizon: [0.05, 0.06, 0.1], zenith: [0.01, 0.015, 0.04] };
+const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
+const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
+
+export function atmosphere(altKm, sunUp = null) { // sunUp: cos of the sun's zenith angle at the eye; null = Sky off (day)
+ const day = sunUp === null ? 1 : smooth(-0.15, 0.1, sunUp);
+ return { amount: 1 - smooth(SKY_FULL_KM, SKY_NONE_KM, altKm),
+ horizon: mix(NIGHT.horizon, DAY.horizon, day), zenith: mix(NIGHT.zenith, DAY.zenith, day) };
+}
+
+const VERT = /* glsl */ `
+varying vec2 vNdc;
+void main() { vNdc = position.xy; gl_Position = vec4(position.xy, 1.0, 1.0); }`;
+
+const FRAG = /* glsl */ `
+uniform mat4 invProj;
+uniform mat4 camWorld;
+uniform vec3 up;
+uniform vec3 horizon;
+uniform vec3 zenith;
+uniform vec3 space;
+uniform float amount;
+varying vec2 vNdc;
+void main() {
+ vec4 v = invProj * vec4(vNdc, 1.0, 1.0);
+ vec3 dir = normalize(mat3(camWorld) * (v.xyz / v.w));
+ vec3 sky = mix(horizon, zenith, smoothstep(0.0, 0.6, dot(dir, up)));
+ gl_FragColor = vec4(mix(space, sky, amount), 1.0);
+}`;
+
+export class SkyDome { // one screen-filling triangle drawn first: sky where no ground covers it
+ constructor(scene, spaceHex) {
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
+ const s = [(spaceHex >> 16) & 255, (spaceHex >> 8) & 255, spaceHex & 255].map(v => v / 255);
+ this.uniforms = { invProj: { value: new THREE.Matrix4() }, camWorld: { value: new THREE.Matrix4() },
+ up: { value: new THREE.Vector3(0, 1, 0) }, horizon: { value: new THREE.Vector3() }, zenith: { value: new THREE.Vector3() },
+ space: { value: new THREE.Vector3(...s) }, amount: { value: 0 } };
+ this.mesh = new THREE.Mesh(g, new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: this.uniforms,
+ depthTest: false, depthWrite: false }));
+ this.mesh.frustumCulled = false;
+ this.mesh.renderOrder = -1000;
+ this.mesh.visible = false;
+ scene.add(this.mesh);
+ }
+
+ update(camera, up, air) {
+ const u = this.uniforms;
+ u.invProj.value.copy(camera.projectionMatrixInverse);
+ u.camWorld.value.copy(camera.matrixWorld);
+ u.up.value.copy(up);
+ u.horizon.value.set(...air.horizon);
+ u.zenith.value.set(...air.zenith);
+ u.amount.value = air.amount;
+ this.mesh.visible = air.amount > 0;
+ }
+}
diff --git a/viewer/js/boot.js b/viewer/js/boot.js
new file mode 100644
index 0000000..edaf2ef
--- /dev/null
+++ b/viewer/js/boot.js
@@ -0,0 +1,10 @@
+// boot.js — the first /api/meta: the era in the URL, else the default one.
+export async function metaFor(get, era) {
+ if (!era) return { meta: await get('api/meta'), note: '' };
+ try {
+ return { meta: await get(`api/meta?era=${encodeURIComponent(era)}`), note: '' };
+ } catch (e) {
+ const meta = await get('api/meta');
+ return { meta, note: `Era “${era}”: ${e.message} — showing ${meta.era}` };
+ }
+}
diff --git a/viewer/js/camera.js b/viewer/js/camera.js
new file mode 100644
index 0000000..35f6ccd
--- /dev/null
+++ b/viewer/js/camera.js
@@ -0,0 +1,91 @@
+// 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;
+}
diff --git a/viewer/js/exportpanel.js b/viewer/js/exportpanel.js
new file mode 100644
index 0000000..9af9526
--- /dev/null
+++ b/viewer/js/exportpanel.js
@@ -0,0 +1,103 @@
+// exportpanel.js — the Export tab: freeze the square around the map centre into game-engine files (map/exports/)
+import { api, el, toast } from './ui.js';
+import { exportSizeMB, exportStatusText, samplesFor, squareOutline } from './exports.js';
+import { fmtLatLon } from './units.js';
+
+const SIZES = [5, 10, 20, 40, 80, 160]; // km
+const RESOLUTIONS = [1, 2, 5, 10, 20, 50, 100]; // m per sample
+const MAX_SAMPLES = 8193; // as the server (serve.EXPORT_MAX_SAMPLES)
+
+export class ExportPanel {
+ constructor(box, { getCenter, onSquare, getEra = () => undefined }) {
+ Object.assign(this, { box, getCenter, onSquare, getEra });
+ this.size = 20;
+ this.res = 10;
+ this.list = [];
+ this.status = null;
+ this.visible = false;
+ this.timer = null;
+ }
+
+ async load() {
+ try {
+ const r = await api('api/exports');
+ this.list = r.exports;
+ this.status = r.status;
+ } catch (e) {
+ toast(`Exports: ${e.message}`);
+ }
+ clearTimeout(this.timer);
+ if (this.status?.state === 'running') this.timer = setTimeout(() => this.load(), 700);
+ if (this.statusEl) this.refresh(); // a poll: never rebuild what is being typed or picked
+ else this.render();
+ }
+
+ refresh() { // the parts that change while an export runs: status line, button, list
+ const busy = this.status?.state === 'running', tooMany = samplesFor(this.size, this.res) > MAX_SAMPLES;
+ this.statusEl.textContent = exportStatusText(this.status);
+ this.statusEl.setAttribute('class', this.status?.state === 'failed' ? 'warn' : 'muted');
+ if (busy || tooMany) this.button.setAttribute('disabled', 'true');
+ else this.button.removeAttribute?.('disabled');
+ this.listEl.replaceChildren(...(this.list.length ? this.list.map(e => this.#item(e)) : [el('p', { class: 'muted' }, 'No exports yet.')]));
+ }
+
+ #item(e) {
+ return el('div', { class: 'pin-item' }, el('b', {}, e.name), el('br'),
+ el('span', { class: 'muted' }, `${(e.size_m / 1000).toLocaleString()} km · ${e.res_m} m · ${e.samples} × ${e.samples}`), el('br'),
+ el('span', { class: 'muted small' }, e.path));
+ }
+
+ show() {
+ this.visible = true;
+ this.render();
+ this.update();
+ this.load();
+ }
+
+ hide() {
+ this.visible = false;
+ this.onSquare(null);
+ }
+
+ update() { // the view moved or the size changed: the square and the centre line follow
+ if (!this.visible) return;
+ const c = this.getCenter();
+ this.onSquare(squareOutline(c.lat, c.lon, this.size));
+ if (this.centre) this.centre.textContent = `Centre (map centre): ${fmtLatLon(c.lat, c.lon)}`;
+ }
+
+ async start(name) {
+ const c = this.getCenter();
+ try {
+ await api('api/exports', { method: 'POST', body: { lat: c.lat, lon: c.lon, size_km: this.size, res_m: this.res,
+ era: this.getEra() || undefined, ...(name.trim() ? { name: name.trim() } : {}) } });
+ } catch (e) {
+ return toast(`Could not export: ${e.message}`);
+ }
+ await this.load();
+ }
+
+ render() {
+ const n = samplesFor(this.size, this.res), tooMany = n > MAX_SAMPLES;
+ const select = (key, values, unit) => {
+ const s = el('select', { name: key }, ...values.map(v => el('option', { value: String(v), selected: v === this[key] ? true : null }, `${v} ${unit}`)));
+ s.addEventListener('change', () => { this[key] = Number(s.value); this.render(); this.update(); });
+ return s;
+ };
+ const name = el('input', { maxlength: 64, placeholder: 'name (optional: a–z 0–9 . _ -)' });
+ name.addEventListener('input', () => { const v = name.value.toLowerCase().replace(/[^a-z0-9._-]/g, '-'); if (v !== name.value) name.value = v; });
+ this.centre = el('p', { class: 'muted' }, '');
+ this.button = el('button', { onclick: () => this.start(name.value) }, 'Export here (map centre)');
+ this.statusEl = el('p', { class: 'muted' }, '');
+ this.listEl = el('div', {});
+ this.box.replaceChildren(
+ el('p', { class: 'muted' }, 'Freeze the square around the map centre into files for a game engine: heights, water, '
+ + 'biomes, rivers, pins (map/exports/, see its README.txt). Generated terrain: idea.'),
+ el('label', { class: 'row' }, 'Size ', select('size', SIZES, 'km'), ' Resolution ', select('res', RESOLUTIONS, 'm')),
+ el('p', { class: tooMany ? 'warn' : 'muted' }, `${n} × ${n} samples · ≈ ${Math.round(exportSizeMB(n))} MB`
+ + (tooMany ? ' — too many: choose a coarser resolution or a smaller square' : '')),
+ this.centre, el('label', { class: 'row' }, name), el('div', {}, this.button), this.statusEl, this.listEl);
+ this.refresh();
+ this.update();
+ }
+}
diff --git a/viewer/js/exports.js b/viewer/js/exports.js
new file mode 100644
index 0000000..25d66ea
--- /dev/null
+++ b/viewer/js/exports.js
@@ -0,0 +1,40 @@
+// exports.js — terrain export helpers (pure): sample count, the square on the map, size estimate, status text
+import { R_KM } from './geo.js';
+export { R_KM };
+
+export function samplesFor(sizeKm, resM) { // the smallest 2^n + 1 covering the size (as export.samples_for)
+ const need = (sizeKm * 1000) / resM + 1;
+ let n = 1;
+ while (2 ** n + 1 < need) n++;
+ return 2 ** n + 1;
+}
+
+function destination(lat, lon, bearing, km, R) {
+ const r = Math.PI / 180, d = km / R, la = lat * r, b = bearing;
+ const la2 = Math.asin(Math.sin(la) * Math.cos(d) + Math.cos(la) * Math.sin(d) * Math.cos(b));
+ const lo2 = lon * r + Math.atan2(Math.sin(b) * Math.sin(d) * Math.cos(la), Math.cos(d) - Math.sin(la) * Math.sin(la2));
+ return { lat: la2 / r, lon: ((lo2 / r + 540) % 360) - 180 };
+}
+
+export function squareOutline(lat, lon, sizeKm, R = R_KM, per = 8) { // the export square (azimuthal equidistant)
+ const h = sizeKm / 2, pts = [];
+ const corners = [[-h, h], [h, h], [h, -h], [-h, -h], [-h, h]]; // [x east, y north], from the north-west
+ for (let k = 0; k < 4; k++) {
+ for (let i = 0; i < per; i++) {
+ const t = i / per, x = corners[k][0] + (corners[k + 1][0] - corners[k][0]) * t;
+ const y = corners[k][1] + (corners[k + 1][1] - corners[k][1]) * t;
+ pts.push(destination(lat, lon, Math.atan2(x, y), Math.hypot(x, y), R));
+ }
+ }
+ pts.push(pts[0]);
+ return pts;
+}
+
+export const exportSizeMB = n => (n * n * 7.8) / 1e6; // f32 + 16-bit PNG + raw + preview (the island: 128 MB)
+
+export function exportStatusText(s) {
+ if (!s || s.state === 'idle') return '';
+ if (s.state === 'running') return `Exporting ${s.name}… ${Math.round((s.fraction ?? 0) * 100)}%`;
+ if (s.state === 'failed') return `Export ${s.name} failed: ${s.error ?? 'unknown error'}`;
+ return `Exported ${s.name}`;
+}
diff --git a/viewer/js/flatmap.js b/viewer/js/flatmap.js
new file mode 100644
index 0000000..63d09d1
--- /dev/null
+++ b/viewer/js/flatmap.js
@@ -0,0 +1,194 @@
+// 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 });
+ }
+}
diff --git a/viewer/js/geo.js b/viewer/js/geo.js
new file mode 100644
index 0000000..c6fc3e9
--- /dev/null
+++ b/viewer/js/geo.js
@@ -0,0 +1,100 @@
+// Sphere maths for World Maps. Scene convention (y up): x = cos φ cos λ, y = sin φ, z = −cos φ sin λ.
+import { PLANET } from './planet.js';
+export const R_KM = PLANET.radius_km;
+const D = Math.PI / 180;
+
+export function toVec(lat, lon) {
+ const la = lat * D, lo = lon * D, c = Math.cos(la);
+ return [c * Math.cos(lo), Math.sin(la), -c * Math.sin(lo)];
+}
+
+export function toLatLon(v) {
+ const n = Math.hypot(v[0], v[1], v[2]) || 1;
+ return { lat: Math.asin(Math.max(-1, Math.min(1, v[1] / n))) / D, lon: Math.atan2(-v[2], v[0]) / D };
+}
+
+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]];
+
+export function angleRad(p, q) {
+ const a = toVec(p.lat, p.lon), b = toVec(q.lat, q.lon);
+ return Math.atan2(Math.hypot(...cross(a, b)), dot(a, b));
+}
+
+export const distanceKm = (p, q) => R_KM * angleRad(p, q);
+
+export function interpolate(p, q, stepDeg = 1) {
+ const a = toVec(p.lat, p.lon), b = toVec(q.lat, q.lon), w = angleRad(p, q);
+ const n = Math.max(1, Math.ceil(w / (stepDeg * D) - 1e-9)); // tolerate rounding: 3° at 1° steps = 3 pieces
+ const out = [];
+ for (let i = 0; i <= n; i++) {
+ if (w < 1e-12) { out.push({ lat: p.lat, lon: p.lon }); continue; }
+ const t = i / n, s1 = Math.sin((1 - t) * w) / Math.sin(w), s2 = Math.sin(t * w) / Math.sin(w);
+ out.push(toLatLon([a[0] * s1 + b[0] * s2, a[1] * s1 + b[1] * s2, a[2] * s1 + b[2] * s2]));
+ }
+ return out;
+}
+
+export function polygonAreaKm2(pts) {
+ if (pts.length < 3) return 0;
+ const v = pts.map(p => toVec(p.lat, p.lon));
+ let e = 0;
+ for (let i = 1; i + 1 < v.length; i++) { // signed spherical excess of a triangle fan (Van Oosterom–Strackee)
+ const a = v[0], b = v[i], c = v[i + 1];
+ e += 2 * Math.atan2(dot(a, cross(b, c)), 1 + dot(a, b) + dot(b, c) + dot(c, a));
+ }
+ return Math.abs(e) * R_KM * R_KM;
+}
+
+// Equal Earth (Šavrič et al. 2018): same constants as map/mapgen/projections.py
+const A1 = 1.340264, A2 = -0.081106, A3 = 0.000893, A4 = 0.003796, M = Math.sqrt(3) / 2, P3 = Math.PI / 3;
+export const EE_XMAX = (2 * Math.sqrt(3) * Math.PI) / (3 * A1);
+export const EE_YMAX = A1 * P3 + A2 * P3 ** 3 + A3 * P3 ** 7 + A4 * P3 ** 9;
+export const PC_XMAX = Math.PI;
+export const PC_YMAX = Math.PI / 2;
+const eeD = t => A1 + 3 * A2 * t ** 2 + 7 * A3 * t ** 6 + 9 * A4 * t ** 8;
+
+export function project(view, lat, lon) {
+ if (view === 'plate_carree') return { x: lon * D, y: lat * D };
+ const t = Math.asin(M * Math.sin(lat * D));
+ return { x: (2 * Math.sqrt(3) * lon * D * Math.cos(t)) / (3 * eeD(t)), y: A1 * t + A2 * t ** 3 + A3 * t ** 7 + A4 * t ** 9 };
+}
+
+export function unproject(view, x, y) {
+ if (view === 'plate_carree') {
+ if (Math.abs(x) > PC_XMAX || Math.abs(y) > PC_YMAX) return null;
+ return { lat: y / D, lon: x / D };
+ }
+ if (Math.abs(y) > EE_YMAX) return null;
+ let t = y / A1;
+ for (let i = 0; i < 12; i++) t -= (A1 * t + A2 * t ** 3 + A3 * t ** 7 + A4 * t ** 9 - y) / eeD(t);
+ const s = Math.sin(t) / M;
+ if (Math.abs(s) > 1 + 1e-12) return null;
+ const lon = (3 * x * eeD(t)) / (2 * Math.sqrt(3) * Math.cos(t)) / D;
+ if (!Number.isFinite(lon) || Math.abs(lon) > 180 + 1e-9) return null;
+ return { lat: Math.asin(Math.max(-1, Math.min(1, s))) / D, lon: Math.max(-180, Math.min(180, lon)) };
+}
+
+export function sphereMesh(latSegs, lonSegs) {
+ const nv = (latSegs + 1) * (lonSegs + 1);
+ const positions = new Float32Array(nv * 3), uvs = new Float32Array(nv * 2);
+ for (let i = 0; i <= latSegs; i++) {
+ const lat = 90 - (180 * i) / latSegs;
+ for (let j = 0; j <= lonSegs; j++) {
+ const k = i * (lonSegs + 1) + j;
+ positions.set(toVec(lat, -180 + (360 * j) / lonSegs), 3 * k);
+ uvs[2 * k] = j / lonSegs;
+ uvs[2 * k + 1] = 1 - i / latSegs;
+ }
+ }
+ const indices = new Uint32Array(latSegs * lonSegs * 6);
+ let n = 0;
+ for (let i = 0; i < latSegs; i++) {
+ for (let j = 0; j < lonSegs; j++) {
+ const a = i * (lonSegs + 1) + j, b = a + lonSegs + 1, c = b + 1, d = a + 1;
+ indices.set([a, b, c, a, c, d], n);
+ n += 6;
+ }
+ }
+ return { positions, uvs, indices };
+}
diff --git a/viewer/js/gestures.js b/viewer/js/gestures.js
new file mode 100644
index 0000000..5e8c697
--- /dev/null
+++ b/viewer/js/gestures.js
@@ -0,0 +1,42 @@
+// gestures.js — two-finger touch: how a pair of fingers moved between two moments. scale > 1: spread (zoom in);
+// rot: degrees the pair turned, clockwise on screen (y down) positive; mid / move: their midpoint and how it moved;
+// tilt: px both fingers moved vertically together (0 when they went opposite ways, as in a pinch along y).
+export function twoFinger(a0, b0, a1, b1) {
+ const d0 = Math.hypot(b0.x - a0.x, b0.y - a0.y), d1 = Math.hypot(b1.x - a1.x, b1.y - a1.y);
+ let rot = (Math.atan2(b1.y - a1.y, b1.x - a1.x) - Math.atan2(b0.y - a0.y, b0.x - a0.x)) * 180 / Math.PI;
+ rot = ((rot + 540) % 360) - 180; // the short way round
+ const mid = { x: (a1.x + b1.x) / 2, y: (a1.y + b1.y) / 2 };
+ const move = { x: mid.x - (a0.x + b0.x) / 2, y: mid.y - (a0.y + b0.y) / 2 };
+ const da = a1.y - a0.y, db = b1.y - b0.y;
+ const tilt = da * db > 0 ? (Math.abs(da) < Math.abs(db) ? da : db) : 0; // the smaller shared move
+ return { scale: d0 > 0 && d1 > 0 ? d1 / d0 : 1, rot, mid, move, tilt };
+}
+
+// Touch pointers by id, in element coordinates. Two at once = a gesture. Browsers send one move per finger, so a
+// shared vertical move (tilt) is pieced together: each finger's vertical travel adds up until both went the same way.
+export class Touches {
+ constructor() { this.pts = new Map(); this.dy = new Map(); }
+ get size() { return this.pts.size; }
+ down(id, at) { this.pts.set(id, at); this.dy.clear(); }
+ up(id) { this.pts.delete(id); this.dy.clear(); }
+ pair() { return [...this.pts.values()].slice(0, 2); }
+ // a pointer moved: the gesture since the last move (null unless two fingers are down)
+ move(id, at) {
+ if (!this.pts.has(id)) return null;
+ const before = this.pair(), was = this.pts.get(id);
+ this.pts.set(id, at);
+ if (this.pts.size < 2) return null;
+ const g = twoFinger(...before, ...this.pair());
+ const ids = [...this.pts.keys()].slice(0, 2);
+ if (!ids.includes(id)) return { ...g, tilt: 0 };
+ this.dy.set(id, (this.dy.get(id) ?? 0) + at.y - was.y);
+ const [a, b] = ids.map(k => this.dy.get(k) ?? 0);
+ let tilt = 0;
+ if (a * b > 0) { // both went the same way: the smaller part tilts
+ tilt = Math.abs(a) < Math.abs(b) ? a : b;
+ for (const k of ids) this.dy.set(k, this.dy.get(k) - tilt);
+ } else if (a * b < 0) this.dy.clear(); // opposite ways: a pinch, not a tilt
+ return { ...g, tilt };
+ }
+ other(id) { for (const [k, v] of this.pts) if (k !== id) return { id: k, at: v }; return null; }
+}
diff --git a/viewer/js/globe.js b/viewer/js/globe.js
new file mode 100644
index 0000000..0850e2f
--- /dev/null
+++ b/viewer/js/globe.js
@@ -0,0 +1,297 @@
+// globe.js — 3D globe: textured unit sphere, orbit camera (north up), analytic picking
+import * as THREE from 'three';
+import { GlobeControls } from './globecontrols.js';
+import { SkyDome, atmosphere, HAZE_MAX } from './atmosphere.js';
+import { clampPose, eyeOf, lift, kmPerPxAt, eyeAltitude, rangeForAltitude, R_M } from './camera.js';
+import { sphereMesh, toVec, toLatLon, R_KM } from './geo.js';
+import { globeSegments, drapedSegments } from './overlay_geom.js';
+import { LineSet } from './lines.js';
+import { SHADE_GLSL, shadeUniforms, applyOver, applySun } from './shading.js';
+
+import { MIN_ALT, MAX_ALT } from './limits.js';
+
+export { MIN_ALT, MAX_ALT };
+export const BACKGROUND = 0x10121a;
+const DRAPE_ALT = 0.05; // below this eye altitude (planet radii) lines lie on the 3D ground
+const TRENCH_KM = 12; // below the deepest sea floor (≈ −11 km)
+
+const VERT = /* glsl */ `
+varying vec2 vUv;
+varying vec3 vNormal;
+varying vec3 vPos;
+void main() {
+ vUv = uv;
+ vPos = position;
+ vNormal = normalize(normalMatrix * position);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}`;
+
+const FRAG = /* glsl */ `${SHADE_GLSL}
+varying vec2 vUv;
+varying vec3 vNormal;
+varying vec3 vPos;
+void main() {
+ gl_FragColor = vec4(shadeColor(vUv, vPos, 0.72 + 0.28 * clamp(vNormal.z, 0.0, 1.0)), 1.0);
+}`;
+
+export class GlobeView {
+ constructor(renderer, onChange) {
+ this.kind = 'globe';
+ this.renderer = renderer;
+ this.w = 1;
+ this.h = 1;
+ this.scene = new THREE.Scene();
+ this.scene.background = new THREE.Color(BACKGROUND);
+ this.camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10);
+ const m = sphereMesh(256, 512);
+ const geom = new THREE.BufferGeometry();
+ geom.setAttribute('position', new THREE.BufferAttribute(m.positions, 3));
+ geom.setAttribute('uv', new THREE.BufferAttribute(m.uvs, 2));
+ geom.setIndex(new THREE.BufferAttribute(m.indices, 1));
+ this.shade = shadeUniforms();
+ this.material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: this.shade });
+ this.base = new THREE.Mesh(geom, this.material);
+ this.scene.add(this.base);
+ this.lines = new LineSet(this.scene);
+ this.raw = new Map(); // id → {lines, color}: kept so they can be laid on the 3D ground
+ this.drape = null; // the current draping frame, or null (lines at sea level)
+ this.horizon = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0); // keeps only the cap the camera can see
+ this.lines.setClip([this.horizon]);
+ renderer.localClippingEnabled = true;
+ this.sphere = new THREE.Sphere(new THREE.Vector3(), 1);
+ this.raycaster = new THREE.Raycaster();
+ this.dome = new SkyDome(this.scene, BACKGROUND);
+ this.onChange = onChange;
+ this.terrain = 0; // 3D exaggeration; 0 = flat tiles (the app sets it)
+ this.groundU = 0; // ground under the eye, planet radii (for the near plane)
+ this.seaFloor = false; // Water off: the ground may lie below 0 m (the app sets it)
+ this.pose = clampPose({ lat: 20, lon: 0, range: 2 });
+ this.controls = new GlobeControls(this, renderer.domElement);
+ this.#place();
+ }
+
+ activate() { this.controls.enabled = true; }
+ deactivate() { this.controls.enabled = false; }
+
+ resize(w, h) {
+ this.w = w;
+ this.h = h;
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ this.#tune();
+ }
+
+ 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);
+ this.#air();
+ }
+
+ setLines(id, lines, color) {
+ this.raw.set(id, { lines, color });
+ this.#line(id);
+ }
+
+ removeLines(id) {
+ this.raw.delete(id);
+ this.lines.remove(id);
+ }
+
+ #line(id) {
+ const { lines, color } = this.raw.get(id);
+ if (this.drape) this.lines.set(id, drapedSegments(lines, this.drape), color, this.drape.origin);
+ else this.lines.set(id, globeSegments(lines, 1.0008, 1), color);
+ }
+
+ updateDrape(now = performance.now()) { // each frame: re-lay lines on the 3D ground when heights or the view change
+ const p = this.camera.position, d = p.length(), alt = d - 1, dr = this.drape;
+ if (!(this.terrain > 0 && this.tiles && alt < DRAPE_ALT)) {
+ if (dr) this.#relay(null);
+ return;
+ }
+ const n = [p.x / d, p.y / d, p.z / d], gen = this.tiles.heightGen ?? 0;
+ const moved = !dr || n[0] * dr.n[0] + n[1] * dr.n[1] + n[2] * dr.n[2] < Math.cos(dr.capRad / 4)
+ || Math.abs(Math.log(alt / dr.alt)) > 0.3 || this.terrain !== dr.terrain;
+ if (!moved && (gen === dr.gen || now - dr.at < 250)) return;
+ const capRad = Math.acos(1 / d) + 0.0376 * Math.sqrt(this.terrain); // horizon + 9 km (× exaggeration) peaks beyond it
+ this.#relay({ eye: [p.x, p.y, p.z], n, origin: n, alt, gen, at: now, capRad, cosCap: Math.cos(capRad),
+ terrain: this.terrain, heightM: (la, lo) => this.#groundM(la, lo) });
+ }
+
+ #relay(drape) {
+ this.drape = drape;
+ this.lines.setClip(drape ? [] : [this.horizon]);
+ for (const id of this.raw.keys()) this.#line(id);
+ this.#tune();
+ }
+
+ #groundM(lat, lon, shown = true, unknown = 0) { // exaggerated height (m) of the drawn 3D ground; 0 without it
+ if (!(this.terrain > 0)) return 0;
+ const h = this.tiles?.heightAt?.(lat, lon, shown); // the drawn surface: sea at 0 m with water on, dry hollows below it
+ return h == null ? unknown : h * this.terrain; // tiles reloading: keep the last known ground
+ }
+
+ #floor(p) { // the eye stays 20 m above the drawn ground under it (sea level without 3D terrain or loaded heights)
+ const known = this.groundU * R_M;
+ this.groundU = 0;
+ for (let i = 0; i < 2; i++) { // the ground under a tilted eye moves as the range grows: settle twice
+ const at = toLatLon(eyeOf(p).eye);
+ const h = Math.max(this.#groundM(at.lat, at.lon, false, known), // the finest heights loaded, and what is drawn:
+ this.#groundM(at.lat, at.lon, true, -Infinity)); // a coarser stand-in may stand higher
+ this.groundU = h / R_M;
+ p = lift(p, h, true); // h is the drawn ground, below 0 m only where something is drawn there
+ }
+ return p;
+ }
+
+ #held = false; // set from state (URL, search, zoom buttons): the range holds from the ground as it loads
+
+ setPose(p) {
+ this.#held = false;
+ this.#apply(p);
+ }
+
+ #apply(p) {
+ p = clampPose(p);
+ p.ground = this.#groundM(p.lat, p.lon, false, this.pose?.ground ?? 0); // the target sits on the (loaded) ground: range counts from there
+ this.pose = this.#floor(p);
+ this.#place();
+ this.onChange();
+ }
+
+ #place() {
+ const { eye, up, target } = eyeOf(this.pose);
+ this.camera.position.set(eye[0], eye[1], eye[2]);
+ this.camera.up.set(up[0], up[1], up[2]);
+ this.camera.lookAt(target[0], target[1], target[2]);
+ this.#tune();
+ }
+
+ keepAboveGround() { // finer heights loaded: the target follows the ground, the eye stays put unless swallowed
+ let p = this.pose;
+ const g = this.#groundM(p.lat, p.lon, false, p.ground);
+ if (this.#held) { // a restored view: its ground loads under it (unknown: wait for it)
+ if (this.terrain > 0 && this.tiles?.heightAt?.(p.lat, p.lon, false) != null && Math.abs(g - p.ground) > 0.01)
+ p = { ...p, ground: g };
+ } else if (Math.abs(g - p.ground) > 0.01) {
+ const r = rangeForAltitude(eyeAltitude(p.range, p.pitch, p.ground), p.pitch, g);
+ p = { ...p, ground: g, range: Math.max(MIN_ALT, Number.isFinite(r) ? r : MIN_ALT) };
+ }
+ p = this.#floor(p);
+ if (p === this.pose) return;
+ this.pose = p;
+ this.#place();
+ this.onChange();
+ }
+
+ #tune() { // depth range and overlay lines follow the eye; the coarse sphere sinks under the tile patches
+ const p = this.camera.position, d = p.length(), alt = d - 1;
+ this.camera.near = Math.max(1e-7, (alt - this.groundU) * 0.3);
+ this.camera.far = Math.sqrt(Math.max(d * d - 1, 0)) + 0.1; // to the horizon and a bit beyond (far mountains)
+ this.camera.updateProjectionMatrix();
+ this.camera.updateMatrixWorld();
+ if (this.drape) this.lines.setTransform(1, true); // laid on the ground: depth hides what is behind hills
+ else {
+ const hidden = alt > 0.05 || (this.terrain > 0 && this.pose.pitch > 0); // tilted 3D: sea-level lines would float
+ this.lines.setTransform((1 + Math.max(2e-6, Math.min(0.0008, alt * 0.02))) / 1.0008, hidden);
+ }
+ this.horizon.normal.copy(p).divideScalar(d); // lines drawn on top when zoomed in: drop the far side explicitly
+ this.horizon.constant = -1 / d;
+ this.base.scale.setScalar(alt < 0.6 ? 1 - (this.terrain > 0 ? (this.seaFloor ? Math.max(2e-3, (TRENCH_KM * this.terrain) / R_KM) : 4e-4) : 3e-5) : 1);
+ this.#air();
+ }
+
+ #air() { // sky pass and haze follow the eye height and the sun
+ const p = this.camera.position, d = p.length(), up = p.clone().divideScalar(d);
+ const air = atmosphere((d - 1) * R_KM, this.shade.sky.value ? up.dot(this.shade.sunDir.value) : null);
+ this.dome.update(this.camera, up, air);
+ this.shade.camPos.value.copy(p);
+ this.shade.hazeCol.value.set(...air.horizon);
+ this.shade.hazeAmt.value = air.amount * HAZE_MAX * Math.min(1, this.pose.pitch / 45); // no horizon, no haze
+ }
+
+ getState() {
+ const p = this.pose;
+ return { lat: p.lat, lon: p.lon, alt: p.range, hd: p.heading, pt: p.pitch };
+ }
+
+ setState({ lat, lon, alt, hd = 0, pt = 0 }) {
+ this.#apply({ lat, lon, range: alt, heading: hd, pitch: pt });
+ this.#held = true;
+ }
+
+ #ray(x, y) {
+ this.camera.updateMatrixWorld();
+ this.raycaster.setFromCamera(new THREE.Vector2((x / this.w) * 2 - 1, 1 - (y / this.h) * 2), this.camera);
+ return this.raycaster.ray;
+ }
+
+ #march(ray) { // where a ray first meets the drawn 3D ground: growing steps, then bisection; null if it doesn't
+ const { origin: o, direction: dir } = ray, top = (9000 * this.terrain) / R_M;
+ const at = t => [o.x + dir.x * t, o.y + dir.y * t, o.z + dir.z * t];
+ const below = t => {
+ const v = at(t), ll = toLatLon(v);
+ return Math.hypot(v[0], v[1], v[2]) - 1 <= this.#groundM(ll.lat, ll.lon) / R_M;
+ };
+ const ceil = ray.intersectSphere(new THREE.Sphere(new THREE.Vector3(), 1 + top), new THREE.Vector3());
+ let lo = ceil && o.length() > 1 + top ? ceil.distanceTo(o) : 0; // nothing stands above the ceiling sphere
+ for (let t = Math.max(1e-7, lo * 1.01), i = 0; i < 120; i++, t *= 1.2) {
+ if (below(t)) {
+ let hi = t;
+ for (let k = 0; k < 16; k++) {
+ const m = (lo + hi) / 2;
+ if (below(m)) hi = m;
+ else lo = m;
+ }
+ return at(hi);
+ }
+ const v = at(t);
+ if (Math.hypot(v[0], v[1], v[2]) - 1 > top && v[0] * dir.x + v[1] * dir.y + v[2] * dir.z > 0) return null; // skyward
+ lo = t;
+ }
+ return null;
+ }
+
+ pickVec(x, y, radius = null) { // the ground this pixel shows (3D ground if drawn), or the sphere of a given radius
+ const ray = this.#ray(x, y);
+ if (radius === null && this.terrain > 0 && this.tiles) {
+ const v = this.#march(ray);
+ if (v) return v;
+ }
+ const hit = ray.intersectSphere(radius === null ? this.sphere : new THREE.Sphere(new THREE.Vector3(), radius), new THREE.Vector3());
+ return hit ? [hit.x, hit.y, hit.z] : null;
+ }
+
+ pick(x, y, ground = true) { // ground = false: the sea-level sphere (cheap; tile sampling looking straight down)
+ const v = ground ? this.pickVec(x, y) : this.pickVec(x, y, 1);
+ return v ? toLatLon(v) : null;
+ }
+
+ groundSample(x, y) { // tilted tile sampling: the ground this pixel sees and km per pixel there
+ const v = this.pickVec(x, y);
+ if (!v) return null;
+ const e = this.camera.position, d = Math.hypot(v[0] - e.x, v[1] - e.y, v[2] - e.z);
+ return { ...toLatLon(v), km: (d * R_KM * 2 * Math.tan((this.camera.fov * Math.PI) / 360)) / this.h };
+ }
+
+ kmPerPxAt(p) {
+ const e = this.camera.position;
+ return kmPerPxAt([e.x, e.y, e.z], p, this.camera.fov, this.h);
+ }
+
+ toScreen(lat, lon) {
+ const v = new THREE.Vector3(...toVec(lat, lon));
+ if (v.dot(this.camera.position) <= 1) return null; // beyond the horizon
+ v.multiplyScalar(1 + this.#groundM(lat, lon) / R_M); // on the drawn 3D ground
+ this.camera.updateMatrixWorld();
+ v.project(this.camera);
+ return { x: ((v.x + 1) / 2) * this.w, y: ((1 - v.y) / 2) * this.h };
+ }
+
+ render() { this.renderer.render(this.scene, this.camera); }
+}
diff --git a/viewer/js/globecontrols.js b/viewer/js/globecontrols.js
new file mode 100644
index 0000000..81a0807
--- /dev/null
+++ b/viewer/js/globecontrols.js
@@ -0,0 +1,88 @@
+// globecontrols.js — mouse input for the globe camera. Left-drag pans (the grabbed ground follows the pointer);
+// the compass heading stays put while panning; right-drag or Shift+drag turns (sideways) and tilts (up = toward the horizon); the wheel zooms toward the target.
+// Touch: one finger pans; two fingers pinch (zoom), twist (turn) and move up/down together (tilt).
+import { rotatePose, keepHeading } from './camera.js';
+import { Touches } from './gestures.js';
+
+export const TURN_DEG_PER_PX = 0.25;
+export const TILT_DEG_PER_PX = 0.2;
+const WHEEL_PER_PX = 0.001;
+
+export class GlobeControls {
+ constructor(view, el) {
+ Object.assign(this, { view, el, enabled: false, drag: null, touches: new Touches() });
+ el.addEventListener('pointerdown', e => this.#down(e));
+ el.addEventListener('pointermove', e => this.#move(e));
+ el.addEventListener('pointerup', e => this.#up(e));
+ el.addEventListener('pointercancel', e => this.#up(e));
+ el.addEventListener('wheel', e => this.#wheel(e), { passive: false });
+ el.addEventListener('contextmenu', e => { if (this.enabled) e.preventDefault(); }); // right-drag turns the globe
+ }
+
+ #at(e) {
+ const r = this.el.getBoundingClientRect();
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
+ }
+
+ #down(e) {
+ if (!this.enabled) return;
+ const at = this.#at(e), turn = e.button === 2 || (e.button === 0 && e.shiftKey);
+ if (e.pointerType === 'touch') {
+ this.touches.down(e.pointerId, at);
+ try { this.el.setPointerCapture?.(e.pointerId); } catch { /* a synthetic or already-ended pointer */ }
+ if (this.touches.size >= 2) { this.drag = null; return; } // a second finger: a gesture, not a pan
+ }
+ if (e.button !== 0 && !turn) return;
+ this.#grab(at, e.pointerId, turn);
+ }
+
+ #grab(at, id, turn = false) {
+ const grab = turn ? null : this.view.pickVec(at.x, at.y);
+ if (!turn && !grab) return; // pressed on space or sky: nothing to hold
+ this.drag = { turn, grab, r: grab && Math.hypot(grab[0], grab[1], grab[2]), x: at.x, y: at.y, id };
+ try { this.el.setPointerCapture?.(id); } catch { /* a synthetic or already-ended pointer */ }
+ }
+
+ #move(e) {
+ if (!this.enabled) return;
+ if (e.pointerType === 'touch') {
+ const g = this.touches.move(e.pointerId, this.#at(e));
+ if (g) return this.#gesture(g);
+ }
+ const d = this.drag;
+ if (!d || e.pointerId !== d.id) return; // one pointer drives
+ const at = this.#at(e), p = this.view.pose;
+ if (d.turn) {
+ this.view.setPose({ ...p, heading: p.heading + (at.x - d.x) * -TURN_DEG_PER_PX, pitch: p.pitch - (at.y - d.y) * TILT_DEG_PER_PX });
+ Object.assign(d, at);
+ return;
+ }
+ for (let i = 0; i < 4; i++) { // a rigid turn would swing north around: keep the heading, re-aim, repeat (converges fast)
+ const cur = this.view.pickVec(at.x, at.y, d.r); // the pointer ray at the grabbed ground's height
+ if (!cur) break;
+ this.view.setPose(keepHeading(p, rotatePose(this.view.pose, cur, d.grab)));
+ }
+ }
+
+ #gesture(g) { // pinch = zoom, twist = turn with the fingers, both up/down = tilt
+ const p = this.view.pose;
+ this.view.setPose({ ...p, range: p.range / g.scale, heading: p.heading - g.rot, pitch: p.pitch - g.tilt * TILT_DEG_PER_PX });
+ }
+
+ #up(e) {
+ try { if (this.drag) this.el.releasePointerCapture?.(this.drag.id); } catch { /* not captured */ }
+ this.drag = null;
+ if (e?.pointerType !== 'touch') return;
+ const wasPair = this.touches.size >= 2;
+ this.touches.up(e.pointerId);
+ const rest = wasPair && this.touches.size === 1 ? this.touches.other(e.pointerId) : null;
+ if (rest && this.enabled) this.#grab(rest.at, rest.id); // one finger left: it pans on from where it is
+ }
+
+ #wheel(e) {
+ if (!this.enabled) return;
+ e.preventDefault();
+ const px = e.deltaY * (e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 800 : 1), p = this.view.pose;
+ this.view.setPose({ ...p, range: p.range * Math.exp(px * WHEEL_PER_PX) });
+ }
+}
diff --git a/viewer/js/hover.js b/viewer/js/hover.js
new file mode 100644
index 0000000..5fab5e5
--- /dev/null
+++ b/viewer/js/hover.js
@@ -0,0 +1,16 @@
+// hover.js — status-bar text for the cell under the resting pointer (pure)
+import { distanceKm } from './geo.js';
+
+export const canReuse = (cell, p, detail) => // same cell for sure: no need to ask the server again
+ !detail && !!cell?.center && distanceKm(p, { lat: cell.center[0], lon: cell.center[1] }) < cell.inner_km;
+const int = v => Math.round(v).toLocaleString('en-US');
+
+export function hoverText(cell, detail = false) { // detail: procedural tiles are showing, so show their height
+ const f = Object.fromEntries(cell.fields.map(x => [x.key, x]));
+ const z = ((detail && f.z_detail_m) || f.z_surface_m)?.value ?? 0;
+ if (f.ocean?.value) return `sea, ${int(Math.max(0, -z))} m deep`;
+ const parts = [`${int(z)} m`];
+ if (f.lake?.value) parts.push('lake');
+ if (f.holdridge?.text) parts.push(f.holdridge.text);
+ return parts.join(' · ');
+}
diff --git a/viewer/js/input.js b/viewer/js/input.js
new file mode 100644
index 0000000..c977e32
--- /dev/null
+++ b/viewer/js/input.js
@@ -0,0 +1,4 @@
+// input.js — tell a click from a drag
+export function isClick(down, up, maxPx = 5, maxMs = 600) {
+ return Math.hypot(up.x - down.x, up.y - down.y) <= maxPx && up.t - down.t <= maxMs;
+}
diff --git a/viewer/js/inspector.js b/viewer/js/inspector.js
new file mode 100644
index 0000000..aad6c9c
--- /dev/null
+++ b/viewer/js/inspector.js
@@ -0,0 +1,39 @@
+// inspector.js — cell details from /api/cell, in the server's groups
+import { api, el } from './ui.js';
+import { fmtLatLon } from './units.js';
+
+export function groupFields(fields) {
+ const groups = new Map();
+ for (const f of fields) {
+ if (!groups.has(f.group)) groups.set(f.group, []);
+ groups.get(f.group).push(f);
+ }
+ return groups;
+}
+
+export function fmtValue(f) {
+ if (f.text !== undefined) return f.text;
+ const v = Math.abs(f.value) >= 100 ? Math.round(f.value).toLocaleString('en-US') : String(+f.value.toFixed(2));
+ return f.unit ? `${v} ${f.unit}` : v;
+}
+
+let latest = 0;
+
+export async function inspect(box, p, era = '') {
+ const mine = ++latest;
+ box.replaceChildren(el('p', { class: 'muted' }, `Loading ${fmtLatLon(p.lat, p.lon)}…`));
+ let c;
+ try {
+ c = await api(`api/cell?lat=${p.lat}&lon=${p.lon}${era ? `&era=${encodeURIComponent(era)}` : ''}`);
+ } catch (e) {
+ if (mine === latest) box.replaceChildren(el('p', { class: 'warn' }, `Could not load the cell: ${e.message}`));
+ throw e;
+ }
+ if (mine !== latest) return; // a newer click won
+ box.replaceChildren(
+ el('p', {}, el('b', {}, fmtLatLon(c.lat, c.lon)), el('br'), el('span', { class: 'muted' }, `H3 cell ${c.cell}`),
+ c.era ? el('br') : null, c.era ? el('span', { class: 'muted' }, c.era.label) : null),
+ ...[...groupFields(c.fields)].map(([group, fs]) => el('div', { class: 'fields' }, el('h4', {}, group),
+ fs.map(f => el('div', {}, el('span', {}, f.label),
+ el('span', {}, fmtValue(f), f.alt ? el('span', { class: 'alt' }, ` · ${f.alt}`) : null))))));
+}
diff --git a/viewer/js/latest.js b/viewer/js/latest.js
new file mode 100644
index 0000000..d20b5d5
--- /dev/null
+++ b/viewer/js/latest.js
@@ -0,0 +1,6 @@
+// latest.js — overlapping async calls: only the newest one's answer is applied.
+export class Latest {
+ n = 0;
+ next() { return ++this.n; }
+ is(k) { return k === this.n; }
+}
diff --git a/viewer/js/layers.js b/viewer/js/layers.js
new file mode 100644
index 0000000..c0ab662
--- /dev/null
+++ b/viewer/js/layers.js
@@ -0,0 +1,84 @@
+// layers.js — layer textures (fetched on first use, a few kept on the GPU)
+import * as THREE from 'three';
+import { el } from './ui.js';
+
+const KEEP = 4; // base + over-layer + 2 recent; an 8192×4096 texture is ≈180 MB of GPU memory with mipmaps
+
+export class LayerStore {
+ constructor(layers, maxTextureSize, anisotropy = 1, load = null) {
+ this.layers = layers;
+ this.loadImpl = load; // tests inject a loader; the browser uses #load
+ this.pinned = new Set(); // ids wanted right now (base + over): never evicted
+ this.shown = new Set(); // ids drawn right now (may lag the wanted ones while they load): never evicted
+ this.max = maxTextureSize;
+ this.anisotropy = anisotropy;
+ this.cache = new Map(); // id → Promise<THREE.Texture>, least recently used first
+ }
+
+ get(id) {
+ return this.layers.find(l => l.id === id);
+ }
+
+ pin(ids) {
+ this.pinned = new Set(ids.filter(Boolean));
+ }
+
+ showing(ids) {
+ this.shown = new Set(ids.filter(Boolean));
+ }
+
+ dispose(keep = []) { // another era's store took over: free the GPU memory, except textures still drawn
+ for (const q of this.cache.values()) q.then(t => { if (!keep.includes(t)) t.dispose(); }, () => {});
+ this.cache.clear();
+ }
+
+ texture(id) {
+ let p = this.cache.get(id);
+ if (p) this.cache.delete(id);
+ else p = (this.loadImpl ?? (l => this.#load(l)))(this.get(id)).catch(e => { this.cache.delete(id); throw e; });
+ this.cache.set(id, p);
+ for (const [old, q] of [...this.cache]) { // oldest first; textures on screen stay
+ if (this.cache.size <= KEEP) break;
+ if (this.pinned.has(old) || this.shown.has(old) || old === id) continue;
+ this.cache.delete(old);
+ q.then(t => t.dispose(), () => {});
+ }
+ return p;
+ }
+
+ async #load(layer) {
+ const img = await new THREE.ImageLoader().loadAsync(layer.file)
+ .catch(() => { throw new Error(`could not load ${layer.file}`); });
+ let src = img;
+ if (img.width > this.max) { // GPU limit: downsample to 4096 wide
+ const w = Math.min(4096, this.max);
+ src = document.createElement('canvas');
+ src.width = w;
+ src.height = w / 2;
+ src.getContext('2d').drawImage(img, 0, 0, w, w / 2);
+ }
+ const tex = new THREE.Texture(src);
+ tex.colorSpace = THREE.NoColorSpace; // shaders pass the JPEG's sRGB bytes straight through
+ tex.anisotropy = this.anisotropy;
+ tex.needsUpdate = true;
+ return tex;
+ }
+}
+
+const UNIT_LABEL = { degC: '°C' }; // layers.json keeps the build's unit codes
+const fmtTick = v => (Math.abs(v) >= 100 ? Math.round(v).toLocaleString('en-US') : String(+v.toFixed(2)));
+
+export function renderLegend(box, layer) {
+ const L = layer.legend;
+ box.hidden = !L;
+ if (!L) return box.replaceChildren();
+ const title = el('div', { class: 'muted' }, layer.name);
+ if (L.type === 'categorical') {
+ return box.replaceChildren(title, ...L.items.map(it =>
+ el('div', {}, el('span', { class: 'sw', style: `background:${it.color}` }), it.name)));
+ }
+ const lo = L.stops[0][0], hi = L.stops.at(-1)[0];
+ const grad = L.stops.map(([v, c]) => `${c} ${(((v - lo) / (hi - lo)) * 100).toFixed(1)}%`).join(', ');
+ box.replaceChildren(title, el('div', { class: 'bar', style: `background: linear-gradient(to right, ${grad})` }),
+ el('div', { class: 'ticks' }, el('span', {}, fmtTick(lo)), el('span', {}, UNIT_LABEL[L.unit] ?? L.unit), el('span', {}, fmtTick(hi))));
+}
diff --git a/viewer/js/layout.js b/viewer/js/layout.js
new file mode 100644
index 0000000..a00ad93
--- /dev/null
+++ b/viewer/js/layout.js
@@ -0,0 +1,33 @@
+// layout.js — phone layouts: 'portrait' (narrow: the panel becomes a bottom sheet) or 'landscape' (short: a narrow
+// side panel); '' = the desktop layout. Both phone layouts fold the toolbar into a ☰ menu.
+export const NARROW = 700, SHORT = 500, SHORT_MAX_W = 1000;
+
+export function compactMode(w, h) {
+ if (w <= NARROW) return 'portrait';
+ if (h <= SHORT && w <= SHORT_MAX_W) return 'landscape';
+ return '';
+}
+
+// The screen part the panel covers, for sampling which tiles are visible: a side panel takes a strip on the right,
+// a bottom sheet one at the bottom. rect: the panel's bounding box; null or collapsed: none.
+export function freeArea(w, h, rect) {
+ if (!rect || rect.width <= 0 || rect.height <= 0) return { w, h };
+ if (rect.width >= w * 0.8) return { w, h: Math.max(64, Math.min(h, rect.top)) }; // bottom sheet
+ return { w: Math.max(64, Math.min(w, rect.left)), h }; // right-hand panel
+}
+
+export function applyMode(body, mode, prev) { // → true when the layout just turned compact
+ body.classList.toggle('compact', !!mode);
+ body.classList.toggle('portrait', mode === 'portrait');
+ body.classList.toggle('landscape', mode === 'landscape');
+ if (!mode) body.classList.remove('menu-open');
+ return !!mode && !prev;
+}
+
+// Whole-planet layer textures: phones and tablets get at most 4096 px (an 8192 one with mipmaps is ≈ 170 MB of GPU
+// memory; mobile browsers lose the WebGL context long before desktops). Deep zoom shows tiles either way.
+export const MOBILE_TEXTURE = 4096;
+export function textureCap(gpuMax, touch = globalThis.matchMedia?.('(pointer: coarse)').matches,
+ w = globalThis.innerWidth, h = globalThis.innerHeight) {
+ return touch || compactMode(w, h) ? Math.min(gpuMax, MOBILE_TEXTURE) : gpuMax;
+}
diff --git a/viewer/js/limits.js b/viewer/js/limits.js
new file mode 100644
index 0000000..60ceb74
--- /dev/null
+++ b/viewer/js/limits.js
@@ -0,0 +1,9 @@
+// limits.js — view limits shared by the views and the URL state (pure)
+import { R_KM } from './geo.js';
+
+export const CLEARANCE_M = 20; // the globe camera stays this far above the ground
+export const MIN_ALT = CLEARANCE_M / (R_KM * 1000); // closest range in planet radii (street level)
+export const MAX_ALT = 5;
+export const MAX_ZOOM = 20000;
+export const MAX_PITCH = 80; // degrees from straight down
+export const MAX_EXAG = 5; // 3D terrain exaggeration
diff --git a/viewer/js/lines.js b/viewer/js/lines.js
new file mode 100644
index 0000000..f29fe1f
--- /dev/null
+++ b/viewer/js/lines.js
@@ -0,0 +1,45 @@
+// lines.js — named groups of 1-px line segments in a three.js scene
+import * as THREE from 'three';
+
+export class LineSet {
+ constructor(scene) {
+ this.scene = scene;
+ this.objects = new Map();
+ }
+
+ set(id, positions, color, origin = null) { // origin: positions are relative to it (float precision near the ground)
+ this.remove(id);
+ if (!positions.length) return;
+ const geom = new THREE.BufferGeometry();
+ geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ const obj = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.85,
+ depthTest: this.depthTest ?? true, clippingPlanes: this.clip ?? null }));
+ obj.scale.setScalar(this.scale ?? 1);
+ if (origin) obj.position.set(origin[0], origin[1], origin[2]);
+ this.objects.set(id, obj);
+ this.scene.add(obj);
+ }
+
+ setClip(planes) { // shared THREE.Plane objects: callers move them, every line follows
+ this.clip = planes;
+ for (const o of this.objects.values()) o.material.clippingPlanes = planes;
+ }
+
+ setTransform(scale, depthTest) { // globe: hug the ground when zoomed in and draw on top of tiles
+ this.scale = scale;
+ this.depthTest = depthTest;
+ for (const o of this.objects.values()) {
+ o.scale.setScalar(scale);
+ o.material.depthTest = depthTest;
+ }
+ }
+
+ remove(id) {
+ const obj = this.objects.get(id);
+ if (!obj) return;
+ this.scene.remove(obj);
+ obj.geometry.dispose();
+ obj.material.dispose();
+ this.objects.delete(id);
+ }
+}
diff --git a/viewer/js/measure.js b/viewer/js/measure.js
new file mode 100644
index 0000000..dee8329
--- /dev/null
+++ b/viewer/js/measure.js
@@ -0,0 +1,99 @@
+// measure.js — great-circle measuring path (pure) and its panel
+import { distanceKm, polygonAreaKm2 } from './geo.js';
+import { fmtDistance, fmtArea, fmtWalk, fmtElev } from './units.js';
+import { el, svg } from './ui.js';
+import { profileModel, sampleAt } from './profile.js';
+
+export class MeasurePath {
+ constructor() {
+ this.points = [];
+ this.closed = false;
+ }
+
+ add(p) {
+ if (this.closed) this.clear();
+ const last = this.points.at(-1);
+ if (last && last.lat === p.lat && last.lon === p.lon) return; // double-click lands twice
+ this.points.push({ lat: p.lat, lon: p.lon });
+ }
+
+ undo() {
+ if (this.closed) this.closed = false;
+ else this.points.pop();
+ }
+
+ clear() {
+ this.points = [];
+ this.closed = false;
+ }
+
+ close() {
+ if (this.points.length >= 3) this.closed = true;
+ return this.closed;
+ }
+
+ #ring() { return this.closed ? [...this.points, this.points[0]] : this.points; }
+
+ segmentsKm() {
+ const r = this.#ring();
+ return r.slice(1).map((p, i) => distanceKm(r[i], p));
+ }
+
+ totalKm() { return this.segmentsKm().reduce((a, b) => a + b, 0); }
+ areaKm2() { return this.closed ? polygonAreaKm2(this.points) : null; }
+ lines() { return this.points.length < 2 ? [] : [[...this.#ring()]]; }
+}
+
+export function renderMeasure(box, path, { undo, close, clear }) {
+ const segs = path.segmentsKm(), n = path.points.length;
+ const profile = el('div', { class: 'profile-box' });
+ box.replaceChildren(...[ // replaceChildren would print null as text: drop the absent parts
+ el('p', { class: 'muted' }, 'Click the map to add points; Esc clears.'),
+ el('div', {}, el('button', { onclick: undo, disabled: !n }, 'Undo'), ' ',
+ el('button', { onclick: close, disabled: n < 3 || path.closed }, 'Close for area'), ' ',
+ el('button', { onclick: clear, disabled: !n }, 'Clear')),
+ segs.length ? el('ol', {}, segs.map(km => el('li', {}, fmtDistance(km)))) : null,
+ segs.length ? el('p', {}, el('b', {}, 'Total '), fmtDistance(path.totalKm()), el('br'),
+ el('span', { class: 'muted' }, `${fmtWalk(path.totalKm())} (30 km a day)`)) : null,
+ path.closed ? el('p', {}, el('b', {}, 'Area '), fmtArea(path.areaKm2())) : null,
+ profile,
+ ].filter(Boolean));
+ return profile;
+}
+
+const CW = 320, CH = 130;
+const int = v => Math.round(v).toLocaleString('en-US');
+
+export function renderProfile(box, prof, onHover) {
+ const s = prof.samples, m = profileModel(s, CW, CH);
+ const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 8, y2: CH - 18, visibility: 'hidden' });
+ const readout = el('div', { class: 'readout muted' }, ' ');
+ const chart = svg('svg', { class: 'profile', viewBox: `0 0 ${CW} ${CH}`, width: CW, height: CH },
+ ...m.water.map(([a, b]) => svg('rect', { class: 'water', x: a, y: 8, width: Math.max(1, b - a), height: CH - 26 })),
+ svg('line', { class: 'sea', x1: m.X(0), x2: m.X(m.L), y1: m.seaY, y2: m.seaY }),
+ svg('path', { class: 'area', d: m.area }),
+ svg('path', { class: 'line', d: m.line }),
+ svg('text', { x: 2, y: 14 }, `${int(m.hi)} m`),
+ svg('text', { x: 2, y: CH - 20 }, `${int(m.lo)} m`),
+ svg('text', { x: CW - 4, y: CH - 4, 'text-anchor': 'end' }, fmtDistance(m.L)),
+ cursor);
+ chart.addEventListener('pointermove', e => {
+ const r = chart.getBoundingClientRect();
+ const p = s[sampleAt(s, m.xAt(((e.clientX - r.left) * CW) / r.width))];
+ const x = m.X(p.d_km);
+ cursor.setAttribute('x1', x);
+ cursor.setAttribute('x2', x);
+ cursor.setAttribute('visibility', 'visible');
+ readout.textContent = `${fmtDistance(p.d_km)} · ${fmtElev(p.z_m)}${p.water ? ' · water' : ''}`;
+ onHover(p);
+ });
+ chart.addEventListener('pointerleave', () => {
+ cursor.setAttribute('visibility', 'hidden');
+ readout.textContent = ' ';
+ onHover(null);
+ });
+ const st = prof.stats;
+ box.replaceChildren(chart, readout,
+ el('p', {}, `Highest ${fmtElev(st.max_m)} · lowest ${fmtElev(st.min_m)}`, el('br'),
+ `Climb ${int(st.ascent_m)} m · descent ${int(st.descent_m)} m · over water ${fmtDistance(st.water_km)}`));
+}
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();
+ });
+}
diff --git a/viewer/js/overlay_geom.js b/viewer/js/overlay_geom.js
new file mode 100644
index 0000000..b43609e
--- /dev/null
+++ b/viewer/js/overlay_geom.js
@@ -0,0 +1,106 @@
+// overlay_geom.js — vector overlays → line-segment vertex arrays for the globe and flat views
+import { R_KM, toVec, interpolate, project, EE_XMAX, PC_XMAX } from './geo.js';
+
+const onSeam = (a, b) => Math.abs(a[0]) === 180 && Math.abs(b[0]) === 180;
+
+export function linesFromGeoJSON(fc, keep = () => true) {
+ const out = [];
+ const add = coords => { // split at segments that run along the antimeridian cut
+ let cur = [];
+ coords.forEach((c, i) => {
+ if (i > 0 && onSeam(coords[i - 1], c)) {
+ if (cur.length > 1) out.push(cur);
+ cur = [];
+ }
+ cur.push({ lat: c[1], lon: c[0] });
+ });
+ if (cur.length > 1) out.push(cur);
+ };
+ for (const f of fc.features || []) {
+ const g = f.geometry;
+ if (!g || !keep(f.properties || {})) continue;
+ if (g.type === 'LineString') add(g.coordinates);
+ else if (g.type === 'MultiLineString' || g.type === 'Polygon') g.coordinates.forEach(add);
+ else if (g.type === 'MultiPolygon') g.coordinates.forEach(poly => poly.forEach(add));
+ }
+ return out;
+}
+
+export function densify(lines, stepDeg = 1) {
+ return lines.map(line => {
+ if (line.length < 2) return [...line];
+ const out = [];
+ for (let i = 0; i + 1 < line.length; i++) out.push(...interpolate(line[i], line[i + 1], stepDeg).slice(i === 0 ? 0 : 1));
+ return out;
+ });
+}
+
+export function globeSegments(lines, radius = 1.002, stepDeg = 1) {
+ const pos = [];
+ for (const line of lines) {
+ for (let i = 0; i + 1 < line.length; i++) {
+ const pts = interpolate(line[i], line[i + 1], stepDeg);
+ for (let k = 0; k + 1 < pts.length; k++) {
+ const a = toVec(pts[k].lat, pts[k].lon), b = toVec(pts[k + 1].lat, pts[k + 1].lon);
+ pos.push(a[0] * radius, a[1] * radius, a[2] * radius, b[0] * radius, b[1] * radius, b[2] * radius);
+ }
+ }
+ }
+ return new Float32Array(pos);
+}
+
+// Lines laid on the 3D ground near the camera. Segments with an end inside the cap (cosCap around n, the eye's
+// direction) are cut into pieces ≈ 1/100 of their distance from the eye; each vertex sits on heightM (the drawn
+// ground, m), then is pulled 1% of the way toward the eye: the same pixel, a nearer depth, so the line wins over
+// the ground it lies on but hills in front still hide it. Positions are relative to origin.
+const PULL = 0.01;
+export function drapedSegments(lines, { eye, n, cosCap, origin, heightM }) {
+ const pos = [], kmPerDeg = (R_KM * Math.PI) / 180;
+ const inCap = p => { const v = toVec(p.lat, p.lon); return v[0] * n[0] + v[1] * n[1] + v[2] * n[2] >= cosCap; };
+ const distKm = p => { const v = toVec(p.lat, p.lon); return Math.hypot(v[0] - eye[0], v[1] - eye[1], v[2] - eye[2]) * R_KM; };
+ const put = p => {
+ const v = toVec(p.lat, p.lon), r = 1 + (Math.max(0, heightM(p.lat, p.lon)) + 1) / (R_KM * 1000);
+ for (let i = 0; i < 3; i++) pos.push(v[i] * r + (eye[i] - v[i] * r) * PULL - origin[i]);
+ };
+ for (const line of lines) {
+ for (let i = 0; i + 1 < line.length; i++) {
+ const a = line[i], b = line[i + 1];
+ if (!inCap(a) && !inCap(b)) continue;
+ const pts = interpolate(a, b, Math.max(0.01, Math.min(distKm(a), distKm(b)) / 100) / kmPerDeg);
+ for (let k = 0; k + 1 < pts.length; k++) {
+ put(pts[k]);
+ put(pts[k + 1]);
+ }
+ }
+ }
+ return new Float32Array(pos);
+}
+
+export function flatSegments(lines, view, z = 0.001) {
+ const xmax = view === 'equal_earth' ? EE_XMAX : PC_XMAX;
+ const pos = [];
+ for (const line of lines) {
+ let prev = null;
+ for (const p of line) {
+ const q = project(view, p.lat, p.lon);
+ if (prev && Math.abs(q.x - prev.x) < xmax) pos.push(prev.x, prev.y, z, q.x, q.y, z);
+ prev = q;
+ }
+ }
+ return new Float32Array(pos);
+}
+
+export function graticuleLines(step = 30) {
+ const lines = [];
+ for (let lon = -180; lon <= 180; lon += step) {
+ const l = [];
+ for (let lat = -90; lat <= 90; lat += 2) l.push({ lat, lon });
+ lines.push(l);
+ }
+ for (let lat = -90 + step; lat < 90; lat += step) {
+ const l = [];
+ for (let lon = -180; lon <= 180; lon += 2) l.push({ lat, lon });
+ lines.push(l);
+ }
+ return lines;
+}
diff --git a/viewer/js/overlays.js b/viewer/js/overlays.js
new file mode 100644
index 0000000..b4eef86
--- /dev/null
+++ b/viewer/js/overlays.js
@@ -0,0 +1,159 @@
+// overlays.js — vector overlays (GeoJSON, graticule, measure path) and HTML markers for the active view
+import { linesFromGeoJSON, graticuleLines } from './overlay_geom.js';
+import { api, el, toast } from './ui.js';
+import { riverMinOrder, riverStats } from './riverlod.js';
+
+export const COLORS = { coast: 0xf2efe6, lakes: 0x8fd0ff, rivers: 0x5ab4ff, plates: 0xff6a4d, graticule: 0x8a93a8,
+ measure: 0x7fd4ff, regions: 0xffd166 };
+
+export class Overlays {
+ constructor(defs, markerBox, onChange = () => {}) {
+ this.defs = defs; // [{id, name, file}] from /api/meta
+ this.onChange = onChange; // lines changed (often after an await): the app must redraw
+ this.box = markerBox;
+ this.on = new Set();
+ this.cache = new Map(); // id → Promise<Line[]> (rivers: {all, big})
+ this.extra = new Map(); // always-shown line sets, e.g. the measure path
+ this.markers = [];
+ this.view = null;
+ this.kmPerPx = null; // rivers: only orders that stay apart on screen at this scale (riverlod.js)
+ this.riverOrder = null;
+ this.cellKm = 10; // the build's cell spacing (setCellKm)
+ this.grat = null;
+ this.suppressed = new Set(); // enabled but hidden (coast/lakes under deep-zoom tiles)
+ this.regionLines = []; // refined-region outlines (from /api/regions)
+ }
+
+ list() {
+ return [...this.defs.map(({ id, name }) => ({ id, name })), { id: 'graticule', name: 'Graticule 30°' },
+ { id: 'regions', name: 'Refined regions' }, { id: 'vents', name: 'Vents (refined areas)' }, { id: 'pins', name: 'Pins' }];
+ }
+
+ setDefs(defs) { // another era: its own coast, lakes, rivers… (loaded again when shown)
+ this.defs = defs;
+ this.cache.clear();
+ for (const id of this.#ids()) this.#push(id);
+ }
+
+ attach(view) {
+ this.view = view;
+ for (const id of this.#ids()) this.#push(id);
+ this.#buildMarkers();
+ }
+
+ setEnabled(ids) {
+ const before = this.on;
+ this.on = new Set(ids);
+ for (const id of this.#ids()) if (before.has(id) !== this.on.has(id)) this.#push(id);
+ this.updateMarkers();
+ }
+
+ setCellKm(km) { this.cellKm = km; }
+
+ setScale(kmPerPx) { // the map's scale changed: rivers may gain or lose an order
+ this.kmPerPx = kmPerPx;
+ const data = this.cache.get('rivers')?.ready;
+ if (!data || !this.on.has('rivers')) return;
+ if (riverMinOrder(kmPerPx, data.stats, this.cellKm) !== this.riverOrder) this.#push('rivers');
+ }
+
+ setSuppressed(ids) {
+ const next = new Set(ids);
+ const changed = [...next, ...this.suppressed].filter(id => next.has(id) !== this.suppressed.has(id));
+ this.suppressed = next;
+ for (const id of changed) this.#push(id);
+ }
+
+ setRegions(lines) {
+ this.regionLines = lines;
+ this.#push('regions');
+ }
+
+ removeExtra(id) {
+ if (!this.extra.delete(id)) return;
+ this.view?.removeLines(id);
+ this.onChange();
+ }
+
+ setExtra(id, lines, color = COLORS.measure) {
+ this.extra.set(id, { lines, color });
+ this.#push(id);
+ }
+
+ setMarkers(kind, list) {
+ const old = this.markers.filter(m => m.kind === kind);
+ if (old.length === list.length && old.every((m, i) => m.label === list[i].label && m.onClick === list[i].onClick &&
+ m.title === list[i].title && m.cls === list[i].cls)) {
+ old.forEach((m, i) => { m.lat = list[i].lat; m.lon = list[i].lon; }); // same markers moved: keep the DOM
+ return this.updateMarkers();
+ }
+ this.markers = this.markers.filter(m => m.kind !== kind).concat(list.map(m => ({ ...m, kind })));
+ this.#buildMarkers();
+ }
+
+ updateMarkers() {
+ for (const m of this.markers) {
+ const shown = (m.kind !== 'pin' || this.on.has('pins')) && (m.kind !== 'vent' || this.on.has('vents'));
+ const p = this.view && shown ? this.view.toScreen(m.lat, m.lon) : null;
+ m.el.style.display = p ? '' : 'none';
+ if (p) m.el.style.transform = `translate(${p.x}px, ${p.y}px) translate(-50%, -50%)`;
+ }
+ }
+
+ #ids() {
+ return [...this.defs.map(d => d.id), 'graticule', 'regions', ...this.extra.keys()];
+ }
+
+ #buildMarkers() {
+ for (const m of this.markers) {
+ m.el ??= el('div', { class: `marker ${m.kind}${m.cls ? ` ${m.cls}` : ''}${m.onClick ? ' clickable' : ''}`,
+ title: m.title, onclick: m.onClick },
+ el('div', { class: 'dot' }), m.label ? el('div', { class: 'label' }, m.label) : null);
+ }
+ this.box.replaceChildren(...this.markers.map(m => m.el));
+ this.updateMarkers();
+ }
+
+ async #push(id) {
+ const view = this.view;
+ if (!view) return;
+ if (this.extra.has(id)) view.setLines(id, this.extra.get(id).lines, this.extra.get(id).color);
+ else if (!this.on.has(id) || this.suppressed.has(id)) view.removeLines(id);
+ else if (id === 'graticule') view.setLines(id, (this.grat ??= graticuleLines(30)), COLORS.graticule);
+ else if (id === 'regions') view.setLines(id, this.regionLines, COLORS.regions);
+ else return this.#pushData(id, view);
+ this.onChange();
+ }
+
+ async #pushData(id, view) {
+ try {
+ const data = await this.#load(id);
+ if (this.view !== view || !this.on.has(id) || this.suppressed.has(id)) return; // switched, toggled or hidden meanwhile
+ let lines = data;
+ if (id === 'rivers') {
+ this.riverOrder = riverMinOrder(this.kmPerPx, data.stats, this.cellKm);
+ lines = data.from(this.riverOrder);
+ }
+ view.setLines(id, lines, COLORS[id] ?? 0xffffff);
+ this.onChange();
+ } catch (e) {
+ toast(`Overlay “${id}”: ${e.message}`);
+ }
+ }
+
+ #load(id) {
+ if (!this.cache.has(id)) {
+ const def = this.defs.find(d => d.id === id);
+ const p = api(def.file).then(fc => (id === 'rivers' ? riverSets(fc) : linesFromGeoJSON(fc)));
+ if (id === 'rivers') p.then(d => { p.ready = d; }, () => { });
+ p.catch(() => this.cache.delete(id));
+ this.cache.set(id, p);
+ }
+ return this.cache.get(id);
+ }
+}
+
+function riverSets(fc) { // the rivers overlay: its statistics, and the lines of order ≥ k (built when first asked)
+ const stats = riverStats(fc), sets = new Map();
+ return { stats, from: k => sets.get(k) ?? sets.set(k, linesFromGeoJSON(fc, q => (q.order ?? 1) >= k)).get(k) };
+}
diff --git a/viewer/js/pins.js b/viewer/js/pins.js
new file mode 100644
index 0000000..4afc16b
--- /dev/null
+++ b/viewer/js/pins.js
@@ -0,0 +1,143 @@
+// pins.js — pin search and checks (pure) + the Pins panel (list, add by clicking the map, edit, delete)
+import { api, el, toast } from './ui.js';
+import { fmtLatLon } from './units.js';
+
+export function filterPins(pins, query) {
+ const q = query.trim().toLowerCase();
+ const hit = p => [p.name, p.lore, p.note].some(v => v && v.toLowerCase().includes(q));
+ return (q ? pins.filter(hit) : [...pins]).sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export const loreWarning = (lore, loreIds) =>
+ (lore && !loreIds.includes(lore) ? `“${lore}” is not a known lore id — saved anyway` : null);
+
+export const pinPayload = f => ({ name: f.name.trim(), lat: Number(f.lat), lon: Number(f.lon), lore: f.lore.trim() || null,
+ note: f.note.trim(), epoch: (f.epoch ?? '').trim() || null });
+export const epochIds = loreIds => loreIds.filter(id => /^epoch-[a-z0-9-]+$/.test(id));
+
+export class PinsPanel {
+ constructor(box, loreIds, { onPins, onFly, onPlacing, onDraft = () => {} }) {
+ Object.assign(this, { box, loreIds, onPins, onFly, onPlacing, onDraft });
+ this.pins = [];
+ this.query = '';
+ this.form = null; // {id?, name, lat, lon, lore, note} while adding or editing
+ this.placing = false;
+ }
+
+ async load() {
+ try {
+ this.pins = (await api('api/pins')).pins;
+ } catch (e) {
+ toast(`Pins: ${e.message}`);
+ }
+ this.onPins(this.pins);
+ this.render();
+ }
+
+ setPlacing(on) {
+ this.placing = on;
+ this.onPlacing(on);
+ this.render();
+ }
+
+ placeAt(p) {
+ this.placing = false;
+ this.onPlacing(false);
+ this.form = { name: '', lat: p.lat, lon: p.lon, lore: '', note: '', epoch: '' };
+ this.onDraft({ lat: p.lat, lon: p.lon });
+ this.render();
+ }
+
+ closeForm() {
+ this.form = null;
+ this.onDraft(null);
+ this.render();
+ }
+
+ edit(pin) {
+ this.form = { ...pin, lore: pin.lore || '', note: pin.note || '', epoch: pin.epoch || '' };
+ this.render();
+ }
+
+ async save() {
+ const f = this.form;
+ try {
+ if (f.id) await api(`api/pins/${f.id}`, { method: 'PUT', body: pinPayload(f) });
+ else await api('api/pins', { method: 'POST', body: pinPayload(f) });
+ this.closeForm();
+ await this.load();
+ } catch (e) {
+ toast(`Could not save the pin: ${e.message}`);
+ }
+ }
+
+ async remove(pin) {
+ if (!confirm(`Delete pin “${pin.name}”?`)) return;
+ try {
+ await api(`api/pins/${pin.id}`, { method: 'DELETE' });
+ await this.load();
+ } catch (e) {
+ toast(`Could not delete the pin: ${e.message}`);
+ }
+ }
+
+ render() {
+ this.box.replaceChildren(...(this.form ? this.#formView() : this.#listView()));
+ }
+
+ #listView() {
+ const list = el('div');
+ const search = el('input', { type: 'search', placeholder: 'Search pins', value: this.query });
+ search.addEventListener('input', () => { this.query = search.value; this.#fill(list); });
+ this.#fill(list);
+ return [
+ el('div', {}, el('button', { class: this.placing ? 'on' : null, onclick: () => this.setPlacing(!this.placing) },
+ this.placing ? 'Click the map… (Esc cancels)' : '+ Add pin'), ' ', search),
+ list,
+ ];
+ }
+
+ #fill(list) {
+ const item = p => el('div', { class: 'pin-item', onclick: () => this.onFly(p) },
+ el('b', {}, p.name), p.lore ? el('span', { class: 'muted' }, ` · ${p.lore}`) : null,
+ p.epoch ? el('span', { class: 'muted' }, ` · ${p.epoch.replace(/^epoch-/, '')}`) : null, el('br'),
+ el('span', { class: 'muted' }, fmtLatLon(p.lat, p.lon)), ' ',
+ el('button', { onclick: e => { e.stopPropagation(); this.edit(p); } }, 'Edit'), ' ',
+ el('button', { onclick: e => { e.stopPropagation(); this.remove(p); } }, 'Delete'),
+ loreWarning(p.lore, this.loreIds) ? el('div', { class: 'warn' }, loreWarning(p.lore, this.loreIds)) : null);
+ const shown = filterPins(this.pins, this.query);
+ list.replaceChildren(...(shown.length ? shown.map(item)
+ : [el('p', { class: 'muted' }, this.pins.length ? 'No pins match.' : 'No pins yet.')]));
+ }
+
+ #formView() {
+ const f = this.form;
+ const warn = el('div', { class: 'warn' }, loreWarning(f.lore.trim(), this.loreIds) ?? '');
+ const field = (key, tag, attrs, ...kids) => {
+ const i = el(tag, attrs, ...kids);
+ i.value = f[key];
+ i.addEventListener('input', () => {
+ f[key] = i.value;
+ if (key === 'lore') warn.textContent = loreWarning(f.lore.trim(), this.loreIds) ?? '';
+ });
+ return i;
+ };
+ const name = field('name', 'input', { required: true, maxlength: 120 });
+ queueMicrotask(() => name.focus());
+ const form = el('form', { class: 'pin' },
+ el('p', { class: 'muted' }, f.id ? `Edit ${f.id}` : 'New pin', ' · ', fmtLatLon(f.lat, f.lon)),
+ el('label', {}, 'Name', name),
+ el('label', {}, 'Lore id (optional)', field('lore', 'input', { list: 'lore-ids', placeholder: 'kebab-case id from lore/' })),
+ warn,
+ el('datalist', { id: 'lore-ids' }, this.loreIds.map(id => el('option', { value: id }))),
+ el('label', {}, 'Note (optional)', field('note', 'textarea', { rows: 4, maxlength: 2000 })),
+ el('label', {}, 'Epoch (optional)', field('epoch', 'select', {},
+ el('option', { value: '' }, '— any —'), ...epochIds(this.loreIds).map(id => el('option', { value: id }, id.replace(/^epoch-/, ''))))),
+ el('div', { class: 'coords' }, el('label', {}, 'Lat', field('lat', 'input', { type: 'number', step: 'any', min: -90, max: 90 })),
+ el('label', {}, 'Lon', field('lon', 'input', { type: 'number', step: 'any', min: -180, max: 180 }))),
+ el('div', {}, el('button', { type: 'submit' }, 'Save'), ' ',
+ el('button', { type: 'button', onclick: () => this.closeForm() }, 'Cancel')));
+ form.addEventListener('submit', e => { e.preventDefault(); this.save(); });
+ return [form];
+ }
+}
diff --git a/viewer/js/planet.js b/viewer/js/planet.js
new file mode 100644
index 0000000..3d068dd
--- /dev/null
+++ b/viewer/js/planet.js
@@ -0,0 +1,10 @@
+// planet.js — the world being shown, from /api/meta before the app loads
+export const PLANET = { name: 'World', radius_km: 6371, tilt_deg: 23.44, year_days: 365, day_hours: 24, sun_lock: null, moment_s: null };
+
+export async function loadPlanet() {
+ try {
+ const m = await (await fetch('api/meta')).json();
+ Object.assign(PLANET, m.planet || {}, { name: m.name || PLANET.name, moment_s: m.units?.moment_s ?? null });
+ } catch { }
+ document.title = `${PLANET.name} — World Maps`;
+}
diff --git a/viewer/js/profile.js b/viewer/js/profile.js
new file mode 100644
index 0000000..695ba29
--- /dev/null
+++ b/viewer/js/profile.js
@@ -0,0 +1,37 @@
+// profile.js — elevation profile chart model (pure): samples {d_km, z_m, water} → SVG path data + scales
+const PAD = { l: 40, r: 6, t: 8, b: 18 };
+
+export function profileModel(samples, w, h) {
+ const L = samples.at(-1).d_km || 1;
+ const zs = samples.map(s => s.z_m);
+ const lo = Math.min(0, ...zs);
+ const hi = Math.max(lo + 100, 0, ...zs);
+ const X = d => PAD.l + (d / L) * (w - PAD.l - PAD.r);
+ const Y = z => PAD.t + (1 - (z - lo) / (hi - lo)) * (h - PAD.t - PAD.b);
+ const line = samples.map((s, i) => `${i ? 'L' : 'M'}${X(s.d_km).toFixed(1)},${Y(s.z_m).toFixed(1)}`).join('');
+ const area = `${line}L${X(L).toFixed(1)},${Y(lo).toFixed(1)}L${X(0).toFixed(1)},${Y(lo).toFixed(1)}Z`;
+ const half = L / Math.max(1, samples.length - 1) / 2;
+ const water = [];
+ let start = null;
+ samples.forEach((s, i) => {
+ if (s.water && start === null) start = i;
+ const last = i === samples.length - 1;
+ if (start !== null && (!s.water || last)) {
+ const end = s.water ? i : i - 1;
+ water.push([X(Math.max(0, samples[start].d_km - half)), X(Math.min(L, samples[end].d_km + half))]);
+ start = null;
+ }
+ });
+ const xAt = px => ((px - PAD.l) / (w - PAD.l - PAD.r)) * L;
+ return { line, area, water, seaY: Y(0), lo, hi, L, X, Y, xAt };
+}
+
+export function sampleAt(samples, d) {
+ let a = 0, b = samples.length - 1;
+ while (b - a > 1) {
+ const m = (a + b) >> 1;
+ if (samples[m].d_km <= d) a = m;
+ else b = m;
+ }
+ return Math.abs(samples[a].d_km - d) <= Math.abs(samples[b].d_km - d) ? a : b;
+}
diff --git a/viewer/js/public.js b/viewer/js/public.js
new file mode 100644
index 0000000..bb4d44c
--- /dev/null
+++ b/viewer/js/public.js
@@ -0,0 +1,13 @@
+// public.js — what a read-only (public) map leaves out: the author's editing tabs.
+export const EDIT_TABS = ['pins', 'regions', 'export'];
+
+export function hiddenTabs(meta) {
+ return meta?.read_only ? [...EDIT_TABS] : [];
+}
+
+export function hideTabs(doc, names) { // removes each tab's button and section
+ for (const t of names) {
+ doc.querySelector(`#panel nav [data-tab="${t}"]`)?.remove();
+ doc.querySelector(`#tab-${t}`)?.remove();
+ }
+}
diff --git a/viewer/js/regions.js b/viewer/js/regions.js
new file mode 100644
index 0000000..de45385
--- /dev/null
+++ b/viewer/js/regions.js
@@ -0,0 +1,58 @@
+// regions.js — refined-region outlines (pure): closing a drawn outline, self-crossing check, payloads, status text
+export function closesOutline(first, click, px = 12) {
+ return !!first && Math.hypot(first.x - click.x, first.y - click.y) <= px;
+}
+
+// a click while drawing (screen points): 'close' on the first point, or on the last one (a double-click), once there
+// are 3 points; 'ignore' a repeat of the last point before that; else 'add'
+export function outlineClick(first, last, at, n, px = 12) {
+ if (n >= 3 && (closesOutline(first, at, px) || closesOutline(last, at, px))) return 'close';
+ return closesOutline(last, at, px) ? 'ignore' : 'add';
+}
+
+const unwrap = pts => pts.map(p => ({ lat: p.lat, lon: pts[0].lon + ((((p.lon - pts[0].lon + 180) % 360) + 360) % 360) - 180 }));
+
+export function crossesItself(points) {
+ const p = unwrap(points), n = p.length;
+ const ccw = (a, b, c) => (c.lat - a.lat) * (b.lon - a.lon) - (b.lat - a.lat) * (c.lon - a.lon);
+ for (let i = 0; i < n; i++) {
+ for (let j = i + 1; j < n; j++) {
+ if (j === i + 1 || (i === 0 && j === n - 1)) continue; // neighbours share a point
+ const a = p[i], b = p[(i + 1) % n], c = p[j], d = p[(j + 1) % n];
+ if (ccw(a, b, c) * ccw(a, b, d) < 0 && ccw(c, d, a) * ccw(c, d, b) < 0) return true;
+ }
+ }
+ return false;
+}
+
+export const regionPayload = (name, points) => ({ name: name.trim(), outline: points.map(p => [p.lat, p.lon]) });
+
+export function statusText(s) {
+ const t = s?.tiles, saving = t && t.total > 0 && t.done < t.total ? Math.round((100 * t.done) / t.total) : null;
+ if (!s || s.state === 'idle') return saving === null ? '' : `Saving region tiles ${saving}%`;
+ if (s.state === 'queued') return 'Queued…';
+ if (s.state === 'building') return `Refining… ${Math.round((s.fraction ?? 0) * 100)}%${s.stage ? ` (${s.stage})` : ''}`;
+ if (s.state === 'failed') return `Refinement failed: ${s.error ?? 'unknown error'}`;
+ return `Refined areas are up to date${saving === null ? '' : ` · saving tiles ${saving}%`}`;
+}
+
+export const outlineLines = regions => regions.map(r => {
+ const pts = r.outline.map(([lat, lon]) => ({ lat, lon }));
+ return [...pts, pts[0]];
+});
+
+import { R_KM } from './geo.js';
+export { R_KM };
+export const WARN_KM2 = 2e6; // bigger regions take minutes and much memory: ask first
+
+export function outlineAreaKm2(points, radiusKm = R_KM) { // spherical polygon area (edges as lat/lon lines)
+ const p = unwrap(points), r = Math.PI / 180;
+ let sum = 0;
+ for (let i = 0; i < p.length; i++) {
+ const a = p[i], b = p[(i + 1) % p.length];
+ sum += (b.lon - a.lon) * r * (2 + Math.sin(a.lat * r) + Math.sin(b.lat * r));
+ }
+ return Math.abs(sum) * radiusKm ** 2 / 2;
+}
+
+export const needsTileSwitch = (current, tiles) => !!tiles?.url && tiles.url !== current?.url;
diff --git a/viewer/js/regionspanel.js b/viewer/js/regionspanel.js
new file mode 100644
index 0000000..e0e7078
--- /dev/null
+++ b/viewer/js/regionspanel.js
@@ -0,0 +1,125 @@
+// regionspanel.js — the Regions tab: list refined regions, draw a new outline by clicking the map, save, delete,
+// and show the background refinement's progress
+import { api, el, toast } from './ui.js';
+import { WARN_KM2, crossesItself, outlineAreaKm2, regionPayload, statusText } from './regions.js';
+
+export class RegionsPanel {
+ constructor(box, { onDraft, onDrawing, onRegions, onStatus, eraQuery = () => '' }) {
+ Object.assign(this, { box, onDraft, onDrawing, onRegions, onStatus, eraQuery });
+ this.regions = [];
+ this.status = null;
+ this.points = null; // the outline being drawn
+ this.closed = false;
+ this.timer = null;
+ }
+
+ async load() {
+ try {
+ const q = this.eraQuery(), r = await api(`api/regions${q}`);
+ this.regions = r.regions;
+ this.#status(r.status, r.tiles, q);
+ } catch (e) {
+ toast(`Regions: ${e.message}`);
+ }
+ this.onRegions(this.regions);
+ this.render();
+ }
+
+ #status(s, tiles, q = this.eraQuery()) {
+ const was = this.status?.state;
+ this.status = s;
+ this.onStatus(s, tiles, was, q);
+ clearTimeout(this.timer);
+ const saving = s?.tiles && s.tiles.done < s.tiles.total; // tiles being saved ahead: keep the percentage fresh
+ const busy = s && ['queued', 'building'].includes(s.state);
+ this.timer = setTimeout(() => this.poll(), busy ? 700 : saving ? 3000 : 30000); // idle: another tab may build
+ }
+
+ async poll() {
+ try {
+ const q = this.eraQuery(), r = await api(`api/regions${q}`);
+ this.regions = r.regions;
+ this.#status(r.status, r.tiles, q);
+ this.onRegions(this.regions);
+ if (!this.points) this.render(); // never wipe a name being typed
+ } catch {
+ this.timer = setTimeout(() => this.poll(), 2000);
+ }
+ }
+
+ startDraw() {
+ this.points = [];
+ this.closed = false;
+ this.onDrawing(true);
+ this.render();
+ }
+
+ cancel() {
+ this.points = null;
+ this.closed = false;
+ this.onDraft(null);
+ this.onDrawing(false);
+ this.render();
+ }
+
+ addPoint(p, action) { // action: 'add' | 'close' | 'ignore' (regions.outlineClick)
+ if (!this.points || this.closed || action === 'ignore') return;
+ if (action === 'close' && this.points.length >= 3) {
+ this.closed = true;
+ this.onDrawing(false);
+ } else this.points.push(p);
+ this.onDraft({ points: this.points, closed: this.closed });
+ this.render();
+ }
+
+ async save(name) {
+ if (crossesItself(this.points)) return toast('The outline crosses itself: redraw it');
+ const km2 = outlineAreaKm2(this.points);
+ if (km2 > WARN_KM2 && !confirm(`This region is ≈ ${Math.round(km2).toLocaleString()} km². Regions over `
+ + `${WARN_KM2.toLocaleString()} km² take minutes to refine and a lot of memory. Refine it anyway?`)) return;
+ try {
+ await api('api/regions', { method: 'POST', body: regionPayload(name, this.points) });
+ this.points = null;
+ this.closed = false;
+ this.onDraft(null);
+ await this.load();
+ } catch (e) {
+ toast(`Could not save the region: ${e.message}`);
+ }
+ }
+
+ async remove(r) {
+ if (!confirm(`Delete region “${r.name}”? Its refined terrain goes too.`)) return;
+ try {
+ await api(`api/regions/${r.id}`, { method: 'DELETE' });
+ await this.load();
+ } catch (e) {
+ toast(`Could not delete the region: ${e.message}`);
+ }
+ }
+
+ render() {
+ const st = el('p', { class: this.status?.state === 'failed' ? 'warn' : 'muted' }, statusText(this.status));
+ if (this.points) {
+ const name = el('input', { required: true, maxlength: 80, placeholder: 'Region name' });
+ const form = el('form', { class: 'region' },
+ el('p', { class: 'muted' }, this.closed ? `${this.points.length} points — name it and save`
+ : `Click the map to outline the region (${this.points.length} points); click the first point to close. Esc cancels.`),
+ this.closed ? el('label', {}, 'Name', name) : null,
+ el('div', {}, this.closed ? el('button', { type: 'submit' }, 'Save and refine') : null, ' ',
+ el('button', { type: 'button', onclick: () => this.cancel() }, 'Cancel')));
+ form.addEventListener('submit', e => { e.preventDefault(); this.save(name.value); });
+ if (this.closed) queueMicrotask(() => name.focus());
+ return this.box.replaceChildren(form, st);
+ }
+ const item = r => el('div', { class: 'pin-item' }, el('b', {}, r.name), el('br'),
+ el('span', { class: 'muted' }, `${Math.round(r.area_km2
+ ?? outlineAreaKm2(r.outline.map(([lat, lon]) => ({ lat, lon })))).toLocaleString()} km² · `
+ + (r.refined ? 'refined' : 'not refined yet')), ' ',
+ el('button', { onclick: () => this.remove(r) }, 'Delete'));
+ this.box.replaceChildren(
+ el('div', {}, el('button', { onclick: () => this.startDraw() }, '+ Draw region')),
+ el('p', { class: 'muted' }, 'Refined regions re-run erosion, rivers, lakes and life zones at ≈ 5 km (generated terrain: idea).'),
+ st, ...(this.regions.length ? this.regions.map(item) : [el('p', { class: 'muted' }, 'No regions yet.')]));
+ }
+}
diff --git a/viewer/js/riverlod.js b/viewer/js/riverlod.js
new file mode 100644
index 0000000..0581f1d
--- /dev/null
+++ b/viewer/js/riverlod.js
@@ -0,0 +1,32 @@
+// riverlod.js — which river orders the rivers overlay draws at a scale: those whose rivers lie far enough apart on
+// screen not to merge into a blue sheet. First-order channels lie about one cell step apart; order k's are as much
+// farther apart as their total length is shorter (rivers are lines: spacing ≈ area / length).
+import { distanceKm } from './geo.js';
+
+export const MIN_GAP_PX = 8;
+
+export function cellSpacingKm(res, radiusKm) { // mean distance between H3 cell centres at a resolution
+ return Math.sqrt((4 * Math.PI * radiusKm ** 2) / (2 + 120 * 7 ** res));
+}
+
+export function riverStats(fc) { // {length: {order: total km}} from rivers.geojson
+ const length = {};
+ for (const f of fc.features ?? []) {
+ const o = f.properties?.order ?? 1, c = f.geometry?.coordinates ?? [];
+ let km = 0;
+ for (let i = 1; i < c.length; i++) {
+ km += distanceKm({ lat: c[i - 1][1], lon: c[i - 1][0] }, { lat: c[i][1], lon: c[i][0] });
+ }
+ length[o] = (length[o] ?? 0) + km;
+ }
+ return { length };
+}
+
+export function riverMinOrder(kmPerPx, { length }, cellKm, minGap = MIN_GAP_PX) {
+ const orders = Object.keys(length).map(Number).filter(k => length[k] > 0).sort((a, b) => a - b);
+ if (!orders.length) return 1;
+ if (!(kmPerPx > 0) || !Number.isFinite(kmPerPx)) return orders.at(-1);
+ const l1 = length[orders[0]];
+ for (const k of orders) if ((cellKm * l1) / length[k] >= minGap * kmPerPx) return k;
+ return orders.at(-1); // whole planet: just the biggest
+}
diff --git a/viewer/js/search.js b/viewer/js/search.js
new file mode 100644
index 0000000..3ddc29c
--- /dev/null
+++ b/viewer/js/search.js
@@ -0,0 +1,68 @@
+// search.js — search box logic (pure): coordinates, H3 cell ids and pin matches → suggestions
+import { filterPins } from './pins.js';
+import { fmtLatLon } from './units.js';
+
+const HEMI = { N: ['lat', 1], S: ['lat', -1], E: ['lon', 1], W: ['lon', -1] };
+const MARKS = ['°', "'", '"'];
+
+function tokens(q) {
+ return q.toUpperCase()
+ .replace(/[°º]/g, ' ° ').replace(/['′’]/g, " ' ").replace(/["″”]/g, ' " ')
+ .replace(/([NSEW])/g, ' $1 ').replace(/[,;]/g, ' , ')
+ .split(/\s+/).filter(Boolean);
+}
+
+export function parseCoords(q) { // "lat,lon" first; failing that, commas between digits are decimal commas
+ return parseCoordsAs(q) ?? (/\d,\d/.test(q) ? parseCoordsAs(q.replace(/(\d),(\d)/g, '$1.$2')) : null);
+}
+
+function parseCoordsAs(q) {
+ const comps = [];
+ let cur = null;
+ const close = () => { if (cur?.parts.length) comps.push(cur); cur = null; };
+ const toks = tokens(q);
+ for (let i = 0; i < toks.length; i++) {
+ const t = toks[i];
+ if (t === ',') { close(); continue; }
+ if (HEMI[t]) {
+ if (cur?.parts.length && !cur.hemi) { cur.hemi = t; close(); }
+ else { close(); cur = { parts: [], units: [], hemi: t }; }
+ continue;
+ }
+ if (!/^[+-]?\d+(\.\d+)?$/.test(t)) return null;
+ const mark = MARKS.includes(toks[i + 1]) ? MARKS.indexOf(toks[++i]) : null;
+ if (cur?.parts.length && (mark === null || mark <= cur.units.at(-1))) close(); // bare number or restarting unit
+ cur ??= { parts: [], units: [], hemi: null };
+ cur.parts.push(parseFloat(t));
+ cur.units.push(mark ?? 0);
+ }
+ close();
+ if (comps.length !== 2 || comps.some(c => c.parts.slice(1).some(v => v < 0 || v >= 60))) return null;
+ const val = c => c.parts.reduce((a, v, k) => a + Math.abs(v) / 60 ** c.units[k], 0) * (c.parts[0] < 0 ? -1 : 1);
+ const out = {};
+ const lettered = comps.filter(c => c.hemi);
+ for (const c of lettered) {
+ const [axis, sign] = HEMI[c.hemi];
+ if (axis in out) return null;
+ out[axis] = Math.abs(val(c)) * sign;
+ }
+ const bare = comps.filter(c => !c.hemi);
+ for (const c of bare) out['lat' in out ? 'lon' : 'lat'] = val(c);
+ if (!(Math.abs(out.lat) <= 90) || !(Math.abs(out.lon) <= 180)) return null;
+ return { lat: out.lat, lon: out.lon };
+}
+
+export const isCellId = q => /^8[0-9a-f]{14}$/i.test(q.trim());
+
+export function suggest(q, pins, max = 8) {
+ const s = q.trim();
+ if (!s) return [];
+ const out = [];
+ const c = parseCoords(s);
+ if (c) out.push({ kind: 'coords', label: fmtLatLon(c.lat, c.lon), ...c });
+ if (isCellId(s)) out.push({ kind: 'cell', label: `H3 cell ${s.toLowerCase()}`, cell: s.toLowerCase() });
+ for (const p of filterPins(pins, s).slice(0, Math.max(0, max - out.length))) {
+ out.push({ kind: 'pin', label: p.name, sub: p.lore || fmtLatLon(p.lat, p.lon), lat: p.lat, lon: p.lon });
+ }
+ return out;
+}
diff --git a/viewer/js/searchbox.js b/viewer/js/searchbox.js
new file mode 100644
index 0000000..c073b65
--- /dev/null
+++ b/viewer/js/searchbox.js
@@ -0,0 +1,59 @@
+// searchbox.js — the search field: suggestions as you type, ↑/↓/Enter/Esc, fly to the choice
+import { el } from './ui.js';
+import { suggest } from './search.js';
+
+export class SearchBox {
+ constructor(input, list, { pins, locate, go }) {
+ Object.assign(this, { input, list, pins, locate, go });
+ this.items = [];
+ this.sel = -1;
+ input.addEventListener('input', () => this.update());
+ input.addEventListener('keydown', e => this.key(e));
+ input.addEventListener('blur', () => setTimeout(() => this.close(), 150)); // let a click on an item land first
+ }
+
+ update() {
+ this.items = suggest(this.input.value, this.pins());
+ this.sel = this.items.length ? 0 : -1;
+ this.render();
+ }
+
+ render() {
+ this.list.hidden = !this.items.length;
+ this.list.replaceChildren(...this.items.map((it, i) => el('div',
+ { class: `item${i === this.sel ? ' sel' : ''}`, onmousedown: e => { e.preventDefault(); this.choose(i); } },
+ it.label, it.sub ? el('span', { class: 'sub' }, it.sub) : null)));
+ }
+
+ key(e) {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (!this.items.length) return;
+ this.sel = (this.sel + (e.key === 'ArrowDown' ? 1 : -1) + this.items.length) % this.items.length;
+ this.render();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (this.sel >= 0) this.choose(this.sel);
+ } else if (e.key === 'Escape') {
+ this.close();
+ this.input.blur();
+ }
+ }
+
+ close() {
+ this.items = [];
+ this.sel = -1;
+ this.render();
+ }
+
+ async choose(i) {
+ const it = this.items[i];
+ this.close();
+ try {
+ const at = it.kind === 'cell' ? await this.locate(it.cell) : { lat: it.lat, lon: it.lon };
+ this.go({ lat: at.lat, lon: at.lon, label: it.label, marker: it.kind !== 'pin' });
+ } catch (e) {
+ this.go({ error: e.message });
+ }
+ }
+}
diff --git a/viewer/js/shading.js b/viewer/js/shading.js
new file mode 100644
index 0000000..7b07059
--- /dev/null
+++ b/viewer/js/shading.js
@@ -0,0 +1,59 @@
+// shading.js — fragment shading shared by both views and the deep-zoom tiles: base texture, optional over-layer,
+// then sun lighting (Sky on) or the plain v1 look (limb factor only).
+import * as THREE from 'three';
+import { R_KM } from './geo.js';
+import { HAZE_KM } from './atmosphere.js';
+
+export const SHADE_GLSL = /* glsl */ `
+uniform sampler2D map;
+uniform sampler2D map2;
+uniform float hasMap;
+uniform float mix2;
+uniform vec3 sunDir;
+uniform float sky;
+uniform vec3 camPos;
+uniform vec3 hazeCol;
+uniform float hazeAmt;
+uniform float hazeK;
+vec3 overColor(vec3 c, vec3 o, float k) {
+ float l = dot(c, vec3(0.299, 0.587, 0.114));
+ vec3 g = mix(c, vec3(l), min(1.0, 2.0 * k));
+ return mix(g, o * clamp(0.55 + 0.9 * l, 0.0, 1.0), k);
+}
+vec3 lightColor(vec2 uv, vec3 n, float limb) {
+ vec3 c = mix(vec3(0.16, 0.2, 0.28), texture2D(map, uv).rgb, hasMap);
+ if (mix2 > 0.0) c = overColor(c, texture2D(map2, uv).rgb, mix2);
+ if (sky < 0.5) return c * limb;
+ float cz = dot(normalize(n), sunDir);
+ float day = smoothstep(-0.1, 0.1, cz);
+ vec3 lit = c * (0.8 + 0.2 * clamp(cz, 0.0, 1.0));
+ vec3 night = c * vec3(0.24, 0.27, 0.36);
+ return mix(night, lit, day) * mix(1.0, limb, 0.4);
+}
+vec3 shadeColor(vec2 uv, vec3 n, float limb) { // near the ground, distance fades into the horizon colour
+ vec3 c = lightColor(uv, n, limb);
+ if (hazeAmt <= 0.0) return c;
+ return mix(c, hazeCol, hazeAmt * (1.0 - exp(-distance(n, camPos) * hazeK)));
+}`;
+
+export function shadeUniforms() {
+ return { map: { value: null }, map2: { value: null }, hasMap: { value: 0 }, mix2: { value: 0 },
+ sunDir: { value: new THREE.Vector3(1, 0, 0) }, sky: { value: 0 },
+ camPos: { value: new THREE.Vector3() }, hazeCol: { value: new THREE.Vector3(0.74, 0.83, 0.93) },
+ hazeAmt: { value: 0 }, hazeK: { value: R_KM / HAZE_KM } };
+}
+
+export function overMix(c, o, k) {
+ const l = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2], d = Math.min(1, 2 * k), s = Math.min(1, Math.max(0, 0.55 + 0.9 * l));
+ return c.map((v, i) => { const g = v + (l - v) * d; return g + (o[i] * s - g) * k; });
+}
+
+export function applyOver(u, tex, op) {
+ u.map2.value = tex;
+ u.mix2.value = tex ? op : 0;
+}
+
+export function applySun(u, v) {
+ if (v) u.sunDir.value.set(v[0], v[1], v[2]);
+ u.sky.value = v ? 1 : 0;
+}
diff --git a/viewer/js/sky.js b/viewer/js/sky.js
new file mode 100644
index 0000000..df4f000
--- /dev/null
+++ b/viewer/js/sky.js
@@ -0,0 +1,41 @@
+// sky.js — sun geometry (pure), values from planet.js. Viewer convention: day 0 = northern spring equinox,
+// moment 0 = midnight at longitude 0°. Circular orbit. A day has in-world moments (units.moment_s) or minutes;
+// a sun-locked world keeps its sun still.
+import { toVec } from './geo.js';
+import { PLANET } from './planet.js';
+
+export const TILT_DEG = PLANET.tilt_deg;
+export const YEAR_DAYS = Math.max(1, Math.round(PLANET.year_days));
+export const MOMENT_S = PLANET.moment_s || 60;
+export const DAY_MOMENTS = Math.round(PLANET.day_hours * 3600 / MOMENT_S);
+const q = YEAR_DAYS / 4;
+export const SEASONS = TILT_DEG > 0 ? [[0, 'northern spring equinox'], [q, 'northern midsummer'], [2 * q, 'northern autumn equinox'],
+ [3 * q, 'northern midwinter'], [YEAR_DAYS, 'northern spring equinox']] : [];
+const D = Math.PI / 180;
+
+export function subsolar(day, moment) {
+ if (PLANET.sun_lock) return { lat: PLANET.sun_lock[0], lon: PLANET.sun_lock[1] };
+ const t = day + moment / DAY_MOMENTS;
+ const lat = Math.asin(Math.sin(TILT_DEG * D) * Math.sin((2 * Math.PI * t) / YEAR_DAYS)) / D;
+ const lon = ((((180 - (360 * moment) / DAY_MOMENTS + 180) % 360) + 360) % 360) - 180;
+ return { lat, lon };
+}
+
+export function sunVec(day, moment) {
+ const s = subsolar(day, moment);
+ return toVec(s.lat, s.lon);
+}
+
+export function dayFraction(lat, day) {
+ const dec = subsolar(day, 0).lat * D; // declination at the start of the day
+ const phi = Math.max(-89.9999, Math.min(89.9999, lat)) * D;
+ const x = -Math.tan(phi) * Math.tan(dec);
+ if (x <= -1) return 1;
+ if (x >= 1) return 0;
+ return Math.acos(x) / Math.PI;
+}
+
+export function seasonName(day) {
+ const hit = SEASONS.find(([d]) => Math.abs(day - d) <= Math.min(2, YEAR_DAYS / 20));
+ return hit ? hit[1] : '';
+}
diff --git a/viewer/js/skypanel.js b/viewer/js/skypanel.js
new file mode 100644
index 0000000..fff0440
--- /dev/null
+++ b/viewer/js/skypanel.js
@@ -0,0 +1,80 @@
+// skypanel.js — Sky & seasons bar: day/moment sliders, play, sun marker, tropics and polar circles
+import { $ } from './ui.js';
+import { sunVec, subsolar, dayFraction, seasonName, TILT_DEG, YEAR_DAYS, DAY_MOMENTS } from './sky.js';
+import { PLANET } from './planet.js';
+
+const parallel = lat => Array.from({ length: 181 }, (_, i) => ({ lat, lon: -180 + 2 * i }));
+export const SKY_LINES = [TILT_DEG, -TILT_DEG, 90 - TILT_DEG, TILT_DEG - 90].map(parallel);
+const int = v => Math.round(v).toLocaleString('en-US');
+
+export function dayLabel(day) {
+ const s = seasonName(day);
+ return `day ${day} of ${YEAR_DAYS}${s ? ` · ${s}` : ''}`;
+}
+const pad = v => String(v).padStart(2, '0');
+export const momentLabel = mo => (PLANET.moment_s ? `moment ${int(mo)} of ${int(DAY_MOMENTS)}`
+ : `${pad(Math.floor(mo / 60))}:${pad(Math.round(mo) % 60)}`);
+export function daylightLabel(lat, day) {
+ const f = dayFraction(lat, day);
+ return `daylight ${Math.round(f * 100)}% (${(f * PLANET.day_hours).toFixed(1)} h)`;
+}
+
+export class SkyPanel {
+ constructor(app) {
+ this.app = app;
+ this.timer = null;
+ this.day = $('#sky-day');
+ this.mo = $('#sky-mo');
+ this.day.max = YEAR_DAYS - 1;
+ this.mo.max = DAY_MOMENTS - 1;
+ $('#sky-toggle').addEventListener('click', () => this.set(!app.state.sky));
+ this.day.addEventListener('input', () => { app.state.day = Number(this.day.value); this.apply(); });
+ this.mo.addEventListener('input', () => { app.state.mo = Number(this.mo.value); this.apply(); });
+ $('#sky-play').addEventListener('click', () => this.play(!this.timer));
+ this.set(app.state.sky);
+ }
+
+ set(on) {
+ this.app.state.sky = on;
+ $('#skybar').hidden = !on;
+ $('#sky-toggle').classList.toggle('on', on);
+ if (!on) this.play(false);
+ this.apply();
+ }
+
+ play(on) { // one day per 10 s
+ clearInterval(this.timer);
+ this.timer = null;
+ $('#sky-play').textContent = on ? '⏸' : '▶';
+ if (!on) return;
+ this.timer = setInterval(() => {
+ const s = this.app.state;
+ s.mo += Math.round(DAY_MOMENTS / 200);
+ if (s.mo >= DAY_MOMENTS) {
+ s.mo -= DAY_MOMENTS;
+ s.day = (s.day + 1) % YEAR_DAYS;
+ }
+ this.apply();
+ }, 50);
+ }
+
+ apply() {
+ const s = this.app.state, sun = subsolar(s.day, s.mo);
+ this.day.value = String(s.day);
+ this.mo.value = String(s.mo);
+ $('#sky-day-label').textContent = dayLabel(s.day);
+ $('#sky-mo-label').textContent = momentLabel(s.mo);
+ this.app.setSun(s.sky ? sunVec(s.day, s.mo) : null);
+ this.app.overlays.setMarkers('sun', s.sky ? [{ lat: sun.lat, lon: sun.lon, label: '☉' }] : []);
+ if (this.linesOn !== s.sky) { // the parallels only change when the sky is switched
+ this.app.overlays.setExtra('sky', s.sky ? SKY_LINES : [], 0xffd166);
+ this.linesOn = s.sky;
+ }
+ this.refresh();
+ }
+
+ refresh() { // day length at the view centre
+ if (!this.app.state.sky || !this.app.view) return;
+ $('#sky-daylen').textContent = daylightLabel(this.app.view.getState().lat, this.app.state.day);
+ }
+}
diff --git a/viewer/js/tilelayer.js b/viewer/js/tilelayer.js
new file mode 100644
index 0000000..7116d87
--- /dev/null
+++ b/viewer/js/tilelayer.js
@@ -0,0 +1,401 @@
+// tilelayer.js — deep-zoom tile patches for one view: pick the tiles on screen, load ≤ 6 at a time,
+// let the nearest loaded ancestor stand in, evict the least recently used.
+import * as THREE from 'three';
+import { SHADE_GLSL } from './shading.js';
+import { MIN_TILE_Z, MAX_TILE_Z, MESH_SEGS, TILE, DETAIL_TILES, detailOrigin, targetZoom, lodTiles, tileKey, tileOf, parentTile,
+ globePatch, flatPatch, gridHeight }
+ from './tiles.js';
+
+export const CONCURRENCY = 6;
+const MAX_WANTED = 96, CACHE = 256, SEGS = 16; // defaults; the app sizes the budget from the viewport
+export const GRAIN = 0.35; // 3D fine grain: ± brightness at full strength (×0.5 from the noise)
+
+const GLOBE_VERT = /* glsl */ `
+attribute float skirt;
+attribute float wet;
+varying float vWet;
+varying vec2 vUv;
+varying vec3 vPos;
+varying vec3 vNormal;
+varying float vSkirt;
+varying vec3 vRel;
+void main() {
+ vUv = uv;
+ vSkirt = skirt;
+ vWet = wet;
+ vRel = mat3(modelMatrix) * position; // world-oriented but patch-relative: float32 keeps its slope at street zoom
+ vec4 wp = modelMatrix * vec4(position, 1.0);
+ vPos = wp.xyz;
+ vNormal = normalize(mat3(viewMatrix) * wp.xyz);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}`;
+
+const FLAT_VERT = /* glsl */ `
+attribute vec2 ll;
+varying vec2 vUv;
+varying vec3 vPos;
+varying vec3 vNormal;
+void main() {
+ vUv = uv;
+ vPos = vec3(cos(ll.x) * cos(ll.y), sin(ll.x), -cos(ll.x) * sin(ll.y));
+ vNormal = vec3(0.0, 0.0, 1.0);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}`;
+
+const FRAG = /* glsl */ `${SHADE_GLSL}
+varying vec2 vUv;
+varying vec3 vPos;
+varying vec3 vNormal;
+void main() {
+ gl_FragColor = vec4(shadeColor(vUv, vPos, 0.72 + 0.28 * clamp(vNormal.z, 0.0, 1.0)), 1.0);
+}`;
+
+export const STEEP_GLSL = /* glsl */ `
+const vec3 ROCK = vec3(0.47, 0.43, 0.38);
+vec3 steepColor(vec3 c, vec3 pos, vec3 rel, float limb) { // cliffs: rock, not the colour of the texel at their foot
+ vec3 n = normalize(cross(dFdx(rel), dFdy(rel)));
+ float up = abs(dot(n, normalize(pos)));
+ return mix(ROCK * limb, c, smoothstep(0.42, 0.71, up));
+}`;
+
+export const GRAIN_GLSL = /* glsl */ `
+uniform vec2 detailOrg;
+uniform float waterOn;
+float grainHash(vec2 p) { // Dave Hoskins, "Hash without Sine" (MIT)
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+ p3 += dot(p3, p3.yzx + 33.33);
+ return fract((p3.x + p3.y) * p3.z);
+}
+float grainNoise(vec2 p, float per) { // value noise, periodic in per lattice cells
+ vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
+ float a = grainHash(mod(i, per)), b = grainHash(mod(i + vec2(1.0, 0.0), per));
+ float c = grainHash(mod(i + vec2(0.0, 1.0), per)), d = grainHash(mod(i + vec2(1.0, 1.0), per));
+ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) - 0.5;
+}
+float fineGrain(vec2 uv) { // below the map's resolution: octaves of 1, 1/2, 1/4 texel, each fading in once it spans a few px
+ vec2 q = detailOrg + vec2(uv.x, 1.0 - uv.y) * ${TILE.toFixed(1)}; // texels, periodic over the tiles (seamless)
+ float px = 1.0 / max(length(fwidth(q)), 1e-6); // screen px per texel
+ float g = 0.0, f = 1.0, a = 0.5;
+ for (int k = 0; k < 3; k++) {
+ g += a * smoothstep(2.0, 6.0, px / f) * grainNoise(q * f, ${(DETAIL_TILES * TILE).toFixed(1)} * f);
+ f *= 2.0;
+ a *= 0.6;
+ }
+ return g;
+}`;
+
+const FRAG_3D = /* glsl */ `${SHADE_GLSL}
+${STEEP_GLSL}
+${GRAIN_GLSL}
+varying vec2 vUv;
+varying vec3 vPos;
+varying vec3 vNormal;
+varying float vSkirt;
+varying float vWet;
+varying vec3 vRel;
+const float GRAIN = ${GRAIN.toFixed(3)};
+void main() {
+ float limb = 0.72 + 0.28 * clamp(vNormal.z, 0.0, 1.0);
+ vec3 c = shadeColor(vUv, vPos, limb);
+ c *= 1.0 + GRAIN * fineGrain(vUv) * (1.0 - vWet * waterOn); // procedural, like the relief detail: not data
+ gl_FragColor = vec4(vSkirt > 0.001 ? c : steepColor(c, vPos, vRel, limb), 1.0); // a skirt seen through a crack: ground
+}`;
+
+let loader = null;
+export function loadTexture(url) {
+ loader ??= new THREE.TextureLoader();
+ return loader.loadAsync(url).then(t => {
+ t.colorSpace = THREE.NoColorSpace; // shaders pass the JPEG's sRGB bytes straight through
+ t.anisotropy = 4;
+ return t;
+ });
+}
+
+export function loadHeights(url) {
+ return fetch(url).then(r => {
+ if (!r.ok) throw new Error(`heights ${r.status}`);
+ return r.arrayBuffer();
+ }).then(b => {
+ const n = (MESH_SEGS + 1) ** 2;
+ if (b.byteLength !== n * 5) throw new Error(`heights: ${b.byteLength} bytes`);
+ return { h: new Float32Array(b, 0, n), water: new Uint8Array(b, 4 * n, n) };
+ });
+}
+
+export class TileLayer {
+ constructor(view, { url, meshUrl = null, onChange = () => {}, load = loadTexture, loadMesh = loadHeights,
+ now = () => performance.now(), later = (fn, ms) => setTimeout(fn, ms) }) { // (Firefox: never a method call)
+ Object.assign(this, { view, url, meshUrl, onChange, load, loadMesh, now, later });
+ this.group = new THREE.Group();
+ this.group.visible = false;
+ view.scene.add(this.group);
+ this.tiles = new Map(); // key → {key, t, state: new|queued|loading|ready|error, mesh, used}
+ this.queue = [];
+ this.inflight = 0;
+ this.gen = 0;
+ this.layer = null;
+ this.over = null;
+ this.z = -1;
+ this.cacheSize = CACHE;
+ this.terrain = 0; // 3D exaggeration; 0 = flat patches
+ this.heightGen = 0; // bumps whenever the drawn heights change (lines draped on them follow)
+ this.water = true; // the water surface drawn at 0 m (off: the sea floor)
+ }
+
+ get active() { return this.group.visible; }
+
+ setLayers(layer, over) {
+ over = over || null;
+ if (layer === this.layer && over === this.over) return;
+ this.layer = layer;
+ this.over = over;
+ this.gen++;
+ for (const k of [...this.tiles.keys()]) this.#drop(k);
+ this.queue = [];
+ this.onChange();
+ }
+
+ setTerrain(exag) { // 0 = flat tiles; > 0 = 3D with heights × exag (globe only)
+ exag = this.meshUrl && this.view.kind === 'globe' && exag > 0 ? exag : 0;
+ if (exag === this.terrain) return;
+ const was = this.terrain;
+ this.terrain = exag;
+ if (!was !== !exag) { // on ↔ off: every tile needs (or sheds) its heights
+ this.gen++;
+ for (const k of [...this.tiles.keys()]) this.#drop(k);
+ this.queue = [];
+ } else for (const e of this.tiles.values()) if (e.mesh && e.h) this.#shape(e);
+ this.heightGen++;
+ this.onChange();
+ }
+
+ heightAt(lat, lon, shown = false) { // metres (unexaggerated) from the finest loaded heights here, or null;
+ // shown: only tiles drawn right now (what picks and draped lines must match)
+ if (!this.terrain || (shown && !this.group.visible)) return null;
+ for (let z = MAX_TILE_Z; z >= MIN_TILE_Z; z--) {
+ const e = this.tiles.get(tileKey(tileOf(lat, lon, z)));
+ if (e?.state === 'ready' && e.h && (!shown || e.mesh.visible)) return gridHeight(e.t, this.#drawn(e), lat, lon);
+ }
+ return null;
+ }
+
+ #drawn(e) { // the heights this tile draws: the water surface at 0 m, or the floor
+ if (!e.h) return null;
+ if (!this.water) return e.h.h;
+ e.surface ??= e.h.h.map((v, k) => (e.h.water[k] ? 0 : v));
+ return e.surface;
+ }
+
+ setWater(on) {
+ if (on === this.water) return;
+ this.water = on;
+ for (const e of this.tiles.values()) if (e.mesh && e.h) {
+ this.#shape(e);
+ e.mesh.material.uniforms.waterOn.value = on ? 1 : 0;
+ }
+ this.heightGen++;
+ this.onChange();
+ }
+
+ #shape(e) {
+ const g = e.mesh.geometry;
+ g.attributes.position.array.set(globePatch(e.t, MESH_SEGS, this.#drawn(e), this.terrain).positions);
+ g.attributes.position.needsUpdate = true;
+ g.computeBoundingSphere();
+ }
+
+ setUrl(url, meshUrl = this.meshUrl) { // regions rebuilt: new tile URLs, the old ones stop answering
+ if (url === this.url && meshUrl === this.meshUrl) return;
+ this.url = url;
+ this.meshUrl = meshUrl;
+ this.gen++;
+ for (const k of [...this.tiles.keys()]) this.#drop(k);
+ this.queue = [];
+ this.onChange();
+ }
+
+ suspend() { // the view is hidden: stop loading, free the GPU (in-flight loads are dropped on arrival)
+ this.gen++;
+ for (const k of [...this.tiles.keys()]) this.#drop(k);
+ this.queue = [];
+ this.group.visible = false;
+ }
+
+ update(kmPerPx, points, cap = MAX_WANTED) {
+ const own = points.map(p => Math.min(MAX_TILE_Z, targetZoom(p.km ?? kmPerPx))); // tilted: each sample its own zoom
+ let drop = 0, zs = own, wanted = lodTiles(points, zs, 1);
+ while (wanted.length > cap && Math.max(...zs) > MIN_TILE_Z) { // coverage first: everything a level coarser
+ drop++;
+ zs = own.map(z => z - drop);
+ wanted = lodTiles(points, zs, 1);
+ }
+ this.z = zs.length ? Math.max(...zs) : Math.min(MAX_TILE_Z, targetZoom(kmPerPx));
+ const on = this.z >= MIN_TILE_Z && !!this.layer && points.length > 0;
+ if (this.group.visible !== on) this.heightGen++;
+ this.group.visible = on;
+ if (!on) {
+ for (const e of this.queue) e.state = 'new';
+ this.queue = [];
+ return;
+ }
+ wanted = wanted.slice(0, cap);
+ this.cacheSize = Math.max(CACHE, 2 * cap);
+ const want = new Set(wanted.map(tileKey)), show = new Set(), standIn = new Set();
+ for (const t of wanted) {
+ const e = this.#entry(t);
+ e.used = this.now(); // wanted now: never the first to go when the cache trims (a failed one keeps its backoff)
+ if (e.state === 'error' && this.now() >= e.retryAt) e.state = 'new'; // try again after the backoff
+ if (e.state === 'ready') {
+ show.add(e.key);
+ continue;
+ }
+ if (e.state === 'new') e.state = 'queued';
+ for (let p = parentTile(t); p.z >= MIN_TILE_Z; p = parentTile(p)) {
+ const a = this.tiles.get(tileKey(p));
+ if (a?.state === 'ready') {
+ show.add(a.key);
+ standIn.add(a.key);
+ break;
+ }
+ }
+ }
+ if (this.terrain && standIn.size) { // 3D: a stand-in's coarser ground would poke through finer tiles inside it
+ for (const k of [...show]) {
+ const [z, x, y] = k.split('/').map(Number);
+ for (let q = parentTile({ z, x, y }); q.z >= MIN_TILE_Z; q = parentTile(q)) {
+ if (standIn.has(tileKey(q))) {
+ show.delete(k);
+ break;
+ }
+ }
+ }
+ }
+ for (const e of this.queue) if (!want.has(e.key)) e.state = 'new'; // left the view: forget, don't fetch
+ this.queue = wanted.map(t => this.tiles.get(tileKey(t))).filter(e => e.state === 'queued'); // this view's order
+ const now = this.now();
+ let changed = false;
+ for (const e of this.tiles.values()) {
+ const vis = show.has(e.key);
+ if (e.mesh && e.mesh.visible !== vis) {
+ e.mesh.visible = vis;
+ changed ||= !!e.h;
+ }
+ if (vis) e.used = now;
+ }
+ if (changed) this.heightGen++;
+ this.#pump();
+ this.#evict();
+ }
+
+ #entry(t) {
+ const key = tileKey(t);
+ let e = this.tiles.get(key);
+ if (!e) {
+ e = { key, t, state: 'new', mesh: null, used: 0 };
+ this.tiles.set(key, e);
+ }
+ return e;
+ }
+
+ #url(layer, t) {
+ return this.url.replace('{layer}', layer).replace('{z}', t.z).replace('{x}', t.x).replace('{y}', t.y);
+ }
+
+ #meshUrl(t) { return this.meshUrl.replace('{z}', t.z).replace('{x}', t.x).replace('{y}', t.y); }
+
+ #pump() {
+ const cost = 1 + (this.over ? 1 : 0) + (this.terrain ? 1 : 0); // HTTP requests per tile (heights ≈ 66 KB)
+ while (this.inflight + cost <= CONCURRENCY && this.queue.length) {
+ const e = this.queue.shift(), gen = this.gen;
+ e.state = 'loading';
+ this.inflight += cost;
+ Promise.allSettled([this.load(this.#url(this.layer, e.t)), this.over ? this.load(this.#url(this.over, e.t)) : null,
+ this.terrain ? this.loadMesh(this.#meshUrl(e.t)) : null])
+ .then(([a, b, c]) => {
+ const tex = a.status === 'fulfilled' ? a.value : null, tex2 = b.status === 'fulfilled' ? b.value : null;
+ const h = this.terrain && c.status === 'fulfilled' ? c.value : null;
+ const failed = !tex || (this.over && !tex2) || (this.terrain && !h);
+ if (failed || gen !== this.gen || !this.tiles.has(e.key)) {
+ tex?.dispose();
+ tex2?.dispose();
+ if (failed && gen === this.gen) this.#fail(e, gen);
+ return;
+ }
+ try {
+ e.h = h;
+ e.mesh = this.#mesh(e.t, tex, tex2, h ? this.#drawn(e) : null, h?.water);
+ } catch (err) {
+ tex.dispose();
+ tex2?.dispose();
+ console.warn('tile', e.key, err);
+ this.#fail(e, gen);
+ return;
+ }
+ e.fails = 0;
+ e.state = 'ready';
+ this.onChange();
+ })
+ .finally(() => {
+ this.inflight -= cost;
+ this.#pump();
+ });
+ }
+ }
+
+ #fail(e, gen) { // back off, then wake the map so an idle view retries too
+ e.fails = (e.fails ?? 0) + 1;
+ const delay = Math.min(60000, 2000 * 2 ** (e.fails - 1));
+ e.retryAt = this.now() + delay;
+ e.state = 'error';
+ this.later(() => { if (gen === this.gen) this.onChange(); }, delay);
+ }
+
+ #mesh(t, tex, tex2, h = null, wet = null) {
+ const kind = this.view.kind;
+ const p = kind === 'globe' ? globePatch(t, h ? MESH_SEGS : SEGS, h, this.terrain) : flatPatch(t, kind, SEGS, 0.0002 + t.z * 1e-5);
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.BufferAttribute(p.positions, 3));
+ g.setAttribute('uv', new THREE.BufferAttribute(p.uvs, 2));
+ if (p.ll) g.setAttribute('ll', new THREE.BufferAttribute(p.ll, 2));
+ if (p.skirt) g.setAttribute('skirt', new THREE.BufferAttribute(p.skirt, 1));
+ if (wet) { // per vertex: 1 on water (no grain there while the water surface is drawn); skirts 0
+ const a = new Float32Array(p.positions.length / 3);
+ for (let k = 0; k < wet.length; k++) a[k] = wet[k];
+ g.setAttribute('wet', new THREE.BufferAttribute(a, 1));
+ }
+ g.setIndex(new THREE.BufferAttribute(p.indices, 1));
+ const m = new THREE.ShaderMaterial({
+ vertexShader: kind === 'globe' ? GLOBE_VERT : FLAT_VERT, fragmentShader: h ? FRAG_3D : FRAG,
+ uniforms: { ...this.view.shade, map: { value: tex }, hasMap: { value: 1 }, map2: { value: tex2 },
+ detailOrg: { value: new THREE.Vector2(...detailOrigin(t)) }, waterOn: { value: this.water ? 1 : 0 } },
+ polygonOffset: true, polygonOffsetUnits: -4 * t.z, side: h ? THREE.DoubleSide : THREE.FrontSide,
+ polygonOffsetFactor: h ? 0 : -1, // 3D: none by slope, it would pull the edge-on skirts over the ground
+ });
+ const mesh = new THREE.Mesh(g, m);
+ mesh.position.set(p.center[0], p.center[1], p.center[2]);
+ mesh.renderOrder = 1 + t.z;
+ mesh.visible = false;
+ this.group.add(mesh);
+ return mesh;
+ }
+
+ #evict() {
+ if (this.tiles.size <= this.cacheSize) return;
+ const idle = [...this.tiles.values()]
+ .filter(e => (e.state === 'ready' && !e.mesh.visible) || e.state === 'error' || e.state === 'new')
+ .sort((a, b) => a.used - b.used);
+ for (const e of idle.slice(0, this.tiles.size - this.cacheSize)) this.#drop(e.key);
+ }
+
+ #drop(key) {
+ const e = this.tiles.get(key);
+ if (e?.mesh) {
+ this.group.remove(e.mesh);
+ e.mesh.geometry.dispose();
+ e.mesh.material.uniforms.map.value?.dispose();
+ e.mesh.material.uniforms.map2.value?.dispose();
+ e.mesh.material.dispose();
+ }
+ this.tiles.delete(key);
+ }
+}
diff --git a/viewer/js/tiles.js b/viewer/js/tiles.js
new file mode 100644
index 0000000..78bac6d
--- /dev/null
+++ b/viewer/js/tiles.js
@@ -0,0 +1,162 @@
+// tiles.js — deep-zoom tile maths (pure): the scheme shared with map/tiles.py, target zoom, visible tiles, patch meshes
+import { R_KM, toVec, project } from './geo.js';
+
+export const TILE = 256;
+export const MIN_TILE_Z = 5; // below this the base texture is as sharp as the tiles
+export const MAX_TILE_Z = 16;
+export const MESH_SEGS = 128; // 3D patches: one vertex per server height (map/tiles.py MESH_N = 129)
+export const tileSpanDeg = z => 180 / 2 ** z;
+export const pxKm = z => (2 * Math.PI * R_KM) / (2 ** (z + 1) * TILE);
+export const tileKey = t => `${t.z}/${t.x}/${t.y}`;
+export const DETAIL_TILES = 16; // the 3D fine grain repeats every 16 tiles (columns per zoom are a multiple of 16)
+export const detailOrigin = t => [(t.x % DETAIL_TILES) * TILE, (t.y % DETAIL_TILES) * TILE]; // texels, small: float32-exact
+export const parentTile = t => ({ z: t.z - 1, x: t.x >> 1, y: t.y >> 1 });
+
+export function tileOf(lat, lon, z) {
+ const s = tileSpanDeg(z), nx = 2 ** (z + 1), ny = 2 ** z;
+ const x = ((Math.floor((lon + 180) / s) % nx) + nx) % nx;
+ const y = Math.min(ny - 1, Math.max(0, Math.floor((90 - lat) / s)));
+ return { z, x, y };
+}
+
+export function tileBounds({ z, x, y }) {
+ const s = tileSpanDeg(z);
+ return { west: -180 + x * s, east: -180 + (x + 1) * s, north: 90 - y * s, south: 90 - (y + 1) * s };
+}
+
+export function targetZoom(kmPerPx) {
+ if (!(kmPerPx > 0)) return 0;
+ const z = Math.ceil(Math.log2((2 * Math.PI * R_KM) / (kmPerPx * 2 * TILE)) - 1e-12);
+ return Math.max(0, Math.min(MAX_TILE_Z, z));
+}
+
+function ringTiles(t, ring) { // the tile first, then its neighbours (rows clipped at the poles, columns wrapped)
+ const nx = 2 ** (t.z + 1), ny = 2 ** t.z, out = [t];
+ for (let dy = -ring; dy <= ring; dy++) {
+ const y = t.y + dy;
+ if (y < 0 || y >= ny) continue;
+ for (let dx = -ring; dx <= ring; dx++) if (dx || dy) out.push({ z: t.z, x: (((t.x + dx) % nx) + nx) % nx, y });
+ }
+ return out;
+}
+
+export function tilesForPoints(points, z, ring = 1) {
+ const out = new Map();
+ for (const p of points) for (const u of ringTiles(tileOf(p.lat, p.lon, z), ring)) if (!out.has(tileKey(u))) out.set(tileKey(u), u);
+ return [...out.values()];
+}
+
+const children = t => [0, 1, 2, 3].map(i => ({ z: t.z + 1, x: 2 * t.x + (i & 1), y: 2 * t.y + (i >> 1) }));
+
+// A quadtree cut for per-point zooms: every point wants its tile (and ring) at its own zoom. Any tile holding a
+// wanted finer tile is split into all four children, so nothing overlaps; a split tile that was itself wanted
+// (on screen) has all its children drawn, so nothing it covered goes missing. Order: first wanted first.
+export function lodTiles(points, zs, ring = 1) {
+ const want = new Map(), split = new Set();
+ points.forEach((p, i) => {
+ if (!(zs[i] >= MIN_TILE_Z)) return;
+ for (const u of ringTiles(tileOf(p.lat, p.lon, zs[i]), ring)) {
+ const k = tileKey(u);
+ if (!want.has(k)) want.set(k, { t: u, n: want.size });
+ for (let a = parentTile(u); a.z >= MIN_TILE_Z; a = parentTile(a)) split.add(tileKey(a));
+ }
+ });
+ const out = [], roots = new Map();
+ const visit = (t, cov, n) => {
+ const k = tileKey(t), w = want.get(k), c = cov || !!w, m = w ? w.n : n;
+ if (split.has(k)) for (const ch of children(t)) visit(ch, c, m);
+ else if (c) out.push({ t, n: w ? w.n : n + 0.5 });
+ };
+ for (const { t } of want.values()) {
+ let r = t;
+ while (r.z > MIN_TILE_Z) r = parentTile(r);
+ roots.set(tileKey(r), r);
+ }
+ for (const r of roots.values()) visit(r, false, Infinity);
+ return out.sort((a, b) => a.n - b.n).map(o => o.t);
+}
+
+function grid(t, segs) { // rows north → south, columns west → east (same winding as geo.sphereMesh)
+ const b = tileBounds(t), n = segs + 1;
+ const lat = new Float64Array(n * n), lon = new Float64Array(n * n), uvs = new Float32Array(n * n * 2);
+ for (let i = 0; i < n; i++) {
+ for (let j = 0; j < n; j++) {
+ const k = i * n + j;
+ lat[k] = b.north - ((b.north - b.south) * i) / segs;
+ lon[k] = b.west + ((b.east - b.west) * j) / segs;
+ uvs[2 * k] = j / segs;
+ uvs[2 * k + 1] = 1 - i / segs;
+ }
+ }
+ const indices = new Uint32Array(segs * segs * 6);
+ let q = 0;
+ for (let i = 0; i < segs; i++) {
+ for (let j = 0; j < segs; j++) {
+ const a = i * n + j, c = a + n;
+ indices.set([a, c, c + 1, a, c + 1, a + 1], q);
+ q += 6;
+ }
+ }
+ return { b, lat, lon, uvs, indices };
+}
+
+const R_M = R_KM * 1000;
+
+function edgeRing(segs) { // grid indices around the border, each once, in order (N, E, S, W)
+ const n = segs + 1, out = [];
+ for (let j = 0; j < segs; j++) out.push(j);
+ for (let i = 0; i < segs; i++) out.push(i * n + segs);
+ for (let j = segs; j > 0; j--) out.push(segs * n + j);
+ for (let i = segs; i > 0; i--) out.push(i * n);
+ return out;
+}
+
+export function globePatch(t, segs = 16, heights = null, exag = 1) { // positions relative to the patch centre
+ const g = grid(t, segs), N = g.lat.length;
+ const center = toVec((g.b.north + g.b.south) / 2, (g.b.west + g.b.east) / 2);
+ const edge = heights ? edgeRing(segs) : [], M = N + edge.length;
+ const spacingM = (((g.b.north - g.b.south) * Math.PI) / 180 / segs) * R_M;
+ const skirt = heights ? 2 * spacingM * Math.max(1, exag) : 0; // m: hides cracks against neighbours up to 2 zooms coarser
+ const positions = new Float32Array(M * 3), uvs = new Float32Array(M * 2);
+ const skirtFlags = heights ? new Float32Array(M).fill(1, N) : undefined; // 1: skirt (the cliff shading skips it)
+ const put = (k, lat, lon, m) => {
+ const v = toVec(lat, lon), r = 1 + m / R_M;
+ for (let i = 0; i < 3; i++) positions[3 * k + i] = v[i] * r - center[i];
+ };
+ for (let k = 0; k < N; k++) put(k, g.lat[k], g.lon[k], heights ? heights[k] * exag : 0);
+ uvs.set(g.uvs);
+ edge.forEach((k, i) => {
+ put(N + i, g.lat[k], g.lon[k], heights[k] * exag - skirt);
+ uvs[2 * (N + i)] = g.uvs[2 * k];
+ uvs[2 * (N + i) + 1] = g.uvs[2 * k + 1];
+ });
+ const indices = new Uint32Array(g.indices.length + edge.length * 6);
+ indices.set(g.indices);
+ for (let i = 0, q = g.indices.length; i < edge.length; i++, q += 6) {
+ const a = edge[i], b = edge[(i + 1) % edge.length], a2 = N + i, b2 = N + ((i + 1) % edge.length);
+ indices.set([a, a2, b2, a, b2, b], q);
+ }
+ return { center, positions, uvs, indices, skirt: skirtFlags };
+}
+
+export function gridHeight(t, heights, lat, lon, segs = MESH_SEGS) { // bilinear height inside a tile's mesh
+ const b = tileBounds(t), n = segs + 1, s = b.east - b.west;
+ const fx = ((((lon - b.west) % 360) + 360) % 360 / s) * segs, fy = ((b.north - lat) / (b.north - b.south)) * segs;
+ const j = Math.min(segs - 1, Math.max(0, Math.floor(fx))), i = Math.min(segs - 1, Math.max(0, Math.floor(fy)));
+ const u = Math.min(1, Math.max(0, fx - j)), v = Math.min(1, Math.max(0, fy - i)), h = (a, c) => heights[a * n + c];
+ return (1 - v) * ((1 - u) * h(i, j) + u * h(i, j + 1)) + v * ((1 - u) * h(i + 1, j) + u * h(i + 1, j + 1));
+}
+
+export function flatPatch(t, kind, segs = 16, zOff = 0) {
+ const g = grid(t, segs), N = g.lat.length, D = Math.PI / 180;
+ const c = project(kind, (g.b.north + g.b.south) / 2, (g.b.west + g.b.east) / 2);
+ const positions = new Float32Array(N * 3), ll = new Float32Array(N * 2);
+ for (let k = 0; k < N; k++) {
+ const p = project(kind, g.lat[k], g.lon[k]);
+ positions[3 * k] = p.x - c.x;
+ positions[3 * k + 1] = p.y - c.y;
+ ll[2 * k] = g.lat[k] * D;
+ ll[2 * k + 1] = g.lon[k] * D;
+ }
+ return { center: [c.x, c.y, zOff], positions, uvs: g.uvs, indices: g.indices, ll };
+}
diff --git a/viewer/js/ui.js b/viewer/js/ui.js
new file mode 100644
index 0000000..82a0505
--- /dev/null
+++ b/viewer/js/ui.js
@@ -0,0 +1,33 @@
+// ui.js — small DOM helpers (text only via append/textContent, never innerHTML)
+export const $ = (sel, root = document) => root.querySelector(sel);
+
+const SVG_NS = 'http://www.w3.org/2000/svg';
+export const el = (tag, attrs = {}, ...kids) => fill(document.createElement(tag), attrs, kids);
+export const svg = (tag, attrs = {}, ...kids) => fill(document.createElementNS(SVG_NS, tag), attrs, kids);
+
+function fill(e, attrs, kids) {
+ for (const [k, v] of Object.entries(attrs)) {
+ if (typeof v === 'function' && k.startsWith('on')) e.addEventListener(k.slice(2), v);
+ else if (v !== undefined && v !== null && v !== false) e.setAttribute(k, v === true ? '' : String(v));
+ }
+ for (const k of kids.flat()) if (k !== undefined && k !== null && k !== false) e.append(k);
+ return e;
+}
+
+let toastTimer;
+export function toast(msg, kind = 'error') {
+ const t = $('#toast');
+ t.textContent = msg;
+ t.className = `show ${kind}`;
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => { t.className = ''; }, 5000);
+}
+
+export async function api(url, { method = 'GET', body } = {}) {
+ const r = await fetch(url, { method, headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body) });
+ if (r.status === 204) return null;
+ const data = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`);
+ return data;
+}
diff --git a/viewer/js/units.js b/viewer/js/units.js
new file mode 100644
index 0000000..4d9332f
--- /dev/null
+++ b/viewer/js/units.js
@@ -0,0 +1,22 @@
+// units.js — SI ↔ optional in-world units (spans, leagues; from /api/meta at startup). Without them: SI only.
+export const UNITS = { span_m: null, moment_s: null, league_km: null, day_hours: 24 };
+export function setUnits(u) { Object.assign(UNITS, u || {}); }
+export const kmToLeagues = km => km / UNITS.league_km;
+export const mToSpans = m => m / UNITS.span_m;
+export const km2ToSqLeagues = a => a / UNITS.league_km ** 2;
+export const walkDays = (km, kmPerDay = 30) => km / kmPerDay;
+const num = (v, d) => v.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
+const spans = m => (UNITS.span_m ? ` · ${num(mToSpans(m), 0)} spans` : '');
+const leagues = (km, d) => (UNITS.league_km ? ` · ${num(kmToLeagues(km), d)} leagues` : '');
+export const fmtDistance = km => (km < 1
+ ? `${num(km * 1000, 0)} m${spans(km * 1000)}`
+ : `${num(km, km < 100 ? 1 : 0)} km${leagues(km, km < 100 ? 2 : 1)}`);
+export const fmtElev = m => `${num(m, 0)} m${spans(m)}`;
+export const fmtArea = km2 => `${num(km2, 0)} km²${UNITS.league_km ? ` · ${num(km2ToSqLeagues(km2), 0)} sq leagues` : ''}`;
+export function fmtWalk(km) { const d = walkDays(km); return `${num(d, d < 10 ? 1 : 0)} days on foot`; }
+export const fmtLatLon = (lat, lon) =>
+ `${Math.abs(lat).toFixed(3)}° ${lat >= 0 ? 'N' : 'S'}, ${Math.abs(lon).toFixed(3)}° ${lon >= 0 ? 'E' : 'W'}`;
+export function niceKm(km) {
+ const p = 10 ** Math.floor(Math.log10(km));
+ return p * [1, 2, 5].findLast(m => m * p <= km * (1 + 1e-12));
+}
diff --git a/viewer/js/urlstate.js b/viewer/js/urlstate.js
new file mode 100644
index 0000000..113ae7f
--- /dev/null
+++ b/viewer/js/urlstate.js
@@ -0,0 +1,59 @@
+// urlstate.js — view state <-> location hash
+import { YEAR_DAYS, DAY_MOMENTS } from './sky.js';
+import { MIN_ALT, MAX_ALT, MAX_ZOOM, MAX_PITCH, MAX_EXAG } from './limits.js';
+
+export const VIEWS = ['globe', 'equal_earth', 'plate_carree'];
+export const DEFAULT = Object.freeze({ layer: 'relief', view: 'globe', lat: 20, lon: 0, alt: 2, zoom: 1,
+ overlays: ['coast', 'lakes', 'rivers', 'graticule', 'regions', 'pins'], over: '', op: 0.5, sky: false, day: Math.round(YEAR_DAYS / 4), mo: Math.round(DAY_MOMENTS / 2),
+ hd: 0, pt: 0, terrain: 1, era: '', water: true });
+const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
+
+export function parse(hash) {
+ const s = { ...DEFAULT, overlays: [...DEFAULT.overlays] };
+ const q = new URLSearchParams((hash || '').replace(/^#/, ''));
+ if (q.get('layer')) s.layer = q.get('layer');
+ if (VIEWS.includes(q.get('view'))) s.view = q.get('view');
+ for (const k of ['lat', 'lon', 'alt', 'zoom', 'op', 'day', 'mo', 'hd', 'pt']) {
+ const v = parseFloat(q.get(k));
+ if (Number.isFinite(v)) s[k] = v;
+ }
+ s.lat = clamp(s.lat, -90, 90);
+ s.lon = ((((s.lon + 180) % 360) + 360) % 360) - 180;
+ s.alt = clamp(s.alt, MIN_ALT, MAX_ALT);
+ s.zoom = clamp(s.zoom, 1, MAX_ZOOM);
+ s.op = clamp(s.op, 0, 1);
+ s.day = clamp(Math.round(s.day), 0, YEAR_DAYS - 1);
+ s.mo = clamp(Math.round(s.mo), 0, DAY_MOMENTS - 1);
+ s.hd = ((s.hd % 360) + 360) % 360;
+ s.pt = clamp(s.pt, 0, MAX_PITCH);
+ const t = parseFloat(q.get('3d'));
+ if (Number.isFinite(t)) s.terrain = t <= 0 ? 0 : clamp(t, 1, MAX_EXAG); // 0 = flat tiles
+ s.over = q.get('over') || '';
+ s.sky = q.get('sky') === '1';
+ s.era = q.get('era') || '';
+ s.water = q.get('water') !== '0';
+ if (q.has('overlays')) s.overlays = q.get('overlays').split(',').filter(Boolean);
+ return s;
+}
+
+export const drawnLayer = (s, kind) => // Water off on the 3D globe: the sea floor in place of relief
+ (kind === 'globe' && s.terrain > 0 && !s.water && s.layer === 'relief' ? 'seafloor' : s.layer);
+
+export function serialize(s) {
+ const q = { layer: s.layer, view: s.view, lat: String(+s.lat.toFixed(4)), lon: String(+s.lon.toFixed(4)),
+ alt: String(+s.alt.toPrecision(4)), zoom: String(+s.zoom.toPrecision(5)), overlays: s.overlays.join(',') };
+ if (s.over) Object.assign(q, { over: s.over, op: String(+s.op.toFixed(2)) });
+ if (s.sky) Object.assign(q, { sky: '1', day: String(s.day), mo: String(s.mo) });
+ if (s.view === 'globe' && (s.hd || s.pt)) Object.assign(q, { hd: String(+s.hd.toFixed(1)), pt: String(+s.pt.toFixed(1)) });
+ if (s.terrain !== 1) q['3d'] = String(s.terrain);
+ if (s.era) q.era = s.era;
+ if (!s.water) q.water = '0';
+ return '#' + new URLSearchParams(q).toString();
+}
+
+export function carryHash(hash) { // to another world: the view carries over; era and time of year/day do not
+ const q = new URLSearchParams((hash || '').replace(/^#/, ''));
+ for (const k of ['era', 'day', 'mo']) q.delete(k);
+ const s = q.toString();
+ return s ? '#' + s : '';
+}
diff --git a/viewer/js/vents.js b/viewer/js/vents.js
new file mode 100644
index 0000000..34eedfc
--- /dev/null
+++ b/viewer/js/vents.js
@@ -0,0 +1,25 @@
+// vents.js — hydrothermal vents as map markers (pure): clusters on a lat/lon grid sized to ≈ px screen pixels
+import { R_KM } from './geo.js';
+
+const FLOW = ['weak flow', 'steady flow', 'strong flow'];
+
+export const zoomBucket = kmPerPx => Math.floor(Math.log(kmPerPx) / Math.log(1.5));
+
+export function clusterVents(vents, kmPerPx, px = 40) {
+ const deg = Math.max(1e-6, (kmPerPx * px * 180) / (Math.PI * R_KM));
+ const cells = new Map();
+ for (const v of vents) {
+ const key = `${Math.floor(v.lat / deg)}:${Math.floor(v.lon / deg)}`;
+ const c = cells.get(key);
+ if (c) {
+ c.lat += v.lat;
+ c.lon += v.lon;
+ c.n++;
+ } else cells.set(key, { lat: v.lat, lon: v.lon, n: 1, v });
+ }
+ return [...cells.values()].map(c => (c.n === 1 ? c : { lat: c.lat / c.n, lon: c.lon / c.n, n: c.n, v: null }));
+}
+
+export function ventText(v, types, minerals) {
+ return `${types[v.type] ?? 'vent'} · ${Math.round(v.temp_c)} °C · ${minerals[v.mineral] ?? '?'} · ${FLOW[v.flow] ?? ''}`;
+}