Last active
March 25, 2026 12:20
-
-
Save henrik242/d75c5ebe83e41c40aa89d67b412ab4c1 to your computer and use it in GitHub Desktop.
Mandelbrot created by AI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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