Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 330 additions & 0 deletions public/mandelbrot.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Mandelbrot Set Renderer</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
background: #000;
overflow: hidden;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#canvas {
display: block;
cursor: grab;
}
#canvas:active {
cursor: grabbing;
}
#hud {
position: fixed;
top: 10px;
left: 10px;
color: #eee;
background: rgba(0, 0, 0, 0.55);
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
pointer-events: none;
max-width: 320px;
}
#controls {
position: fixed;
top: 10px;
right: 10px;
display: flex;
gap: 8px;
}
button {
background: #222;
color: #eee;
border: 1px solid #555;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
button:hover {
background: #333;
}
#loading {
position: fixed;
bottom: 10px;
left: 10px;
color: #888;
font-size: 11px;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="hud">
<div><strong>Mandelbrot Set</strong></div>
<div>Center: <span id="hud-center">-0.5, 0</span></div>
<div>Zoom: <span id="hud-zoom">1x</span></div>
<div>Iterations: <span id="hud-iter">100</span></div>
<div style="margin-top:4px;color:#aaa;">Scroll to zoom &middot; drag to pan</div>
</div>
<div id="controls">
<button id="reset-btn">Reset</button>
</div>
<div id="loading"></div>

<script>
(function () {
"use strict";

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const hudCenter = document.getElementById("hud-center");
const hudZoom = document.getElementById("hud-zoom");
const hudIter = document.getElementById("hud-iter");
const resetBtn = document.getElementById("reset-btn");
const loadingEl = document.getElementById("loading");

// View state: center point in the complex plane and vertical span it covers.
const DEFAULT_VIEW = { centerX: -0.5, centerY: 0, scale: 3.0 };
let view = Object.assign({}, DEFAULT_VIEW);

let width = 0;
let height = 0;
let dpr = Math.max(1, window.devicePixelRatio || 1);
let imageData = null;
let renderToken = 0;

function resize() {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = width + "px";
canvas.style.height = height + "px";
imageData = ctx.createImageData(canvas.width, canvas.height);
render();
}

function maxIterationsForScale(scale) {
// Increase iteration count as we zoom in for more detail.
const zoomFactor = DEFAULT_VIEW.scale / scale;
const base = 100;
const extra = Math.max(0, Math.log2(Math.max(1, zoomFactor))) * 40;
return Math.min(2000, Math.round(base + extra));
}

// Smooth HSV -> RGB colouring based on escape-time + fractional smoothing.
function paletteColor(t, out, offset) {
// t is in [0, 1)
const hue = 360 * t;
const s = 0.8;
const v = 1.0;
const c = v * s;
const hh = hue / 60;
const x = c * (1 - Math.abs((hh % 2) - 1));
let r = 0, g = 0, b = 0;
if (hh >= 0 && hh < 1) { r = c; g = x; b = 0; }
else if (hh < 2) { r = x; g = c; b = 0; }
else if (hh < 3) { r = 0; g = c; b = x; }
else if (hh < 4) { r = 0; g = x; b = c; }
else if (hh < 5) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
const m = v - c;
out[offset] = Math.round((r + m) * 255);
out[offset + 1] = Math.round((g + m) * 255);
out[offset + 2] = Math.round((b + m) * 255);
out[offset + 3] = 255;
}

function render() {
if (!imageData) return;
const token = ++renderToken;
const pw = canvas.width;
const ph = canvas.height;
const data = imageData.data;

const maxIter = maxIterationsForScale(view.scale);
hudIter.textContent = String(maxIter);
hudZoom.textContent = (DEFAULT_VIEW.scale / view.scale).toFixed(1) + "x";
hudCenter.textContent = view.centerX.toFixed(6) + ", " + view.centerY.toFixed(6);

const scaleY = view.scale;
const scaleX = (scaleY * pw) / ph;
const xMin = view.centerX - scaleX / 2;
const yMin = view.centerY - scaleY / 2;

loadingEl.textContent = "Rendering...";

// Render in horizontal chunks so the UI can update the loading indicator.
const chunkHeight = 48;
let rowStart = 0;

function renderChunk() {
if (token !== renderToken) return; // A newer render superseded this one.

const rowEnd = Math.min(ph, rowStart + chunkHeight);
for (let py = rowStart; py < rowEnd; py++) {
const y0 = yMin + (py / ph) * scaleY;
for (let px = 0; px < pw; px++) {
const x0 = xMin + (px / pw) * scaleX;

let x = 0, y = 0, x2 = 0, y2 = 0;
let iter = 0;

// Quick check: main cardioid and period-2 bulb membership test.
const q = (x0 - 0.25) * (x0 - 0.25) + y0 * y0;
if (q * (q + (x0 - 0.25)) < 0.25 * y0 * y0 ||
(x0 + 1) * (x0 + 1) + y0 * y0 < 0.0625) {
iter = maxIter;
} else {
while (x2 + y2 <= 4 && iter < maxIter) {
y = 2 * x * y + y0;
x = x2 - y2 + x0;
x2 = x * x;
y2 = y * y;
iter++;
}
}

const offset = (py * pw + px) * 4;
if (iter >= maxIter) {
data[offset] = 0;
data[offset + 1] = 0;
data[offset + 2] = 0;
data[offset + 3] = 255;
} else {
// Smooth coloring using the renormalized escape count.
const logZn = Math.log(x2 + y2) / 2;
const nu = Math.log(logZn / Math.LN2) / Math.LN2;
const smoothIter = iter + 1 - nu;
const t = (smoothIter % 64) / 64;
paletteColor(t, data, offset);
}
}
}

rowStart = rowEnd;
if (rowStart < ph) {
setTimeout(renderChunk, 0);
} else {
ctx.putImageData(imageData, 0, 0);
loadingEl.textContent = "";
}
}

renderChunk();
}

// --- Interaction: zoom with wheel, pan with drag ---

canvas.addEventListener("wheel", function (e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width;
const py = (e.clientY - rect.top) / rect.height;

const scaleY = view.scale;
const scaleX = (scaleY * canvas.width) / canvas.height;
const xMin = view.centerX - scaleX / 2;
const yMin = view.centerY - scaleY / 2;
const mouseX = xMin + px * scaleX;
const mouseY = yMin + py * scaleY;

const zoomIntensity = 0.0015;
const factor = Math.exp(e.deltaY * zoomIntensity);
const newScale = Math.min(4, Math.max(1e-13, view.scale * factor));

// Zoom centered on the cursor position.
view.centerX = mouseX - (mouseX - view.centerX) * (newScale / view.scale);
view.centerY = mouseY - (mouseY - view.centerY) * (newScale / view.scale);
view.scale = newScale;

render();
}, { passive: false });

let dragging = false;
let lastX = 0, lastY = 0;

canvas.addEventListener("mousedown", function (e) {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
});

window.addEventListener("mousemove", function (e) {
if (!dragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;

const scaleY = view.scale;
const scaleX = (scaleY * canvas.width) / canvas.height;
view.centerX -= (dx / canvas.width) * scaleX;
view.centerY -= (dy / canvas.height) * scaleY;
render();
});

window.addEventListener("mouseup", function () {
dragging = false;
});

// Touch support (single-finger pan, pinch zoom).
let touchStartDist = null;
let touchStartScale = null;

canvas.addEventListener("touchstart", function (e) {
if (e.touches.length === 1) {
dragging = true;
lastX = e.touches[0].clientX;
lastY = e.touches[0].clientY;
} else if (e.touches.length === 2) {
dragging = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
touchStartDist = Math.hypot(dx, dy);
touchStartScale = view.scale;
}
}, { passive: true });

canvas.addEventListener("touchmove", function (e) {
if (e.touches.length === 1 && dragging) {
const dx = e.touches[0].clientX - lastX;
const dy = e.touches[0].clientY - lastY;
lastX = e.touches[0].clientX;
lastY = e.touches[0].clientY;
const scaleY = view.scale;
const scaleX = (scaleY * canvas.width) / canvas.height;
view.centerX -= (dx / canvas.width) * scaleX;
view.centerY -= (dy / canvas.height) * scaleY;
render();
} else if (e.touches.length === 2 && touchStartDist) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.hypot(dx, dy);
view.scale = Math.min(4, Math.max(1e-13, touchStartScale * (touchStartDist / dist)));
render();
}
}, { passive: true });

canvas.addEventListener("touchend", function (e) {
if (e.touches.length === 0) {
dragging = false;
touchStartDist = null;
}
});

resetBtn.addEventListener("click", function () {
view = Object.assign({}, DEFAULT_VIEW);
render();
});

window.addEventListener("resize", resize);
resize();
})();
</script>
</body>
</html>