// 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 = '';
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();