Skip to content
Open
Show file tree
Hide file tree
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
34 changes: 34 additions & 0 deletions examples/mandelbrot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Mandelbrot Set Renderer

A single-file, dependency-free HTML/JavaScript renderer for everyone's
favorite infinitely-zoomable blob of math: the Mandelbrot set.

![Mandelbrot set screenshot](./screenshot.png)

## Usage

Open `index.html` in a browser. That's it - no `npm install`, no build
step, no bundler to configure. Just double-click the file and go stare
into the fractal abyss.

## Controls

- **Click** to zoom in, **Shift+Click** to zoom back out
- **Drag** to lasso a region and zoom into it
- **Scroll** to zoom in/out under the cursor
- **Reset View** when you get lost (you will get lost)
- **Cycle Palette** to switch up the vibe (classic / fire / grayscale)

Iteration count quietly ramps up the deeper you zoom, so the edges stay
crisp instead of turning into a blurry mess.

## How it works

Every pixel becomes a point `c` on the complex plane. Starting from
`z = 0`, we repeatedly compute `z = z^2 + c` and count how many
iterations it survives before `|z|` blows past 2 and "escapes." Points
that never escape belong to the set and get colored black; everything
else gets shaded by how fast it fled. A couple of early-out checks
(the main cardioid and period-2 bulb formulas) let us skip the math
entirely for points we already know are staying put, which is the
closest thing this renderer has to a shortcut.
258 changes: 258 additions & 0 deletions examples/mandelbrot/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
<!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: #111;
color: #eee;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
height: 100%;
}
#container {
position: relative;
width: 100vw;
height: 100vh;
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
#panel {
position: absolute;
top: 12px;
left: 12px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid #444;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
line-height: 1.5;
max-width: 280px;
}
#panel button {
background: #333;
color: #eee;
border: 1px solid #555;
border-radius: 4px;
padding: 4px 10px;
margin: 4px 4px 0 0;
cursor: pointer;
font-size: 12px;
}
#panel button:hover {
background: #444;
}
#panel code {
color: #9cf;
}
#status {
margin-top: 6px;
opacity: 0.8;
font-size: 11px;
}
</style>
</head>
<body>
<div id="container">
<canvas id="mandelbrot"></canvas>
<div id="panel">
<strong>Mandelbrot Set</strong><br />
Click to zoom in &middot; Shift+Click to zoom out<br />
Drag to select a zoom region<br />
<button id="resetBtn">Reset View</button>
<button id="paletteBtn">Cycle Palette</button>
<div id="status">iterations: <span id="iterVal">0</span></div>
</div>
</div>

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

const canvas = document.getElementById("mandelbrot");
const ctx = canvas.getContext("2d");
const iterVal = document.getElementById("iterVal");
Comment on lines +80 to +82
const resetBtn = document.getElementById("resetBtn");
const paletteBtn = document.getElementById("paletteBtn");

// Viewport in the complex plane.
const DEFAULT_VIEW = { centerX: -0.5, centerY: 0, scale: 3.0 };
let view = Object.assign({}, DEFAULT_VIEW);

let maxIterations = 200;
let paletteIndex = 0;
const palettes = ["classic", "fire", "grayscale"];

let dragStart = null;
let dragCurrent = null;

function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
render();
}

function colorFor(iter, maxIter) {
if (iter >= maxIter) {
return [0, 0, 0];
}
const t = iter / maxIter;
const palette = palettes[paletteIndex];

if (palette === "classic") {
const r = Math.floor(9 * (1 - t) * t * t * t * 255);
const g = Math.floor(15 * (1 - t) * (1 - t) * t * t * 255);
const b = Math.floor(8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255);
return [r, g, b];
} else if (palette === "fire") {
const r = Math.floor(Math.min(255, t * 3 * 255));
const g = Math.floor(Math.min(255, Math.max(0, (t * 3 - 1)) * 255));
const b = Math.floor(Math.min(255, Math.max(0, (t * 3 - 2)) * 255));
return [r, g, b];
} else {
const v = Math.floor(t * 255);
return [v, v, v];
}
}

// Compute number of iterations for a point c = (cx, cy) using the
// escape-time algorithm, with an optional cardioid/bulb early-out check
// for performance.
function mandelbrotIterations(cx, cy, maxIter) {
// Quick rejection using the main cardioid and period-2 bulb formulas.
const q = (cx - 0.25) * (cx - 0.25) + cy * cy;
if (q * (q + (cx - 0.25)) < 0.25 * cy * cy) {
return maxIter;
}
if ((cx + 1) * (cx + 1) + cy * cy < 0.0625) {
return maxIter;
}

let x = 0, y = 0, x2 = 0, y2 = 0;
let iter = 0;
while (x2 + y2 <= 4 && iter < maxIter) {
y = 2 * x * y + cy;
x = x2 - y2 + cx;
x2 = x * x;
y2 = y * y;
iter++;
}
return iter;
}

function render() {
const width = canvas.width;
const height = canvas.height;
if (width === 0 || height === 0) return;

const imageData = ctx.createImageData(width, height);
const data = imageData.data;

const aspect = width / height;
const scaleX = view.scale * aspect;
const scaleY = view.scale;

for (let py = 0; py < height; py++) {
const cy = view.centerY + (py / height - 0.5) * scaleY;
for (let px = 0; px < width; px++) {
const cx = view.centerX + (px / width - 0.5) * scaleX;
const iter = mandelbrotIterations(cx, cy, maxIterations);
const [r, g, b] = colorFor(iter, maxIterations);
const idx = (py * width + px) * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = 255;
}
}

ctx.putImageData(imageData, 0, 0);
iterVal.textContent = String(maxIterations);
}

function screenToComplex(screenX, screenY) {
const rect = canvas.getBoundingClientRect();
const px = ((screenX - rect.left) / rect.width) * canvas.width;
const py = ((screenY - rect.top) / rect.height) * canvas.height;
const aspect = canvas.width / canvas.height;
const scaleX = view.scale * aspect;
const scaleY = view.scale;
const cx = view.centerX + (px / canvas.width - 0.5) * scaleX;
const cy = view.centerY + (py / canvas.height - 0.5) * scaleY;
return { cx, cy };
}

function zoomAt(cx, cy, factor) {
view.centerX = cx;
view.centerY = cy;
view.scale *= factor;
// Increase iteration count as we zoom in for better detail.
maxIterations = Math.min(2000, Math.round(200 + 100 * Math.log2(DEFAULT_VIEW.scale / view.scale + 1)));
render();
}

canvas.addEventListener("mousedown", (e) => {
dragStart = { x: e.clientX, y: e.clientY };
});
Comment on lines +203 to +205

canvas.addEventListener("mousemove", (e) => {
if (dragStart) {
dragCurrent = { x: e.clientX, y: e.clientY };
}
});

canvas.addEventListener("mouseup", (e) => {
if (!dragStart) return;
const dx = Math.abs(e.clientX - dragStart.x);
const dy = Math.abs(e.clientY - dragStart.y);

if (dx > 5 && dy > 5) {
// Drag box zoom: zoom into the midpoint of the dragged rectangle.
const midX = (dragStart.x + e.clientX) / 2;
const midY = (dragStart.y + e.clientY) / 2;
const { cx, cy } = screenToComplex(midX, midY);
const rect = canvas.getBoundingClientRect();
const factor = Math.max(dx / rect.width, dy / rect.height);
zoomAt(cx, cy, Math.max(factor, 0.02));
} else {
// Simple click: zoom in, or zoom out with shift.
const { cx, cy } = screenToComplex(e.clientX, e.clientY);
zoomAt(cx, cy, e.shiftKey ? 2 : 0.5);
}
dragStart = null;
dragCurrent = null;
});
Comment on lines +231 to +233

canvas.addEventListener("wheel", (e) => {
e.preventDefault();
const { cx, cy } = screenToComplex(e.clientX, e.clientY);
const factor = e.deltaY > 0 ? 1.2 : 1 / 1.2;
zoomAt(cx, cy, factor);
}, { passive: false });
Comment on lines +234 to +240

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

paletteBtn.addEventListener("click", () => {
paletteIndex = (paletteIndex + 1) % palettes.length;
render();
});

window.addEventListener("resize", resizeCanvas);
resizeCanvas();
})();
</script>
</body>
</html>
Binary file added examples/mandelbrot/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading