1643 lines
73 KiB
JavaScript
1643 lines
73 KiB
JavaScript
// 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();
|