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