Files
2026-09-25 17:02:24 +03:00

1900 lines
90 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<meta charset="utf-8">
<title>terrain studio</title>
<style>
:root {
/* The stage keeps its dark chrome: the toast and the cursor readout float over the world map,
not over the rail, and a pale plate there would hide what it is describing. */
--bg: #14181d; --line: #2b323b; --ink: #d8dee6; --dim: #8b95a1; --ok: #6fbf73;
/* The rail is a minihompy column. Pastel surfaces, one accent, and dark ink on every reading. */
--cream: #fff7ee; --paper: #ffffff; --mink: #3d2b34; --mink2: #6b5560; --mline: #dcc2cd;
/* --accd carries text, so it is the one pastel that is not pastel: 5.6:1 on white, 5.3:1 on cream. */
--acc: #ffa8bd; --accd: #b8365a; --accl: #fff2f5; --lemon: #ffe9a8;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
font: 13px/1.45 ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; }
#app { display: grid; grid-template-columns: 300px 1fr; height: 100%; }
/* The void is a CSS gradient rather than a fill on the canvas: a flat colour reads as a hole cut in the
page and a shallow vertical grade reads as space, and putting it here is what lets the fragment shader
simply drop every pixel that is not world. touch-action keeps a pen or a trackpad from turning a stroke
into a browser gesture. */
#stage { position: relative; overflow: hidden; touch-action: none;
background: linear-gradient(#0c1016, #070a0d); }
#stage canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
canvas#view { cursor: crosshair; }
/* The chrome sheet carries everything that is a line or a letter over the world - its top and bottom
edges, the region numbers, the brush ring. It never takes a pointer event: the brush is on the GL
canvas underneath it. */
canvas#chrome { pointer-events: none; }
/* Floating over the map, so they are blurred rather than opaque: a readout has to be legible over whatever
the world happens to put under it, and still let you see what it is describing. */
#hud { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; border-radius: 10px;
background: rgba(10,13,17,.72); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(216,222,230,.14); color: #c7cedb; pointer-events: none; white-space: pre;
font-size: 11.5px; box-shadow: 0 2px 10px rgba(0,0,0,.35); }
#msg { position: absolute; left: 12px; top: 12px; padding: 8px 11px; border-radius: 10px;
background: rgba(10,13,17,.82); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(216,222,230,.18); color: #dfe5ee; max-width: 70%; display: none;
box-shadow: 0 4px 16px rgba(0,0,0,.4); }
#msg.err { border-color: #e0785c; color: #ffc9b8; }
#msg.ok { border-color: #7fd08a; color: #cdf0d2; }
/* ---- the rail --------------------------------------------------------------------------------- */
#side { background: var(--cream); color: var(--mink); border-right: 1.5px solid var(--mline);
overflow-y: auto; padding: 10px; line-height: 1.5; font-size: 12px;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", system-ui, sans-serif; }
#side::-webkit-scrollbar { width: 10px; }
#side::-webkit-scrollbar-track { background: #f6e6dc; }
#side::-webkit-scrollbar-thumb { background: var(--acc); border: 2px solid #f6e6dc; border-radius: 6px; }
#side::-webkit-scrollbar-thumb:hover { background: var(--accd); }
#side ::selection { background: var(--acc); color: #2a1b23; }
#side :focus-visible { outline: 2px solid var(--accd); outline-offset: 2px; border-radius: 4px; }
#hompy { background: var(--paper); border: 1.5px solid var(--mline); border-radius: 12px;
overflow: hidden; box-shadow: 0 3px 10px rgba(107,85,96,.14); }
#hompy .photo { height: 86px; background: #dff0f8 center / cover no-repeat;
border-bottom: 1.5px solid var(--mline); image-rendering: pixelated; }
#hompy .id { padding: 8px; }
#hompy .nm { font-weight: 800; font-size: 13px; line-height: 1.2; }
#hompy .mood { font-size: 11px; color: var(--mink2); line-height: 1.35; margin-top: 1px; }
#hompy .count { display: flex; border-top: 1.5px dashed var(--mline); }
#hompy .count > div { flex: 1; text-align: center; padding: 5px 2px; font-size: 11px; color: var(--mink2); }
#hompy .count > div + div { border-left: 1.5px dashed var(--mline); }
#hompy .count b { display: block; font-size: 12px; color: var(--accd); }
h2 { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 800; color: var(--accd);
margin: 12px 0 5px; }
h2::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--accd); flex: none; }
h2::after { content: ""; flex: 1; height: 1.5px; background: var(--mline); }
/* The menu sits on the list, so it keeps no gap under it. */
.tabs { display: flex; margin: 0; border-bottom: 2px solid var(--accd); }
.tabs button { flex: 1; font-size: 11px; padding: 4px 6px; margin-right: 3px; color: var(--mink2);
background: #f7e7dd; border: 1.5px solid var(--mline); border-bottom: none; border-radius: 8px 8px 0 0; }
.tabs button:last-child { margin-right: 0; }
.tabs button:hover { border-color: var(--accd); background: #fbf0e9; }
.tabs button.on, .tabs button.on:hover { background: var(--accd); border-color: var(--accd); color: #fff; }
#classes { background: var(--paper); border: 1.5px solid var(--mline); border-top: none;
border-radius: 0 0 10px 10px; overflow: hidden; }
#classes > .note { padding: 8px; margin: 0; }
/* One track in the playlist. The bars stand in every row so selecting one never moves the text. */
.sw { display: flex; align-items: center; gap: 7px; padding: 5px 8px; cursor: pointer;
border-bottom: 1px solid #f3e6ec; }
.sw:last-child { border-bottom: none; }
.sw::before { content: ""; width: 11px; height: 11px; flex: none;
background: linear-gradient(transparent, transparent) 0 100% / 2.5px 55% no-repeat,
linear-gradient(transparent, transparent) 50% 100% / 2.5px 100% no-repeat,
linear-gradient(transparent, transparent) 100% 100% / 2.5px 35% no-repeat; }
.sw:hover { background: var(--accl); }
/* The equalizer, the tinted row and the accent reading already say "this one". A coloured spine down the
edge would be a fourth signal and the most tired one. */
.sw.on { background: var(--accl); }
.sw.on::before { background-image: linear-gradient(var(--accd), var(--accd)),
linear-gradient(var(--accd), var(--accd)),
linear-gradient(var(--accd), var(--accd)); }
.chip { width: 14px; height: 14px; border-radius: 4px; border: 1.5px solid rgba(61,43,52,.45); flex: none; }
.chip.erase { background: repeating-linear-gradient(45deg,#c9b3bd 0 4px,#efe2e8 4px 8px); }
.sw .nm { flex: 1; font-size: 11.5px; font-weight: 700; }
.sw .ang, .sw .kind { color: var(--mink2); font-size: 11px; font-variant-numeric: tabular-nums; }
.sw.on .ang, .sw.on .kind { color: var(--accd); font-weight: 700; }
.panel, #numbers, #planet, #bakeCtl { background: var(--paper); border: 1.5px solid var(--mline);
border-radius: 10px; padding: 8px; }
.row { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
.row label { flex: 1; color: var(--mink2); }
input[type=number], input[type=text] { width: 78px; background: var(--paper); color: var(--mink);
border: 1.5px solid var(--mline); border-radius: 8px; padding: 3px 7px; font: inherit; }
input[type=number]:hover, input[type=text]:hover { border-color: var(--accd); }
input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px;
background: transparent; cursor: pointer; }
input[type=range]::-webkit-slider-runnable-track { height: 8px; border-radius: 6px; background: #fbe3ea;
border: 1.5px solid var(--mline); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px;
height: 16px; margin-top: -6px; border-radius: 50%; background: linear-gradient(#ffd0dc,#ff9fb5);
border: 1.5px solid var(--accd); }
button { background: var(--paper); color: var(--mink); border: 1.5px solid var(--mline);
border-bottom-width: 3px; border-radius: 10px; padding: 6px 10px; font: inherit; font-weight: 700;
cursor: pointer; }
button:hover { border-color: var(--accd); background: #fffdfb; }
button:active { border-bottom-width: 1.5px; transform: translateY(1.5px); }
button:disabled { opacity: .45; cursor: default; border-bottom-width: 1.5px; }
button:disabled:hover { border-color: var(--mline); background: var(--paper); }
button.primary { background: linear-gradient(#ffc2d1, var(--acc)); border-color: var(--accd); color: #40202c; }
button.primary:hover { background: linear-gradient(#ffd0dc, #ffb3c6); }
button.warn { background: linear-gradient(#ffd9c2, #ffb894); border-color: #c9663a; color: #40241a; }
.bar { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.note { color: var(--mink2); font-size: 11px; margin: 5px 0 0; }
hr { border: 0; border-top: 1.5px dashed var(--mline); margin: 14px 0 0; }
.maps { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
.maps button { padding: 3px 9px; font-size: 11px; border-radius: 999px; border-bottom-width: 1.5px; }
.maps button.on { background: var(--accd); border-color: var(--accd); color: #fff; }
#key { margin-top: 8px; display: none; background: var(--paper); border: 1.5px solid var(--mline);
border-radius: 10px; padding: 8px; }
#key.on { display: block; }
#key .ramp { height: 12px; border-radius: 3px; border: 1px solid rgba(61,43,52,.35); }
#key .ends { display: flex; justify-content: space-between; color: var(--mink2); font-size: 11px; }
#key .item { display: flex; align-items: center; gap: 6px; font-size: 11.5px; }
#key .item .chip { width: 12px; height: 12px; }
#key .title { color: var(--accd); font-size: 11px; font-weight: 800; margin-bottom: 4px; }
#key .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 8px; }
/* Reports stay monospace: their columns are padded in the code that writes them. */
#report { white-space: pre; font-size: 11.5px; color: var(--mink2); margin-top: 6px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; }
#bakeStatus { white-space: pre-wrap; font-size: 11.5px; color: var(--mink2); margin-top: 6px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; }
#bakeStatus b { color: var(--mink); font-weight: 800; }
.prog { height: 12px; background: var(--accl); border: 1.5px solid var(--mline); border-radius: 7px;
overflow: hidden; margin: 7px 0 4px; }
.prog > div { height: 100%; background: linear-gradient(#ffc2d1, #ff8fa8); width: 0; transition: width .3s; }
</style>
<div id="app">
<div id="side">
<!-- The hompy card: the painting is its own profile photo, and the three costs are the counter. -->
<div id="hompy">
<div class="photo" id="hompyPhoto"></div>
<div class="id">
<div class="nm" id="hompyName">planet</div>
<div class="mood" id="hompyMood"></div>
</div>
<div class="count">
<div>plan <b>4 s</b></div>
<div>bake <b>~2 h</b></div>
<div>tile <b>12 s</b></div>
</div>
</div>
<h2 id="brushHead">brush</h2>
<div class="tabs" id="tabs"></div>
<div id="classes"></div>
<div class="panel" style="margin-top:8px">
<div class="row">
<label>size <span id="sizeN">24</span> px</label>
</div>
<input type="range" id="size" min="1" max="400" value="24">
<p class="note" id="sizeM"></p>
<p class="note"><b>ctrl+z</b> takes back a stroke, <b>ctrl+shift+z</b> puts it back · [ and ] resize ·
shift-drag or space-drag pans · wheel zooms · <b>f</b> fits the world · <b>1</b> is one cell to one
pixel</p>
</div>
<h2 id="numbersHead">this class</h2>
<div id="numbers"></div>
<h2>planet</h2>
<div id="planet"></div>
<hr>
<div class="bar">
<button id="plan" class="primary">Plan (4 s)</button>
<button id="genOv">Generate marks</button>
<button id="savePaint">Save painting</button>
</div>
<p class="note" id="pending"></p>
<div class="maps" id="maps"></div>
<div id="key"></div>
<div id="report"></div>
<hr>
<h2>bake</h2>
<div id="bakeCtl"></div>
<div id="bakeStatus"></div>
</div>
<div id="stage">
<canvas id="view"></canvas>
<canvas id="chrome"></canvas>
<div id="msg"></div>
<div id="hud"></div>
</div>
</div>
<script>
"use strict";
const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;");
const view = $("#view"), chromeCv = $("#chrome");
const cctx = chromeCv.getContext("2d");
let S = null; // server state
let full = null; // ImageData of the whole painting: the authority in the browser, and what the
// GPU texture is uploaded from. There is no second copy of it in a canvas.
let mapView = null; // {name, w, h} for a plan map drawn over the painting, or null
let mapLabels = null; // [{id, u, v}] to write over that map, or null. Only the region map has any
let layer = "class"; // which sheet the brush is on: "class" or "ov"
let sel = 0; // selected class index
let cam = { x: 0, y: 0, s: 1 }; // top-left in paint pixels, and scale
const readsAs = d => d < 3 ? "plain" : d < 8 ? "rolling" : d < 16 ? "hill country"
: d < 28 ? "mountain" : "alpine";
// tan(angle) = U / (K * cell^2m): the steady-state divide slope, which is the number an uplift rate
// actually chooses. Kept identical to internal/planet's divideAngle.
function divideDeg(rateMmYr, kMult) {
if (!S) return 0;
const k = S.k * (kMult || 1);
return Math.atan((rateMmYr / 1000) / (k * Math.pow(S.cell_m * S.cell_m, S.m))) * 180 / Math.PI;
}
// And what that rate makes of the ground, which is not the same thing. A divide is the *steepest* place in a
// catchment - A is smallest there - and almost none of a map is divide, so the median slope comes out at a
// third of the divide angle in tangent. Measured; internal/planet's typicalFromDivide holds the same number
// and the note explaining it. This is the one an author should be steering by.
const typicalDeg = deg => Math.atan(Math.tan(deg * Math.PI / 180) * 0.33) * 180 / Math.PI;
const groundDeg = (rateMmYr, kMult) => typicalDeg(divideDeg(rateMmYr, kMult));
function say(text, kind) {
const m = $("#msg");
m.textContent = text;
m.className = kind || "";
m.style.display = text ? "block" : "none";
if (kind === "ok") setTimeout(() => { if (m.textContent === text) m.style.display = "none"; }, 4000);
}
// ---- loading ------------------------------------------------------------------------------------
// A sheet decoded to pixels. The canvas is scratch - it exists for the length of this call, to turn a PNG
// into an array - and is thrown away as soon as the ImageData is out, because two of them held open would
// be another two hundred and thirty megabytes for nothing.
async function decodeSheet(url) {
const bmp = await createImageBitmap(await (await fetch(url)).blob());
const c = typeof OffscreenCanvas !== "undefined"
? new OffscreenCanvas(S.paint_w, S.paint_h)
: Object.assign(document.createElement("canvas"), { width: S.paint_w, height: S.paint_h });
const x = c.getContext("2d");
x.drawImage(bmp, 0, 0);
bmp.close();
return x.getImageData(0, 0, S.paint_w, S.paint_h);
}
async function boot() {
S = await (await fetch("/api/state")).json();
initGL();
const max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
if (S.paint_w > max || S.paint_h > max) {
throw new Error(`the painting is ${S.paint_w} x ${S.paint_h} and this GPU will not hold a texture ` +
`larger than ${max} a side. Paint a smaller template.`);
}
full = await decodeSheet("/api/paint.png?" + Date.now());
// The annotation sheet, if this planet has one. Alpha is what says "nothing is marked here", so unlike
// the class painting it must not be flattened onto a background.
if (S.overlay) ovFull = await decodeSheet("/api/overlay.png?" + Date.now());
if (S.plates) plFull = await decodeSheet("/api/plates.png?" + Date.now());
resize();
uploadWorld();
fitWorld();
syncGenButton();
// Default to a land class an author would actually reach for rather than to whatever is first in the file,
// which on any legend written so far is the polar ice.
const pick = S.classes.findIndex(c => !c.sea && !c.derived && !c.stroke && c.has_massif);
sel = pick >= 0 ? pick : S.classes.findIndex(c => !c.sea && !c.derived);
buildHompy();
buildTabs(); buildClasses(); buildPlanet(); buildNumbers(); buildBake();
$("#size").oninput();
invalidate();
pollBake(); // a bake may already be running from before this page was reloaded
}
const paintable = () => S.classes.filter(c => !c.derived);
// ---- layers -------------------------------------------------------------------------------------
//
// Two sheets over one world. The class painting is the geology - every colour on it is an uplift rate the
// solve answers for - and the annotation layer is everything placed on the finished world that the
// simulation has no opinion about: a forest, a village, a road, and the one stretch of coast the author drew
// deliberately and wants left alone. They are separate images because a mark has to be able to sit on top of
// any class without changing it, and because there is no uplift rate for a town.
let ovFull = null; // ImageData of the annotation sheet, or null when the planet has no overlay
let ovSel = 0; // selected mark, or -1 for the eraser
// The tectonic sheet. No alpha and no eraser: every pixel of it is some plate, so there is nothing to rub
// out - painting one plate over another is the only edit there is.
let plFull = null; // ImageData of the tectonic sheet, or null when the planet has no plate layer
let plSel = 0; // selected plate
let plLegendDirty = false;
const onPlates = () => layer === "pl" && !!S.plates;
const platesOf = () => (S.plates ? S.plates.plates : []);
let ovLegendDirty = false;
const marks = () => (S.overlay ? S.overlay.marks : []);
const onOverlay = () => layer === "ov" && !!S.overlay;
// The card at the top of the rail. The profile photo is the painting itself, because the one picture that
// says which planet this is, is the one the author drew; the counter holds the three costs in the tool.
function buildHompy() {
$("#hompyPhoto").style.backgroundImage = `url(/api/paint.png?${Date.now()})`;
const stem = String(S.template || "planet").replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
$("#hompyName").textContent = stem;
const n = S.classes.filter(c => !c.derived).length;
$("#hompyMood").textContent =
`${n} classes · ${S.paint_w} × ${S.paint_h} · ${Math.round(S.circumference_km)} km round`;
}
// The generate button belongs to the annotation sheet and to nothing else, so it appears with it. It is
// also the one control here that can put marks on ground the author is not looking at, which is the other
// reason not to leave it visible while they are painting classes.
function syncGenButton() {
const b = $("#genOv");
if (!b) return;
b.style.display = onOverlay() ? "" : "none";
b.title = "Fill the blank parts of this sheet from the world - woodland, settlements and the roads "
+ "between them. Every press is a new seed, and it never touches a pixel you painted.";
}
function buildTabs() {
const el = $("#tabs"); el.innerHTML = "";
const mk = (id, label, enabled, hint) => {
const b = document.createElement("button");
b.textContent = label;
b.title = hint;
b.className = layer === id ? "on" : "";
b.disabled = !enabled;
b.onclick = () => {
layer = id;
buildTabs(); buildClasses(); buildNumbers();
$("#savePaint").textContent = saveLabel();
syncGenButton();
invalidate();
};
el.appendChild(b);
};
mk("class", "classes", true, "the geology: every colour is an uplift rate");
mk("ov", "overlay", !!S.overlay,
S.overlay ? "annotation: forests, settlements, roads, and coastlines to leave alone"
: "this planet has no overlay; set planet.overlay_legend in the manifest");
mk("pl", "plates", !!S.plates,
S.plates ? "tectonics: every colour is a plate, and where two meet is a margin"
: "this planet has no tectonic layer; set planet.plates.legend in the manifest");
}
function buildMarks() {
const el = $("#classes"); el.innerHTML = "";
$("#brushHead").textContent = "marks";
const row = (i, name, rgb, kind, erase) => {
const d = document.createElement("div");
d.className = "sw" + (i === ovSel ? " on" : "");
d.innerHTML = `<div class="chip${erase ? " erase" : ""}" style="${erase ? "" : `background:rgb(${rgb.join(",")})`}"></div>
<div class="nm">${name}</div><div class="kind">${kind}</div>`;
d.onclick = () => { ovSel = i; buildMarks(); buildNumbers(); invalidate(); };
el.appendChild(d);
};
marks().forEach((m, i) => {
let kind = m.kind;
if (m.has_coast_jitter) kind = m.coast_jitter === 0 ? "coast pinned" : `coast x${m.coast_jitter}`;
row(i, m.name, m.rgb, kind, false);
});
row(-1, "erase", [0, 0, 0], "", true);
if (!marks().length) {
el.insertAdjacentHTML("beforeend",
`<p class="note">The overlay legend has no marks yet. Add some to ${S.overlay.legend}.</p>`);
}
}
function buildMarkNumbers() {
const el = $("#numbers"); el.innerHTML = "";
$("#numbersHead").textContent = "this mark";
const m = marks()[ovSel];
if (!m) {
el.insertAdjacentHTML("beforeend",
`<p class="note">The eraser takes a mark off the sheet. It never touches the class painting.</p>`);
return;
}
const jr = document.createElement("div"); jr.className = "row";
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = m.has_coast_jitter;
cb.onchange = () => {
m.has_coast_jitter = cb.checked;
// Ticking the box means "I want to say something about the coast here", and what an author almost always
// wants to say is "leave this one alone". 1 is the planet's own amplitude, which is exactly what an
// unmarked pixel already gets, so starting there would be a box that did nothing.
if (m.has_coast_jitter && (m.coast_jitter === undefined || m.coast_jitter === 1)) m.coast_jitter = 0;
buildNumbers(); markOverlayDirty();
};
const jl = document.createElement("label"); jl.textContent = "controls the coastline";
jr.append(jl, cb); el.appendChild(jr);
if (m.has_coast_jitter) {
el.appendChild(numRow(" jitter x", m.coast_jitter, 0.25, v => {
m.coast_jitter = v; markOverlayDirty(); jitterNote();
}));
el.insertAdjacentHTML("beforeend", `<p class="note" id="jnote"></p>`);
}
if (m.kind === "path") {
el.appendChild(numRow("width m", m.width_m, 1, v => { m.width_m = v; markOverlayDirty(); }));
el.insertAdjacentHTML("beforeend",
`<p class="note">A path is thinned to its centreline and comes out of overlay.json as an ordered
polyline in world metres. The width travels with it; nothing here uses it.</p>`);
}
if (m.note) el.insertAdjacentHTML("beforeend", `<p class="note">${esc(m.note)}</p>`);
const bar = document.createElement("div"); bar.className = "bar";
const b = document.createElement("button"); b.textContent = "Save overlay legend";
b.onclick = () => saveOverlayLegend(false); bar.appendChild(b);
el.appendChild(bar);
jitterNote();
}
function jitterNote() {
const n = $("#jnote"); if (!n) return;
const m = marks()[ovSel]; if (!m || !m.has_coast_jitter) return;
const amp = S.coast_jitter_px * m.coast_jitter * S.metres_per_px;
n.textContent = m.coast_jitter === 0
? "The waterline under this mark stays exactly where you painted it, while the rest of the world is "
+ "still roughened. Paint over the shore; either side is enough."
: `The waterline under this mark moves up to ${amp.toFixed(0)} m, against `
+ `${(S.coast_jitter_px * S.metres_per_px).toFixed(0)} m elsewhere.`;
}
function markOverlayDirty() { ovLegendDirty = true; buildMarks(); refreshPending(); }
async function saveOverlayLegend(quiet) {
const edits = marks().map(m => ({
mark: m.name,
has_coast_jitter: m.has_coast_jitter,
coast_jitter: m.coast_jitter,
width_m: m.kind === "path" ? m.width_m : undefined,
}));
const r = await fetch("/api/overlay/legend", { method: "POST", body: JSON.stringify(edits) });
if (!r.ok) { say(await r.text(), "err"); return false; }
ovLegendDirty = false; refreshPending();
if (!quiet) say("overlay legend saved", "ok");
return true;
}
// ---- side panel ---------------------------------------------------------------------------------
// The tectonic brushes. A plate's chip carries its drift as a little arrow, because where a plate is going is
// the only thing that decides what happens where it meets the next one - a legend of seven colours with no
// directions on it tells you nothing about the world you are painting.
function buildPlateBrushes() {
const el = $("#classes"); el.innerHTML = "";
$("#brushHead").textContent = "plates";
platesOf().forEach((p, i) => {
const d = document.createElement("div");
d.className = "sw" + (i === plSel ? " on" : "");
// 0 is up the map and the arrow glyphs run clockwise from there, so the chip agrees with the heading.
const arrows = ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"];
const a = p.speed_cm_yr > 0 ? arrows[Math.round(((p.heading_deg % 360) + 360) % 360 / 45) % 8] : "·";
const kind = p.continental ? "continental" : "oceanic";
d.innerHTML = `<div class="chip" style="background:rgb(${p.rgb.join(",")})"></div>
<div class="nm">${a} ${p.name}</div>
<div class="ang">${p.speed_cm_yr} cm/yr · ${kind}</div>`;
d.onclick = () => { plSel = i; buildPlateBrushes(); buildNumbers(); invalidate(); };
el.appendChild(d);
});
if (!platesOf().length) {
el.insertAdjacentHTML("beforeend",
`<p class="note">The tectonic legend has no plates yet. Add some to ${S.plates.legend}.</p>`);
}
}
function buildPlateNumbers() {
const el = $("#numbers"); el.innerHTML = "";
$("#numbersHead").textContent = "this plate";
const p = platesOf()[plSel];
if (!p) return;
el.appendChild(numRow("speed cm/yr", p.speed_cm_yr, 0.1, v => { p.speed_cm_yr = v; markPlatesDirty(); }));
el.appendChild(numRow("heading °", p.heading_deg, 1, v => { p.heading_deg = v; markPlatesDirty(); }));
el.appendChild(numRow("spin °/Myr", p.spin_deg_myr, 0.5, v => { p.spin_deg_myr = v; markPlatesDirty(); }));
const forced = p.forced ? " The legend says so rather than the painting." : "";
el.insertAdjacentHTML("beforeend",
`<p class="note">Heading is a compass bearing over the map: 0 is up, 90 is right.
What happens at a margin is the <i>difference</i> between the two plates either side, so two plates
drifting the same way are a boundary doing nothing. Spin turns the plate about its own centre, and it is
what makes one end of a margin collide while the other slides.<br><br>
This one is <b>${p.continental ? "continental" : "oceanic"}</b>.${forced}
Ocean against continent subducts; continent against continent collides.</p>`);
}
function markPlatesDirty() { plLegendDirty = true; buildPlateBrushes(); refreshPending(); }
function buildClasses() {
if (onPlates()) return buildPlateBrushes();
if (onOverlay()) return buildMarks();
const el = $("#classes"); el.innerHTML = "";
$("#brushHead").textContent = "brush";
S.classes.forEach((c, i) => {
if (c.derived) return;
const d = document.createElement("div");
d.className = "sw" + (i === sel ? " on" : "");
const g = c.sea ? 0 : groundDeg(c.uplift_mm_yr, c.k_mult);
const ang = c.sea ? `${c.depth_m|0} m deep` : `${g.toFixed(1)}° ${readsAs(g)}`;
d.innerHTML = `<div class="chip" style="background:rgb(${c.rgb.join(",")})"></div>
<div class="nm">${c.name}</div><div class="ang">${ang}</div>`;
d.onclick = () => { sel = i; buildClasses(); buildNumbers(); invalidate(); };
el.appendChild(d);
});
}
function numRow(label, value, step, on) {
const r = document.createElement("div"); r.className = "row";
const l = document.createElement("label"); l.textContent = label;
const i = document.createElement("input");
i.type = "number"; i.step = step; i.value = value;
i.onchange = () => on(parseFloat(i.value));
r.append(l, i); return r;
}
function buildNumbers() {
if (onPlates()) return buildPlateNumbers();
if (onOverlay()) return buildMarkNumbers();
const el = $("#numbers"); el.innerHTML = "";
$("#numbersHead").textContent = "this class";
const c = S.classes[sel];
if (!c) return;
if (c.sea) {
el.appendChild(numRow("depth m", c.depth_m, 1, () => {}));
el.insertAdjacentHTML("beforeend",
`<p class="note">Sea. The solve holds every one of these cells at base level, so it carries no rate.</p>`);
return;
}
// Both numbers, because the author needs the second and the first is the one the physics is written in.
// The ground is what they are choosing; the divide is where it comes from and what the repose clamp
// eventually binds against.
const show = () => {
const d = divideDeg(c.uplift_mm_yr, c.k_mult), g = typicalDeg(d);
const fd = c.has_massif ? divideDeg(c.floor_mm_yr, c.k_mult) : null;
$("#angle").innerHTML =
`massif <b>${g.toFixed(1)}°</b> ${readsAs(g)} &nbsp;<span style="opacity:.6">(${d.toFixed(1)}° at a divide)</span>` +
(fd === null ? "" : `<br>plain&nbsp; <b>${typicalDeg(fd).toFixed(1)}°</b> ${readsAs(typicalDeg(fd))}` +
` &nbsp;<span style="opacity:.6">(${fd.toFixed(1)}° at a divide)</span>`) +
`<br><span style="opacity:.6">the bold number is the median over the class; a divide is the steepest` +
` place in a catchment and almost none of a map is divide.</span>`;
};
el.appendChild(numRow("uplift mm/yr", c.uplift_mm_yr, 0.005, v => { c.uplift_mm_yr = v; show(); markDirty(); }));
el.appendChild(numRow("k_mult", c.k_mult, 0.1, v => { c.k_mult = v; show(); markDirty(); }));
const mr = document.createElement("div"); mr.className = "row";
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = c.has_massif;
cb.onchange = () => {
c.has_massif = cb.checked;
if (c.has_massif && !c.floor_mm_yr) { c.floor_mm_yr = +(c.uplift_mm_yr / 7).toFixed(4); c.fraction = 0.16; }
buildNumbers(); markDirty();
};
const ml = document.createElement("label"); ml.textContent = "massif: plain + upland";
mr.append(ml, cb); el.appendChild(mr);
if (c.has_massif) {
el.appendChild(numRow(" floor mm/yr", c.floor_mm_yr, 0.002, v => { c.floor_mm_yr = v; show(); markDirty(); }));
el.appendChild(numRow(" fraction", c.fraction, 0.02, v => { c.fraction = v; markDirty(); }));
}
el.appendChild(numRow("coastal plain km", c.coastal_plain_km, 0.5, v => { c.coastal_plain_km = v; markDirty(); }));
if (c.coastal_plain_km > 0) {
el.appendChild(numRow(" shore mm/yr", c.coastal_floor_mm_yr, 0.005, v => { c.coastal_floor_mm_yr = v; markDirty(); }));
}
el.insertAdjacentHTML("beforeend", `<p class="note" id="angle"></p>`);
show();
const bar = document.createElement("div"); bar.className = "bar";
const b = document.createElement("button"); b.textContent = "Save legend";
b.onclick = () => saveLegend(false); bar.appendChild(b);
el.appendChild(bar);
}
// planetP is the planet block as the panel has it, which is not the same thing as what the manifest has until
// somebody saves. Hoisted out of buildPlanet because Plan and Bake both apply it first now: see flushPending.
let planetP = null;
let planetDirty = false;
function markPlanetDirty() { planetDirty = true; refreshPending(); }
function buildPlanet() {
const el = $("#planet"); el.innerHTML = "";
const P = planetP = {
seed: S.seed,
massif_wavelength_km: S.massif_wavelength_km,
lithology_wavelength_km: S.lithology_wavelength_km,
fault_grain_km: S.fault_grain_km,
coast_jitter_px: S.coast_jitter_px,
coast_jitter_wavelength_px: S.coast_jitter_wavelength_px,
coast_jitter_octaves: S.coast_jitter_octaves,
coast_jitter_gain: S.coast_jitter_gain,
};
const mpp = S.metres_per_px;
// The seed and the re-roll button. The painting fixes the composition - where the continents are, where the
// ranges run - and the seed decides everything inside it that nobody drew: where the massifs stand, which
// rock is where, where the faults are, and the detail of the coastline. Re-rolling is how one painting
// becomes many worlds, so it belongs next to a button rather than in a text editor.
const seedRow = numRow("seed", P.seed, 1, v => { P.seed = v | 0; markPlanetDirty(); });
el.appendChild(seedRow);
const rollBar = document.createElement("div"); rollBar.className = "bar";
const roll = document.createElement("button"); roll.textContent = "Re-roll";
roll.title = "a new seed: same painting, different massifs, rock, faults and coastline detail";
roll.onclick = () => {
P.seed = Math.floor(Math.random() * 100000);
seedRow.querySelector("input").value = P.seed;
markPlanetDirty();
};
rollBar.appendChild(roll);
el.appendChild(rollBar);
el.insertAdjacentHTML("beforeend",
`<p class="note">Re-roll, then Plan: the seed is applied for you. The painting stays exactly as drawn.</p>`);
el.appendChild(numRow("massif km", P.massif_wavelength_km, 0.5, v => { P.massif_wavelength_km = v; markPlanetDirty(); }));
el.appendChild(numRow("rock km", P.lithology_wavelength_km, 1, v => { P.lithology_wavelength_km = v; markPlanetDirty(); }));
el.appendChild(numRow("fault grain km", P.fault_grain_km, 1, v => { P.fault_grain_km = v; markPlanetDirty(); }));
el.appendChild(numRow("coast amp px", P.coast_jitter_px, 4, v => { P.coast_jitter_px = v; note(); markPlanetDirty(); }));
el.appendChild(numRow("coast bay px", P.coast_jitter_wavelength_px, 16, v => { P.coast_jitter_wavelength_px = v; note(); markPlanetDirty(); }));
el.appendChild(numRow("coast octaves", P.coast_jitter_octaves, 1, v => { P.coast_jitter_octaves = v; markPlanetDirty(); }));
el.appendChild(numRow("coast gain", P.coast_jitter_gain, 0.05, v => { P.coast_jitter_gain = v; markPlanetDirty(); }));
el.insertAdjacentHTML("beforeend", `<p class="note" id="coastNote"></p>`);
const note = () => $("#coastNote").textContent =
`bays about ${(P.coast_jitter_wavelength_px * mpp / 1000).toFixed(1)} km wide, ` +
`up to ${(P.coast_jitter_px * mpp).toFixed(0)} m deep`;
note();
const bar = document.createElement("div"); bar.className = "bar";
const b = document.createElement("button"); b.textContent = "Save planet";
b.onclick = () => savePlanet(false);
bar.appendChild(b); el.appendChild(bar);
}
async function savePlanet(quiet) {
if (!planetP) return true;
const r = await fetch("/api/planet", { method: "POST", body: JSON.stringify(planetP) });
if (!r.ok) { say(await r.text(), "err"); return false; }
Object.assign(S, planetP);
planetDirty = false; refreshPending();
if (!quiet) say("planet saved", "ok");
return true;
}
let legendDirty = false;
function markDirty() { legendDirty = true; buildClasses(); refreshPending(); }
async function saveLegend(quiet) {
const edits = S.classes.filter(c => !c.sea && !c.derived).map(c => ({
class: c.name,
uplift_mm_yr: c.uplift_mm_yr, k_mult: c.k_mult,
coastal_plain_km: c.coastal_plain_km, coastal_floor_mm_yr: c.coastal_floor_mm_yr,
has_massif: c.has_massif, floor_mm_yr: c.floor_mm_yr, fraction: c.fraction,
}));
const r = await fetch("/api/legend", { method: "POST", body: JSON.stringify(edits) });
if (!r.ok) { say(await r.text(), "err"); return false; }
legendDirty = false; refreshPending();
if (!quiet) say("legend saved", "ok");
return true;
}
// What is sitting in the panel and not yet in a file. The rail edits three separate things - the planet block
// in the manifest, the class legend, the overlay legend - and each has its own Save, so it is entirely possible
// to have changed all three and saved none.
function refreshPending() {
const n = $("#pending");
if (!n) return;
const parts = [];
if (planetDirty) parts.push("planet");
if (legendDirty) parts.push("legend");
if (ovLegendDirty) parts.push("overlay legend");
if (plLegendDirty) parts.push("plate legend");
n.textContent = parts.length
? `unsaved: ${parts.join(", ")}. Plan and Bake apply them first.`
: "";
}
// flushPending writes the panel's numbers before a run reads them off disk.
//
// This is the fix for the trap that made a re-rolled seed produce an identical map. Plan has always pushed both
// paintings before running, and not pushing the numbers beside them was the whole bug: the plan handler reads
// the legend and the manifest from *disk*, so a seed still sitting in the panel is a seed the prepare never
// sees, the plan key does not change, and the cached prepare comes back with the same uplift and erodibility
// maps - which looks exactly like a generator that ignores its seed. A bake reloads the manifest too, and gets
// two hours to be wrong about it, so it flushes the same way.
//
// Returns the list of what it applied, or null if a write was refused - in which case the caller stops, because
// planning against numbers the server rejected would answer a question nobody asked.
async function flushPending() {
const applied = [];
if (planetDirty) { if (!await savePlanet(true)) return null; applied.push("planet"); }
if (legendDirty) { if (!await saveLegend(true)) return null; applied.push("legend"); }
if (ovLegendDirty) { if (!await saveOverlayLegend(true)) return null; applied.push("overlay legend"); }
if (plLegendDirty) { if (!await savePlatesLegend(true)) return null; applied.push("plate legend"); }
return applied;
}
async function savePlatesLegend(quiet) {
const edits = platesOf().map(p => ({
plate: p.name,
speed_cm_yr: p.speed_cm_yr,
heading_deg: p.heading_deg,
spin_deg_myr: p.spin_deg_myr,
}));
const r = await fetch("/api/plates/legend", { method: "POST", body: JSON.stringify(edits) });
if (!r.ok) { say(await r.text(), "err"); return false; }
plLegendDirty = false; refreshPending();
if (!quiet) say("plate legend saved", "ok");
return true;
}
// ---- the canvas ---------------------------------------------------------------------------------
//
// The template that ships with the tool is 7738 x 3761: twenty-nine million cells, a hundred and sixteen
// megabytes of pixels. The first renderer put all of that through the 2D canvas on every pointer event - a
// full-width putImageData band to push the stroke back into an offscreen copy, then a high-quality
// downsample of the whole image, once per repetition across the seam. Both costs are linear in the size of
// the world and neither is a function of what the stroke actually touched, so a twenty-four pixel brush cost
// exactly what a four hundred pixel one did, and a mouse reporting a thousand times a second asked for it a
// thousand times a second.
//
// The world is a GPU texture now. A stroke uploads the rectangle it touched and nothing else, read straight
// out of the ImageData with no intermediate copy - that is what WebGL2's UNPACK_ROW_LENGTH is for - and
// drawing is one textured quad minified by the hardware off a mip chain. Neither cost is a function of the
// size of the painting any more. The seam comes free with REPEAT, which also fixes what the tiling loop
// could not: every repetition was a separate drawImage with texture coordinates of its own, so the
// derivative at the wrap was wrong and the seam blurred whenever the world was minified.
//
// Three layers over the stage. The void is a CSS gradient; the world is the GL canvas, transparent wherever
// there is no world; #chrome is a 2D canvas for everything that is a line or a letter - the world's top and
// bottom edges, the region numbers, the brush ring. Chrome is a viewport rather than a world, so redrawing
// the whole of it every frame costs nothing.
let gl = null, prog = null, quad = null;
const U = {};
let texPaint = null, texOv = null, texPl = null, texMap = null;
let paintBytes = null, ovBytes = null, plBytes = null; // Uint8Array views on the ImageData, which is what GL uploads take
let vw = 1, vh = 1, dpr = 1, vrect = { left: 0, top: 0 };
let mipDirty = false, mipAt = 0;
const VS = `#version 300 es
in vec2 aPos; // the unit square, one corner per vertex
uniform vec3 uCam; // top-left in paint pixels, and device pixels per paint pixel
uniform vec2 uView; // the viewport in device pixels
out vec2 vPaint;
void main() {
gl_Position = vec4(aPos.x * 2.0 - 1.0, 1.0 - aPos.y * 2.0, 0.0, 1.0);
vPaint = uCam.xy + aPos * uView / uCam.z;
}`;
// X is left to the sampler rather than wrapped here: REPEAT wraps the fetch *and* keeps the derivative
// continuous across the seam, which is what picks the right mip there. fract() would do neither.
const FS = `#version 300 es
precision highp float;
in vec2 vPaint;
uniform sampler2D uSrc;
uniform sampler2D uOv;
uniform vec2 uWorld; // the source's own size in paint pixels
uniform float uOvAmt; // 0 when no annotation sheet is being shown
out vec4 frag;
void main() {
vec2 uv = vPaint / uWorld;
// The world is a cylinder: it has no side edges, but it does begin and end in Y. Off either end is void,
// which the page shows through, rather than a stretched edge row.
if (uv.y < 0.0 || uv.y > 1.0) discard;
vec3 c = texture(uSrc, uv).rgb;
if (uOvAmt > 0.0) {
vec4 o = texture(uOv, uv);
c = mix(c, o.rgb, o.a * uOvAmt);
}
frag = vec4(c, 1.0);
}`;
function initGL() {
gl = view.getContext("webgl2", {
alpha: true, antialias: false, depth: false, stencil: false,
premultipliedAlpha: true, powerPreference: "high-performance",
});
if (!gl) throw new Error("this browser has no WebGL2, which the canvas is drawn with");
const sh = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
};
prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
for (const n of ["uCam", "uView", "uWorld", "uOvAmt", "uSrc", "uOv"]) U[n] = gl.getUniformLocation(prog, n);
gl.uniform1i(U.uSrc, 0);
gl.uniform1i(U.uOv, 1);
quad = gl.createVertexArray();
gl.bindVertexArray(quad);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);
const a = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(a);
gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
}
// A texture the size of a sheet, with its mip chain. Non-power-of-two with REPEAT and mipmaps is a WebGL2
// thing; under WebGL1 a 7738-wide world would have had to be padded to 8192 first.
function makeSheet(data) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, S.paint_w, S.paint_h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
gl.generateMipmap(gl.TEXTURE_2D);
return t;
}
// uploadWorld builds both sheets from the ImageData the browser holds. Called at boot, and again if the
// driver takes the context away - which is a thing that happens on a machine that is also compiling shaders
// for an engine, and used to leave a black rectangle with no way back but a reload.
function uploadWorld() {
paintBytes = new Uint8Array(full.data.buffer);
texPaint = makeSheet(paintBytes);
if (ovFull) {
ovBytes = new Uint8Array(ovFull.data.buffer);
texOv = makeSheet(ovBytes);
}
if (plFull) {
plBytes = new Uint8Array(plFull.data.buffer);
texPl = makeSheet(plBytes);
}
mipDirty = false;
}
// pushRect sends one painted rectangle to the GPU, reading it in place out of the sheet's own bytes.
// UNPACK_ROW_LENGTH is the whole reason this is cheap: without it every upload would have to be a full-width
// band, or a copy into a scratch buffer first, which is what the 2D canvas made it.
function pushRect(tex, data, rect) {
const W = S.paint_w;
const y0 = Math.max(0, rect[1]), y1 = Math.min(S.paint_h - 1, rect[3]);
if (y1 < y0 || !tex) return;
const rows = y1 - y0 + 1;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, W);
gl.pixelStorei(gl.UNPACK_SKIP_ROWS, y0);
const band = (x, w) => {
gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, x);
gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y0, w, rows, gl.RGBA, gl.UNSIGNED_BYTE, data);
};
const span = Math.min(W, rect[2] - rect[0] + 1);
const a = ((rect[0] % W) + W) % W;
if (span >= W) band(0, W);
else if (a + span <= W) band(a, span);
else { band(a, W - a); band(0, a + span - W); } // a stroke over the seam is two rectangles, not one
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0);
gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0);
mipDirty = true;
}
// The mip chain is what makes the zoomed-out view both correct and free, and regenerating it off a
// twenty-nine megapixel base is not something to do between two points of a stroke. So it is rebuilt at most
// ten times a second while the brush is down, never while the world is magnified past 1:1 because nothing
// reads it there, and once for certain on the frame after the brush comes up.
function refreshMips() {
const now = performance.now();
if (drawing && (cam.s >= 1 || now - mipAt < 100)) return;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texPaint); gl.generateMipmap(gl.TEXTURE_2D);
if (texOv) { gl.bindTexture(gl.TEXTURE_2D, texOv); gl.generateMipmap(gl.TEXTURE_2D); }
mipDirty = false; mipAt = now;
}
// The size of whatever is on screen, in paint pixels. A plan map is narrower than the painting and covers
// the same cylinder, so its width is the painting's and its height follows from its own aspect.
const worldSize = () => mapView
? [S.paint_w, mapView.h * S.paint_w / mapView.w]
: [S.paint_w, S.paint_h];
function resize() {
const r = $("#stage").getBoundingClientRect();
vrect = r;
vw = Math.max(1, r.width); vh = Math.max(1, r.height);
// Rendered at the screen's own pixels rather than at CSS pixels. On a scaled display the old canvas was a
// smaller image stretched up, which is a soft coastline in a tool whose entire job is where the coast is.
dpr = Math.min(2, window.devicePixelRatio || 1);
view.width = chromeCv.width = Math.round(vw * dpr);
view.height = chromeCv.height = Math.round(vh * dpr);
}
// ---- the frame ----------------------------------------------------------------------------------
//
// One rAF loop owns everything that touches the screen. Pointer events do no work beyond writing down where
// the pointer was: a thousand-hertz mouse used to mean a thousand full repaints a second, all but sixty of
// which were thrown away before anybody saw them.
let raf = 0, lastT = 0;
function invalidate() { if (!raf) raf = requestAnimationFrame(frame); }
function frame(t) {
raf = 0;
if (!S || !gl || gl.isContextLost()) return;
const dt = Math.min(64, t - (lastT || t)); lastT = t;
const easing = easeZoom(dt);
drainStroke();
clampCam();
render();
drawChrome();
updateHud();
if (easing || (mipDirty && !drawing)) invalidate();
}
function render() {
if (mipDirty) refreshMips();
const [worldW, worldH] = worldSize();
const tex = mapView ? texMap : texPaint;
if (!tex) return;
gl.viewport(0, 0, view.width, view.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(prog);
gl.bindVertexArray(quad);
// Nearest at or above 1:1, because the cells are the data and a blended edge describes a class that is not
// there. Below it, trilinear off the mips: a minified nearest sample throws four fifths of the painting
// away and crawls as you pan.
const mag = cam.s >= 1 ? gl.NEAREST : gl.LINEAR;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mag);
// One sheet rides on top of the base, and which one is whichever tab the brush is on.
//
// The annotation sheet is the usual passenger: a mark means nothing except against the coastline or the
// range it was drawn against, so it is dimmed rather than hidden while the brush is on the classes - a road
// cannot then be mistaken for something the solve reads, and a mark you cannot see is one you will paint
// over. The tectonic sheet takes the slot instead while the brush is on the plates, and it is *translucent*
// rather than opaque for the same reason one level up: a plate boundary is only worth anything relative to
// the coastlines under it, and painting plates over an opaque sheet would be painting them blind.
let over = null, ovAmt = 0;
if (!mapView) {
if (onPlates() && texPl) { over = texPl; ovAmt = 0.55; }
else if (texOv) { over = texOv; ovAmt = onOverlay() ? 1 : 0.45; }
}
if (over) {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, over);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mag);
}
gl.uniform3f(U.uCam, cam.x, cam.y, cam.s * dpr);
gl.uniform2f(U.uView, view.width, view.height);
gl.uniform2f(U.uWorld, worldW, worldH);
gl.uniform1f(U.uOvAmt, ovAmt);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
function drawChrome() {
cctx.setTransform(1, 0, 0, 1, 0, 0);
cctx.clearRect(0, 0, chromeCv.width, chromeCv.height);
cctx.setTransform(dpr, 0, 0, dpr, 0, 0); // everything below is written in CSS pixels
seatWorld();
if (mapView && mapLabels) drawMapLabels();
drawBrush();
setCursor();
}
// The world is a cylinder: X wraps, so it has no side edges, but it does begin and end in Y. Mark where, and
// let the void darken as it meets them, so the map reads as a surface lying in space rather than as the
// background showing through. Everything here is drawn outside the world except the boundary line itself,
// so no cell the author is trying to read is dimmed by it.
function seatWorld() {
const top = -cam.y * cam.s, bot = top + worldSize()[1] * cam.s;
const fade = Math.min(30, Math.max(10, vh * 0.03));
if (top > 0) {
const g = cctx.createLinearGradient(0, top - fade, 0, top);
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,.5)");
cctx.fillStyle = g;
cctx.fillRect(0, top - fade, vw, fade);
}
if (bot < vh) {
const g = cctx.createLinearGradient(0, bot, 0, bot + fade);
g.addColorStop(0, "rgba(0,0,0,.5)"); g.addColorStop(1, "rgba(0,0,0,0)");
cctx.fillStyle = g;
cctx.fillRect(0, bot, vw, fade);
}
cctx.fillStyle = "rgba(216,222,230,.22)";
if (top >= 1 && top <= vh) cctx.fillRect(0, Math.round(top) - 1, vw, 1);
if (bot >= 0 && bot <= vh - 1) cctx.fillRect(0, Math.round(bot), vw, 1);
}
// The region ids, written over the region map rather than baked into the PNG.
//
// In the image they would be numbers at the image's resolution: illegible at the zoom that shows the whole
// world, a block of soft pixels at the zoom that shows one coastline. Drawn here they are one size at every
// zoom, and they tile across the seam with everything else, so a landmass that wraps carries its number on
// both sides of the window rather than losing it at the edge.
//
// Numbers and not only colour, because these ids are what `bake --only` takes. Forty regions are forty hues
// however carefully the hues are chosen, and reading one off a swatch list is work a two-digit number does
// for you.
function drawMapLabels() {
const [worldW, worldH] = worldSize();
const w = worldW * cam.s, h = worldH * cam.s;
const y = -cam.y * cam.s;
let x0 = -cam.x * cam.s;
x0 = x0 % w; if (x0 > 0) x0 -= w;
cctx.font = `600 13px ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace`;
cctx.textAlign = "center";
cctx.textBaseline = "middle";
cctx.lineJoin = "round";
cctx.lineWidth = 3;
cctx.strokeStyle = "#0b0e11"; // the void: darker than anything the hue walk can produce, so a number reads
cctx.fillStyle = "#d8dee6";
for (const r of mapLabels) {
const py = y + r.v * h;
if (py < -8 || py > vh + 8) continue;
const text = String(r.id);
for (let x = x0; x < vw; x += w) {
const px = x + r.u * w;
if (px < -16 || px > vw + 16) continue;
cctx.strokeText(text, px, py);
cctx.fillText(text, px, py);
}
}
cctx.lineWidth = 1;
cctx.textAlign = "start";
cctx.textBaseline = "alphabetic";
}
// The brush, drawn where it will land. Four hundred pixels is four hundred pixels at any zoom and the slider
// cannot say how much of *this* view that is; the ring can, and it is the one reading that has to be right
// before the stroke rather than after it. Two rings, dark under bright, so it reads over ice and over ocean
// alike; the bright one carries the colour that is about to be painted.
function drawBrush() {
if (mapView || !cursor || panning || spaceDown) return;
const rad = Math.max(1.5, (parseInt($("#size").value) / 2) * cam.s);
const c = onOverlay() ? (marks()[ovSel] || null) : S.classes[sel];
cctx.beginPath();
cctx.arc(cursor[0], cursor[1], rad, 0, Math.PI * 2);
cctx.lineWidth = 3; cctx.strokeStyle = "rgba(6,9,13,.6)"; cctx.stroke();
cctx.lineWidth = 1.5;
cctx.strokeStyle = c ? `rgba(${c.rgb.join(",")},.95)` : "rgba(255,255,255,.9)";
cctx.stroke();
if (rad > 9) { // the centre, for placing a big brush; inside a small one it would be the whole brush
cctx.fillStyle = "rgba(6,9,13,.6)";
cctx.fillRect(cursor[0] - 2.5, cursor[1] - 0.5, 5, 1);
cctx.fillRect(cursor[0] - 0.5, cursor[1] - 2.5, 1, 5);
}
}
let shownCursor = "";
function setCursor() {
const want = mapView ? "default"
: panning ? "grabbing"
: spaceDown ? "grab"
: cursor ? "none" // the ring is the cursor
: "crosshair";
if (want !== shownCursor) { view.style.cursor = shownCursor = want; }
}
function updateHud() {
const h = $("#hud");
if (!cursor) { h.textContent = `zoom ${(cam.s * 100).toFixed(0)}% ${brushName()}`; return; }
const px = Math.floor(cam.x + cursor[0] / cam.s), py = Math.floor(cam.y + cursor[1] / cam.s);
const wx = ((px % S.paint_w) + S.paint_w) % S.paint_w;
h.textContent =
`${wx}, ${py} px ${(wx * S.metres_per_px / 1000).toFixed(2)} km east` +
`\nzoom ${(cam.s * 100).toFixed(0)}% ${brushName()}`;
}
// ---- the camera ---------------------------------------------------------------------------------
// Zoom eases to where the wheel asked for instead of jumping there, and holds the point under the cursor
// under the cursor the whole way. The jump is the harder thing to read: at these scales one notch of the
// wheel can be a factor of two, and the eye has nothing to follow it with.
let zoom = null; // {s, ax, ay, wx, wy}: the target, the anchor on screen, the world point beneath it
function easeZoom(dt) {
if (!zoom) return false;
cam.s += (zoom.s - cam.s) * (1 - Math.exp(-dt / 55));
if (Math.abs(zoom.s - cam.s) < zoom.s * 0.002) cam.s = zoom.s;
cam.x = zoom.wx - zoom.ax / cam.s;
cam.y = zoom.wy - zoom.ay / cam.s;
if (cam.s !== zoom.s) return true;
zoom = null; return false;
}
const minScale = () => vw / S.paint_w / 4;
function zoomTo(s, ax, ay) {
s = Math.min(16, Math.max(minScale(), s));
zoom = { s, ax, ay, wx: cam.x + ax / cam.s, wy: cam.y + ay / cam.s };
invalidate();
}
// X wraps, so cam.x is kept inside one turn of the world rather than allowed to wander off into the range
// where a float stops having sub-pixel precision. Y does not wrap, and a pan that loses the world entirely
// leaves nothing on screen to pan back by, so half of it always stays.
function clampCam() {
const h = worldSize()[1] * cam.s;
const keep = Math.min(vh, h) * 0.5;
let top = -cam.y * cam.s;
top = Math.min(vh - keep, Math.max(keep - h, top));
cam.y = -top / cam.s;
cam.x = ((cam.x % S.paint_w) + S.paint_w) % S.paint_w;
}
function fitWorld() {
zoom = null;
cam.s = vw / S.paint_w;
cam.x = 0; cam.y = (S.paint_h - vh / cam.s) / 2;
invalidate();
}
// ---- painting -----------------------------------------------------------------------------------
function toPaint(ev) {
const px = Math.floor(cam.x + (ev.clientX - vrect.left) / cam.s);
const py = Math.floor(cam.y + (ev.clientY - vrect.top) / cam.s);
return [((px % S.paint_w) + S.paint_w) % S.paint_w, py];
}
// A hard-edged disc written straight into the ImageData. No canvas arc/fill: those antialias, and a
// blended pixel is not a colour between two classes - it is a pixel that classifies as whichever third
// class happens to sit near the midpoint, which is the artefact the despeckle pass exists to clean up.
function stamp(cx, cy, r, rgb, alpha, into) {
const W = S.paint_w, H = S.paint_h, d = into.data, r2 = r * r;
let x0 = Math.floor(cx - r), x1 = Math.ceil(cx + r);
let y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(H - 1, Math.ceil(cy + r));
keepTiles(x0, y0, x1, y1); // before the first write, because what it keeps is what undo puts back
for (let y = y0; y <= y1; y++) {
const dy = y - cy;
for (let x = x0; x <= x1; x++) {
const dx = x - cx;
if (dx * dx + dy * dy > r2) continue;
const wx = ((x % W) + W) % W; // the brush wraps round the seam, because the world does
const o = (y * W + wx) * 4;
d[o] = rgb[0]; d[o + 1] = rgb[1]; d[o + 2] = rgb[2]; d[o + 3] = alpha === undefined ? 255 : alpha;
}
}
return [x0, y0, x1, y1];
}
const grow = (a, b) => !a ? b : !b ? a
: [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
let drawing = false, panning = false, spaceDown = false;
let pan0 = null; // the last pan position, in client pixels
let cursor = null; // the pointer over the stage, in CSS pixels, or null when it is elsewhere
const queue = []; // paint-pixel positions the pointer has visited since the last frame
let strokeFrom = null; // where the stroke was when the last frame ended
// drainStroke lays down everything the pointer did since the last frame, in one pass, and sends the one
// rectangle that covers all of it. What used to be a stamp, a full-width band upload and a whole-world
// repaint per event is now all three per frame.
function drainStroke() {
if (!queue.length) return;
let rgb, alpha, into, tex, bytes;
if (onPlates()) {
// No eraser here: every pixel of a tectonic layer is some plate, so painting one over another is the
// only edit there is.
if (!plFull) { queue.length = 0; return; }
const p = platesOf()[plSel];
if (!p) { queue.length = 0; return; }
rgb = p.rgb; into = plFull; tex = texPl; bytes = plBytes;
} else if (onOverlay()) {
if (!ovFull) { queue.length = 0; return; }
// The eraser writes transparent black rather than a colour: alpha is what says "nothing is marked here",
// and a mark scrubbed out with a background colour would still classify as whatever that colour is
// nearest to.
const m = marks()[ovSel];
rgb = m ? m.rgb : [0, 0, 0];
alpha = m ? 255 : 0;
into = ovFull; tex = texOv; bytes = ovBytes;
} else {
const c = S.classes[sel];
if (!c) { queue.length = 0; return; }
rgb = c.rgb; into = full; tex = texPaint; bytes = paintBytes;
}
beginTake(into); // the step this stroke will become; stamp fills it in as it goes
const r = Math.max(0.5, parseInt($("#size").value) / 2);
let rect = null;
for (const p of queue) {
if (!strokeFrom) rect = grow(rect, stamp(p[0], p[1], r, rgb, alpha, into));
else {
// Interpolate along the drag so a fast stroke is a line and not a row of dots.
let dx = p[0] - strokeFrom[0];
if (dx > S.paint_w / 2) dx -= S.paint_w; else if (dx < -S.paint_w / 2) dx += S.paint_w;
const dy = p[1] - strokeFrom[1];
const n = Math.max(1, Math.ceil(Math.hypot(dx, dy) / Math.max(1, r * 0.6)));
for (let i = 1; i <= n; i++) {
rect = grow(rect, stamp(strokeFrom[0] + dx * i / n, strokeFrom[1] + dy * i / n, r, rgb, alpha, into));
}
}
strokeFrom = p;
}
queue.length = 0;
if (rect) pushRect(tex, bytes, rect);
}
function brushName() {
if (onPlates()) {
const p = platesOf()[plSel];
return "plate: " + (p ? p.name : "none");
}
if (onOverlay()) {
const m = marks()[ovSel];
return "overlay: " + (m ? m.name : "erase");
}
return S.classes[sel] ? S.classes[sel].name : "";
}
// ---- undo ---------------------------------------------------------------------------------------
//
// One stroke is one step, which is what a hand expects: brush down, drag, brush up, and ctrl+z takes all of
// it back at once rather than the last frame of it.
//
// What a step keeps is the pixels the stroke covered, never the sheet it covered them on - a sheet is a
// hundred and sixteen megabytes and a stack of those is not a stack. So the sheet is read as tiles and a tile
// is copied out the first time a stroke writes into it. Copy-on-write, with the grain coarse enough that the
// bookkeeping is a Map lookup per stamp rather than rectangle algebra, and fine enough that a dab costs a
// quarter of a megabyte instead of the world. A tile never straddles the seam, because the grid starts at
// column zero and the last column is the short one, so putting a tile back is one upload with no wrap in it.
const TILE = 256;
// What the undo stack may hold before its oldest step is dropped. It bounds both stacks: any new stroke
// empties redo, and a step *moves* between the two rather than being copied into it.
const UNDO_LIMIT = 192 << 20;
const undoStack = [], redoStack = [];
let taking = null; // the step being built while the brush is down
const tileCols = () => Math.ceil(S.paint_w / TILE);
const tileW = tx => Math.min(TILE, S.paint_w - tx * TILE);
const tileH = ty => Math.min(TILE, S.paint_h - ty * TILE);
function readTile(img, tx, ty) {
const w = tileW(tx), h = tileH(ty), W = S.paint_w, d = img.data;
const out = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
const src = ((ty * TILE + y) * W + tx * TILE) * 4;
out.set(d.subarray(src, src + w * 4), y * w * 4);
}
return out;
}
function writeTile(img, tx, ty, px) {
const w = tileW(tx), h = tileH(ty), W = S.paint_w, d = img.data;
for (let y = 0; y < h; y++) {
const dst = ((ty * TILE + y) * W + tx * TILE) * 4;
d.set(px.subarray(y * w * 4, (y + 1) * w * 4), dst);
}
}
// keepTiles copies whatever a rectangle is about to overwrite, once per tile per stroke. It is called from
// stamp before stamp writes, so there is no path by which a pixel changes that undo has not already recorded.
//
// The rectangle arrives unwrapped - a brush at x=2 starts at -98 - and it is wrapped into runs *before* being
// cut into tiles, never after. Tile columns are only meaningful inside [0, W), because the world is 7738 wide
// and the tile is 256: the last column is the short one, so the grid does not line up with itself across the
// seam and one negative tile index covers parts of two real columns. Cutting first and wrapping afterwards
// kept 30 and 0 for a brush that had written into 29 as well, and the pixels it missed were unrecoverable.
function keepTiles(x0, y0, x1, y1) {
if (!taking || y1 < y0) return;
const W = S.paint_w;
const span = Math.min(W, x1 - x0 + 1);
const a = ((x0 % W) + W) % W;
if (a + span <= W) keepRun(a, a + span - 1, y0, y1);
else { keepRun(a, W - 1, y0, y1); keepRun(0, a + span - W - 1, y0, y1); }
}
function keepRun(xa, xb, y0, y1) {
const cols = tileCols();
for (let ty = Math.floor(y0 / TILE), lastY = Math.floor(y1 / TILE); ty <= lastY; ty++) {
for (let tx = Math.floor(xa / TILE), lastX = Math.floor(xb / TILE); tx <= lastX; tx++) {
const key = ty * cols + tx;
if (taking.tiles.has(key)) continue;
const px = readTile(taking.into, tx, ty);
taking.tiles.set(key, px);
taking.bytes += px.byteLength;
}
}
}
function beginTake(into) { if (!taking) taking = { into, tiles: new Map(), bytes: 0 }; }
function endTake() {
if (!taking) return;
if (taking.tiles.size) {
undoStack.push(taking);
redoStack.length = 0; // the future stops being reachable the moment the past is painted over
let total = 0;
for (const s of undoStack) total += s.bytes;
while (total > UNDO_LIMIT && undoStack.length > 1) total -= undoStack.shift().bytes;
}
taking = null;
}
// A step swaps what the record holds against what is on the sheet now, which both applies it and turns it
// into the record that puts it back: undo and redo are one move in opposite directions, and neither needs a
// copy the other does not already hold.
//
// Both sheets are on screen at all times - the annotation layer is only dimmed while the brush is on the
// classes, never hidden - so a step shows whichever tab it belongs to and no tab has to be switched under
// anybody. A map view does have to go, because it is the one thing covering what just changed.
function stepBack(from, to, nothing) {
if (!from.length) { say(nothing, "ok"); return; }
if (mapView) clearMapView();
const rec = from.pop();
const cols = tileCols();
const tex = rec.into === full ? texPaint : rec.into === plFull ? texPl : texOv;
const bytes = rec.into === full ? paintBytes : rec.into === plFull ? plBytes : ovBytes;
const now = new Map();
for (const [key, px] of rec.tiles) {
const tx = key % cols, ty = (key - tx) / cols;
now.set(key, readTile(rec.into, tx, ty));
writeTile(rec.into, tx, ty, px);
pushRect(tex, bytes, [tx * TILE, ty * TILE, tx * TILE + tileW(tx) - 1, ty * TILE + tileH(ty) - 1]);
}
rec.tiles = now;
to.push(rec);
invalidate();
}
// ---- input --------------------------------------------------------------------------------------
view.addEventListener("pointerdown", ev => {
if (mapView) {
say(mapView.name === "bake"
? "showing the bake preview. Escape, or “back to painting” under the key, to paint again"
: "showing a plan map; press its button again, or Escape, to paint");
return;
}
view.setPointerCapture(ev.pointerId);
if (ev.button === 1 || ev.button === 2 || ev.shiftKey || spaceDown) {
panning = true; zoom = null; pan0 = [ev.clientX, ev.clientY];
} else {
drawing = true; strokeFrom = null; queue.push(toPaint(ev));
}
invalidate();
});
view.addEventListener("pointermove", ev => {
cursor = [ev.clientX - vrect.left, ev.clientY - vrect.top];
if (panning) {
cam.x -= (ev.clientX - pan0[0]) / cam.s;
cam.y -= (ev.clientY - pan0[1]) / cam.s;
pan0 = [ev.clientX, ev.clientY];
} else if (drawing) {
// Every position the pointer actually visited, not only the one that survived to this event. A mouse
// polling at a kilohertz reports a dozen or more between two frames, and a stroke drawn through one of
// them is a polygon with its corners cut off.
const pts = ev.getCoalescedEvents ? ev.getCoalescedEvents() : [];
if (pts.length) for (const p of pts) queue.push(toPaint(p));
else queue.push(toPaint(ev));
}
invalidate();
});
view.addEventListener("pointerleave", () => { cursor = null; invalidate(); });
addEventListener("pointerup", () => {
drawing = panning = false; strokeFrom = null;
endTake(); // brush up is where one stroke becomes one step
invalidate(); // the frame that folds the last of the stroke into the mip chain
});
view.addEventListener("contextmenu", e => e.preventDefault());
view.addEventListener("wheel", ev => {
ev.preventDefault();
// Chained onto the target rather than onto wherever the ease has reached, so three quick notches travel
// three notches instead of stalling at the first.
zoomTo((zoom ? zoom.s : cam.s) * Math.exp(-ev.deltaY * 0.0015),
ev.clientX - vrect.left, ev.clientY - vrect.top);
}, { passive: false });
new ResizeObserver(() => { resize(); invalidate(); }).observe($("#stage"));
// The driver can take the context away - a shader compile in the engine next door is enough to do it - and
// before this that left a black rectangle with no way back but a page reload, and an unsaved painting behind
// it. Both sheets are still in JavaScript, so restoring them is just uploading them again.
view.addEventListener("webglcontextlost", ev => {
ev.preventDefault();
say("the browser dropped the GPU context. Rebuilding — the painting is safe.", "err");
});
view.addEventListener("webglcontextrestored", () => {
initGL();
uploadWorld();
clearMapView(); // the map textures went with the context, and the painting is the way back
say("");
invalidate();
});
$("#size").oninput = () => {
$("#sizeN").textContent = $("#size").value;
$("#sizeM").textContent =
`${(parseInt($("#size").value) * S.metres_per_px).toFixed(0)} m across on the ground`;
invalidate(); // the ring is the brush size, so it changes when the slider does
};
// ---- save and plan ------------------------------------------------------------------------------
// A sheet leaves the browser only when somebody saves, plans or bakes, so it is encoded here rather than
// kept as a second copy of the world in a canvas the whole time. The full putImageData that the old blit
// paid on every mouse move now happens once, on the way out, and the canvas is released straight after.
async function push(url, img, save) {
const c = document.createElement("canvas");
c.width = img.width; c.height = img.height;
c.getContext("2d").putImageData(img, 0, 0);
const blob = await new Promise(res => c.toBlob(res, "image/png"));
c.width = c.height = 0;
const r = await fetch(url + (save ? "?save=1" : ""), { method: "POST", body: blob });
if (!r.ok) { say(await r.text(), "err"); return null; }
return r.json();
}
// Every sheet goes up before a plan, because any of them can change the answer: the classes decide the
// geology, the overlay's coast_jitter marks decide where the waterline ends up, and the tectonic layer
// decides every margin and therefore the whole fault set.
async function pushPainting(save) {
const j = await push("/api/paint", full, save);
if (!j) return null;
if (ovFull && !await push("/api/overlay", ovFull, save)) return null;
if (plFull && !await push("/api/plates", plFull, save)) return null;
return j;
}
function saveLabel() {
return onPlates() ? "Save plates" : onOverlay() ? "Save overlay" : "Save painting";
}
$("#savePaint").onclick = async () => {
const b = $("#savePaint"); b.disabled = true;
const label = saveLabel();
b.textContent = "saving…";
const j = onPlates() ? await push("/api/plates", plFull, true)
: onOverlay() ? await push("/api/overlay", ovFull, true)
: await push("/api/paint", full, true);
b.disabled = false; b.textContent = label;
if (j) say("saved to " + j.saved + (j.repointed ? "\nthe manifest now points at it" : ""), "ok");
};
// Fill the annotation sheet in from the world. See internal/studio/overlaygen.go.
//
// The sheet is replaced wholesale rather than stroked, so the undo tiles no longer describe it and the
// history is dropped. That is said out loud in the status line: an author who has just spent ten minutes
// painting and then presses this deserves to know that ctrl+z will not walk back over it - though nothing
// they painted has been touched, because generation only ever fills blanks.
$("#genOv").onclick = async () => {
const b = $("#genOv");
b.disabled = true;
const was = b.textContent;
b.textContent = "generating…";
try {
// The sheet on the server has to be the sheet on screen, or the generator fills around a stale file.
if (!await pushPainting(false)) return;
const r = await fetch("/api/overlay/generate", { method: "POST", body: "{}" });
if (!r.ok) { say(await r.text(), "err"); return; }
const j = await r.json();
// Read the new sheet back and put it on the screen. One full-width rectangle is one upload; the
// ImageData is reused rather than replaced so that ovBytes, which the GPU path holds, stays valid.
const fresh = await decodeSheet("/api/overlay.png?" + Date.now());
ovFull.data.set(fresh.data);
pushRect(texOv, ovBytes, [0, 0, S.paint_w - 1, S.paint_h - 1]);
undoStack.length = 0; redoStack.length = 0;
invalidate();
let t = j.from_bake
? `generated from ${j.bake}, seed ${j.seed}\n`
: `generated from the painting alone, seed ${j.seed}\n` +
`no bake yet, so there are no rivers to sit on and no slope to bend a road around.\n` +
`Bake first and press again for a draft that reads the terrain.\n`;
t += "\n" + j.marks.join("\n") + "\n";
for (const line of j.lines) t += line + "\n";
t += "\nPress again for another. Undo history was cleared: the sheet was replaced, not stroked.\n" +
"Nothing you painted was touched - generation only fills blanks.\n";
$("#report").textContent = t;
say(j.from_bake ? "marks generated" : "marks generated (no bake: a sketch)", "ok");
} finally { b.disabled = false; b.textContent = was; }
};
$("#plan").onclick = async () => {
const b = $("#plan"); b.disabled = true; b.textContent = "planning…";
try {
const applied = await flushPending();
if (applied === null) return;
if (!await pushPainting(false)) return;
const r = await fetch("/api/plan", { method: "POST" });
if (!r.ok) { say(await r.text(), "err"); return; }
const j = await r.json();
showReport(j, applied);
} finally { b.disabled = false; b.textContent = "Plan (4 s)"; }
};
function showReport(j, applied) {
const rep = j.report;
let t = "";
// Said rather than done silently: applying these writes the author's files, and a run that quietly edited
// Planet.json is a run they cannot account for afterwards.
if (applied && applied.length) t += `applied ${applied.join(", ")} before planning\n`;
t += `planned in ${j.seconds.toFixed(1)} s` +
(j.cached ? ` (legend only: the prepare was reused)\n` : `\n`);
if (rep.wrap_land_against_sea > 0) {
t += `\nSEAM: the two edges disagree on ${rep.wrap_differ} of ${rep.wrap_rows} rows,\n` +
`${rep.wrap_land_against_sea} of them land against water. Paint round the edge.\n`;
}
t += `\nclass share at a divide\n`;
for (const c of rep.classes) {
if (c.sea) continue;
t += ` ${c.name.padEnd(11)} ${(100 * c.share).toFixed(1).padStart(5)}% ` +
`${c.divide_deg.toFixed(1).padStart(5)}° ${c.reads_as || ""}\n`;
if (c.massif_fraction) {
t += ` ${(100 * c.massif_fraction).toFixed(0)}% massif, rest ` +
`${c.floor_mm_yr} mm/yr ${c.floor_divide_deg.toFixed(1)}° ${c.floor_reads_as}\n`;
}
}
if (rep.faults || rep.lithology_wavelength_km) {
t += `
the seed re-rolls, the painting does not
`;
if (rep.lithology_wavelength_km) {
t += ` rock ${rep.lithology_types} types over ${rep.lithology_wavelength_km.toFixed(1)} km provinces
`;
}
if (rep.faults) {
t += ` faults ${rep.faults} traces on a ${rep.fault_grain_km.toFixed(0)} km grain
`;
for (const c of rep.classes) {
if (c.faults) t += ` ${c.name.padEnd(11)} ${String(c.faults).padStart(4)} throw ` +
`${c.fault_throw_m[0]}..${c.fault_throw_m[1]} m
`;
}
}
}
if (j.overlay) {
t += `\noverlay painted pieces\n`;
for (const m of j.overlay.marks) {
t += ` ${m.name.padEnd(11)} ${m.area_km2.toFixed(2).padStart(7)} km2 ${String(m.pieces).padStart(5)}` +
(m.has_coast_jitter ? (m.coast_jitter === 0 ? " coast pinned" : ` coast x${m.coast_jitter}`) : "") + `\n`;
}
if (j.overlay.far_px) t += ` ${j.overlay.far_px} px match no mark and were dropped\n`;
}
t += `\n${rep.regions.length} regions, ${(rep.estimate_minutes).toFixed(0)} min of solve estimated\n`;
for (const r of rep.regions.slice(0, 6)) {
t += ` ${String(r.id).padStart(3)} ${r.width_km.toFixed(1)} x ${r.height_km.toFixed(1)} km` +
` ${r.estimate_minutes.toFixed(1)} min\n`;
}
$("#report").textContent = t;
const m = $("#maps"); m.innerHTML = "";
for (const name of j.maps) {
const b = document.createElement("button");
b.textContent = name.replace("map_", "");
b.onclick = async () => {
if (mapView && mapView.name === name) { clearMapView(); return; }
await showMap(name, `/api/map/${name}.png?${j.stamp}`,
name === "map_regions" ? j.scales.regions : null);
[...m.children].forEach(x => x.classList.remove("on"));
b.classList.add("on");
showKey(name, j.scales);
};
m.appendChild(b);
}
}
// showMap puts a plan map or a bake preview over the painting. One texture, reused: only one of them is ever
// on screen, and a plan writes six of these.
async function showMap(name, url, labels) {
const bmp = await createImageBitmap(await (await fetch(url)).blob());
if (!texMap) texMap = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texMap);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, bmp);
gl.generateMipmap(gl.TEXTURE_2D);
mapView = { name, w: bmp.width, h: bmp.height };
mapLabels = labels || null;
bmp.close();
invalidate();
}
function clearMapView() {
mapView = null;
mapLabels = null;
[...$("#maps").children].forEach(x => x.classList.remove("on"));
$("#key").className = "";
$("#key").innerHTML = "";
invalidate();
}
// Every map view needs a way out that can be seen. Escape has always worked, and a key panel that tells you to
// press Escape is a panel explaining a control it could simply be - which matters most for the bake preview,
// the one map view with no button of its own to press again.
function appendKeyExit() {
const bar = document.createElement("div"); bar.className = "bar";
const b = document.createElement("button");
b.textContent = "← back to painting";
b.title = "Escape does this too";
b.onclick = clearMapView;
bar.appendChild(b);
$("#key").appendChild(bar);
}
const rgbOf = c => `rgb(${c[0]},${c[1]},${c[2]})`;
// A map with no key is a picture. These come from the plan reply, which takes them from the code that drew
// the maps, so the key cannot drift away from the thing it describes.
function showKey(name, sc) {
const k = $("#key"); k.className = "on"; k.innerHTML = "";
showKeyBody(name, sc);
appendKeyExit();
}
function showKeyBody(name, sc) {
const k = $("#key");
const title = t => `<div class="title">${t}</div>`;
const ramp = (stops, lo, hi) =>
`<div class="ramp" style="background:linear-gradient(to right,${stops.map(rgbOf).join(",")})"></div>
<div class="ends"><span>${lo}</span><span>${hi}</span></div>`;
if (name === "map_class") {
const rows = S.classes.filter(c => !c.derived || c.name === "ice").map(c =>
`<div class="item"><span class="chip" style="background:${rgbOf(c.rgb)}"></span>${c.name}</div>`).join("");
k.innerHTML = title("class") + `<div class="grid">${rows}</div>` +
`<p class="note">The painting after the strokes are dissolved and the specks removed — what the
solve will actually read, which is not always what you drew.</p>`;
return;
}
if (name === "map_uplift") {
k.innerHTML = title("uplift, mm/yr") + ramp(sc.uplift.ramp, "0", sc.uplift.hi.toFixed(3)) +
`<p class="note">The field everything else is a consequence of, massifs included — so a class with a
massif shows as plain with upland in it, not one flat colour. The cyan lines are the fault traces:
a rate difference across a line, steep one side and gentle the other, which erosion carves into a
scarp. The line rather than what it contributes, because a scarp is two hundred metres wide and this
map is a hundred kilometres across.</p>`;
return;
}
if (name === "map_erodibility") {
k.innerHTML = title("k_mult") +
ramp(sc.erodibility.ramp, sc.erodibility.lo.toFixed(2), sc.erodibility.hi.toFixed(2)) +
`<p class="note">The multiplier on stream-power K. Below 1 is hard rock: less water doing less work,
so steeper and more angular ground. Each class's own <code>k_mult</code> times the planet's rock
field, which the seed re-rolls and <code>lithology_mix</code> lets through class by class.</p>`;
return;
}
if (name === "map_overlay") {
const rows = marks().map(m =>
`<div class="item"><span class="chip" style="background:${rgbOf(m.rgb)}"></span>${m.name}</div>`).join("");
k.innerHTML = title("overlay marks") + `<div class="grid">${rows}</div>` +
`<p class="note">The annotation layer over a dimmed class map. Only <b>coast_jitter</b> is read by the
generator; everything else comes out as a per-tile mask and as features in world metres in
<code>overlay.json</code>.</p>`;
return;
}
if (name === "map_regions") {
const rows = sc.regions.slice(0, 24).map(r =>
`<div class="item"><span class="chip" style="background:${rgbOf(r.rgb)}"></span>${r.id}</div>`).join("");
const hidden = sc.regions.length - Math.min(24, sc.regions.length);
k.innerHTML = title(`regions (${sc.regions.length})`) + `<div class="grid">${rows}</div>` +
(hidden > 0 ? `<p class="note">${hidden} more, numbered on the map.</p>` : "") +
`<p class="note">One colour per landmass, its ocean margin the same colour dimmed. A landmass across
the seam must be <b>one</b> colour — check the number, not the hue: the ids are written on the map at
the centre of each region's land, and a wrapped landmass carries its number on both sides of the seam.
These ids are what <code>bake --only</code> takes.</p>`;
return;
}
}
// ---- bake ---------------------------------------------------------------------------------------
// A bake is hours, so it runs in the server and the page watches it. What makes watching worth anything is
// that the solve is decomposed per landmass: each one comes out whole, so the preview fills in a continent at
// a time and the first one answers "is this the world I meant" long before the last one finishes.
let bakeTimer = null, bakeStamp = 0, bakeSeen = false;
const bakeOpts = { only: "", steps: 0, jobs: 4 };
function buildBake() {
const el = $("#bakeCtl"); el.innerHTML = "";
const r1 = document.createElement("div"); r1.className = "row";
const l1 = document.createElement("label"); l1.textContent = "regions (blank = all)";
const i1 = document.createElement("input");
i1.type = "text"; i1.placeholder = "11,13"; i1.style.width = "78px";
i1.oninput = () => bakeOpts.only = i1.value;
r1.append(l1, i1); el.appendChild(r1);
el.appendChild(numRow("steps (0 = full)", 0, 50, v => bakeOpts.steps = v | 0));
el.appendChild(numRow("jobs", 4, 1, v => bakeOpts.jobs = v | 0));
const bar = document.createElement("div"); bar.className = "bar";
const go = document.createElement("button"); go.textContent = "Bake"; go.className = "primary";
const stop = document.createElement("button"); stop.textContent = "Cancel"; stop.className = "warn";
stop.style.display = "none";
const show = document.createElement("button"); show.textContent = "Preview";
show.title = "the last preview this bake produced";
show.disabled = true;
show.onclick = () => showBakePreview(bakeStamp, false);
bar.append(go, stop, show); el.appendChild(bar);
go.onclick = async () => {
const only = bakeOpts.only.split(",").map(x => parseInt(x.trim())).filter(x => !isNaN(x));
if (!only.length && !confirm([
"Bake every region? On the 100 km template that is about two hours.",
"",
"The preview fills in a landmass at a time, and Cancel stops it.",
"A few region ids in the box, or a low step count, is the quick version.",
].join("\n"))) return;
go.disabled = true;
// The flush Plan does, and here it is worth two hours rather than four seconds. A bake reloads the manifest
// from disk and solves the *server's* copy of the painting, so an unsaved seed, or a stroke the browser has
// not pushed since the last plan, is two hours spent answering a question nobody asked.
if (await flushPending() === null || !await pushPainting(false)) { go.disabled = false; return; }
const r = await fetch("/api/bake", {
method: "POST",
body: JSON.stringify({ only, steps: bakeOpts.steps, jobs: bakeOpts.jobs }),
});
if (!r.ok) { go.disabled = false; return say(await r.text(), "err"); }
say("baking. The preview updates as each landmass lands.", "ok");
pollBake(); // which owns go.disabled from here: it is the thing that knows whether a bake is running
};
stop.onclick = () => fetch("/api/bake/cancel", { method: "POST" });
buildBake.go = go; buildBake.stop = stop; buildBake.show = show;
}
async function pollBake() {
let j;
try { j = await (await fetch("/api/bake")).json(); } catch (e) { return; }
const el = $("#bakeStatus");
// A finished bake keeps its status and its stamp for as long as the studio process runs. Without this, the
// first poll of *every* page load would open the preview of a bake that ended hours ago - over the painting,
// blocking the brush, with no button of its own to press again - and reloading the page, the obvious way out
// of a stuck screen, would put it straight back. So the first poll of a load that finds nothing running
// adopts the stamp instead of drawing it. A bake that finishes while somebody is watching still lands its
// final preview, because that poll finds it running.
if (!bakeSeen) {
bakeSeen = true;
if (!j.running) bakeStamp = j.stamp;
}
buildBake.go.disabled = j.running;
buildBake.stop.style.display = j.running ? "" : "none";
buildBake.show.disabled = !j.stamp;
const rows = [];
if (j.running || j.finished) {
const state = j.running ? "baking" : (j.err ? "failed" : "done");
rows.push("<b>" + state + "</b> " + j.done.length + "/" + (j.total || "?") +
" regions " + (j.seconds / 60).toFixed(1) + " min");
}
if (j.err) rows.push(esc(j.err));
if (j.note) rows.push(esc(j.note));
for (const d of j.done.slice(-8)) {
rows.push(" " + String(d.ID).padStart(3) + " " +
d.MinM.toFixed(0) + ".." + d.MaxM.toFixed(0) + " m " +
(100 * d.ClipFrac).toFixed(2) + "% clipped " + d.Seconds.toFixed(0) + " s");
}
const frac = j.total ? j.done.length / j.total : 0;
let html = '<div class="prog"><div style="width:' + (100 * frac).toFixed(1) + '%"></div></div>';
html += rows.join("\n");
const tail = j.lines.slice(-4).map(esc).join("\n");
if (tail) html += '\n<span style="opacity:.65">' + tail + "</span>";
el.innerHTML = html;
// A new preview only arrives when a region lands, so the image is fetched on the stamp rather than on the
// poll: otherwise this would re-decode the same picture every two seconds for two hours.
if (j.stamp && j.stamp !== bakeStamp) {
bakeStamp = j.stamp;
await showBakePreview(j.stamp, j.running);
}
clearTimeout(bakeTimer);
if (j.running) bakeTimer = setTimeout(pollBake, 2000);
}
// showBakePreview draws the planet as the bake has it so far. Separate from the poll because the Preview button
// calls it too: a finished bake is a picture somebody will want to look at again after they have gone back to
// painting, and before this there was no way to ask for it.
async function showBakePreview(stamp, running) {
try {
await showMap("bake", "/api/bake/preview.png?" + stamp, null);
[...$("#maps").children].forEach(x => x.classList.remove("on"));
$("#key").className = "on";
$("#key").innerHTML = running
? '<div class="title">bake in progress</div>' +
'<p class="note">The planet as it stands, one landmass at a time. The preview refreshes as each ' +
'region lands.</p>'
: '<div class="title">bake finished</div>' +
'<p class="note">The planet as this bake left it. Nothing more is coming; go back to painting and ' +
'press Preview under <b>bake</b> to see it again.</p>';
appendKeyExit();
invalidate();
} catch (e) { /* the first poll can beat the first preview */ }
}
// A shortcut is only a shortcut when the hands are on the map. Typed into the region box or a number field
// these were characters the author meant to type, and `o` silently swapping the sheet under a half-written
// number is the worst of them.
const typing = t => t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
addEventListener("keydown", ev => {
if (typing(ev.target)) return;
// A step is a whole stroke, so there is no taking half of one back: while the brush is down these do
// nothing rather than committing a stroke the hand has not finished.
if ((ev.ctrlKey || ev.metaKey) && !drawing && "zZyY".includes(ev.key)) {
ev.preventDefault();
const back = (ev.key === "z" || ev.key === "Z") && !ev.shiftKey;
if (back) stepBack(undoStack, redoStack, "nothing to undo");
else stepBack(redoStack, undoStack, "nothing to redo");
return;
}
if (ev.code === "Space" && !spaceDown) { spaceDown = true; ev.preventDefault(); invalidate(); }
if (ev.key === "Escape" && mapView) clearMapView();
if (ev.key === "o" && S && S.overlay && !ev.ctrlKey && !ev.metaKey) {
layer = layer === "ov" ? "class" : "ov";
buildTabs(); buildClasses(); buildNumbers();
$("#savePaint").textContent = onOverlay() ? "Save overlay" : "Save painting";
syncGenButton();
invalidate();
}
if (ev.key === "[") { $("#size").value = Math.max(1, +$("#size").value - 4); $("#size").oninput(); }
if (ev.key === "]") { $("#size").value = Math.min(400, +$("#size").value + 4); $("#size").oninput(); }
// Back out of a deep zoom, or down to where a cell is a pixel and the brush is exact. Scrolling all the
// way back was the only way out of either.
if (ev.key === "f" && S) fitWorld();
if (ev.key === "1" && S) zoomTo(1, vw / 2, vh / 2);
});
addEventListener("keyup", ev => {
if (ev.code === "Space") { spaceDown = false; invalidate(); }
});
addEventListener("blur", () => { spaceDown = false; invalidate(); });
// Opening this file from disk is the one mistake worth spelling out. page.html is *served* by
// `terrain studio`, which holds the painting in memory and answers /api/*; from a file:// URL every one of
// those fetches resolves against the filesystem and fails as a bare NetworkError, which says nothing at all
// about what is actually wrong.
if (location.protocol === "file:") {
say([
"This page is served by the studio, not opened from disk.",
"",
" Tools/Terrain/bin/terrain.exe studio",
"",
"then open http://127.0.0.1:8099 in a browser.",
"Loaded from a file:// URL every /api/ request fails, which is the error you are looking at.",
].join("\n"), "err");
} else {
boot().catch(e => say([
"Could not reach the studio at " + location.origin + ".",
"",
"Is `terrain studio` still running in the terminal you started it from?",
"",
String(e),
].join("\n"), "err"));
}
</script>