Files
UnrealPrototyping/Tools/Orogen/js/import-main.js
T
2026-09-25 17:02:24 +03:00

1643 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `<span title="Divide ${f(a.divide)}\u00b0 (the steepest ground this rate makes), P90 ${f(a.p90)}\u00b0">${f(a.median)}\u00b0</span>`;
if (a.clamped) html += ` <span class="clamped" title="The divide is past the angle of repose, so the repose clamp shapes this ground rather than the rivers">clamped</span>`;
html += `<span class="muted">${a.readsAs}</span>`;
if (a.floor) html += `<span class="muted" title="The plain between the massifs, at ${a.floor.rateMmYr} mm/yr: divide ${f(a.floor.divide)}\u00b0">floor ${f(a.floor.median)}\u00b0 ${a.floor.readsAs}</span>`;
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 = `<span class="sw" style="background:rgb(${c.rgb.join(',')})"></span>`;
let painted = '';
if (c.derived) painted = '<span class="muted">derived</span>';
else if (rep) painted = (100 * rep.counts[c.index] / rep.total).toFixed(1) + ' %';
let value, k = '';
if (c.stroke) {
value = `<span class="muted">stroke → ${c.edgeClass ? c.edgeClass + ' at the poles, else' : ''} nearest</span>`;
} else if (c.sea) {
value = `<input type="number" data-i="${c.index}" data-f="depthM" value="${c.depthM}" min="0" step="10" aria-label="${c.name} depth in metres">`;
} else {
value = `<input type="number" data-i="${c.index}" data-f="upliftMmYr" value="${c.upliftMmYr}" min="0" step="0.01" aria-label="${c.name} uplift in mm per year">`;
k = `<input type="number" data-i="${c.index}" data-f="kMult" value="${c.kMult}" min="0.05" step="0.1" aria-label="${c.name} erodibility multiplier">`;
}
const tag = c.massif ? ' <span class="muted" title="A plain with hill masses standing out of it: the rate is what the massifs reach, the floor is the ground between them">massif</span>' : '';
rows.push(`<tr><td>${sw}</td><td>${c.name}${tag}</td><td>${painted}</td><td>${value}</td><td>${k}</td><td class="slope" data-slope="${c.index}">${slopeCellHTML(c)}</td></tr>`);
}
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 = `<div class="legend-gradient" style="background:linear-gradient(to right,${gradStr})"></div>` +
`<div class="legend-labels"><span>Deep Ocean</span><span>Sea Level</span><span>Peak</span></div>`;
} else if (layer === 'koppen') {
let html = '<div class="legend-koppen-header"><a href="https://en.wikipedia.org/wiki/K%C3%B6ppen_climate_classification" target="_blank" rel="noopener">K\u00f6ppen climate classification</a></div>';
html += '<div class="legend-koppen">';
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 += `<div class="legend-koppen-item" data-code="${k.code}"><span class="legend-koppen-swatch" style="background:${hex}"></span>${k.code}</div>`;
}
html += '<div class="legend-koppen-tooltip" id="koppenTip"></div>';
html += '</div>';
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 = `<div class="legend-gradient" style="background:linear-gradient(to right,${biomeGrad})"></div>` +
`<div class="legend-labels"><span>${biomeStops[0].label}</span><span>${biomeStops[3].label}</span><span>${biomeStops[6].label}</span></div>`;
} else if (layer === 'rainShadowSummer' || layer === 'rainShadowWinter') {
vizLegend.innerHTML = `<div class="legend-gradient" style="background:linear-gradient(to right,rgb(230,51,33) 0%,rgb(140,140,148) 50%,rgb(38,102,243) 100%)"></div>` +
`<div class="legend-labels"><span>Rain Shadow</span><span>Neutral</span><span>Windward</span></div>`;
} else if (layer === 'landheightmap') {
vizLegend.innerHTML = `<div class="legend-gradient" style="background:linear-gradient(to right,#000 0%,#fff 100%)"></div>` +
`<div class="legend-labels"><span>Ocean / Sea Level</span><span>Peak</span></div>`;
} 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 = `<span class="hi-label">Elev</span> ${heightKm} km (${isOcean ? 'ocean' : 'land'})<br>`;
html += `<span class="hi-label">Coord</span> ${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 += `<br><span class="hi-label">Class</span> ${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 += `<br><span class="hi-label">Mark</span> ${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 += `<br><span class="hi-label">Temp</span> ${tAvg}\u00b0C avg (${ts.toFixed(1)} summer, ${tw.toFixed(1)} winter)`;
html += `<br><span class="hi-label">Prec</span> ${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 += `<br><span class="hi-label">Clim</span> ${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();