From 3443c1c65e9f1753e1e656b35d08416c1fa298f2 Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 00:14:38 +0200 Subject: worldmap-viewer: initial public history --- viewer/index.html | 103 + viewer/js/app.js | 654 + viewer/js/atmosphere.js | 65 + viewer/js/boot.js | 10 + viewer/js/camera.js | 91 + viewer/js/exportpanel.js | 103 + viewer/js/exports.js | 40 + viewer/js/flatmap.js | 194 + viewer/js/geo.js | 100 + viewer/js/gestures.js | 42 + viewer/js/globe.js | 297 + viewer/js/globecontrols.js | 88 + viewer/js/hover.js | 16 + viewer/js/input.js | 4 + viewer/js/inspector.js | 39 + viewer/js/latest.js | 6 + viewer/js/layers.js | 84 + viewer/js/layout.js | 33 + viewer/js/limits.js | 9 + viewer/js/lines.js | 45 + viewer/js/measure.js | 99 + viewer/js/nav.js | 126 + viewer/js/overlay_geom.js | 106 + viewer/js/overlays.js | 159 + viewer/js/pins.js | 143 + viewer/js/planet.js | 10 + viewer/js/profile.js | 37 + viewer/js/public.js | 13 + viewer/js/regions.js | 58 + viewer/js/regionspanel.js | 125 + viewer/js/riverlod.js | 32 + viewer/js/search.js | 68 + viewer/js/searchbox.js | 59 + viewer/js/shading.js | 59 + viewer/js/sky.js | 41 + viewer/js/skypanel.js | 80 + viewer/js/tilelayer.js | 401 + viewer/js/tiles.js | 162 + viewer/js/ui.js | 33 + viewer/js/units.js | 22 + viewer/js/urlstate.js | 59 + viewer/js/vents.js | 25 + viewer/package.json | 4 + viewer/style.css | 165 + viewer/tests/_planet.mjs | 3 + viewer/tests/atmosphere.test.mjs | 24 + viewer/tests/camera.test.mjs | 139 + viewer/tests/exports.test.mjs | 46 + viewer/tests/fakedom.mjs | 29 + viewer/tests/fixtures/projections.json | 42 + viewer/tests/flatmap.test.mjs | 83 + viewer/tests/geo.test.mjs | 77 + viewer/tests/gestures.test.mjs | 61 + viewer/tests/globe.test.mjs | 377 + viewer/tests/hover.test.mjs | 30 + viewer/tests/importmap-hooks.mjs | 11 + viewer/tests/importmap.mjs | 15 + viewer/tests/inspector.test.mjs | 30 + viewer/tests/latest.test.mjs | 24 + viewer/tests/layers.test.mjs | 50 + viewer/tests/layout.test.mjs | 40 + viewer/tests/measure.test.mjs | 82 + viewer/tests/modules.test.mjs | 23 + viewer/tests/nav.test.mjs | 64 + viewer/tests/overlay_geom.test.mjs | 69 + viewer/tests/overlays.test.mjs | 147 + viewer/tests/pins.test.mjs | 51 + viewer/tests/profile.test.mjs | 36 + viewer/tests/public.test.mjs | 18 + viewer/tests/regions.test.mjs | 82 + viewer/tests/riverlod.test.mjs | 37 + viewer/tests/search.test.mjs | 55 + viewer/tests/searchbox.test.mjs | 65 + viewer/tests/shading.test.mjs | 57 + viewer/tests/sky.test.mjs | 38 + viewer/tests/skypanel.test.mjs | 16 + viewer/tests/tilelayer.test.mjs | 402 + viewer/tests/tiles.test.mjs | 126 + viewer/tests/units.test.mjs | 38 + viewer/tests/urlstate.test.mjs | 77 + viewer/tests/vents.test.mjs | 27 + viewer/tools/bidi.mjs | 83 + viewer/tools/smoke.sh | 45 + viewer/tools/smoke/blend.mjs | 20 + viewer/tools/smoke/core.mjs | 18 + viewer/tools/smoke/coverage.mjs | 17 + viewer/tools/smoke/deepzoom.mjs | 33 + viewer/tools/smoke/era-fallback.mjs | 7 + viewer/tools/smoke/export.mjs | 18 + viewer/tools/smoke/mobile.mjs | 48 + viewer/tools/smoke/nav.mjs | 42 + viewer/tools/smoke/pins.mjs | 21 + viewer/tools/smoke/regions.mjs | 43 + viewer/tools/smoke/search.mjs | 29 + viewer/tools/smoke/sky.mjs | 26 + viewer/tools/smoke/street.mjs | 64 + viewer/tools/smoke/terrain.mjs | 22 + viewer/tools/smoke/touch.mjs | 42 + viewer/vendor/LICENSE | 21 + viewer/vendor/VERSION | 1 + viewer/vendor/addons/controls/OrbitControls.js | 1972 + viewer/vendor/three.core.js | 60586 +++++++++++++++++++++++ viewer/vendor/three.module.js | 19719 ++++++++ 103 files changed, 89577 insertions(+) create mode 100644 viewer/index.html create mode 100644 viewer/js/app.js create mode 100644 viewer/js/atmosphere.js create mode 100644 viewer/js/boot.js create mode 100644 viewer/js/camera.js create mode 100644 viewer/js/exportpanel.js create mode 100644 viewer/js/exports.js create mode 100644 viewer/js/flatmap.js create mode 100644 viewer/js/geo.js create mode 100644 viewer/js/gestures.js create mode 100644 viewer/js/globe.js create mode 100644 viewer/js/globecontrols.js create mode 100644 viewer/js/hover.js create mode 100644 viewer/js/input.js create mode 100644 viewer/js/inspector.js create mode 100644 viewer/js/latest.js create mode 100644 viewer/js/layers.js create mode 100644 viewer/js/layout.js create mode 100644 viewer/js/limits.js create mode 100644 viewer/js/lines.js create mode 100644 viewer/js/measure.js create mode 100644 viewer/js/nav.js create mode 100644 viewer/js/overlay_geom.js create mode 100644 viewer/js/overlays.js create mode 100644 viewer/js/pins.js create mode 100644 viewer/js/planet.js create mode 100644 viewer/js/profile.js create mode 100644 viewer/js/public.js create mode 100644 viewer/js/regions.js create mode 100644 viewer/js/regionspanel.js create mode 100644 viewer/js/riverlod.js create mode 100644 viewer/js/search.js create mode 100644 viewer/js/searchbox.js create mode 100644 viewer/js/shading.js create mode 100644 viewer/js/sky.js create mode 100644 viewer/js/skypanel.js create mode 100644 viewer/js/tilelayer.js create mode 100644 viewer/js/tiles.js create mode 100644 viewer/js/ui.js create mode 100644 viewer/js/units.js create mode 100644 viewer/js/urlstate.js create mode 100644 viewer/js/vents.js create mode 100644 viewer/package.json create mode 100644 viewer/style.css create mode 100644 viewer/tests/_planet.mjs create mode 100644 viewer/tests/atmosphere.test.mjs create mode 100644 viewer/tests/camera.test.mjs create mode 100644 viewer/tests/exports.test.mjs create mode 100644 viewer/tests/fakedom.mjs create mode 100644 viewer/tests/fixtures/projections.json create mode 100644 viewer/tests/flatmap.test.mjs create mode 100644 viewer/tests/geo.test.mjs create mode 100644 viewer/tests/gestures.test.mjs create mode 100644 viewer/tests/globe.test.mjs create mode 100644 viewer/tests/hover.test.mjs create mode 100644 viewer/tests/importmap-hooks.mjs create mode 100644 viewer/tests/importmap.mjs create mode 100644 viewer/tests/inspector.test.mjs create mode 100644 viewer/tests/latest.test.mjs create mode 100644 viewer/tests/layers.test.mjs create mode 100644 viewer/tests/layout.test.mjs create mode 100644 viewer/tests/measure.test.mjs create mode 100644 viewer/tests/modules.test.mjs create mode 100644 viewer/tests/nav.test.mjs create mode 100644 viewer/tests/overlay_geom.test.mjs create mode 100644 viewer/tests/overlays.test.mjs create mode 100644 viewer/tests/pins.test.mjs create mode 100644 viewer/tests/profile.test.mjs create mode 100644 viewer/tests/public.test.mjs create mode 100644 viewer/tests/regions.test.mjs create mode 100644 viewer/tests/riverlod.test.mjs create mode 100644 viewer/tests/search.test.mjs create mode 100644 viewer/tests/searchbox.test.mjs create mode 100644 viewer/tests/shading.test.mjs create mode 100644 viewer/tests/sky.test.mjs create mode 100644 viewer/tests/skypanel.test.mjs create mode 100644 viewer/tests/tilelayer.test.mjs create mode 100644 viewer/tests/tiles.test.mjs create mode 100644 viewer/tests/units.test.mjs create mode 100644 viewer/tests/urlstate.test.mjs create mode 100644 viewer/tests/vents.test.mjs create mode 100644 viewer/tools/bidi.mjs create mode 100755 viewer/tools/smoke.sh create mode 100644 viewer/tools/smoke/blend.mjs create mode 100644 viewer/tools/smoke/core.mjs create mode 100644 viewer/tools/smoke/coverage.mjs create mode 100644 viewer/tools/smoke/deepzoom.mjs create mode 100644 viewer/tools/smoke/era-fallback.mjs create mode 100644 viewer/tools/smoke/export.mjs create mode 100644 viewer/tools/smoke/mobile.mjs create mode 100644 viewer/tools/smoke/nav.mjs create mode 100644 viewer/tools/smoke/pins.mjs create mode 100644 viewer/tools/smoke/regions.mjs create mode 100644 viewer/tools/smoke/search.mjs create mode 100644 viewer/tools/smoke/sky.mjs create mode 100644 viewer/tools/smoke/street.mjs create mode 100644 viewer/tools/smoke/terrain.mjs create mode 100644 viewer/tools/smoke/touch.mjs create mode 100644 viewer/vendor/LICENSE create mode 100644 viewer/vendor/VERSION create mode 100644 viewer/vendor/addons/controls/OrbitControls.js create mode 100644 viewer/vendor/three.core.js create mode 100644 viewer/vendor/three.module.js (limited to 'viewer') diff --git a/viewer/index.html b/viewer/index.html new file mode 100644 index 0000000..125f10d --- /dev/null +++ b/viewer/index.html @@ -0,0 +1,103 @@ + + + + + + World Maps + + + + +
+
+
+ + + + + + +
+ +
+
Overlays
+ + +
+ +
+ + – + + +
+ + + + + +
+ + + 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, 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 (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] ?? ''}`; +} diff --git a/viewer/package.json b/viewer/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/viewer/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/viewer/style.css b/viewer/style.css new file mode 100644 index 0000000..73c046f --- /dev/null +++ b/viewer/style.css @@ -0,0 +1,165 @@ +:root { --bg: #10121a; --panel: rgba(20, 23, 32, 0.92); --fg: #e6e8ee; --muted: #9aa3b5; --accent: #e0b44c; + --line: #2c3242; font: 13px/1.4 system-ui, sans-serif; color: var(--fg); } +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); } +[hidden] { display: none !important; } +#map { position: fixed; inset: 0; } +#map canvas { display: block; cursor: grab; } +body.measuring #map canvas, body.placing #map canvas { cursor: crosshair; } +.box { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; } +select, button, input, textarea { font: inherit; color: var(--fg); background: #1c2130; border: 1px solid var(--line); + border-radius: 4px; padding: 3px 6px; } +button { cursor: pointer; } +button.on { background: #3a3220; border-color: var(--accent); color: var(--accent); } +button:disabled { opacity: 0.4; cursor: default; } +#toolbar { position: fixed; top: 10px; left: 10px; max-width: calc(100vw - 370px); display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; } +.seg button { border-radius: 0; margin-right: -1px; } +.seg button:first-child { border-radius: 4px 0 0 4px; } +.seg button:last-child { border-radius: 0 4px 4px 0; } +#overlays summary { cursor: pointer; } +#overlays label { display: block; white-space: nowrap; } +#legend { position: fixed; top: 56px; left: 10px; max-height: 45vh; overflow: auto; min-width: 200px; } +#legend .sw { display: inline-block; width: 12px; height: 12px; margin-right: 6px; vertical-align: -2px; border: 1px solid #0006; } +#legend .bar { height: 10px; border-radius: 2px; margin-top: 4px; } +#legend .ticks { display: flex; justify-content: space-between; color: var(--muted); } +#status { position: fixed; left: 10px; bottom: 10px; display: flex; gap: 14px; align-items: center; color: var(--muted); } +#scale-bar { display: inline-block; height: 6px; border: 1px solid var(--fg); border-top: none; vertical-align: middle; } +#panel { position: fixed; top: 10px; right: 10px; bottom: 10px; width: 340px; display: flex; flex-direction: column; } +#panel nav { display: flex; gap: 4px; margin-bottom: 6px; } +#panel-toggle { margin-left: auto; } +#panel section { overflow: auto; flex: 1; } +#panel.collapsed { bottom: auto; } +#panel.collapsed section { display: none; } +.fields h4 { margin: 10px 0 2px; color: var(--accent); font-weight: 600; } +.fields div { display: flex; justify-content: space-between; gap: 8px; } +.fields .alt { color: var(--muted); font-size: 12px; } +.muted { color: var(--muted); } +.warn { color: #f0a050; } +.pin-item { padding: 4px 0; border-bottom: 1px solid var(--line); cursor: pointer; } +.pin-item:hover b { color: var(--accent); } +form.pin label { display: block; margin: 6px 0; } +form.pin input, form.pin textarea { display: block; width: 100%; } +form.pin select { display: block; width: 100%; } +form.pin .coords { display: flex; gap: 8px; } +form.pin .coords label { flex: 1; } +#markers { position: fixed; inset: 0; pointer-events: none; overflow: hidden; } +.marker { position: absolute; left: 0; top: 0; } +.marker.clickable { pointer-events: auto; cursor: pointer; } +.marker.draft .dot { background: #fff; } +.marker .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); border: 1px solid #000; } +.marker.measure .dot { background: #7fd4ff; } +.marker.vent { pointer-events: auto; } +.marker.vent .dot { width: 7px; height: 7px; } +.marker.vent.t0 .dot { background: #e6281e; } +.marker.vent.t1 .dot { background: #f5f5f5; } +.marker.vent.t2 .dot { background: #faa028; } +.marker.vent.t3 .dot { background: #28dce6; } +.marker.vent.cluster .dot { width: 12px; height: 12px; background: #e6281e; opacity: 0.8; } +.marker .label { position: absolute; left: 11px; top: -5px; white-space: nowrap; text-shadow: 0 0 3px #000, 0 0 3px #000; } +#toast { position: fixed; left: 50%; bottom: 40px; transform: translateX(-50%); background: #5a2020; padding: 6px 12px; + border-radius: 4px; display: none; max-width: 80vw; } +#toast.show { display: block; } +#toast.info { background: #2c3242; } +#nav { position: fixed; right: 362px; bottom: 10px; display: flex; flex-direction: column; gap: 4px; padding: 4px; } +#nav button { width: 30px; height: 30px; padding: 0; font-size: 15px; line-height: 1; } +#map canvas { touch-action: none; } /* drags belong to the map, not to page scrolling */ +#nav-north span { display: inline-block; font-weight: 700; transition: transform 0.1s; } +#nav-north.on span { color: var(--accent); } +#nav-3d { font-size: 11px !important; font-weight: 700; } +#help { position: fixed; right: 404px; bottom: 50px; max-width: 420px; padding: 10px 14px; z-index: 20; cursor: pointer; } +#help dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin: 8px 0; } +#help dt { color: var(--accent); white-space: nowrap; } +#help dd { margin: 0; } +@media (max-width: 700px) { #help { right: 8px; left: 8px; max-width: none; } } +#nav-3d.on { color: var(--accent); } +#exag-box { position: fixed; right: 404px; bottom: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; } +#exag { width: 110px; } +#opacity { width: 90px; height: 30px; } +#searchbox { position: relative; } +#search { width: 250px; height: 30px; } +#suggest { position: absolute; top: 34px; left: 0; min-width: 250px; max-height: 50vh; overflow: auto; padding: 2px; } +#suggest .item { padding: 4px 6px; cursor: pointer; border-radius: 3px; white-space: nowrap; } +#suggest .item.sel, #suggest .item:hover { background: #3a3220; } +#suggest .sub { color: var(--muted); font-size: 12px; margin-left: 6px; } +.marker.search .dot { background: #ff6a4d; } +.marker.probe .dot { background: #fff; width: 6px; height: 6px; } +.profile { display: block; margin-top: 6px; background: #151924; border-radius: 4px; } +.profile .area { fill: rgba(224, 180, 76, 0.18); } +.profile .line { fill: none; stroke: var(--accent); stroke-width: 1.5; } +.profile .sea { stroke: #5ab4ff; stroke-dasharray: 3 3; } +.profile .water { fill: rgba(90, 180, 255, 0.18); } +.profile .cursor { stroke: #fff; } +.profile text { fill: var(--muted); font-size: 10px; } +.readout { min-height: 1.4em; } +#sky-toggle { height: 30px; } +#skybar { position: fixed; left: 10px; bottom: 52px; max-width: calc(100vw - 420px); display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; white-space: nowrap; } +#skybar input[type=range] { width: 130px; vertical-align: middle; } +.marker.sun .dot { background: #ffd166; width: 12px; height: 12px; box-shadow: 0 0 8px #ffd166; } +.region label { display: block; margin: 6px 0; } +.region input { width: 100%; } + +/* Phones (layout.js sets body.compact + .portrait / .landscape): the toolbar folds into ☰, the panel becomes a bottom + sheet (portrait) or a narrow side panel (landscape), map buttons get finger-sized. */ +#menu-toggle { display: none; } +body.compact #menu-toggle { display: block; width: 40px; height: 40px; padding: 0; font-size: 18px; } +body.compact #toolbar { top: 8px; left: 8px; right: 8px; max-width: none; gap: 6px; } +body.compact #toolbar > * { display: none; } +body.compact #toolbar > #menu-toggle, body.compact #toolbar > #searchbox { display: block; } +body.compact #searchbox { flex: 1; min-width: 0; } +body.compact #search { width: 100%; height: 40px; font-size: 16px; } /* 16 px: phones don't zoom into the field */ +body.compact #suggest { min-width: 100%; } +body.compact.menu-open #toolbar { max-height: calc(100vh - 16px); overflow: auto; background: var(--panel); + border: 1px solid var(--line); border-radius: 8px; padding: 6px; } +body.compact.menu-open #toolbar > :not([hidden]) { display: block; flex: 1 1 100%; } +body.compact.menu-open #toolbar > #menu-toggle { flex: 0 0 40px; } +body.compact.menu-open #toolbar > #searchbox { flex: 1 1 0; order: 0; } +body.compact.menu-open #toolbar > :not(#menu-toggle):not(#searchbox) { order: 1; } +body.compact.menu-open #toolbar > #views { display: flex; } +body.compact #views button { flex: 1; min-height: 40px; } +body.compact select { min-height: 40px; width: 100%; font-size: 15px; } +body.compact #overlays summary { min-height: 32px; line-height: 32px; } +body.compact #overlays label { padding: 6px 0; } +body.compact #sky-toggle { width: 100%; height: 40px; } +body.compact #opacity { width: 100%; } +body.compact #legend { top: 56px; left: 8px; max-width: calc(100vw - 72px); min-width: 0; max-height: 28vh; font-size: 11px; } +body.compact #status { gap: 8px; padding: 3px 6px; font-size: 12px; } +body.compact #cursor, body.compact #hover { display: none; } +body.compact #nav { right: 8px; gap: 4px; } +body.compact #nav button { width: 40px; height: 40px; font-size: 17px; } +body.compact #nav-in, body.compact #nav-out, body.compact #nav-full, body.compact #nav-export, +body.compact #nav-help { display: none; } /* zoom: pinch; the rest are desktop tools */ +body.compact #help { display: none !important; } +body.compact #panel nav button { min-height: 36px; } +body.compact #exag-box { right: 56px; } + +/* portrait: the panel is a bottom sheet, folded to its tab row until opened */ +body.portrait #panel { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 45vh; border-radius: 12px 12px 0 0; + padding-bottom: max(6px, env(safe-area-inset-bottom)); } +body.portrait #panel.collapsed { height: auto; } +body.portrait #nav, body.portrait #exag-box { bottom: 60px; } +body.portrait #status { bottom: 60px; left: 8px; } +body.portrait #skybar { left: 8px; right: 8px; bottom: 100px; max-width: none; } +body.portrait:has(#panel:not(.collapsed)) #nav, body.portrait:has(#panel:not(.collapsed)) #exag-box, +body.portrait:has(#panel:not(.collapsed)) #status { bottom: calc(45vh + 8px); } +body.portrait:has(#panel:not(.collapsed)) #skybar { bottom: calc(45vh + 48px); } + +/* landscape phone: a narrow side panel; map buttons left of it */ +body.landscape #toolbar { right: calc(min(300px, 42vw) + 16px); } +body.landscape #panel { width: min(300px, 42vw); top: 8px; right: 8px; bottom: 8px; } +body.landscape #nav, body.landscape #exag-box { right: calc(min(300px, 42vw) + 16px); bottom: 8px; } +body.landscape #exag-box { right: calc(min(300px, 42vw) + 64px); } +body.landscape #status { bottom: 8px; left: 8px; } +body.landscape #skybar { left: 8px; bottom: 44px; max-width: calc(100vw - min(300px, 42vw) - 80px); } +body.landscape #legend { max-height: 40vh; } +body.landscape #panel.collapsed { bottom: auto; } +body.compact.menu-open #toolbar { z-index: 30; } +body.compact #views button { font-size: 13px; white-space: nowrap; padding: 3px 2px; } +body.compact #panel section { scrollbar-width: thin; scrollbar-color: #3a4256 transparent; padding-right: 2px; } +body.compact #exag { width: 90px; } +body.portrait #exag-box { right: auto; left: 8px; bottom: 96px; } +body.portrait:has(#panel:not(.collapsed)) #exag-box { bottom: calc(45vh + 44px); } +body.landscape #exag-box { right: auto; left: 8px; bottom: 44px; } +body.landscape #skybar { bottom: 80px; } +body.compact #sky-toggle::after { content: " Sky & seasons"; } +body.compact #status { max-width: calc(100vw - 72px); flex-wrap: wrap; row-gap: 0; } +body.compact #detail-badge { white-space: nowrap; } diff --git a/viewer/tests/_planet.mjs b/viewer/tests/_planet.mjs new file mode 100644 index 0000000..a1f9822 --- /dev/null +++ b/viewer/tests/_planet.mjs @@ -0,0 +1,3 @@ +// _planet.mjs — the test world (import first): 12,742 km, 20° tilt, 216-day year, 31.149 h days of 46,656 moments +import { PLANET } from '../js/planet.js'; +Object.assign(PLANET, { radius_km: 12742, tilt_deg: 20, year_days: 216, day_hours: 31.149, moment_s: 2.403473 }); diff --git a/viewer/tests/atmosphere.test.mjs b/viewer/tests/atmosphere.test.mjs new file mode 100644 index 0000000..79f183a --- /dev/null +++ b/viewer/tests/atmosphere.test.mjs @@ -0,0 +1,24 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import './importmap.mjs'; + +const { atmosphere, SKY_FULL_KM, SKY_NONE_KM } = await import('../js/atmosphere.js'); +const lum = c => c[0] + c[1] + c[2]; + +test('full sky near the ground, none in space, a smooth fade between', () => { + assert.equal(atmosphere(5).amount, 1); + assert.equal(atmosphere(SKY_FULL_KM).amount, 1); + assert.equal(atmosphere(SKY_NONE_KM).amount, 0); + assert.equal(atmosphere(5000).amount, 0); + const m = atmosphere(60).amount; + assert.ok(m > 0.2 && m < 0.8); +}); + +test('day sky is blue, lighter at the horizon; night is dark; Sky off = day', () => { + const d = atmosphere(1, null); + assert.ok(d.zenith[2] > d.zenith[0] && lum(d.horizon) > lum(d.zenith)); + const n = atmosphere(1, -1); + assert.ok(lum(n.horizon) < 0.3 * lum(d.horizon)); + assert.deepEqual(atmosphere(1, 1), d); +}); diff --git a/viewer/tests/camera.test.mjs b/viewer/tests/camera.test.mjs new file mode 100644 index 0000000..f2ed115 --- /dev/null +++ b/viewer/tests/camera.test.mjs @@ -0,0 +1,139 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toVec, R_KM } from '../js/geo.js'; +import { MIN_ALT, MAX_ALT, CLEARANCE_M } from '../js/limits.js'; +import { R_M, TILT_FULL, TILT_NONE, frame, maxPitch, clampPose, eyeOf, eyeAltitude, rangeForAltitude, headingOf, + rotatePose, panPose, lift, kmPerPxAt } from '../js/camera.js'; + +const near = (a, b, eps, m = '') => assert.ok(Math.abs(a - b) <= eps, `${m} ${a} vs ${b}`); +const nearV = (a, b, eps) => a.forEach((v, i) => near(v, b[i], eps, `[${i}]`)); +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const sub = (a, b) => a.map((v, i) => v - b[i]); +const len = a => Math.hypot(...a); +const pose = o => clampPose({ lat: 40, lon: -10, range: 0.01, heading: 0, pitch: 0, ...o }); + +test('frame: east, north, up are orthonormal and right-handed (east × north = up)', () => { + for (const [la, lo] of [[0, 0], [40, -10], [-65, 170], [90, 30], [-90, -120]]) { + const { east: e, north: n, up: u } = frame(la, lo); + for (const v of [e, n, u]) near(len(v), 1, 1e-12); + near(dot(e, n), 0, 1e-12); near(dot(e, u), 0, 1e-12); near(dot(n, u), 0, 1e-12); + nearV([e[1] * n[2] - e[2] * n[1], e[2] * n[0] - e[0] * n[2], e[0] * n[1] - e[1] * n[0]], u, 1e-12); + } + nearV(frame(0, 0).north, [0, 1, 0], 1e-12); +}); + +test('straight down: the eye is above the target, screen-up is the heading', () => { + const p = pose({ heading: 0 }), { eye, up, target } = eyeOf(p); + nearV(target, toVec(40, -10), 1e-12); + nearV(eye, toVec(40, -10).map(v => v * 1.01), 1e-12); + nearV(up, frame(40, -10).north, 1e-12); + nearV(eyeOf(pose({ heading: 90 })).up, frame(40, -10).east, 1e-12); +}); + +test('tilted: range is the eye–target distance, pitch the angle from vertical, eye behind the heading', () => { + const p = pose({ heading: 30, pitch: 60 }), { eye, up, target } = eyeOf(p), back = sub(eye, target); + near(len(back), 0.01, 1e-12); + near(Math.acos(dot(back, target) / len(back)) * 180 / Math.PI, 60, 1e-9); + near(headingOf(40, -10, sub(target, eye)), 30, 1e-9); + near(dot(up, sub(target, eye)), 0, 1e-12); + near(len(up), 1, 1e-12); +}); + +test('clampPose: range limits, tilt only when low (ramp 1,000 → 2,000 km), heading wraps, junk → defaults', () => { + assert.equal(clampPose({ range: 99 }).range, MAX_ALT); + assert.equal(clampPose({ range: 0 }).range, MIN_ALT); + near(TILT_FULL * R_KM, 1000, 1e-9); near(TILT_NONE * R_KM, 2000, 1e-9); + assert.equal(pose({ range: 500 / R_KM, pitch: 70 }).pitch, 70); + assert.equal(pose({ range: 3000 / R_KM, pitch: 70 }).pitch, 0); + near(maxPitch(1500 / R_KM), 40, 1e-9); + assert.equal(pose({ range: 500 / R_KM, pitch: 95 }).pitch, 80); + assert.equal(pose({ heading: -30 }).heading, 330); + assert.equal(pose({ heading: 720 }).heading, 0); + const j = clampPose({ lat: NaN, lon: 'x', range: undefined, heading: null, pitch: Infinity }); + assert.deepEqual([j.lat, j.lon, j.range, j.heading], [0, 0, 2, 0]); + assert.equal(pose({ lon: 190 }).lon, -170); +}); + +test('eye altitude ↔ range round trip', () => { + for (const pt of [0, 30, 80]) for (const a of [1e-6, 3e-4, 0.05]) near(eyeAltitude(rangeForAltitude(a, pt), pt), a, 1e-15); + near(eyeAltitude(0.01, 0), 0.01, 1e-15); +}); + +test('rotatePose moves the target exactly; a meridian move keeps the heading; poles and the antimeridian stay finite', () => { + const p = pose({ heading: 45, pitch: 30 }), q = rotatePose(p, toVec(40, -10), toVec(41, -10)); + near(q.lat, 41, 1e-9); near(q.lon, -10, 1e-9); near(q.heading, 45, 1e-9); + assert.equal(q.range, p.range); assert.equal(q.pitch, p.pitch); + const r = rotatePose(pose({ lon: 179.5 }), toVec(40, 179.5), toVec(40, -179.5)); + near(r.lon, -179.5, 1e-9); + const s = rotatePose(pose({ lat: 89.9, heading: 10 }), toVec(89.9, -10), toVec(90, 0)); + assert.ok([s.lat, s.lon, s.heading].every(Number.isFinite)); + assert.deepEqual(rotatePose(p, toVec(1, 2), toVec(1, 2)), p); +}); + +test('rotatePose is a rigid turn: the eye keeps its distance to the moved point (the grab invariant)', () => { + const p = pose({ heading: 200, pitch: 50 }), a = toVec(40.2, -9.9), b = toVec(40.05, -10.3); + const e1 = eyeOf(p).eye, e2 = eyeOf(rotatePose(p, a, b)).eye; + near(len(sub(e2, b)), len(sub(e1, a)), 1e-12); +}); + +test('panPose: screen-up moves forward along the heading, right moves right, by a fraction of the range', () => { + const up = panPose(pose({ heading: 0 }), 0, -0.2); + near(up.lat - 40, (0.2 * 0.01 * 180) / Math.PI, 1e-9); near(up.lon, -10, 1e-9); + const right = panPose(pose({ lat: 0, lon: 0, heading: 0 }), 0.2, 0); + near(right.lat, 0, 1e-9); near(right.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9); + const east = panPose(pose({ lat: 0, lon: 0, heading: 90 }), 0, -0.2); + near(east.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9); + assert.ok(panPose(pose({ range: 5 }), 0, -1).lat - 40 <= 0.5 * 180 / Math.PI + 1e-9, 'big steps are capped'); +}); + +test('lift: never below the ground + 20 m (sea level when the ground is lower or unknown)', () => { + const low = pose({ range: 1e-9, pitch: 0 }), g = 1500; + near(eyeAltitude(lift(low, g).range, 0) * R_M, g + CLEARANCE_M, 1e-6); + near(eyeAltitude(lift(low, -300).range, 0) * R_M, CLEARANCE_M, 1e-6); + const tilted = pose({ range: 2e-6, pitch: 80 }); + near(eyeAltitude(lift(tilted, 0).range, 80) * R_M, CLEARANCE_M, 1e-6); + const high = pose({ range: 0.01 }); + assert.equal(lift(high, 1500), high); +}); + +test('kmPerPxAt: distance × the pixel angle', () => { + const { eye } = eyeOf(pose()), fov = 35, h = 800; + near(kmPerPxAt(eye, { lat: 40, lon: -10 }, fov, h), (0.01 * R_KM * 2 * Math.tan((fov * Math.PI) / 360)) / h, 1e-9); +}); + +test('panning keeps the compass heading (north stays where it was)', () => { + const p = pose({ lat: 20, lon: 0, range: 2, heading: 0 }); + assert.equal(panPose(p, 0.2, 0).heading, 0); + assert.equal(panPose(pose({ heading: 30, pitch: 40 }), 0, -0.2).heading, 30); +}); + +test('the target sits on the ground: range, altitude and the floor measure from it', () => { + const g = 3000, p = pose({ range: 20 / R_M, ground: g }); + const { eye, target } = eyeOf(p); + near(len(target), 1 + g / R_M, 1e-15); + near(len(sub(eye, target)), 20 / R_M, 1e-15); + near(eyeAltitude(p.range, 0, g) * R_M, g + 20, 1e-6); + for (const pt of [0, 45, 80]) near(eyeAltitude(rangeForAltitude(0.001, pt, g), pt, g), 0.001, 1e-15); + near(eyeAltitude(lift(pose({ range: 1e-9, ground: g }), g).range, 0, g) * R_M, g + CLEARANCE_M, 1e-6); + assert.equal(clampPose({ ground: -5 }).ground, 0); + assert.equal(clampPose({}).ground, 0); +}); + +test('holding the forward key across a pole keeps going (no bouncing back and forth)', async () => { + const { angleRad } = await import('../js/geo.js'); + let p = pose({ lat: 89.5, lon: 0, range: 0.01, heading: 0 }), last = 0; + const start = { lat: 89.5, lon: 0 }; + for (let i = 0; i < 12; i++) { + p = panPose(p, 0, -0.2); + const d = angleRad(start, p); + assert.ok(d > last, `press ${i}: ${d} after ${last} (lat ${p.lat}, lon ${p.lon})`); + last = d; + } +}); + +test('lift: a floor below sea level only when asked', () => { + const p = { lat: 0, lon: 0, range: MIN_ALT, heading: 0, pitch: 0, ground: -3000 }; + near(eyeAltitude(lift(p, -3000).range, 0, -3000) * R_M, CLEARANCE_M, 1, 'default: 20 m above sea level'); + near(eyeAltitude(lift(p, -3000, true).range, 0, -3000) * R_M, -3000 + CLEARANCE_M, 1, 'below: 20 m above the floor'); +}); diff --git a/viewer/tests/exports.test.mjs b/viewer/tests/exports.test.mjs new file mode 100644 index 0000000..c3735fb --- /dev/null +++ b/viewer/tests/exports.test.mjs @@ -0,0 +1,46 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { samplesFor, squareOutline, exportSizeMB, exportStatusText } from '../js/exports.js'; + +test('samples: the smallest 2^n + 1 covering the size (as the server)', () => { + assert.equal(samplesFor(40, 10), 4097); + assert.equal(samplesFor(100, 2000), 65); + assert.equal(samplesFor(5, 20), 257); +}); + +test('the square outline: corners size/√2 from the centre, closed', () => { + const R = 12742, pts = squareOutline(0, 0, 100); + assert.deepEqual(pts[0], pts[pts.length - 1]); + const d = p => R * Math.acos(Math.cos(p.lat * Math.PI / 180) * Math.cos(p.lon * Math.PI / 180)); + assert.ok(Math.abs(d(pts[0]) - 50 * Math.SQRT2) < 0.05); + const north = pts.reduce((a, b) => (b.lat > a.lat ? b : a)); + assert.ok(Math.abs(north.lat * Math.PI / 180 * R - 50) < 0.1, 'the north edge 50 km up'); +}); + +test('size estimate and status', () => { + assert.ok(exportSizeMB(4097) > 100 && exportSizeMB(4097) < 200, "the island export was 128 MB"); + assert.equal(exportStatusText({ state: 'running', name: 'x', fraction: 0.5 }), 'Exporting x… 50%'); + assert.equal(exportStatusText({ state: 'failed', name: 'x', error: 'boom' }), 'Export x failed: boom'); + assert.equal(exportStatusText({ state: 'idle' }), ''); +}); + +test('polling while an export runs keeps what is being typed', async () => { + const { installFakeDom, FakeEl } = await import('./fakedom.mjs'); + installFakeDom(); + let calls = 0; + globalThis.fetch = async () => ({ ok: true, status: 200, headers: { get: () => 'application/json' }, + json: async () => ({ exports: [], status: { state: 'running', name: 'x', fraction: 0.1 * ++calls } }) }); + const { ExportPanel } = await import('../js/exportpanel.js'); + const box = new FakeEl('section'); + const p = new ExportPanel(box, { getCenter: () => ({ lat: 1, lon: 2 }), onSquare: () => {} }); + p.visible = true; + await p.load(); + const input = box.find(e => e.tag === 'input'); + input.value = 'my-area'; + await p.load(); // a poll + clearTimeout(p.timer); + assert.equal(box.find(e => e.tag === 'input'), input, 'the same input, not rebuilt'); + assert.equal(input.value, 'my-area'); + assert.match(box.textContent, /Exporting x… 20%/); +}); diff --git a/viewer/tests/fakedom.mjs b/viewer/tests/fakedom.mjs new file mode 100644 index 0000000..5b2fadd --- /dev/null +++ b/viewer/tests/fakedom.mjs @@ -0,0 +1,29 @@ +// Minimal DOM stand-in for node tests of the panels: just what ui.el() and the panels touch. +export class FakeEl { + constructor(tag) { Object.assign(this, { tag, kids: [], attrs: {}, on: {}, style: {}, value: '', hidden: false }); } + setAttribute(k, v) { this.attrs[k] = v; } + get className() { return this.attrs.class ?? ''; } + addEventListener(t, f) { (this.on[t] ??= []).push(f); } + fire(t, e = {}) { for (const f of this.on[t] ?? []) f({ preventDefault() {}, stopPropagation() {}, target: this, ...e }); } + append(...k) { this.kids.push(...k); } + replaceChildren(...k) { this.kids = k; } + focus() {} + blur() {} + getBoundingClientRect() { return { left: 0, top: 0, width: 320, height: 130 }; } + get textContent() { return this.kids.map(k => (typeof k === 'string' ? k : k.textContent)).join(''); } + set textContent(v) { this.kids = [String(v)]; } + find(pred) { + for (const k of this.kids) { + if (typeof k === 'string') continue; + if (pred(k)) return k; + const r = k.find(pred); + if (r) return r; + } + return null; + } +} + +export function installFakeDom() { + globalThis.document = { createElement: t => new FakeEl(t), createElementNS: (_, t) => new FakeEl(t), + querySelector: () => null, querySelectorAll: () => [] }; +} diff --git a/viewer/tests/fixtures/projections.json b/viewer/tests/fixtures/projections.json new file mode 100644 index 0000000..cb29f74 --- /dev/null +++ b/viewer/tests/fixtures/projections.json @@ -0,0 +1,42 @@ +{ + "ee_xmax": 2.7066299836960743, + "ee_ymax": 1.317362759157413, + "equal_earth": [ + { + "lat": 0.0, + "lon": 0.0, + "x": 0.0, + "y": 0.0 + }, + { + "lat": 30.0, + "lon": 120.0, + "x": 1.6878011172181764, + "y": 0.592935119848017 + }, + { + "lat": -60.0, + "lon": -170.0, + "x": -1.9257789715953615, + "y": -1.0883008355053196 + }, + { + "lat": 89.0, + "lon": 45.0, + "x": 0.40107231234847257, + "y": 1.3170644437079506 + }, + { + "lat": 10.0, + "lon": -179.0, + "x": -2.672037641978515, + "y": 0.20204243960029805 + }, + { + "lat": -45.5, + "lon": 33.3, + "x": 0.42759882515649705, + "y": -0.8685664740830532 + } + ] +} \ No newline at end of file diff --git a/viewer/tests/flatmap.test.mjs b/viewer/tests/flatmap.test.mjs new file mode 100644 index 0000000..2cafb36 --- /dev/null +++ b/viewer/tests/flatmap.test.mjs @@ -0,0 +1,83 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fakeRenderer } from './importmap.mjs'; + +const { FlatView, MAX_ZOOM } = await import('../js/flatmap.js'); +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); +const ev = (type, props) => Object.assign(new Event(type, { cancelable: true }), { button: 0, pointerId: 1, deltaMode: 0, ...props }); + +function flat(kind, st = { lat: 20, lon: 30, zoom: 3 }) { + const r = fakeRenderer(); + const v = new FlatView(r, kind, () => {}); + v.resize(800, 400); + v.setState(st); + v.activate(); + return { v, canvas: r.domElement }; +} + +for (const kind of ['equal_earth', 'plate_carree']) { + test(`${kind}: state round trip, zoom clamped`, () => { + const s = flat(kind).v.getState(); + near(s.lat, 20, 1e-9); near(s.lon, 30, 1e-9); + assert.equal(s.zoom, 3); + assert.equal(flat(kind, { lat: 0, lon: 0, zoom: 1e9 }).v.getState().zoom, MAX_ZOOM); + assert.equal(MAX_ZOOM, 20000); + assert.equal(flat(kind, { lat: 0, lon: 0, zoom: 0 }).v.getState().zoom, 1); + }); + + test(`${kind}: pick and toScreen agree, incl. near the seam and poles`, () => { + const { v } = flat(kind, { lat: 0, lon: 0, zoom: 1 }); + for (const [lat, lon] of [[0, 0], [45, 179.9], [-89, -120], [60, -179.9]]) { + const q = v.toScreen(lat, lon), p = v.pick(q.x, q.y); + near(p.lat, lat, 1e-6); near(p.lon, lon, 1e-6); + } + }); + + test(`${kind}: wheel zoom keeps the point under the cursor`, () => { + const { v, canvas } = flat(kind, { lat: 10, lon: 10, zoom: 2 }); + const before = v.pick(600, 120); + canvas.dispatchEvent(ev('wheel', { deltaY: -300, clientX: 600, clientY: 120 })); + assert.ok(v.getState().zoom > 2); + const after = v.pick(600, 120); + near(after.lat, before.lat, 1e-6); near(after.lon, before.lon, 1e-6); + }); + + test(`${kind}: drag pans with the cursor; an inactive view ignores input`, () => { + const { v, canvas } = flat(kind, { lat: 0, lon: 0, zoom: 4 }); + const p = v.pick(400, 200); + canvas.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 200 })); + canvas.dispatchEvent(ev('pointermove', { clientX: 300, clientY: 200 })); + canvas.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 200 })); + const q = v.pick(300, 200); + near(q.lat, p.lat, 1e-6); near(q.lon, p.lon, 1e-6); + v.deactivate(); + const s = v.getState(); + canvas.dispatchEvent(ev('wheel', { deltaY: -300, clientX: 10, clientY: 10 })); + assert.deepEqual(v.getState(), s); + }); +} + +test('equal earth: outside the outline picks null', () => { + assert.equal(flat('equal_earth', { lat: 0, lon: 0, zoom: 1 }).v.pick(2, 2), null); +}); + +test('long lines are drawn along the great circle (bowing poleward), not straight in the projection', () => { + const { v } = flat('plate_carree', { lat: 0, lon: 0, zoom: 1 }); + v.setLines('m', [[{ lat: 40, lon: -60 }, { lat: 40, lon: 60 }]], 0xffffff); + const pos = v.lines.objects.get('m').geometry.attributes.position.array; + assert.ok(pos.length / 6 > 10, `${pos.length / 6} segments`); + const maxLat = Math.max(...pos.filter((_, i) => i % 3 === 1)) * 180 / Math.PI; + assert.ok(maxLat > 50, `peak latitude ${maxLat}`); +}); + +test('equal earth: the centre cannot leave the outline, so the saved state reproduces the view', () => { + const { v, canvas } = flat('equal_earth', { lat: 60, lon: 170, zoom: 8 }); + canvas.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 200 })); + canvas.dispatchEvent(ev('pointermove', { clientX: 100, clientY: 200 })); // pan east past lon 180 + canvas.dispatchEvent(ev('pointerup', { clientX: 100, clientY: 200 })); + const s = v.getState(), before = v.toWorld(400, 200); + v.setState(s); + const after = v.toWorld(400, 200); + near(after.X, before.X, 1e-9); near(after.Y, before.Y, 1e-9); +}); diff --git a/viewer/tests/geo.test.mjs b/viewer/tests/geo.test.mjs new file mode 100644 index 0000000..4ebb2ea --- /dev/null +++ b/viewer/tests/geo.test.mjs @@ -0,0 +1,77 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import * as G from '../js/geo.js'; + +const near = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +test('lat/lon <-> vector round trip incl. poles and seam', () => { + for (const [lat, lon] of [[0, 0], [45, 120], [-60, -170], [89.999, 10], [-89.999, -100], [10, 179.999], [10, -179.999]]) { + const p = G.toLatLon(G.toVec(lat, lon)); + near(p.lat, lat, 1e-9); + near(p.lon, lon, 1e-7); + } + const pole = G.toLatLon(G.toVec(90, 0)); + near(pole.lat, 90, 1e-9); + assert.ok(Number.isFinite(pole.lon)); +}); + +test('distances on the 12,742 km sphere', () => { + near(G.distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 90 }), (Math.PI / 2) * 12742, 1e-6); + near(G.distanceKm({ lat: 90, lon: 0 }, { lat: -90, lon: 0 }), Math.PI * 12742, 1e-6); + near(G.distanceKm({ lat: 10, lon: 179 }, { lat: 10, lon: -179 }), G.distanceKm({ lat: 10, lon: 0 }, { lat: 10, lon: 2 }), 1e-6); +}); + +test('interpolate follows the great circle', () => { + const pts = G.interpolate({ lat: 0, lon: 0 }, { lat: 0, lon: 90 }, 10); + assert.equal(pts.length, 10); + near(pts[5].lon, 50, 1e-9); + near(pts[5].lat, 0, 1e-9); +}); + +test('spherical polygon area', () => { + near(G.polygonAreaKm2([{ lat: 0, lon: 0 }, { lat: 0, lon: 90 }, { lat: 90, lon: 0 }]), (4 * Math.PI * 12742 ** 2) / 8, 1); + const cell = ((12742 * Math.PI) / 180) ** 2; + near(G.polygonAreaKm2([{ lat: 0, lon: 0 }, { lat: 0, lon: 1 }, { lat: 1, lon: 1 }, { lat: 1, lon: 0 }]), cell, cell * 0.01); +}); + +test('equal earth matches mapgen/projections.py', () => { + const fx = JSON.parse(readFileSync(new URL('./fixtures/projections.json', import.meta.url))); + near(G.EE_XMAX, fx.ee_xmax); + near(G.EE_YMAX, fx.ee_ymax); + for (const p of fx.equal_earth) { + const q = G.project('equal_earth', p.lat, p.lon); + near(q.x, p.x); + near(q.y, p.y); + const r = G.unproject('equal_earth', p.x, p.y); + near(r.lat, p.lat, 1e-6); + near(r.lon, p.lon, 1e-6); + } + assert.equal(G.unproject('equal_earth', G.EE_XMAX, G.EE_YMAX), null); + near(G.unproject('equal_earth', 0, G.EE_YMAX).lat, 90, 1e-6); // pole survives rounding +}); + +test('plate carree projection', () => { + const q = G.project('plate_carree', 45, -90); + near(q.x, -Math.PI / 2); + near(q.y, Math.PI / 4); + assert.equal(G.unproject('plate_carree', 4, 0), null); +}); + +test('sphere mesh: uv follows lat/lon and faces point outward', () => { + const m = G.sphereMesh(8, 16); + for (let i = 0; i < m.uvs.length / 2; i++) { + const ll = G.toLatLon([m.positions[3 * i], m.positions[3 * i + 1], m.positions[3 * i + 2]]); + const u = m.uvs[2 * i], v = m.uvs[2 * i + 1]; + near((ll.lat + 90) / 180, v, 1e-6); + if (Math.abs(ll.lat) < 89.9 && u > 1e-6 && u < 1 - 1e-6) near((ll.lon + 180) / 360, u, 1e-6); + } + assert.equal(m.indices.length, 8 * 16 * 6); + const q = 3 * 16 * 6 + 5 * 6; // a quad just north of the equator + const P = k => [m.positions[3 * k], m.positions[3 * k + 1], m.positions[3 * k + 2]]; + const [a, b, c] = [P(m.indices[q]), P(m.indices[q + 1]), P(m.indices[q + 2])]; + const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; + assert.ok(n[0] * a[0] + n[1] * a[1] + n[2] * a[2] > 0, 'triangles wind counter-clockwise seen from outside'); +}); diff --git a/viewer/tests/gestures.test.mjs b/viewer/tests/gestures.test.mjs new file mode 100644 index 0000000..3236f44 --- /dev/null +++ b/viewer/tests/gestures.test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { twoFinger, Touches } = await import('../js/gestures.js'); +const close = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) < eps, `${a} ≉ ${b}`); + +test('spreading the fingers to twice the distance is scale 2, no turn, no tilt', () => { + const g = twoFinger({ x: 90, y: 100 }, { x: 110, y: 100 }, { x: 80, y: 100 }, { x: 120, y: 100 }); + close(g.scale, 2); close(g.rot, 0); close(g.tilt, 0); + assert.deepEqual(g.mid, { x: 100, y: 100 }); assert.deepEqual(g.move, { x: 0, y: 0 }); +}); + +test('turning the pair a quarter clockwise on screen (y down) is +90°', () => { + const g = twoFinger({ x: -10, y: 0 }, { x: 10, y: 0 }, { x: 0, y: -10 }, { x: 0, y: 10 }); + close(g.rot, 90); close(g.scale, 1); +}); + +test('turns take the short way across ±180°', () => { + const g = twoFinger({ x: 0, y: 0 }, { x: -10, y: 1 }, { x: 0, y: 0 }, { x: -10, y: -1 }); + assert.ok(Math.abs(g.rot) < 12, String(g.rot)); +}); + +test('both fingers moving up together tilt by the smaller move; a vertical pinch does not', () => { + close(twoFinger({ x: 0, y: 100 }, { x: 50, y: 100 }, { x: 0, y: 80 }, { x: 50, y: 70 }).tilt, -20); + close(twoFinger({ x: 0, y: 100 }, { x: 0, y: 150 }, { x: 0, y: 90 }, { x: 0, y: 160 }).tilt, 0); +}); + +test('Touches reports a gesture only while two fingers are down', () => { + const t = new Touches(); + t.down(1, { x: 0, y: 0 }); + assert.equal(t.move(1, { x: 5, y: 0 }), null); + t.down(2, { x: 20, y: 0 }); + const g = t.move(2, { x: 35, y: 0 }); // 15 → 30 apart + close(g.scale, 2); + assert.equal(t.move(9, { x: 0, y: 0 }), null, 'unknown pointer'); + t.up(1); + assert.equal(t.size, 1); + assert.deepEqual(t.other(1), { id: 2, at: { x: 35, y: 0 } }); +}); + +test('fingers moving up one event at a time still tilt (browsers send one move per finger)', () => { + const t = new Touches(); + t.down(1, { x: 0, y: 100 }); t.down(2, { x: 50, y: 100 }); + let tilt = 0; + for (let s = 1; s <= 5; s++) { + tilt += t.move(1, { x: 0, y: 100 - 10 * s }).tilt; + tilt += t.move(2, { x: 50, y: 100 - 10 * s }).tilt; + } + close(tilt, -50); +}); + +test('a vertical pinch, one finger at a time, does not tilt', () => { + const t = new Touches(); + t.down(1, { x: 0, y: 100 }); t.down(2, { x: 0, y: 200 }); + let tilt = 0; + for (let s = 1; s <= 5; s++) { + tilt += t.move(1, { x: 0, y: 100 - 10 * s }).tilt; + tilt += t.move(2, { x: 0, y: 200 + 10 * s }).tilt; + } + close(tilt, 0); +}); diff --git a/viewer/tests/globe.test.mjs b/viewer/tests/globe.test.mjs new file mode 100644 index 0000000..147a16a --- /dev/null +++ b/viewer/tests/globe.test.mjs @@ -0,0 +1,377 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fakeRenderer } from './importmap.mjs'; + +const { GlobeView, MIN_ALT, MAX_ALT } = await import('../js/globe.js'); +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +function globe(st = { lat: 40, lon: -10, alt: 0.5 }) { + const g = new GlobeView(fakeRenderer(), () => {}); + g.resize(800, 400); + g.setState(st); + return g; +} + +test('state round trip; altitude clamped', () => { + const s = globe().getState(); + near(s.lat, 40, 1e-6); near(s.lon, -10, 1e-6); near(s.alt, 0.5, 1e-9); + assert.equal(globe({ lat: 0, lon: 0, alt: 99 }).getState().alt.toFixed(6), MAX_ALT.toFixed(6)); + assert.equal(globe({ lat: 0, lon: 0, alt: 0 }).getState().alt.toFixed(6), MIN_ALT.toFixed(6)); +}); + +test('screen centre picks the sub-camera point; off-globe picks null', () => { + const p = globe().pick(400, 200); + near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6); + assert.equal(globe({ lat: 0, lon: 0, alt: 4 }).pick(2, 2), null); +}); + +test('toScreen inverts pick; far side hidden', () => { + const g = globe({ lat: 10, lon: 179, alt: 0.3 }); // straddles the antimeridian + for (const [x, y] of [[100, 50], [400, 200], [700, 350], [620, 90]]) { + const p = g.pick(x, y); + assert.ok(p && Math.abs(p.lon) <= 180, 'finite pick'); + const q = g.toScreen(p.lat, p.lon); + near(q.x, x, 1e-6); near(q.y, y, 1e-6); + } + assert.equal(g.toScreen(-10, 1), null); +}); + +test('poles stay finite', () => { + for (const lat of [90, -90]) { + const s = globe({ lat, lon: 0, alt: 1 }).getState(); + assert.ok(Number.isFinite(s.lat) && Number.isFinite(s.lon) && Math.abs(Math.abs(s.lat) - 90) < 1e-3, JSON.stringify(s)); + } +}); + +test('overlay lines are clipped to the visible cap (far-side lines never show through when drawn on top)', async () => { + const THREE = await import('three'); + const { toVec } = await import('../js/geo.js'); + const g = globe({ lat: 10, lon: 20, alt: 0.01 }); + const plane = g.lines.clip?.[0]; + assert.ok(plane, 'lines carry a clipping plane'); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 20))) >= 0); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-10, -160))) < 0); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 60))) < 0, '40° away is beyond the horizon at 64 km up'); + g.setState({ lat: -30, lon: 100, alt: 2 }); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-30, 100))) >= 0); +}); + +const ev = (type, o = {}) => Object.assign(new Event(type, { cancelable: true }), + { clientX: 0, clientY: 0, button: 0, buttons: 1, shiftKey: false, pointerId: 1, deltaY: 0, deltaMode: 0, ...o }); + +test('tilted state round trip; the pitch levels out when high', async () => { + const { R_KM } = await import('../js/geo.js'); + const s = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 }).getState(); + near(s.lat, 40, 1e-9); near(s.lon, -10, 1e-9); near(s.alt, 0.01, 1e-12); near(s.hd, 30, 1e-9); near(s.pt, 60, 1e-9); + assert.equal(globe({ lat: 0, lon: 0, alt: 0.5, hd: 30, pt: 60 }).getState().pt, 0); + assert.equal(globe({ lat: 0, lon: 0, alt: 3000 / R_KM, pt: 60 }).getState().pt, 0); + const h = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 }); + h.setState({ lat: 1, lon: 2, alt: 2 }); + assert.deepEqual([h.getState().hd, h.getState().pt], [0, 0], 'no hd/pt = north up, straight down'); +}); + +test('tilted: the centre still picks the target, the top of the screen is sky', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.002, hd: 0, pt: 80 }); + const p = g.pick(400, 200); + near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6); + assert.equal(g.pick(400, 3), null); + assert.ok(g.pick(400, 390).lat < 40, 'the bottom of the screen is nearer ground, behind the target (south)'); +}); + +test('the eye never goes below 20 m; with 3D terrain, 20 m above the loaded ground × exaggeration', async () => { + const { eyeAltitude, R_M } = await import('../js/camera.js'); + const g = globe({ lat: 40, lon: -10, alt: 1e-9, pt: 0 }); + near(eyeAltitude(g.pose.range, g.pose.pitch) * R_M, 20, 1e-6); + g.terrain = 2; + g.tiles = { heightAt: () => 1000 }; + g.setState({ lat: 40, lon: -10, alt: 1e-9 }); + near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 2020, 1e-6); + g.tiles = { heightAt: () => 3000 }; // finer ground arrived under the camera + g.keepAboveGround(); + near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6); + g.tiles = { heightAt: () => null }; + g.keepAboveGround(); + near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6, 'unknown ground never pulls the camera down'); +}); + +test('a restored view keeps its range from the ground that loads after it (a reload lands where it was)', async () => { + const { R_M } = await import('../js/camera.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.resize(800, 400); + g.terrain = 1; + let h = null; + g.tiles = { heightAt: () => h }; + g.setState({ lat: -53, lon: -44, alt: 120 / R_M, pt: 50 }); // the URL's view, heights not loaded yet + g.keepAboveGround(); + h = 560; // coarse heights arrive + g.keepAboveGround(); + near(g.pose.range * R_M, 120, 1e-6); + h = null; // tiles reloading: nothing moves + g.keepAboveGround(); + near(g.pose.range * R_M, 120, 1e-6); + h = 575; // finer heights + g.keepAboveGround(); + near(g.pose.range * R_M, 120, 1e-6); + assert.equal(g.getState().alt, 120 / R_M, 'the URL round-trips'); + g.setPose({ ...g.pose, heading: 10 }); // the user moves: the eye stays put from here on + h = 700; + g.keepAboveGround(); + assert.ok(g.pose.range * R_M < 120 - 50, 'finer ground after a gesture keeps the eye, not the range'); +}); + +test('drag pans: the grabbed ground stays under the pointer', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement; + g.activate(); + const grabbed = g.pick(300, 150); + c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150 })); + c.dispatchEvent(ev('pointermove', { clientX: 350, clientY: 230 })); + c.dispatchEvent(ev('pointermove', { clientX: 420, clientY: 260 })); + c.dispatchEvent(ev('pointerup', { clientX: 420, clientY: 260 })); + const now = g.pick(420, 260); + near(now.lat, grabbed.lat, 1e-6); near(now.lon, grabbed.lon, 1e-6); +}); + +test('Shift+drag or right-drag: up tilts toward the horizon, sideways turns; the wheel zooms; off when inactive', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement; + c.dispatchEvent(ev('wheel', { deltaY: 100 })); + near(g.getState().alt, 0.01, 1e-15, 'inactive: ignored'); + g.activate(); + c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, shiftKey: true })); + c.dispatchEvent(ev('pointermove', { clientX: 400, clientY: 100, shiftKey: true })); + c.dispatchEvent(ev('pointerup', { clientX: 400, clientY: 100 })); + assert.ok(g.getState().pt > 30, `pitch ${g.getState().pt}`); + c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, button: 2, buttons: 2 })); + c.dispatchEvent(ev('pointermove', { clientX: 300, clientY: 300, buttons: 2 })); + c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 300, button: 2 })); + assert.ok(Math.abs(g.getState().hd - 25) < 1e-9, `heading ${g.getState().hd}`); + const e = ev('wheel', { deltaY: 100 }); + c.dispatchEvent(e); + assert.ok(g.getState().alt > 0.0105 && e.defaultPrevented); + const ctx = ev('contextmenu'); + c.dispatchEvent(ctx); + assert.ok(ctx.defaultPrevented, 'right button belongs to the map'); +}); + +test('pixel size grows with distance when tilted', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 75 }); + assert.ok(g.kmPerPxAt(g.pick(400, 60)) > 3 * g.kmPerPxAt(g.pick(400, 390))); +}); + +test('a long diagonal drag keeps north up and the grabbed ground under the pointer', () => { + const g = globe({ lat: 50, lon: 10, alt: 0.3 }), c = g.renderer.domElement; + g.activate(); + const grabbed = g.pick(200, 100); + c.dispatchEvent(ev('pointerdown', { clientX: 200, clientY: 100 })); + for (let i = 1; i <= 10; i++) c.dispatchEvent(ev('pointermove', { clientX: 200 + 40 * i, clientY: 100 + 25 * i })); + c.dispatchEvent(ev('pointerup', { clientX: 600, clientY: 350 })); + assert.equal(g.getState().hd, 0); + const q = g.toScreen(grabbed.lat, grabbed.lon); + near(q.x, 600, 0.05); near(q.y, 350, 0.05); // within a twentieth of a pixel +}); + +test('tilted over 3D terrain, overlay lines are depth-tested (no rivers floating through mountains)', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.001 }); + assert.equal(g.lines.depthTest, false, 'straight down: lines drawn on top of the tiles'); + g.terrain = 1; + g.setState({ lat: 40, lon: -10, alt: 0.001, pt: 60 }); + assert.equal(g.lines.depthTest, true); +}); + +test('sky dome and haze near the ground, gone from orbit', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.0005, pt: 70 }); + assert.equal(g.dome.mesh.visible, true); + assert.ok(g.shade.hazeAmt.value > 0); + g.setState({ lat: 40, lon: -10, alt: 2 }); + assert.equal(g.dome.mesh.visible, false); + assert.equal(g.shade.hazeAmt.value, 0); +}); + +test('tilted over high 3D ground: tile samples hit the ground, nearer and finer than the sea-level pick', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + g.terrain = 1; + g.tiles = { heightAt: () => 3000 }; + g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + const sea = g.pick(400, 200, false), s = g.groundSample(400, 200); + assert.ok(s.km < 0.95 * g.kmPerPxAt(sea), `${s.km} vs ${g.kmPerPxAt(sea)}`); + assert.ok(s.lat < sea.lat, 'the ground hit is nearer the camera (south of the sea-level point)'); + g.terrain = 0; + const f = g.groundSample(400, 200); + near(f.lat, sea.lat, 1e-9); near(f.km, g.kmPerPxAt(sea), 1e-9); + g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 80 }); + assert.equal(g.pick(400, 3), null); + assert.equal(g.groundSample(400, 3), null, 'sky'); +}); + +test('ground sampling finds a slope rising above the eye (camera below the far ground level)', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 }); + g.terrain = 1; + g.tiles = { heightAt: lat => (lat > 40.02 ? 6000 : 500) }; // a wall of mountains just north of the target + g.setState({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 }); + const s = g.groundSample(400, 100); // this ray meets the wall face before the 500 m ground + assert.ok(s, 'hits the wall'); + assert.ok(s.lat > 40.02 && s.lat < 40.1, `wall face at ${s.lat}`); +}); + +function hillGlobe(h = 3000) { // tilted over flat 3D ground at h metres + const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + g.terrain = 1; + g.tiles = { heightAt: () => h, heightGen: 1 }; + g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + return g; +} + +test('with 3D terrain a click picks the ground it sees; markers sit on that ground (toScreen inverts pick)', () => { + const g = hillGlobe(), sea = g.pick(400, 200, false), p = g.pick(400, 200); + assert.ok(p.lat < sea.lat - 0.001, 'the ground hit is nearer than the sea-level point behind it'); + for (const [x, y] of [[400, 200], [120, 330], [700, 90]]) { + const q = g.pick(x, y), s = g.toScreen(q.lat, q.lon); + near(s.x, x, 0.5); near(s.y, y, 0.5); + } +}); + +test('grabbing a hill in 3D keeps that hill under the pointer', () => { + const g = hillGlobe(), c = g.renderer.domElement; + g.activate(); + const grabbed = g.pick(400, 250); + c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 250 })); + c.dispatchEvent(ev('pointermove', { clientX: 450, clientY: 290 })); + c.dispatchEvent(ev('pointerup', { clientX: 450, clientY: 290 })); + const s = g.toScreen(grabbed.lat, grabbed.lon); + near(s.x, 450, 0.5); near(s.y, 290, 0.5); +}); + +test('with 3D terrain close up, lines lie on the drawn ground (depth-tested); flat again without it', async () => { + const { R_M } = await import('../js/camera.js'); + const g = hillGlobe(1000); + g.setLines('rivers', [[{ lat: 40.01, lon: -10 }, { lat: 40.05, lon: -10.02 }]], 0x3399ff); + g.updateDrape(0); + let o = g.lines.objects.get('rivers'); + const a = o.geometry.attributes.position.array, r = Math.hypot(a[0] + o.position.x, a[1] + o.position.y, a[2] + o.position.z); + assert.ok(r > 1 + 1000 / R_M && r < 1 + 1100 / R_M, `${(r - 1) * R_M} m`); + assert.equal(o.material.depthTest, true); + assert.equal(o.material.clippingPlanes?.length ?? 0, 0, 'no horizon clip: depth hides what is behind hills'); + g.tiles.heightGen = 2; + g.tiles.heightAt = () => 2000; + g.updateDrape(1000); // heights changed: re-laid + o = g.lines.objects.get('rivers'); + const b = o.geometry.attributes.position.array; + assert.ok(Math.hypot(b[0] + o.position.x, b[1] + o.position.y, b[2] + o.position.z) > 1 + 2000 / R_M); + g.terrain = 0; + g.updateDrape(2000); + o = g.lines.objects.get('rivers'); + const c = o.geometry.attributes.position.array; + assert.ok(Math.hypot(c[0], c[1], c[2]) * o.scale.x < 1 + 600 / R_M, 'back near sea level (lifted alt × 0.02, as before)'); + assert.equal(o.position.length(), 0); +}); + +test('20 m above high ground: a wheel notch and an arrow key move by the height above the ground, not sea level', async () => { + const { R_M, eyeAltitude } = await import('../js/camera.js'); + const { panPose } = await import('../js/camera.js'); + const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement; + g.terrain = 1; + g.tiles = { heightAt: () => 3000, heightGen: 1 }; + g.setState({ lat: 40, lon: -10, alt: 1e-9 }); + const clear = () => eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M - 3000; + near(clear(), 20, 1e-6); + g.activate(); + c.dispatchEvent(ev('wheel', { deltaY: 100 })); + assert.ok(clear() > 21 && clear() < 25, `one notch: ${clear()} m above the ground`); + const before = g.pick(400, 200); + g.setPose(panPose(g.pose, 0.2, 0)); + const after = g.pick(400, 200); + const movedM = Math.hypot(after.lat - before.lat, (after.lon - before.lon) * Math.cos(40 * Math.PI / 180)) * Math.PI / 180 * R_M; + assert.ok(movedM < 20, `ArrowRight moved ${movedM} m with a ~40 m wide view`); +}); + +test('dragging over a pole keeps the grabbed ground under the pointer', () => { + const g = globe({ lat: 89.8, lon: 30, alt: 0.01 }), c = g.renderer.domElement; + g.activate(); + for (const x0 of [400, 250]) { + const grabbed = g.pick(x0, 20); + c.dispatchEvent(ev('pointerdown', { clientX: x0, clientY: 20 })); + for (let i = 1; i <= 37; i++) c.dispatchEvent(ev('pointermove', { clientX: x0, clientY: 20 + 10 * i })); + c.dispatchEvent(ev('pointerup', { clientX: x0, clientY: 390 })); + const s = g.toScreen(grabbed.lat, grabbed.lon); + near(s.x, x0, 1); near(s.y, 390, 1); + } +}); + +test('looking straight down there is no haze (data colours match their legend); tilting brings it in', () => { + const g = globe({ lat: 40, lon: -10, alt: 40 / 12742 }); + assert.equal(g.shade.hazeAmt.value, 0); + g.setState({ lat: 40, lon: -10, alt: 40 / 12742, pt: 60 }); + assert.ok(g.shade.hazeAmt.value > 0); +}); + +test('controls follow one pointer; the right-click menu is only taken while the globe is active; no touch scrolling', async () => { + const { readFileSync } = await import('node:fs'); + const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement; + const menu = ev('contextmenu'); + c.dispatchEvent(menu); + assert.equal(menu.defaultPrevented, false, 'inactive: the browser keeps its menu'); + g.activate(); + const before = g.getState(); + c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150, pointerId: 1 })); + c.dispatchEvent(ev('pointermove', { clientX: 500, clientY: 300, pointerId: 2 })); // a second finger + near(g.getState().lat, before.lat, 1e-12); + c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 150, pointerId: 1 })); + assert.match(readFileSync(new URL('../style.css', import.meta.url), 'utf8'), /#map canvas\s*\{[^}]*touch-action:\s*none/); +}); + +test('dry land below sea level (water on): the eye comes down to 20 m above it, not above 0 m', async () => { + const { eyeAltitude, R_M } = await import('../js/camera.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.tiles = { heightAt: () => -14 }; // a dry hollow: the tiles draw it at −14 m (sea cells they draw at 0 m) + g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 60 }); + near(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M, -14 + 20, 1e-6); + near(g.pose.ground, -14, 1e-9); +}); + +test('the eye clears the drawn ground too, not only finer heights loaded but hidden (a coarser stand-in drawn higher)', async () => { + const { eyeAltitude, R_M } = await import('../js/camera.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.tiles = { heightAt: (lat, lon, shown) => (shown ? 30 : 0) }; // drawn: a stand-in 30 m up; loaded finer: 0 m + g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 0 }); + assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6, 'not inside the drawn ground'); + g.keepAboveGround(); + assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6); +}); + +test('camera floor follows the water mode', () => { + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.tiles = { heightAt: () => (g.seaFloor ? -4000 : 0) }; // what the tiles draw: the water surface, or the floor + g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 }); + const above = g.camera.position.length(); + assert.ok(above > 1, 'water on: above sea level'); + g.seaFloor = true; + g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 }); + assert.ok(g.camera.position.length() < 1, 'water off: may dive below 0 m'); +}); + +test('water off: tiles reloading (no heights yet) keep the camera where it was, not at sea level', () => { + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.seaFloor = true; + let h = -4000; + g.tiles = { heightAt: () => h }; + g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 }); + const below = g.camera.position.length(); + assert.ok(below < 1); + h = null; // a layer switch dropped every tile + g.keepAboveGround(); + assert.ok(Math.abs(g.camera.position.length() - below) < 1e-9, 'not lifted to sea level'); +}); + +test('water off: the coarse sphere sinks below the deepest trench at any exaggeration', async () => { + const { R_KM } = await import('../js/geo.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 4; + g.seaFloor = true; + g.tiles = { heightAt: () => -2000 }; + g.setPose({ lat: 0, lon: 0, range: 0.01, heading: 0, pitch: 0 }); + assert.ok(g.base.scale.x < 1 - (11 * 4) / R_KM, `sphere at ${g.base.scale.x}`); +}); diff --git a/viewer/tests/hover.test.mjs b/viewer/tests/hover.test.mjs new file mode 100644 index 0000000..7b26be5 --- /dev/null +++ b/viewer/tests/hover.test.mjs @@ -0,0 +1,30 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hoverText } from '../js/hover.js'; + +const cell = fields => ({ cell: 'x', lat: 0, lon: 0, fields }); + +test('land shows elevation and life zone', () => { + assert.equal(hoverText(cell([{ key: 'z_surface_m', value: 2344.4 }, { key: 'ocean', value: false, text: 'no' }, + { key: 'lake', value: false, text: 'no' }, { key: 'holdridge', value: 3, text: 'cool temperate desert scrub' }])), + '2,344 m · cool temperate desert scrub'); +}); + +test('sea shows depth; lakes are named; procedural detail only when tiles show', () => { + assert.equal(hoverText(cell([{ key: 'z_surface_m', value: -3300 }, { key: 'ocean', value: true, text: 'yes' }])), 'sea, 3,300 m deep'); + assert.equal(hoverText(cell([{ key: 'z_surface_m', value: 120 }, { key: 'ocean', value: false }, { key: 'lake', value: true }, + { key: 'holdridge', value: 1, text: 'boreal moist forest' }])), '120 m · lake · boreal moist forest'); + const both = cell([{ key: 'z_surface_m', value: 120 }, { key: 'z_detail_m', value: 131.6 }, { key: 'ocean', value: false }]); + assert.equal(hoverText(both), '120 m', 'zoomed out: the build value'); + assert.equal(hoverText(both, true), '132 m', 'procedural tiles showing: the detail value'); +}); + +test('a cell answer is reused near its centre, never under procedural detail', async () => { + const { canReuse } = await import('../js/hover.js'); + const c = { center: [10, 20], inner_km: 50 }; + assert.equal(canReuse(c, { lat: 10.1, lon: 20 }, false), true); + assert.equal(canReuse(c, { lat: 11, lon: 20 }, false), false); + assert.equal(canReuse(c, { lat: 10.1, lon: 20 }, true), false); + assert.equal(canReuse(null, { lat: 10, lon: 20 }, false), false); +}); diff --git a/viewer/tests/importmap-hooks.mjs b/viewer/tests/importmap-hooks.mjs new file mode 100644 index 0000000..3769c21 --- /dev/null +++ b/viewer/tests/importmap-hooks.mjs @@ -0,0 +1,11 @@ +// Node module-resolution hook: resolve bare specifiers with the page's import map. +let map; +export function initialize(data) { map = data; } +export async function resolve(spec, ctx, next) { + for (const [k, v] of Object.entries(map.imports)) { + if (spec === k || (k.endsWith('/') && spec.startsWith(k))) { + return { url: new URL(v + spec.slice(k.length), map.base).href, shortCircuit: true }; + } + } + return next(spec, ctx); +} diff --git a/viewer/tests/importmap.mjs b/viewer/tests/importmap.mjs new file mode 100644 index 0000000..301d2bc --- /dev/null +++ b/viewer/tests/importmap.mjs @@ -0,0 +1,15 @@ +// Import first, then load viewer modules with dynamic import(): bare 'three' resolves as in index.html. +import { register } from 'node:module'; +import { readFileSync } from 'node:fs'; + +export const HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +export const IMPORTS = JSON.parse(HTML.match(/