diff options
Diffstat (limited to 'viewer/js/app.js')
| -rw-r--r-- | viewer/js/app.js | 654 |
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 }); + }); + } +} |
