Skip to content

Instantly share code, notes, and snippets.

@henrik242
Last active March 25, 2026 12:20
Show Gist options
  • Select an option

  • Save henrik242/d75c5ebe83e41c40aa89d67b412ab4c1 to your computer and use it in GitHub Desktop.

Select an option

Save henrik242/d75c5ebe83e41c40aa89d67b412ab4c1 to your computer and use it in GitHub Desktop.
Mandelbrot created by AI
<html>
<head>
<title>Mandelbrot Set</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; }
canvas { display: block; cursor: crosshair; }
#hud {
position: fixed;
top: 10px;
left: 10px;
color: #fff;
font: 12px/1.5 monospace;
background: rgba(0,0,0,0.6);
padding: 6px 10px;
border-radius: 4px;
pointer-events: none;
user-select: none;
z-index: 10;
}
#help {
position: fixed;
bottom: 10px;
left: 10px;
color: rgba(255,255,255,0.5);
font: 11px/1.5 monospace;
background: rgba(0,0,0,0.4);
padding: 6px 10px;
border-radius: 4px;
pointer-events: none;
user-select: none;
z-index: 10;
}
#rendering {
position: fixed;
top: 10px;
right: 10px;
color: #ff0;
font: 12px monospace;
background: rgba(0,0,0,0.6);
padding: 6px 10px;
border-radius: 4px;
pointer-events: none;
user-select: none;
z-index: 10;
display: none;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="hud"></div>
<div id="help">
Scroll: zoom | Double-click: zoom in | Right-click: zoom out | Drag: pan | Arrows: pan | +/−: zoom | R: reset
</div>
<div id="rendering">Rendering...</div>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const hudEl = document.getElementById("hud");
const renderingEl = document.getElementById("rendering");
// Reusable off-screen canvas for snapshot previews
const snapshotCanvas = document.createElement("canvas");
const snapshotCtx = snapshotCanvas.getContext("2d");
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
snapshotCanvas.width = canvas.width;
snapshotCanvas.height = canvas.height;
canvasRect = canvas.getBoundingClientRect();
}
// Cached bounding rect, updated on resize
let canvasRect;
resize();
window.addEventListener("resize", () => { resize(); scheduleDraw(); });
// View state
let centerX = -0.5;
let centerY = 0;
let scale = Math.min(canvas.width, canvas.height) / 3;
// Adaptive max iterations based on zoom level
function getMaxIter() {
const zoomFactor = Math.log2(scale / 100);
return Math.floor(Math.max(200, 200 + zoomFactor * 60));
}
// Color palette
const palette = [
[66, 30, 15],
[25, 7, 26],
[9, 1, 47],
[4, 4, 73],
[0, 7, 100],
[12, 44, 138],
[24, 82, 177],
[57, 125, 209],
[134, 181, 229],
[211, 236, 248],
[241, 233, 191],
[248, 201, 95],
[255, 170, 0],
[204, 128, 0],
[153, 87, 0],
[106, 52, 3],
];
const paletteLen = palette.length;
// Write interpolated color directly into ImageData buffer (avoids array allocation per pixel)
function writeColor(data, idx, n, maxIter) {
if (n >= maxIter) {
data[idx] = 0; data[idx + 1] = 0; data[idx + 2] = 0; data[idx + 3] = 255;
return;
}
const t = n % paletteLen;
const i = Math.floor(t);
const f = t - i;
const c1 = palette[i];
const c2 = palette[(i + 1) % paletteLen];
data[idx] = c1[0] + (c2[0] - c1[0]) * f;
data[idx + 1] = c1[1] + (c2[1] - c1[1]) * f;
data[idx + 2] = c1[2] + (c2[2] - c1[2]) * f;
data[idx + 3] = 255;
}
// Mandelbrot iteration with smooth (fractional) escape count
// Escape radius 256 (|z|² > 65536) for better smooth coloring
function mandelbrot(cr, ci, maxIter) {
let zr = 0, zi = 0;
let zr2 = 0, zi2 = 0;
let n = 0;
while (zr2 + zi2 <= 65536 && n < maxIter) {
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
n++;
}
if (n === maxIter) return maxIter;
// Corrected smooth coloring: log2(log2(|z|²) / 2) = log2(0.5 * log2(|z|²))
return n + 1 - Math.log2(0.5 * Math.log2(zr2 + zi2));
}
// --- Rendering ---
let drawId = 0;
function draw() {
const id = ++drawId;
const w = canvas.width;
const h = canvas.height;
const maxIter = getMaxIter();
const imageData = ctx.createImageData(w, h);
const data = imageData.data;
renderingEl.style.display = "block";
const x0 = centerX - w / (2 * scale);
const y0 = centerY - h / (2 * scale);
const dx = 1 / scale;
const dy = 1 / scale;
let row = 0;
const chunkSize = 40;
function renderChunk() {
if (id !== drawId) return;
const endRow = Math.min(row + chunkSize, h);
for (let y = row; y < endRow; y++) {
const ci = y0 + y * dy;
for (let x = 0; x < w; x++) {
const cr = x0 + x * dx;
const n = mandelbrot(cr, ci, maxIter);
writeColor(data, (y * w + x) * 4, n, maxIter);
}
}
// Only write the rows we just computed (dirty rect)
ctx.putImageData(imageData, 0, 0, 0, row, w, endRow - row);
row = endRow;
if (row < h) {
requestAnimationFrame(renderChunk);
} else {
renderingEl.style.display = "none";
}
}
requestAnimationFrame(renderChunk);
updateHud(maxIter);
}
function updateHud(maxIter) {
const zoom = scale / (Math.min(canvas.width, canvas.height) / 3);
hudEl.textContent =
`Center: ${centerX.toFixed(12)}, ${centerY.toFixed(12)}\n` +
`Zoom: ${zoom.toFixed(1)}x | Iterations: ${maxIter}`;
}
let drawTimer = null;
function scheduleDraw() {
clearTimeout(drawTimer);
drawTimer = setTimeout(draw, 50);
}
// --- Cancel all animations (mutual exclusion) ---
function cancelAllAnimations() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
if (wheelAnimId) { cancelAnimationFrame(wheelAnimId); wheelAnimId = null; }
wheelTargetLogScale = null;
wheelSnapshot = false;
clearTimeout(drawTimer);
drawId++;
}
// --- Snapshot helpers ---
let snapshotCenterX, snapshotCenterY, snapshotScale;
function takeSnapshot() {
snapshotCtx.drawImage(canvas, 0, 0);
snapshotCenterX = centerX;
snapshotCenterY = centerY;
snapshotScale = scale;
}
function drawSnapshotPreview() {
const w = canvas.width;
const h = canvas.height;
const r = scale / snapshotScale;
const ox = w / 2 + (snapshotCenterX - centerX) * scale;
const oy = h / 2 + (snapshotCenterY - centerY) * scale;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(snapshotCanvas, ox - w * r / 2, oy - h * r / 2, w * r, h * r);
}
// --- Animated zoom ---
let animId = null;
function animateZoom(targetCenterX, targetCenterY, targetScale, duration = 300, pivotSX = null, pivotSY = null) {
cancelAllAnimations();
const startCenterX = centerX;
const startCenterY = centerY;
const startScale = scale;
const startTime = performance.now();
const logStart = Math.log(startScale);
const logEnd = Math.log(targetScale);
const hasPivot = pivotSX !== null;
const pivotCR = hasPivot ? centerX + (pivotSX - canvas.width / 2) / scale : 0;
const pivotCI = hasPivot ? centerY + (pivotSY - canvas.height / 2) / scale : 0;
takeSnapshot();
function step(now) {
const t = Math.min((now - startTime) / duration, 1);
const e = 1 - Math.pow(1 - t, 3); // ease-out cubic
scale = Math.exp(logStart + (logEnd - logStart) * e);
if (hasPivot) {
centerX = pivotCR - (pivotSX - canvas.width / 2) / scale;
centerY = pivotCI - (pivotSY - canvas.height / 2) / scale;
} else {
centerX = startCenterX + (targetCenterX - startCenterX) * e;
centerY = startCenterY + (targetCenterY - startCenterY) * e;
}
drawSnapshotPreview();
updateHud(getMaxIter());
if (t < 1) {
animId = requestAnimationFrame(step);
} else {
animId = null;
draw();
}
}
animId = requestAnimationFrame(step);
}
// Animated or instant zoom centered on a screen point
function zoomAtScreen(mx, my, factor, animated = true) {
const newScale = scale * factor;
if (animated) {
animateZoom(null, null, newScale, 300, mx, my);
} else {
const cr = centerX + (mx - canvas.width / 2) / scale;
const ci = centerY + (my - canvas.height / 2) / scale;
scale = newScale;
centerX = cr - (mx - canvas.width / 2) / scale;
centerY = ci - (my - canvas.height / 2) / scale;
scheduleDraw();
}
}
// --- Mouse wheel / trackpad zoom ---
let wheelTargetLogScale = null;
let wheelPivotX = canvas.width / 2;
let wheelPivotY = canvas.height / 2;
let wheelAnimId = null;
let wheelSnapshot = false;
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
wheelPivotX = e.clientX - canvasRect.left;
wheelPivotY = e.clientY - canvasRect.top;
// Cancel competing animations on first wheel event of a gesture
if (wheelTargetLogScale === null) {
if (animId) { cancelAnimationFrame(animId); animId = null; }
clearTimeout(drawTimer);
drawId++;
wheelTargetLogScale = Math.log(scale);
}
// Normalize deltaY across deltaMode (pixels, lines, pages)
let delta = e.deltaY;
if (e.deltaMode === 1) delta *= 16; // DOM_DELTA_LINE
if (e.deltaMode === 2) delta *= 100; // DOM_DELTA_PAGE
delta = Math.max(-50, Math.min(50, delta));
wheelTargetLogScale -= delta * 0.005;
if (!wheelSnapshot) {
takeSnapshot();
wheelSnapshot = true;
}
if (!wheelAnimId) {
wheelAnimId = requestAnimationFrame(wheelAnimStep);
}
}, { passive: false });
function wheelAnimStep() {
const currentLog = Math.log(scale);
const diff = wheelTargetLogScale - currentLog;
const done = Math.abs(diff) < 0.001;
const factor = Math.exp(done ? diff : diff * 0.3);
// Apply zoom keeping pivot pinned
const cr = centerX + (wheelPivotX - canvas.width / 2) / scale;
const ci = centerY + (wheelPivotY - canvas.height / 2) / scale;
scale *= factor;
centerX = cr - (wheelPivotX - canvas.width / 2) / scale;
centerY = ci - (wheelPivotY - canvas.height / 2) / scale;
drawSnapshotPreview();
updateHud(getMaxIter());
if (done) {
wheelAnimId = null;
wheelTargetLogScale = null;
wheelSnapshot = false;
draw();
} else {
wheelAnimId = requestAnimationFrame(wheelAnimStep);
}
}
// --- Double-click detection ---
let lastClickTime = 0;
let lastClickX = 0;
let lastClickY = 0;
// --- Drag to pan (with snapshot preview) ---
let dragging = false;
let dragStartX, dragStartY;
let dragCenterX, dragCenterY;
canvas.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
const now = performance.now();
const dx = e.clientX - lastClickX;
const dy = e.clientY - lastClickY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (now - lastClickTime < 350 && dist < 10) {
// Double-click — zoom in
lastClickTime = 0;
const mx = e.clientX - canvasRect.left;
const my = e.clientY - canvasRect.top;
zoomAtScreen(mx, my, 3, true);
return;
}
lastClickTime = now;
lastClickX = e.clientX;
lastClickY = e.clientY;
// Start drag — cancel animations and take snapshot for live preview
cancelAllAnimations();
takeSnapshot();
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragCenterX = centerX;
dragCenterY = centerY;
canvas.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
// Always track mouse position for keyboard zoom
mouseX = e.clientX - canvasRect.left;
mouseY = e.clientY - canvasRect.top;
if (!dragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
centerX = dragCenterX - dx / scale;
centerY = dragCenterY - dy / scale;
// Instant visual feedback by translating the snapshot
drawSnapshotPreview();
updateHud(getMaxIter());
});
window.addEventListener("mouseup", (e) => {
if (e.button === 0 && dragging) {
dragging = false;
canvas.style.cursor = "crosshair";
draw(); // full render at new position
}
});
// Suppress native dblclick
canvas.addEventListener("dblclick", (e) => e.preventDefault());
// --- Right-click animated zoom out ---
canvas.addEventListener("contextmenu", (e) => {
e.preventDefault();
const mx = e.clientX - canvasRect.left;
const my = e.clientY - canvasRect.top;
zoomAtScreen(mx, my, 1 / 3, true);
});
// --- Keyboard controls ---
let mouseX = canvas.width / 2;
let mouseY = canvas.height / 2;
// Use key state tracking for smooth held-key panning
const keysDown = new Set();
let keyPanAnimId = null;
window.addEventListener("keydown", (e) => {
let handled = true;
switch (e.key) {
case "ArrowLeft": case "ArrowRight": case "ArrowUp": case "ArrowDown":
if (!keysDown.has(e.key)) {
keysDown.add(e.key);
if (!keyPanAnimId) startKeyPan();
}
break;
case "+": case "=":
zoomAtScreen(mouseX, mouseY, 1.5, true);
break;
case "-": case "_":
zoomAtScreen(mouseX, mouseY, 1 / 1.5, true);
break;
case "r": case "R":
animateZoom(-0.5, 0, Math.min(canvas.width, canvas.height) / 3, 400);
break;
default: handled = false;
}
if (handled) e.preventDefault();
});
window.addEventListener("keyup", (e) => {
keysDown.delete(e.key);
});
function startKeyPan() {
cancelAllAnimations();
takeSnapshot();
function panStep() {
if (keysDown.size === 0) {
keyPanAnimId = null;
draw();
return;
}
const panAmount = 4 / scale; // pixels per frame equivalent
if (keysDown.has("ArrowLeft")) centerX -= panAmount;
if (keysDown.has("ArrowRight")) centerX += panAmount;
if (keysDown.has("ArrowUp")) centerY -= panAmount;
if (keysDown.has("ArrowDown")) centerY += panAmount;
drawSnapshotPreview();
updateHud(getMaxIter());
keyPanAnimId = requestAnimationFrame(panStep);
}
keyPanAnimId = requestAnimationFrame(panStep);
}
// Initial draw
draw();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment