const SIZE = 512;
const STEPS = 30;
const STEP_MS = 140;
const palettes = [
{ sky: ["#1b1740", "#6b3a78", "#f39a6b"], sun: "255, 211, 154", ridges: ["#8a5580", "#553463", "#2a1c40"], lake: ["#3a2552", "#140f26"] },
{ sky: ["#0f2438", "#3f5f7f", "#f2b36b"], sun: "255, 226, 168", ridges: ["#6b7f96", "#3d5068", "#1c2738"], lake: ["#26384d", "#0b1420"] },
{ sky: ["#2a1433", "#9c4466", "#ffb38a"], sun: "255, 240, 201", ridges: ["#9a5672", "#633152", "#2f172c"], lake: ["#4a2440", "#170a18"] },
{ sky: ["#07252a", "#1f6b6b", "#e8c07a"], sun: "255, 243, 196", ridges: ["#4a8a84", "#285a58", "#11302f"], lake: ["#154040", "#061617"] }
];
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const scene = document.createElement("canvas");
const sceneCtx = scene.getContext("2d");
const tiny = document.createElement("canvas");
const tinyCtx = tiny.getContext("2d");
const progress = document.getElementById("progress");
const fill = document.getElementById("progress-fill");
const stepText = document.getElementById("step-text");
const percent = document.getElementById("percent");
const seedText = document.getElementById("seed");
const generateBtn = document.getElementById("generate");
const cancelBtn = document.getElementById("cancel");
const saveBtn = document.getElementById("save");
const status = document.getElementById("status");
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
scene.width = SIZE;
scene.height = SIZE;
let run = 0;
let seed = 0;
let step = 0;
// Seeded random numbers, so one seed always paints one picture
function random(a) {
return function() {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function paintScene(g, n) {
const rand = random(n);
const p = palettes[Math.floor(rand() * palettes.length)];
const horizon = 330;
const sky = g.createLinearGradient(0, 0, 0, horizon);
sky.addColorStop(0, p.sky[0]);
sky.addColorStop(0.62, p.sky[1]);
sky.addColorStop(1, p.sky[2]);
g.fillStyle = sky;
g.fillRect(0, 0, SIZE, horizon);
for (let i = 0; i < 70; i++) {
g.fillStyle = "rgba(255, 255, 255, " + (0.15 + rand() * 0.6).toFixed(2) + ")";
g.fillRect(rand() * SIZE, rand() * horizon * 0.45, 1.5, 1.5);
}
const sx = 140 + rand() * 232;
const sy = 240 + rand() * 40;
const sr = 30 + rand() * 16;
const glow = g.createRadialGradient(sx, sy, sr * 0.5, sx, sy, sr * 5);
glow.addColorStop(0, "rgba(" + p.sun + ", 0.5)");
glow.addColorStop(1, "rgba(" + p.sun + ", 0)");
g.fillStyle = glow;
g.fillRect(0, 0, SIZE, horizon);
g.fillStyle = "rgb(" + p.sun + ")";
g.beginPath();
g.arc(sx, sy, sr, 0, Math.PI * 2);
g.fill();
// Three ridges, far to near, each a sum of two sine waves
p.ridges.forEach(function(color, i) {
const base = 300 + i * 14;
const height = 120 - i * 36;
const f1 = 1 + rand() * 1.5;
const f2 = 3 + rand() * 4;
const p1 = rand() * Math.PI * 2;
const p2 = rand() * Math.PI * 2;
g.beginPath();
g.moveTo(0, horizon);
for (let x = 0; x <= SIZE; x += 4) {
const u = (x / SIZE) * Math.PI;
const h = 0.5 + 0.35 * Math.sin(u * f1 + p1) + 0.15 * Math.sin(u * f2 + p2);
g.lineTo(x, Math.min(horizon, base - height * h + rand() * 3));
}
g.lineTo(SIZE, horizon);
g.closePath();
g.fillStyle = color;
g.fill();
});
const lake = g.createLinearGradient(0, horizon, 0, SIZE);
lake.addColorStop(0, p.lake[0]);
lake.addColorStop(1, p.lake[1]);
g.fillStyle = lake;
g.fillRect(0, horizon, SIZE, SIZE - horizon);
g.save();
g.globalAlpha = 0.3;
g.translate(0, horizon * 2);
g.scale(1, -1);
g.drawImage(g.canvas, 0, 0, SIZE, horizon, 0, 0, SIZE, horizon);
g.restore();
for (let y = horizon + 3; y < SIZE; y += 3 + rand() * 5) {
const fade = 1 - (y - horizon) / (SIZE - horizon);
const width = sr * (0.6 + rand() * 1.8) * (0.3 + fade);
const shift = (rand() - 0.5) * sr * 0.8;
g.fillStyle = "rgba(" + p.sun + ", " + (0.2 + rand() * 0.45 * fade).toFixed(2) + ")";
g.fillRect(sx + shift - width / 2, y, width, 1.5);
}
}
// Blur the scene and add grain, a little less at every step
function renderStep(t) {
const block = Math.max(1, Math.round(48 * Math.pow(1 - t, 2)));
ctx.imageSmoothingEnabled = true;
if (block > 1) {
const small = Math.ceil(SIZE / block);
tiny.width = small;
tiny.height = small;
tinyCtx.drawImage(scene, 0, 0, small, small);
ctx.drawImage(tiny, 0, 0, SIZE, SIZE);
} else {
ctx.drawImage(scene, 0, 0);
}
const amount = calm.matches ? 0 : Math.pow(1 - t, 1.5) * 0.85;
if (amount < 0.01) return;
const image = ctx.getImageData(0, 0, SIZE, SIZE);
const d = image.data;
const spread = 400 * amount;
for (let i = 0; i < d.length; i += 4) {
d[i] += (Math.random() - 0.5) * spread;
d[i + 1] += (Math.random() - 0.5) * spread;
d[i + 2] += (Math.random() - 0.5) * spread;
}
ctx.putImageData(image, 0, 0);
}
function setProgress(value, label) {
const pct = Math.round(value * 100);
fill.style.width = pct + "%";
percent.textContent = pct + "%";
progress.setAttribute("aria-valuenow", String(pct));
stepText.textContent = label;
}
function setBusy(busy) {
const focused = document.activeElement;
generateBtn.disabled = busy;
cancelBtn.disabled = !busy;
saveBtn.disabled = busy || step < STEPS;
// Move focus off a button that was just disabled
if (focused && focused.disabled) (busy ? cancelBtn : generateBtn).focus();
}
function generate() {
const id = ++run;
seed = 10000 + Math.floor(Math.random() * 90000);
step = 0;
seedText.textContent = String(seed);
paintScene(sceneCtx, seed);
canvas.setAttribute("aria-label", "Image generating");
setBusy(true);
status.textContent = "Generating image";
function next() {
if (id !== run) return;
step++;
renderStep(step / STEPS);
setProgress(step / STEPS, "Step " + step + " of " + STEPS);
if (step < STEPS) {
setTimeout(next, STEP_MS);
return;
}
stepText.textContent = "Done";
generateBtn.textContent = "Generate again";
canvas.setAttribute("aria-label", "Generated image of a calm lake at dusk below three mountain ridges");
setBusy(false);
status.textContent = "Image ready";
}
renderStep(0);
setProgress(0, "Starting");
setTimeout(next, STEP_MS);
}
function cancel() {
run++;
stepText.textContent = "Canceled at step " + step;
canvas.setAttribute("aria-label", "Canceled image, partly generated");
setBusy(false);
status.textContent = "Canceled at step " + step + " of " + STEPS;
}
function save() {
const name = "generated-" + seed + ".png";
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = name;
document.body.append(link);
link.click();
link.remove();
setTimeout(function() {
URL.revokeObjectURL(url);
}, 1000);
});
}
generateBtn.addEventListener("click", generate);
cancelBtn.addEventListener("click", cancel);
saveBtn.addEventListener("click", save);
ctx.fillStyle = "#151b2b";
ctx.fillRect(0, 0, SIZE, SIZE);
setTimeout(function() {
if (!run) generate();
}, 300);