aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/js/app.js')
-rw-r--r--viewer/js/app.js654
1 files changed, 654 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 });
+ });
+ }
+}