// Import page entry point — handles heightmap file upload, import dispatch, // terrain sculpting reapply, and all visualization wiring. import * as THREE from 'three'; import { renderer, scene, camera, ctrl, waterMesh, atmosMesh, starsMesh, mapCamera, updateMapCameraFrustum, mapCtrl, canvas, tickZoom, tickMapZoom } from './scene.js'; import { state } from './state.js'; import { importHeightmap, importPainted, reapplyViaWorker, computeClimateViaWorker } from './generate.js'; import { buildMesh, updateMeshColors, buildMapMesh, rebuildGrids, exportMap, exportMapBatch, buildWindArrows, buildOceanCurrentArrows, updateKoppenHoverHighlight, updateMapKoppenHoverHighlight } from './planet-mesh.js'; import { detailFromSlider } from './detail-scale.js'; import { KOPPEN_CLASSES } from './koppen.js'; import { elevationToColor } from './color-map.js'; import { parseLegend, classifyImage, serializeLegend, applyPlanetManifest, PLANET_DEFAULTS } from './painted.js'; import { PAINTED_LAYERS, PAINTED_EXPORT_LABELS, paintedLegendHTML } from './painted-layers.js'; import { classAngles, clampCeilingMmYr } from './painted-report.js'; import { parseOverlayLegend, classifyOverlay, overlayReportText, overlayTouchesCoast } from './painted-overlay.js'; import { setOverlaySheet, setOverlayVisible, syncOverlayMapCenter } from './painted-overlay-view.js'; import { installUnrealExportButton } from './unreal-ui.js'; // ─── File Upload ────────────────────────────────────────────────── const fileInput = document.getElementById('heightmapFile'); const fileNameEl = document.getElementById('importFileName'); const previewCanvas = document.getElementById('importPreview'); const importDimsEl = document.getElementById('importDims'); const importBtn = document.getElementById('importBtn'); let storedGrayscale = null; let storedWidth = 0; let storedHeight = 0; // ─── Default heightmap (Earth) ──────────────────────────────────── function loadImageAsHeightmap(img, displayName) { storedWidth = img.width; storedHeight = img.height; const offscreen = document.createElement('canvas'); offscreen.width = img.width; offscreen.height = img.height; const offCtx = offscreen.getContext('2d'); offCtx.drawImage(img, 0, 0); const previewW = Math.min(img.width, 400); const previewH = Math.round(previewW * img.height / img.width); previewCanvas.width = previewW; previewCanvas.height = previewH; const ctx = previewCanvas.getContext('2d'); ctx.drawImage(img, 0, 0, previewW, previewH); previewCanvas.style.display = 'block'; importDimsEl.textContent = `${img.width} × ${img.height}`; importDimsEl.style.display = 'block'; const expectEl = document.getElementById('importExpect'); if (expectEl) expectEl.style.display = 'block'; const data = offCtx.getImageData(0, 0, img.width, img.height).data; const numPx = img.width * img.height; storedGrayscale = new Uint8Array(numPx); for (let i = 0; i < numPx; i++) { const r = data[i * 4], g = data[i * 4 + 1], b = data[i * 4 + 2]; storedGrayscale[i] = Math.round(0.299 * r + 0.587 * g + 0.114 * b); } fileNameEl.textContent = displayName; updateImportButton(); } (function loadDefaultHeightmap() { const img = new Image(); img.onload = () => { loadImageAsHeightmap(img, 'Earth (default)'); importBtn.click(); }; img.src = 'assets/earth.png'; })(); fileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const img = new Image(); img.onload = () => loadImageAsHeightmap(img, file.name); img.src = ev.target.result; }; reader.readAsDataURL(file); }); // ─── Painted map ────────────────────────────────────────────────── // // The second source: a painting whose colours are legend classes — uplift rates and // erodibilities — solved into terrain by the stream-power solve in painted.js. The legend is // parsed and the pixels classified here, on the main thread, so the match report and the // class table are on screen before the worker is asked to solve anything. const paintFileInput = document.getElementById('paintFile'); const paintFileNameEl = document.getElementById('paintFileName'); const legendFileInput = document.getElementById('legendFile'); const legendFileNameEl = document.getElementById('legendFileName'); const paintPreviewCanvas = document.getElementById('paintPreview'); const paintDimsEl = document.getElementById('paintDims'); const paintReportEl = document.getElementById('paintReport'); const paintLegendWrap = document.getElementById('paintLegendWrap'); const paintLegendBody = document.querySelector('#paintLegend tbody'); const buildOverlayTitle = document.getElementById('buildOverlayTitle'); const paintSolvedEl = document.createElement('div'); paintSolvedEl.className = 'import-report solved'; paintSolvedEl.style.display = 'none'; paintReportEl.after(paintSolvedEl); let source = 'heightmap'; let paintImage = null; // { rgba, w, h, name } let paintRaster = null; // classifyImage result: { classes, counts, far, ... } let paintLegend = null; // parseLegend result let builtinLegendJson = null; let planetManifest = null; // Planet.json as loaded; re-applied to every legend loaded after it let overlayImage = null; // { rgba, w, h, name } let overlayLegend = null; // parseOverlayLegend result const planetFileNameEl = document.getElementById('planetFileName'); function setSource(next) { source = next; document.body.dataset.source = next; document.querySelectorAll('#sourceTabs .map-tab').forEach(t => t.classList.toggle('active', t.dataset.source === next)); const topInfo = document.getElementById('topInfo'); if (topInfo) { topInfo.textContent = next === 'painted' ? 'Paint the uplift, never the height — the stream-power solve makes the terrain and its rivers' : 'Import an equirectangular heightmap to visualize it on a globe'; } updateImportButton(); } function updateImportButton() { if (importBtn.textContent.endsWith('…')) return; // busy if (source === 'painted') { importBtn.textContent = 'Solve Terrain'; importBtn.disabled = !(paintRaster && paintLegend); } else { importBtn.textContent = 'Import'; importBtn.disabled = !storedGrayscale; } } document.getElementById('sourceTabs').addEventListener('click', (e) => { const tab = e.target.closest('.map-tab'); if (tab) setSource(tab.dataset.source); }); function showPaintReport(text, warn) { paintReportEl.textContent = text; paintReportEl.style.display = text ? 'block' : 'none'; paintReportEl.classList.toggle('warn', !!warn); } function applyLegend(json, name) { let parsed; try { parsed = parseLegend(json); } catch (err) { showPaintReport(`Legend error: ${err.message}`, true); return false; } paintLegend = parsed; legendFileNameEl.textContent = name; // Planet.json, once loaded, outranks the legend's own planet block: it is the manifest the bake reads. if (planetManifest) { try { applyPlanetManifest(paintLegend, planetManifest); } catch (err) { showPaintReport(`Planet.json error: ${err.message}`, true); } } syncPlanetInputs(paintLegend.planet); paintSolvedEl.style.display = 'none'; if (paintImage) classifyPainting(); else { showPaintReport('', false); renderLegendTable(null); } updateImportButton(); return true; } fetch('assets/painted-legend.json').then(r => r.json()).then(json => { builtinLegendJson = json; if (!paintLegend) applyLegend(json, 'Built-in legend'); }).catch(err => console.warn('[World Orogen] Built-in painted legend failed to load:', err)); legendFileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { let json; try { json = JSON.parse(ev.target.result); } catch (err) { showPaintReport(`The legend is not valid JSON: ${err.message}`, true); return; } applyLegend(json, file.name); }; reader.readAsText(file); }); // ─── Planet.json ────────────────────────────────────────────────── // // The Go tool's manifest: the planet block (circumference, massif and rock sizes, swell, seed) and the // pipeline constants the class table's angles are about. Loaded once, it outranks the legend's own copy // and is re-applied to every legend loaded after it. function syncPlanetInputs(p) { document.getElementById('nCirc').value = p.circumferenceKm; document.getElementById('nMassif').value = p.massifWavelengthKm; document.getElementById('nLith').value = p.lithologyWavelengthKm; document.getElementById('nSeed').value = p.seed; document.getElementById('sUv').value = p.variation; document.getElementById('vUv').textContent = (+p.variation).toFixed(2); } function applyPlanet(json, name) { try { if (!json || typeof json !== 'object' || !json.planet) throw new Error('no "planet" block; the painted path reads a painted planet'); planetManifest = json; if (paintLegend) { applyPlanetManifest(paintLegend, json); syncPlanetInputs(paintLegend.planet); renderLegendTable(paintRaster); } planetFileNameEl.textContent = name; return true; } catch (err) { planetManifest = null; showPaintReport(`Planet.json error: ${err.message}`, true); return false; } } document.getElementById('planetFile').addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { let json; try { json = JSON.parse(ev.target.result); } catch (err) { showPaintReport(`Planet.json is not valid JSON: ${err.message}`, true); return; } applyPlanet(json, file.name); }; reader.readAsText(file); }); // The plan's two angles per class (painted-report.js). The typical slope is the column; the divide angle, // the steepest ground the rate can make, is the tooltip, because reading it as the landscape is how a // legend gets set two or three times too hot. function slopeCellHTML(c) { const a = classAngles(c, paintLegend.planet); if (!a) return ''; const f = x => x.toFixed(1); let html = `${f(a.median)}\u00b0`; if (a.clamped) html += ` clamped`; html += `${a.readsAs}`; if (a.floor) html += `floor ${f(a.floor.median)}\u00b0 ${a.floor.readsAs}`; return html; } function renderLegendNote() { const el = document.getElementById('legendNote'); if (!el || !paintLegend) return; const p = paintLegend.planet; el.textContent = `Slope is the typical hillslope the bake makes at ${p.cellM} m cells and K ${p.k}; hover for the divide angle. Above ${clampCeilingMmYr(p).toFixed(2)} mm/yr at k = 1 the repose clamp shapes the ground.`; } // ─── Overlay ────────────────────────────────────────────────────── // // The second painting (painted-overlay.js): classified here so its report is on screen at once, shown as a // texture over the terrain (painted-overlay-view.js), and handed to the solve for its one instruction, the // coast_jitter marks. const overlayFileNameEl = document.getElementById('overlayFileName'); const overlayLegendFileNameEl = document.getElementById('overlayLegendFileName'); const overlayReportEl = document.getElementById('overlayReport'); const chkOverlay = document.getElementById('chkOverlay'); function showOverlayReport(text, warn) { overlayReportEl.textContent = text; overlayReportEl.style.display = text ? 'block' : 'none'; overlayReportEl.classList.toggle('warn', !!warn); } function applyOverlayLegend(json, name) { let parsed; try { parsed = parseOverlayLegend(json); } catch (err) { showOverlayReport(`Overlay legend error: ${err.message}`, true); return false; } overlayLegend = parsed; overlayLegendFileNameEl.textContent = name; classifyOverlayIfReady(); return true; } function loadImageAsOverlay(img, name) { const w = img.width, h = img.height; const off = document.createElement('canvas'); off.width = w; off.height = h; const octx = off.getContext('2d', { willReadFrequently: true }); octx.drawImage(img, 0, 0); const rgba = octx.getImageData(0, 0, w, h).data; off.width = 0; off.height = 0; overlayImage = { rgba, w, h, name }; overlayFileNameEl.textContent = name; classifyOverlayIfReady(); } function loadOverlayFromUrl(url, name) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { loadImageAsOverlay(img, name || url.split('/').pop()); resolve(); }; img.onerror = () => reject(new Error(`Could not load ${url}`)); img.src = url; }); } function classifyOverlayIfReady() { if (!overlayImage || !overlayLegend) return; if (paintImage && (overlayImage.w !== paintImage.w || overlayImage.h !== paintImage.h)) { showOverlayReport(`The overlay is ${overlayImage.w} \u00d7 ${overlayImage.h} and the painting is ${paintImage.w} \u00d7 ${paintImage.h}; they are registered to each other, so they have to be the same size.`, true); setOverlaySheet(null); syncOverlayControls(); return; } const t0 = performance.now(); const rep = classifyOverlay(overlayImage.rgba, overlayImage.w, overlayImage.h, overlayLegend); const ms = performance.now() - t0; const lines = [overlayReportText(rep) + ` ${ms.toFixed(0)} ms.`]; if (state.curData && state.curData.painted && overlayTouchesCoast(overlayLegend)) lines.push('Solve again to apply its coast marks.'); showOverlayReport(lines.join(' '), rep.far > 0); setOverlaySheet({ legend: overlayLegend, raster: rep.marks, w: rep.w, h: rep.h, report: rep, name: overlayImage.name }); syncOverlayControls(); if (state.debugLayer === 'paintOverlay') updateLegend('paintOverlay'); } function syncOverlayControls() { const has = !!state.overlay; const solved = !!(state.curData && state.curData.painted); chkOverlay.disabled = !has; if (!has) { chkOverlay.checked = false; state.overlayVisible = false; } for (const opt of document.querySelectorAll('#paintedInspectGroup option[value="paintOverlay"], #paintedExportGroup option[value="overlay"]')) opt.disabled = !(has && solved); setOverlayVisible(); } chkOverlay.addEventListener('change', (e) => { state.overlayVisible = e.target.checked; setOverlayVisible(); }); document.getElementById('overlayFile').addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const img = new Image(); img.onload = () => loadImageAsOverlay(img, file.name); img.src = ev.target.result; }; reader.readAsDataURL(file); }); document.getElementById('overlayLegendFile').addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { let json; try { json = JSON.parse(ev.target.result); } catch (err) { showOverlayReport(`The overlay legend is not valid JSON: ${err.message}`, true); return; } applyOverlayLegend(json, file.name); }; reader.readAsText(file); }); // ─── From terrain studio ────────────────────────────────────────── // // The studio (Tools/Terrain, `terrain studio`) serves the painting it holds in memory, both legends and // the manifest, read-only across origins. One button loads the planet as its next plan would read it. const studioNoteEl = document.getElementById('studioNote'); function showStudioNote(text, warn) { studioNoteEl.textContent = text; studioNoteEl.style.display = text ? 'block' : 'none'; studioNoteEl.classList.toggle('warn', !!warn); } async function loadFromStudio(base) { base = String(base || '').trim().replace(/\/+$/, ''); if (!base) throw new Error('no studio address'); const get = async (path) => { const r = await fetch(base + path); if (!r.ok) throw new Error(`${path} answered ${r.status}`); return r; }; const fileName = p => String(p || '').split(/[\\/]/).pop(); setSource('painted'); const st = await (await get('/api/state')).json(); const legendJson = await (await get('/api/legend')).json(); if (!applyLegend(legendJson, fileName(st.legend) || 'studio legend')) throw new Error('the legend was rejected'); const manifest = await (await get('/api/planet')).json(); if (!applyPlanet(manifest, fileName(st.manifest) || 'Planet.json')) throw new Error('Planet.json was rejected'); await loadPaintingFromUrl(base + '/api/paint.png', fileName(st.template) || 'studio painting'); let overlayNote = ''; if (st.overlay) { const ovLegend = await (await get('/api/overlay/legend')).json(); if (!applyOverlayLegend(ovLegend, fileName(st.overlay.legend) || 'overlay legend')) throw new Error('the overlay legend was rejected'); try { await loadOverlayFromUrl(base + '/api/overlay.png', fileName(st.overlay.image) || 'studio overlay'); } catch { overlayNote = ' The studio has no overlay image yet.'; } } return { state: st, overlayNote }; } document.getElementById('studioLoad').addEventListener('click', async () => { const btn = document.getElementById('studioLoad'); btn.disabled = true; btn.textContent = 'Loading\u2026'; showStudioNote('', false); try { const { state: st, overlayNote } = await loadFromStudio(document.getElementById('studioUrl').value); showStudioNote(`Loaded the ${st.paint_w} \u00d7 ${st.paint_h} painting, the legend and Planet.json${st.overlay ? ' and the overlay' : ''} from the studio.${overlayNote}`, false); } catch (err) { showStudioNote(`Could not load from the studio: ${err.message}. Is terrain studio running there, built after its read-only API was added (2026-09-20)?`, true); } finally { btn.disabled = false; btn.textContent = 'Load from studio'; } }); function loadImageAsPainting(img, displayName) { const w = img.width, h = img.height; const off = document.createElement('canvas'); off.width = w; off.height = h; const octx = off.getContext('2d', { willReadFrequently: true }); octx.drawImage(img, 0, 0); const rgba = octx.getImageData(0, 0, w, h).data; off.width = 0; off.height = 0; paintImage = { rgba, w, h, name: displayName }; if (overlayImage) classifyOverlayIfReady(); const previewW = Math.min(w, 400); const previewH = Math.round(previewW * h / w); paintPreviewCanvas.width = previewW; paintPreviewCanvas.height = previewH; paintPreviewCanvas.getContext('2d').drawImage(img, 0, 0, previewW, previewH); paintPreviewCanvas.style.display = 'block'; paintDimsEl.textContent = `${w} × ${h}`; paintDimsEl.style.display = 'block'; paintFileNameEl.textContent = displayName; paintRaster = null; paintSolvedEl.style.display = 'none'; if (paintLegend) classifyPainting(); updateImportButton(); } function classifyPainting() { if (!paintImage || !paintLegend) return; const t0 = performance.now(); const rep = classifyImage(paintImage.rgba, paintImage.w, paintImage.h, paintLegend); const ms = performance.now() - t0; paintRaster = rep; const farPct = 100 * rep.far / rep.total; const lines = [`${(rep.total / 1e6).toFixed(1)} MP classified in ${ms.toFixed(0)} ms.`]; if (rep.far > 0) { lines.push(`${farPct.toFixed(2)} % of pixels sit further than ${paintLegend.warnDistance} from every class (worst ${rep.maxDist.toFixed(0)} at ${rep.maxAt[0]}, ${rep.maxAt[1]}) — a colour the legend has not heard of, snapped to its nearest.`); } if (rep.wrapDiffer > 0) { lines.push(`The left and right edges disagree on ${(100 * rep.wrapDiffer / rep.wrapRows).toFixed(1)} % of rows (${rep.wrapLandSea} land against water); they are the same meridian.`); } showPaintReport(lines.join(' '), farPct > 1 || rep.wrapLandSea > rep.wrapRows * 0.02); renderLegendTable(rep); } function renderLegendTable(rep) { if (!paintLegend) { paintLegendWrap.style.display = 'none'; return; } const rows = []; for (const c of paintLegend.classes) { const sw = ``; let painted = ''; if (c.derived) painted = 'derived'; else if (rep) painted = (100 * rep.counts[c.index] / rep.total).toFixed(1) + ' %'; let value, k = ''; if (c.stroke) { value = `stroke → ${c.edgeClass ? c.edgeClass + ' at the poles, else' : ''} nearest`; } else if (c.sea) { value = ``; } else { value = ``; k = ``; } const tag = c.massif ? ' massif' : ''; rows.push(`${sw}${c.name}${tag}${painted}${value}${k}${slopeCellHTML(c)}`); } paintLegendBody.innerHTML = rows.join(''); renderLegendNote(); paintLegendWrap.style.display = 'block'; } paintLegendBody.addEventListener('change', (e) => { const inp = e.target.closest('input[data-i]'); if (!inp || !paintLegend) return; const c = paintLegend.classes[+inp.dataset.i]; const v = +inp.value; if (!c || !isFinite(v)) return; c[inp.dataset.f] = Math.max(0, v); const cell = paintLegendBody.querySelector(`td[data-slope="${c.index}"]`); if (cell) cell.innerHTML = slopeCellHTML(c); }); document.getElementById('legendDownload').addEventListener('click', () => { if (!paintLegend) return; const blob = new Blob([serializeLegend(paintLegend)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = ((legendFileNameEl.textContent || 'legend').replace(/\.json$/i, '').replace(/[^\w.-]+/g, '-') || 'legend') + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); }); paintFileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const img = new Image(); img.onload = () => loadImageAsPainting(img, file.name); img.src = ev.target.result; }; reader.readAsDataURL(file); }); function loadPaintingFromUrl(url, name) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { loadImageAsPainting(img, name || url.split('/').pop()); resolve(); }; img.onerror = () => reject(new Error(`Could not load ${url}`)); img.src = url; }); } document.getElementById('paintDemoLink').addEventListener('click', (e) => { e.preventDefault(); setSource('painted'); if (builtinLegendJson) applyLegend(builtinLegendJson, 'Built-in legend'); loadPaintingFromUrl('assets/painted-demo.png', 'Demo painting').catch(err => showPaintReport(err.message, true)); }); const trimNum = x => x.toFixed(2).replace(/\.?0+$/, ''); for (const [s, v, fmt] of [ ['sPk', 'vPk', x => trimNum(x) + ' km'], ['sOd', 'vOd', x => x.toFixed(1) + ' km'], ['sCd', 'vCd', x => x.toFixed(2)], ['sUv', 'vUv', x => x.toFixed(2)], ['sSt', 'vSt', x => String(Math.round(x))], ]) { const slider = document.getElementById(s); if (!slider) continue; initSliderTooltip(slider); slider.addEventListener('input', e => { document.getElementById(v).textContent = fmt(+e.target.value); }); } document.getElementById('paintReroll').addEventListener('click', () => { document.getElementById('nSeed').value = Math.floor(Math.random() * 1000000); }); function readPaintedParams() { const num = (id, d) => { const v = +document.getElementById(id).value; return isFinite(v) ? v : d; }; return { circumferenceKm: Math.max(1, num('nCirc', PLANET_DEFAULTS.circumferenceKm)), massifWavelengthKm: Math.max(0, num('nMassif', PLANET_DEFAULTS.massifWavelengthKm)), lithologyWavelengthKm: Math.max(0, num('nLith', PLANET_DEFAULTS.lithologyWavelengthKm)), lithology: paintLegend ? paintLegend.planet.lithology : PLANET_DEFAULTS.lithology, variation: num('sUv', PLANET_DEFAULTS.variation), coastDetail: num('sCd', PLANET_DEFAULTS.coastDetail), steps: Math.round(num('sSt', PLANET_DEFAULTS.steps)), peakKm: num('sPk', PLANET_DEFAULTS.peakKm), oceanDepthKm: num('sOd', PLANET_DEFAULTS.oceanDepthKm), seed: Math.round(num('nSeed', PLANET_DEFAULTS.seed)), }; } function solvePainted() { if (!paintRaster || !paintLegend || !paintImage) return; importBtn.disabled = true; importBtn.textContent = 'Solving…'; clearReapplyPending(); buildWindArrows(null); buildOceanCurrentArrows(null); if (buildOverlayTitle) buildOverlayTitle.textContent = 'Solving the painted map'; showBuildOverlay(); const ui = document.getElementById('ui'); if (window.innerWidth <= 768 && ui) ui.classList.add('collapsed'); // The raster is transferred to the worker, so hand over a copy and keep ours for the next solve. const rasterCopy = new Uint8Array(paintRaster.classes); const legendJson = JSON.parse(serializeLegend(paintLegend)); // The overlay travels as its mark raster plus the one property a pass reads, the coast_jitter per mark. let overlayPayload = null; if (state.overlay && state.overlay.raster) { overlayPayload = { marks: new Uint8Array(state.overlay.raster), w: state.overlay.w, h: state.overlay.h, markJitter: [null, ...state.overlay.legend.marks.map(m => m.coastJitter)], }; } importPainted(rasterCopy, paintImage.w, paintImage.h, legendJson, readPaintedParams(), onProgress, shouldSkipClimate(), overlayPayload); } function reportSolved(p) { const s = p.stats; const parts = [ `Solved ${s.steps} steps in ${(s.solveMs / 1000).toFixed(1)} s over ${s.regions.toLocaleString()} regions, ${(100 * s.landFraction).toFixed(0)} % land.`, `Relief scaled ×${s.kmScale.toFixed(2)} so the 99.5th percentile stands at ${trimNum(+p.params.peakKm)} km (highest ${s.maxKm.toFixed(2)} km).`, ]; if (s.coastFlipped) parts.push(`The coast moved on ${s.coastFlipped.toLocaleString()} regions.`); if (s.overlay) parts.push(`Overlay marks on ${s.overlay.marked.toLocaleString()} regions, ${s.overlay.pinned.toLocaleString()} with the coast pinned.`); if (s.strokesToEdge || s.strokesDissolved) parts.push(`${s.strokesToEdge.toLocaleString()} stroke regions became the polar class, ${s.strokesDissolved.toLocaleString()} dissolved into their neighbours.`); paintSolvedEl.textContent = parts.join(' '); paintSolvedEl.style.display = 'block'; } // Scripted use (tests, tooling): load a painting and a legend by URL, then solve. window.orogenPainted = { async load(imageUrl, legendUrl, planetUrl) { setSource('painted'); if (legendUrl) { const json = await (await fetch(legendUrl)).json(); if (!applyLegend(json, legendUrl.split('/').pop())) throw new Error('The legend was rejected'); } if (planetUrl) { const json = await (await fetch(planetUrl)).json(); if (!applyPlanet(json, planetUrl.split('/').pop())) throw new Error('Planet.json was rejected'); } await loadPaintingFromUrl(imageUrl); return { far: paintRaster.far, total: paintRaster.total, counts: Array.from(paintRaster.counts) }; }, async loadOverlay(imageUrl, legendUrl) { const json = await (await fetch(legendUrl)).json(); if (!applyOverlayLegend(json, legendUrl.split('/').pop())) throw new Error('The overlay legend was rejected'); await loadOverlayFromUrl(imageUrl); return this.overlay(); }, overlay() { const rep = state.overlay && state.overlay.report; return rep ? { painted: rep.total - rep.blank, total: rep.total, far: rep.far, counts: Array.from(rep.counts), sheet: [state.overlay.sheet.width, state.overlay.sheet.height] } : null; }, loadStudio: loadFromStudio, solve() { solvePainted(); }, params: readPaintedParams, planet() { return paintLegend ? { ...paintLegend.planet } : null; }, angles() { return paintLegend ? paintLegend.classes.map(c => ({ name: c.name, ...(classAngles(c, paintLegend.planet) || {}) })) : null; }, }; // ─── Detail slider ──────────────────────────────────────────────── const AUTO_CLIMATE_THRESHOLD = 300000; const WARN_ORANGE = state.isTouchDevice ? 200000 : 640000; const WARN_RED = state.isTouchDevice ? 500000 : 1280000; function shouldSkipClimate() { return detailFromSlider(+document.getElementById('sN').value) > AUTO_CLIMATE_THRESHOLD; } function updateDetailWarning(detail) { const cg = document.getElementById('sN').closest('.cg'); const warn = document.getElementById('detailWarn'); cg.classList.remove('detail-orange', 'detail-red'); warn.className = 'detail-warn'; if (detail > WARN_RED) { cg.classList.add('detail-red'); warn.classList.add('red'); warn.textContent = '\u26A0 Very high \u2014 generation may be slow and unstable'; } else if (detail > WARN_ORANGE) { cg.classList.add('detail-orange'); warn.classList.add('orange'); warn.textContent = '\u26A0 High detail \u2014 generation may be slow and unstable'; } else { warn.textContent = ''; } } // Slider tooltip function initSliderTooltip(slider) { const cg = slider.closest('.cg'); if (!cg) return; cg.style.position = 'relative'; const tip = document.createElement('div'); tip.className = 'slider-tooltip'; cg.appendChild(tip); function positionTip() { const pct = (+slider.value - +slider.min) / (+slider.max - +slider.min); tip.style.left = (pct * slider.offsetWidth) + 'px'; } slider.addEventListener('pointerdown', () => { const vEl = document.getElementById(slider.id.replace('s', 'v')); if (vEl) tip.textContent = vEl.textContent; positionTip(); tip.classList.add('visible'); }); slider.addEventListener('input', () => { const vEl = document.getElementById(slider.id.replace('s', 'v')); if (vEl) tip.textContent = vEl.textContent; positionTip(); }); const hide = () => tip.classList.remove('visible'); slider.addEventListener('pointerup', hide); slider.addEventListener('pointercancel', hide); } // Wire sliders for (const [s, v] of [['sN','vN'],['sTw','vTw'],['sS','vS'],['sGl','vGl'],['sHEr','vHEr'],['sTEr','vTEr'],['sRs','vRs'],['sTmp','vTmp'],['sPrc','vPrc']]) { const slider = document.getElementById(s); if (!slider) continue; initSliderTooltip(slider); slider.addEventListener('input', e => { if (s === 'sN') { const detail = detailFromSlider(+e.target.value); document.getElementById(v).textContent = detail.toLocaleString(); updateDetailWarning(detail); } else if (s === 'sTmp') { const val = +e.target.value; document.getElementById(v).textContent = (val > 0 ? '+' : val === 0 ? '\u00b1' : '') + val + '\u00b0C'; } else if (s === 'sPrc') { const val = +e.target.value; const pct = Math.round(val * 50); document.getElementById(v).textContent = (pct > 0 ? '+' : pct === 0 ? '\u00b1' : '') + pct + '%'; } else { document.getElementById(v).textContent = e.target.value; } if (s === 'sTw' || s === 'sS' || s === 'sGl' || s === 'sHEr' || s === 'sTEr' || s === 'sRs') { markReapplyPending(); } }); // Climate sliders: recompute only on release (change), not every drag tick if (s === 'sTmp' || s === 'sPrc') { slider.addEventListener('change', () => { if (!state.curData) return; showBuildOverlay(); computeClimateViaWorker(onProgress, () => { hideBuildOverlay(); updateMeshColors(); updateLegend(state.debugLayer); }); }); } } // ─── Reapply ────────────────────────────────────────────────────── const reapplyBtn = document.getElementById('reapplyBtn'); function markReapplyPending() { if (!state.curData) return; // only after first import reapplyBtn.disabled = false; reapplyBtn.classList.add('ready'); } function clearReapplyPending() { reapplyBtn.disabled = true; reapplyBtn.classList.remove('ready'); } function reapplyPostProcessing() { const d = state.curData; if (!d || !d.prePostElev) return; const skipClimate = shouldSkipClimate(); reapplyViaWorker(() => { reapplyBtn.classList.remove('spinning'); if (skipClimate && CLIMATE_LAYERS.has(state.debugLayer)) { state.debugLayer = ''; if (debugLayerEl) debugLayerEl.value = ''; syncTabsToLayer(''); updateMeshColors(); updateLegend(''); } }, skipClimate); } reapplyBtn.addEventListener('click', () => { if (reapplyBtn.disabled) return; clearReapplyPending(); reapplyBtn.classList.add('spinning'); reapplyPostProcessing(); }); // ─── Import button ──────────────────────────────────────────────── importBtn.addEventListener('click', () => { if (source === 'painted') { solvePainted(); return; } if (!storedGrayscale) return; importBtn.disabled = true; importBtn.textContent = 'Importing\u2026'; clearReapplyPending(); buildWindArrows(null); buildOceanCurrentArrows(null); if (buildOverlayTitle) buildOverlayTitle.textContent = 'Importing heightmap'; showBuildOverlay(); // Collapse bottom sheet on mobile const ui = document.getElementById('ui'); if (window.innerWidth <= 768 && ui) ui.classList.add('collapsed'); // Copy grayscale since the buffer will be transferred const grayCopy = new Uint8Array(storedGrayscale); importHeightmap(grayCopy, storedWidth, storedHeight, onProgress, shouldSkipClimate()); }); // Generate-done handler (fired from generate.js after 'done' message) const genBtn = document.getElementById('importBtn'); // The generate.js dispatches 'generate-done' on #generate, but for import page // we use the same element ID pattern. Actually generate.js dispatches on // document.getElementById('generate'). Since import page has no #generate, // we need to listen on the actual element. Let me check... // Actually, generate.js does: document.getElementById('generate').dispatchEvent(...) // Since import page doesn't have a #generate element, we need a workaround. // The cleanest approach: add a hidden #generate element, or listen differently. // For now, we'll create a hidden generate button to receive the event const hiddenGenBtn = document.createElement('button'); hiddenGenBtn.id = 'generate'; hiddenGenBtn.style.display = 'none'; document.body.appendChild(hiddenGenBtn); hiddenGenBtn.addEventListener('generate-done', () => { hideBuildOverlay(); importBtn.textContent = ''; updateImportButton(); state.importedHeightmap = true; const painted = !!(state.curData && state.curData.painted); for (const opt of document.querySelectorAll('#paintedInspectGroup option, #paintedExportGroup option')) opt.disabled = !painted; syncOverlayControls(); if (painted) reportSolved(state.curData.painted); else paintSolvedEl.style.display = 'none'; if (!painted && PAINTED_LAYERS.has(state.debugLayer)) { state.debugLayer = ''; if (debugLayerEl) debugLayerEl.value = ''; syncTabsToLayer(''); updateMeshColors(); } // Update info text const infoEl = document.getElementById('info'); if (infoEl) infoEl.textContent = 'Drag to rotate \u00b7 Scroll to zoom'; // Sync view if (!state.climateComputed && CLIMATE_LAYERS.has(state.debugLayer)) { state.debugLayer = ''; if (debugLayerEl) debugLayerEl.value = ''; syncTabsToLayer(''); updateMeshColors(); } syncTabsToLayer(state.debugLayer); if (debugLayerEl) debugLayerEl.value = state.debugLayer; updateLegend(state.debugLayer); // Rebuild arrows if needed const v = state.debugLayer; const isWindLayer = v === 'pressureSummer' || v === 'pressureWinter' || v === 'windSpeedSummer' || v === 'windSpeedWinter'; const isOceanLayer = v === 'oceanCurrentSummer' || v === 'oceanCurrentWinter'; if (isWindLayer) buildWindArrows(v.includes('Winter') ? 'winter' : 'summer'); else if (isOceanLayer) buildOceanCurrentArrows(v.includes('Winter') ? 'winter' : 'summer'); }); // ─── Climate layers ─────────────────────────────────────────────── const CLIMATE_LAYERS = new Set([ 'pressureSummer', 'pressureWinter', 'windSpeedSummer', 'windSpeedWinter', 'oceanCurrentSummer', 'oceanCurrentWinter', 'precipSummer', 'precipWinter', 'rainShadowSummer', 'rainShadowWinter', 'tempSummer', 'tempWinter', 'koppen', 'biome', 'continentality' ]); // ─── Visualization (debug layers, tabs, legend) ─────────────────── const mapTabs = document.getElementById('mapTabs'); const vizLegend = document.getElementById('vizLegend'); const debugLayerEl = document.getElementById('debugLayer'); function switchVisualization(layer) { if (CLIMATE_LAYERS.has(layer) && !state.climateComputed) { showBuildOverlay(); computeClimateViaWorker(onProgress, () => { hideBuildOverlay(); applyLayer(layer); }); return; } applyLayer(layer); } function applyLayer(layer) { state.debugLayer = layer; setOverlayVisible(); state.hoveredKoppen = -1; updateMeshColors(); const isWindLayer = layer === 'pressureSummer' || layer === 'pressureWinter' || layer === 'windSpeedSummer' || layer === 'windSpeedWinter'; const isOceanLayer = layer === 'oceanCurrentSummer' || layer === 'oceanCurrentWinter'; if (isOceanLayer) { buildWindArrows(null); buildOceanCurrentArrows(layer.includes('Winter') ? 'winter' : 'summer'); } else if (isWindLayer) { buildOceanCurrentArrows(null); buildWindArrows(layer.includes('Winter') ? 'winter' : 'summer'); } else { buildWindArrows(null); buildOceanCurrentArrows(null); } updateLegend(layer); } function syncTabsToLayer(layer) { mapTabs.querySelectorAll('.map-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.layer === layer); }); const mvs = document.getElementById('mobileViewSwitch'); if (mvs && [...mvs.options].some(o => o.value === layer)) { mvs.value = layer; } } mapTabs.addEventListener('click', (e) => { const tab = e.target.closest('.map-tab'); if (!tab) return; const layer = tab.dataset.layer; mapTabs.querySelectorAll('.map-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); if (debugLayerEl) debugLayerEl.value = layer; const mvs = document.getElementById('mobileViewSwitch'); if (mvs) mvs.value = layer; switchVisualization(layer); }); const mobileViewSwitch = document.getElementById('mobileViewSwitch'); if (mobileViewSwitch) { mobileViewSwitch.addEventListener('change', (e) => { const layer = e.target.value; syncTabsToLayer(layer); if (debugLayerEl) debugLayerEl.value = layer; switchVisualization(layer); }); } if (debugLayerEl) { debugLayerEl.addEventListener('change', (e) => { const layer = e.target.value; syncTabsToLayer(layer); switchVisualization(layer); }); } // ─── Legend ──────────────────────────────────────────────────────── const KOPPEN_DESCRIPTIONS = { Af: 'Tropical rainforest \u2014 Hot and wet year-round.', Am: 'Tropical monsoon \u2014 Brief dry season offset by heavy monsoon rains.', Aw: 'Tropical savanna \u2014 Distinct wet and dry seasons.', BWh: 'Hot desert \u2014 Extremely dry with scorching summers.', BWk: 'Cold desert \u2014 Arid with cold winters.', BSh: 'Hot steppe \u2014 Semi-arid grassland with hot summers.', BSk: 'Cold steppe \u2014 Semi-arid with cold winters.', Cfa: 'Humid subtropical \u2014 Hot humid summers, mild winters.', Cfb: 'Oceanic \u2014 Mild year-round, cool summers, frequent rain.', Cfc: 'Subpolar oceanic \u2014 Cool year-round with short summers.', Csa: 'Hot-summer Mediterranean \u2014 Dry hot summers, mild wet winters.', Csb: 'Warm-summer Mediterranean \u2014 Dry warm summers, mild wet winters.', Csc: 'Cold-summer Mediterranean \u2014 Cool dry summers, mild wet winters.', Cwa: 'Humid subtropical monsoon \u2014 Warm with dry winters.', Cwb: 'Subtropical highland \u2014 Mild with dry winters.', Cwc: 'Cold subtropical highland \u2014 Cool with dry winters.', Dfa: 'Hot-summer continental \u2014 Hot summers, cold snowy winters.', Dfb: 'Warm-summer continental \u2014 Warm summers, cold winters.', Dfc: 'Subarctic \u2014 Long cold winters, brief cool summers.', Dfd: 'Extremely cold subarctic \u2014 Harshest winters on Earth.', Dsa: 'Hot-summer continental, dry summer.', Dsb: 'Warm-summer continental, dry summer.', Dsc: 'Subarctic, dry summer.', Dsd: 'Extremely cold subarctic, dry summer.', Dwa: 'Hot-summer continental, monsoon.', Dwb: 'Warm-summer continental, monsoon.', Dwc: 'Subarctic monsoon \u2014 Brief wet summers, long frigid winters.', Dwd: 'Extremely cold subarctic, monsoon.', ET: 'Tundra \u2014 Permafrost, only warmest month above 0\u00b0C.', EF: 'Ice cap \u2014 Permanent ice, never above 0\u00b0C.', }; function updateLegend(layer) { if (!vizLegend) return; if (PAINTED_LAYERS.has(layer)) { vizLegend.innerHTML = paintedLegendHTML(layer, state.curData, state.overlay); return; } if (layer === '' || !layer) { const stops = [ { e: -0.50 }, { e: -0.25 }, { e: -0.05 }, { e: 0.00 }, { e: 0.03 }, { e: 0.15 }, { e: 0.35 }, { e: 0.55 }, { e: 0.80 } ]; const colors = stops.map(s => { const [r, g, b] = elevationToColor(s.e); return `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`; }); const pcts = stops.map((_, i) => Math.round(i / (stops.length - 1) * 100)); const gradStr = colors.map((c, i) => `${c} ${pcts[i]}%`).join(', '); vizLegend.innerHTML = `
` + `
Deep OceanSea LevelPeak
`; } else if (layer === 'koppen') { let html = '
K\u00f6ppen climate classification
'; html += '
'; for (let i = 1; i < KOPPEN_CLASSES.length; i++) { const k = KOPPEN_CLASSES[i]; const [r, g, b] = k.color; const hex = `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`; html += `
${k.code}
`; } html += '
'; html += '
'; vizLegend.innerHTML = html; const tipEl = document.getElementById('koppenTip'); const container = vizLegend.querySelector('.legend-koppen'); vizLegend.querySelectorAll('.legend-koppen-item').forEach(item => { item.addEventListener('mouseenter', () => { const code = item.dataset.code; tipEl.textContent = KOPPEN_DESCRIPTIONS[code] || ''; tipEl.classList.add('visible'); const itemRect = item.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const tipWidth = 240; let left = itemRect.left - containerRect.left + itemRect.width / 2 - tipWidth / 2; left = Math.max(0, Math.min(left, containerRect.width - tipWidth)); tipEl.style.left = left + 'px'; tipEl.style.bottom = (containerRect.bottom - itemRect.top + 6) + 'px'; const classId = KOPPEN_CLASSES.findIndex(c => c.code === code); if (classId >= 0) { state.hoveredKoppen = classId; updateKoppenHoverHighlight(); updateMapKoppenHoverHighlight(); } }); item.addEventListener('mouseleave', () => { tipEl.classList.remove('visible'); state.hoveredKoppen = -1; updateKoppenHoverHighlight(); updateMapKoppenHoverHighlight(); }); }); } else if (layer === 'biome') { const biomeStops = [ { color: [0.82,0.72,0.50], label: 'Desert' }, { color: [0.72,0.62,0.30], label: 'Steppe' }, { color: [0.42,0.50,0.18], label: 'Savanna' }, { color: [0.12,0.38,0.10], label: 'Forest' }, { color: [0.06,0.22,0.08], label: 'Taiga' }, { color: [0.35,0.32,0.22], label: 'Tundra' }, { color: [0.78,0.80,0.84], label: 'Ice' }, ]; const biomeColors = biomeStops.map(s => `rgb(${Math.round(s.color[0]*255)},${Math.round(s.color[1]*255)},${Math.round(s.color[2]*255)})`); const biomePcts = biomeStops.map((_, i) => Math.round(i / (biomeStops.length - 1) * 100)); const biomeGrad = biomeColors.map((c, i) => `${c} ${biomePcts[i]}%`).join(', '); vizLegend.innerHTML = `
` + `
${biomeStops[0].label}${biomeStops[3].label}${biomeStops[6].label}
`; } else if (layer === 'rainShadowSummer' || layer === 'rainShadowWinter') { vizLegend.innerHTML = `
` + `
Rain ShadowNeutralWindward
`; } else if (layer === 'landheightmap') { vizLegend.innerHTML = `
` + `
Ocean / Sea LevelPeak
`; } else { vizLegend.innerHTML = ''; } } // ─── Build overlay ──────────────────────────────────────────────── const buildOverlay = document.getElementById('buildOverlay'); const buildBarFill = document.getElementById('buildBarFill'); const buildBarLabel = document.getElementById('buildBarLabel'); let overlayActive = false; function onProgress(pct, label) { if (!overlayActive) return; if (buildBarFill) buildBarFill.style.transform = 'scaleX(' + (pct / 100) + ')'; if (buildBarLabel) buildBarLabel.textContent = label; } function showBuildOverlay() { if (!buildBarFill || !buildOverlay) return; buildBarFill.style.transition = 'none'; buildBarFill.style.transform = 'scaleX(0)'; buildBarLabel.textContent = ''; buildBarFill.offsetWidth; buildBarFill.style.transition = ''; overlayActive = true; buildOverlay.classList.remove('hidden'); } function hideBuildOverlay() { setTimeout(() => { overlayActive = false; if (buildOverlay) { buildOverlay.classList.add('hidden'); buildOverlay.classList.remove('initial'); } }, 500); } // ─── View mode ──────────────────────────────────────────────────── document.getElementById('chkWire').addEventListener('change', buildMesh); const gridSpacingGroup = document.getElementById('gridSpacingGroup'); document.getElementById('chkGrid').addEventListener('change', (e) => { state.gridEnabled = e.target.checked; gridSpacingGroup.style.display = state.gridEnabled ? '' : 'none'; if (state.mapMode) { if (state.mapGridMesh) state.mapGridMesh.visible = state.gridEnabled; if (state.globeGridMesh) state.globeGridMesh.visible = false; } else { if (state.globeGridMesh) state.globeGridMesh.visible = state.gridEnabled; if (state.mapGridMesh) state.mapGridMesh.visible = false; } }); document.getElementById('gridSpacing').addEventListener('change', (e) => { state.gridSpacing = parseFloat(e.target.value); rebuildGrids(); }); // Map center longitude const mapCenterLonGroup = document.getElementById('mapCenterLonGroup'); const sMapCenterLon = document.getElementById('sMapCenterLon'); const vMapCenterLon = document.getElementById('vMapCenterLon'); sMapCenterLon.addEventListener('input', () => { const lon = +sMapCenterLon.value; const suffix = lon > 0 ? 'E' : lon < 0 ? 'W' : ''; vMapCenterLon.textContent = Math.abs(lon) + '\u00B0' + suffix; state.mapCenterLon = lon * Math.PI / 180; if (state.mapMode && state.mapMesh) { const builtLon = state.mapMesh._builtCenterLon || 0; const dx = (builtLon - state.mapCenterLon) * (2 / Math.PI); state.mapMesh.position.x = dx; if (state.mapGridMesh) state.mapGridMesh.position.x = dx; syncOverlayMapCenter(); } }); sMapCenterLon.addEventListener('change', () => { if (state.mapMode) { buildMapMesh(); const layer = state.debugLayer; const isWind = layer === 'pressureSummer' || layer === 'pressureWinter' || layer === 'windSpeedSummer' || layer === 'windSpeedWinter'; const isOcean = layer === 'oceanCurrentSummer' || layer === 'oceanCurrentWinter'; if (isWind) buildWindArrows(layer.includes('Winter') ? 'winter' : 'summer'); if (isOcean) buildOceanCurrentArrows(layer.includes('Winter') ? 'winter' : 'summer'); } }); // Globe / Map toggle document.getElementById('viewMode').addEventListener('change', (e) => { state.mapMode = e.target.value === 'map'; if (state.mapMode) { if (state.planetMesh) state.planetMesh.visible = false; waterMesh.visible = false; atmosMesh.visible = false; starsMesh.visible = false; if (state.wireMesh) state.wireMesh.visible = false; if (state.arrowGroup) state.arrowGroup.visible = false; if (!state.mapMesh) { showBuildOverlay(); onProgress(0, 'Building map mesh\u2026'); setTimeout(() => { buildMapMesh(); if (state.mapMesh) state.mapMesh.visible = true; hideBuildOverlay(); }, 50); } if (state.mapMesh) state.mapMesh.visible = true; if (state.mapGridMesh) state.mapGridMesh.visible = state.gridEnabled; if (state.globeGridMesh) state.globeGridMesh.visible = false; if (state.windArrowGroup) { state.windArrowGroup.traverse(c => { if (c.name === 'windGlobe') c.visible = false; if (c.name === 'windMap') c.visible = true; }); } if (state.oceanCurrentArrowGroup) { state.oceanCurrentArrowGroup.traverse(c => { if (c.name === 'oceanGlobe') c.visible = false; if (c.name === 'oceanMap') c.visible = true; }); } scene.background = new THREE.Color(0x1a1a2e); ctrl.enabled = false; mapCtrl.enabled = true; mapCamera.position.set(0, 0, 5); mapCamera.lookAt(0, 0, 0); updateMapCameraFrustum(); mapCtrl.target.set(0, 0, 0); mapCtrl.update(); mapCenterLonGroup.style.display = ''; setOverlayVisible(); } else { if (state.planetMesh) state.planetMesh.visible = true; atmosMesh.visible = true; starsMesh.visible = true; if (state.wireMesh) state.wireMesh.visible = true; if (state.arrowGroup) state.arrowGroup.visible = true; if (state.mapMesh) state.mapMesh.visible = false; if (state.mapGridMesh) state.mapGridMesh.visible = false; if (state.globeGridMesh) state.globeGridMesh.visible = state.gridEnabled; if (state.windArrowGroup) { state.windArrowGroup.traverse(c => { if (c.name === 'windGlobe') c.visible = true; if (c.name === 'windMap') c.visible = false; }); } if (state.oceanCurrentArrowGroup) { state.oceanCurrentArrowGroup.traverse(c => { if (c.name === 'oceanGlobe') c.visible = true; if (c.name === 'oceanMap') c.visible = false; }); } waterMesh.visible = !state.debugLayer; scene.background = new THREE.Color(0x030308); mapCtrl.enabled = false; ctrl.enabled = true; mapCenterLonGroup.style.display = 'none'; setOverlayVisible(); } }); // ─── Export modal ───────────────────────────────────────────────── (function initExport() { const overlay = document.getElementById('exportOverlay'); const closeBtn = document.getElementById('exportClose'); const cancelBtn = document.getElementById('exportCancel'); const goBtn = document.getElementById('exportGo'); const widthEl = document.getElementById('exportWidth'); const dimsEl = document.getElementById('exportDims'); const typeEl = document.getElementById('exportType'); const openBtn = document.getElementById('exportBtn'); function updateDims() { const w = +widthEl.value; dimsEl.textContent = w + ' \u00D7 ' + (w / 2); } function openModal() { overlay.classList.remove('hidden'); updateDims(); const painted = !!(state.curData && state.curData.painted); for (const opt of typeEl.options) { if (opt.value === 'biome' || opt.value === 'koppen') { opt.disabled = !state.climateComputed; if (opt.disabled && typeEl.value === opt.value) typeEl.value = 'color'; } else if (opt.value in PAINTED_EXPORT_LABELS) { opt.disabled = !painted || (opt.value === 'overlay' && !state.overlay); if (opt.disabled && typeEl.value === opt.value) typeEl.value = 'color'; } } } function closeModal() { overlay.classList.add('hidden'); } openBtn.addEventListener('click', openModal); closeBtn.addEventListener('click', closeModal); cancelBtn.addEventListener('click', closeModal); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !overlay.classList.contains('hidden')) closeModal(); }); widthEl.addEventListener('change', updateDims); goBtn.addEventListener('click', async () => { const type = typeEl.value; const w = +widthEl.value; closeModal(); showBuildOverlay(); onProgress(0, 'Preparing export...'); await exportMap(type, w, onProgress); hideBuildOverlay(); }); const exportAllBtn = document.getElementById('exportAllGo'); const EXPORT_ALL_TYPES = [ { type: 'biome', label: 'Satellite' }, { type: 'koppen', label: 'Climate' }, { type: 'landheightmap', label: 'Heightmap' }, { type: 'landmask', label: 'Land Mask' }, ]; exportAllBtn.addEventListener('click', async () => { const w = +widthEl.value; closeModal(); showBuildOverlay(); if (!state.climateComputed) { onProgress(0, 'Computing climate...'); await new Promise(resolve => computeClimateViaWorker(onProgress, resolve)); } const types = EXPORT_ALL_TYPES.slice(); if (state.curData && state.curData.painted) { for (const [type, label] of Object.entries(PAINTED_EXPORT_LABELS)) { if (type === 'overlay' && !state.overlay) continue; types.push({ type, label }); } } await exportMapBatch(types, w, onProgress); hideBuildOverlay(); }); installUnrealExportButton(); })(); // ─── Sidebar toggle + bottom sheet ──────────────────────────────── const sidebarToggle = document.getElementById('sidebarToggle'); const uiPanel = document.getElementById('ui'); const isMobileLayout = () => window.innerWidth <= 768; if (isMobileLayout()) { uiPanel.classList.add('collapsed'); } sidebarToggle.addEventListener('click', () => { const collapsed = uiPanel.classList.toggle('collapsed'); sidebarToggle.innerHTML = collapsed ? '\u00BB' : '\u00AB'; sidebarToggle.title = collapsed ? 'Show panel' : 'Collapse panel'; }); (function initBottomSheet() { const handle = document.getElementById('sheetHandle'); if (!handle) return; let startY = 0, startTransform = 0, dragging = false; let lastY = 0, lastTime = 0, velocity = 0; let didDrag = false; let rafId = 0, pendingY = null; function getTranslateY() { const st = getComputedStyle(uiPanel); const m = new DOMMatrix(st.transform); return m.m42; } function getCollapsedY() { return uiPanel.offsetHeight - 60; } function applyTransform() { if (pendingY !== null) { uiPanel.style.transform = `translateY(${pendingY}px)`; pendingY = null; } rafId = 0; } function scheduleTransform(y) { pendingY = y; if (!rafId) rafId = requestAnimationFrame(applyTransform); } function cleanup() { dragging = false; uiPanel.style.transition = ''; uiPanel.classList.remove('dragging'); if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } pendingY = null; } handle.addEventListener('pointerdown', (e) => { if (!isMobileLayout()) return; e.preventDefault(); handle.setPointerCapture(e.pointerId); dragging = true; didDrag = false; startY = e.clientY; lastY = e.clientY; lastTime = performance.now(); velocity = 0; startTransform = uiPanel.classList.contains('collapsed') ? getTranslateY() : 0; uiPanel.style.transition = 'none'; uiPanel.classList.add('dragging'); }); handle.addEventListener('pointermove', (e) => { if (!dragging) return; const y = e.clientY; const now = performance.now(); const dt = now - lastTime; if (dt > 0) velocity = (y - lastY) / dt; lastY = y; lastTime = now; const dy = y - startY; if (Math.abs(dy) > 5) didDrag = true; const collapsedY = getCollapsedY(); scheduleTransform(Math.max(0, Math.min(collapsedY, startTransform + dy))); }); handle.addEventListener('pointerup', (e) => { if (!dragging) return; handle.releasePointerCapture(e.pointerId); cleanup(); const curY = getTranslateY(); const collapsedY = getCollapsedY(); const progress = collapsedY > 0 ? 1 - curY / collapsedY : 0; if (velocity > 0.3 || (velocity > -0.3 && progress < 0.3)) { uiPanel.classList.add('collapsed'); } else { uiPanel.classList.remove('collapsed'); } uiPanel.style.transform = ''; }); handle.addEventListener('pointercancel', (e) => { if (!dragging) return; try { handle.releasePointerCapture(e.pointerId); } catch (_) {} cleanup(); uiPanel.style.transform = ''; }); handle.addEventListener('click', () => { if (!isMobileLayout()) return; if (didDrag) { didDrag = false; return; } uiPanel.classList.toggle('collapsed'); }); })(); // ─── Mobile info text ───────────────────────────────────────────── if (state.isTouchDevice) { const infoEl = document.getElementById('info'); if (infoEl) infoEl.textContent = 'Import a heightmap to get started'; } // Disable export widths > 8192 on touch devices if (state.isTouchDevice) { const exportWidth = document.getElementById('exportWidth'); if (exportWidth) { for (const opt of exportWidth.options) { if (+opt.value > 8192) { opt.disabled = true; opt.textContent = opt.value + ' (too large for mobile)'; } } } } // ─── Orientation change ─────────────────────────────────────────── window.addEventListener('orientationchange', () => { setTimeout(() => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); updateMapCameraFrustum(); renderer.setSize(innerWidth, innerHeight); }, 100); }); // ─── Animation loop ─────────────────────────────────────────────── function animate() { requestAnimationFrame(animate); if (state.mapMode) { tickMapZoom(); mapCtrl.update(); } else { tickZoom(); ctrl.update(); } if (!state.mapMode && state.planetMesh && document.getElementById('chkRotate').checked) { state.planetMesh.rotation.y += 0.0008; waterMesh.rotation.y = state.planetMesh.rotation.y; if (state.wireMesh) state.wireMesh.rotation.y = state.planetMesh.rotation.y; if (state.arrowGroup) state.arrowGroup.rotation.y = state.planetMesh.rotation.y; if (state.windArrowGroup) state.windArrowGroup.rotation.y = state.planetMesh.rotation.y; if (state.oceanCurrentArrowGroup) state.oceanCurrentArrowGroup.rotation.y = state.planetMesh.rotation.y; if (state.globeGridMesh) state.globeGridMesh.rotation.y = state.planetMesh.rotation.y; } renderer.render(scene, state.mapMode ? mapCamera : camera); } // ─── Resize handler ─────────────────────────────────────────────── window.addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); updateMapCameraFrustum(); renderer.setSize(innerWidth, innerHeight); }); // ─── Hover info (analytical ray-sphere, no mesh raycasting) ─────── (function initHoverInfo() { const hoverEl = document.getElementById('hoverInfo'); if (!hoverEl) return; const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); const _inverseMatrix = new THREE.Matrix4(); const _localRay = new THREE.Ray(); const HOVER_INTERVAL = 50; // ms throttle let lastHoverTime = 0; let lastRegion = -1; /** Find nearest region to a unit-sphere direction (max dot product). */ function findNearestRegion(nx, ny, nz) { const { mesh, r_xyz } = state.curData; const N = mesh.numRegions; let bestDot = -2, bestR = -1; for (let r = 0; r < N; r++) { const dot = nx * r_xyz[3 * r] + ny * r_xyz[3 * r + 1] + nz * r_xyz[3 * r + 2]; if (dot > bestDot) { bestDot = dot; bestR = r; } } return bestR; } function getHitRegionGlobe(e) { if (!state.planetMesh) return -1; const rect = canvas.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); _inverseMatrix.copy(state.planetMesh.matrixWorld).invert(); _localRay.copy(raycaster.ray).applyMatrix4(_inverseMatrix); const ox = _localRay.origin.x, oy = _localRay.origin.y, oz = _localRay.origin.z; const dx = _localRay.direction.x, dy = _localRay.direction.y, dz = _localRay.direction.z; const R = 1.08; const b = 2 * (ox * dx + oy * dy + oz * dz); const c = ox * ox + oy * oy + oz * oz - R * R; const disc = b * b - 4 * c; if (disc < 0) return -1; const t = (-b - Math.sqrt(disc)) * 0.5; if (t < 0) return -1; const hx = ox + t * dx, hy = oy + t * dy, hz = oz + t * dz; const len = Math.sqrt(hx * hx + hy * hy + hz * hz) || 1; return findNearestRegion(hx / len, hy / len, hz / len); } function getHitRegionMap(e) { if (!state.mapMesh) return -1; const rect = canvas.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, mapCamera); const o = raycaster.ray.origin, d = raycaster.ray.direction; if (Math.abs(d.z) < 1e-10) return -1; const t = -o.z / d.z; const wx = o.x + t * d.x, wy = o.y + t * d.y; const PI = Math.PI, sx = 2 / PI; let lon = wx / sx + (state.mapCenterLon || 0); const lat = wy / sx; if (lat < -PI / 2 || lat > PI / 2) return -1; if (lon > PI) lon -= 2 * PI; else if (lon < -PI) lon += 2 * PI; const cosLat = Math.cos(lat); return findNearestRegion(cosLat * Math.sin(lon), Math.sin(lat), cosLat * Math.cos(lon)); } function updateHoverInfo(e) { if (!state.curData) return; const now = performance.now(); if (now - lastHoverTime < HOVER_INTERVAL) return; lastHoverTime = now; const r = state.mapMode ? getHitRegionMap(e) : getHitRegionGlobe(e); if (r === lastRegion) return; // no change lastRegion = r; if (r < 0) { hoverEl.style.display = 'none'; return; } showRegionInfo(r); } function showRegionInfo(r) { const d = state.curData; if (!d || r < 0 || r >= d.mesh.numRegions) { hoverEl.style.display = 'none'; return; } const x = d.r_xyz[3*r], y = d.r_xyz[3*r+1], z = d.r_xyz[3*r+2]; const lat = Math.asin(Math.max(-1, Math.min(1, y))) * 180 / Math.PI; const lon = Math.atan2(x, z) * 180 / Math.PI; const elev = d.r_elevation[r]; const heightKm = elev <= 0 ? (elev * 10).toFixed(1) : (6 * elev * elev).toFixed(1); const isOcean = elev <= 0; let html = `Elev ${heightKm} km (${isOcean ? 'ocean' : 'land'})
`; html += `Coord ${Math.abs(lat).toFixed(1)}\u00b0${lat >= 0 ? 'N' : 'S'}, ${Math.abs(lon).toFixed(1)}\u00b0${lon >= 0 ? 'E' : 'W'}`; if (d.painted && d.debugLayers && d.debugLayers.paintClass) { const c = d.painted.legend.classes[d.debugLayers.paintClass[r]]; if (c) { let extra; if (c.sea || !d.debugLayers.paintUplift || d.debugLayers.paintUplift[r] < 0) { extra = `${c.depthM} m class depth`; } else { extra = `${d.debugLayers.paintUplift[r].toFixed(3)} mm/yr uplift`; if (d.debugLayers.flow && d.debugLayers.flow[r] >= 0) { const cells = Math.round(Math.pow(10, d.debugLayers.flow[r])); extra += cells === 1 ? ', drains only itself' : `, drains ${cells.toLocaleString()} cells`; } } html += `
Class ${c.name} \u2014 ${extra}`; } } if (state.overlay && d.debugLayers && d.debugLayers.overlayMark) { const mi = d.debugLayers.overlayMark[r]; const m = mi ? state.overlay.legend.marks[mi - 1] : null; if (m) { const coast = m.coastJitter === null ? '' : (m.coastJitter === 0 ? ' (coast pinned)' : ` (coast \u00d7${m.coastJitter})`); html += `
Mark ${m.name}${coast}`; } } if (d.r_temperature_summer && d.r_precip_summer) { const ts = d.r_temperature_summer[r], tw = d.r_temperature_winter[r]; const ps = d.r_precip_summer[r], pw = d.r_precip_winter[r]; const tAvg = ((ts + tw) / 2).toFixed(1); const pTotal = Math.round(ps + pw); html += `
Temp ${tAvg}\u00b0C avg (${ts.toFixed(1)} summer, ${tw.toFixed(1)} winter)`; html += `
Prec ${pTotal} mm/yr`; } if (d.debugLayers?.koppen) { const kIdx = d.debugLayers.koppen[r]; if (kIdx > 0 && kIdx < KOPPEN_CLASSES.length) { const k = KOPPEN_CLASSES[kIdx]; html += `
Clim ${k.code} \u2014 ${k.name}`; } } hoverEl.innerHTML = html; hoverEl.style.display = 'block'; } canvas.addEventListener('mousemove', updateHoverInfo); canvas.addEventListener('mouseleave', () => { lastRegion = -1; hoverEl.style.display = 'none'; }); })(); // ─── Screenshot helper ──────────────────────────────────────────── window.takePreview = function(width = 1200, height = 630) { const savedW = renderer.domElement.width; const savedH = renderer.domElement.height; const savedAspect = camera.aspect; const savedPixelRatio = renderer.getPixelRatio(); const hiddenEls = []; for (const sel of ['#ui', '#topInfo', '#info', '#hoverInfo', '#helpBtn', '#editToggle', '#refreshFab', '#rebuildFab', '#mobileViewSwitch', '#buildOverlay', '#tutorialOverlay', '#exportOverlay', '#whatsNewOverlay']) { const el = document.querySelector(sel); if (el && el.style.display !== 'none') { hiddenEls.push({ el, prev: el.style.display }); el.style.display = 'none'; } } camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setPixelRatio(1); renderer.setSize(width, height); renderer.render(scene, state.mapMode ? mapCamera : camera); const link = document.createElement('a'); link.download = 'preview.png'; link.href = renderer.domElement.toDataURL('image/png'); link.click(); renderer.setPixelRatio(savedPixelRatio); renderer.setSize(savedW / savedPixelRatio, savedH / savedPixelRatio); camera.aspect = savedAspect; camera.updateProjectionMatrix(); for (const { el, prev } of hiddenEls) el.style.display = prev; renderer.render(scene, state.mapMode ? mapCamera : camera); console.log('preview.png downloaded!'); }; // ─── Start ──────────────────────────────────────────────────────── animate();