// 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 }); }); } }