Mandelbrot Zoom
An endless dive into the Mandelbrot set. The view doubles in magnification every couple of seconds, heading into Seahorse Valley, where spirals, curling tails and tiny copies of the whole set keep appearing. When it runs out of precision it fades back out and dives again.
How to use it
- Paste the snippet just before
</head>. - Add
data-mandelbrot-zoomto a box with a height.
Options
Add any of these attributes to the same element.
data-x | Real part of the point to dive into. Default -0.743643887037151 (Seahorse Valley). |
data-y | Imaginary part of that point. Default 0.131825904205330. |
data-depth | How many times the magnification doubles before it starts again, 4 to 38. Default 32, about four billion times. |
data-speed | Seconds per doubling. Default 2. |
data-palette | violet, fire or ocean. Default violet. |
Each next view is worked out a few rows at a time while the current one is zoomed, so it stays smooth; deep down, where the maths gets heavy, the zoom eases off until the next view is ready. It’s paused when off screen, and with reduced motion it shows one still view.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Mandelbrot Zoom: an endless dive into the Mandelbrot set. The view
// doubles in magnification every couple of seconds, heading into Seahorse
// Valley, where spirals, seahorse tails and tiny copies of the whole set
// keep appearing. When it runs out of precision it fades back out and
// dives again. Each next view is worked out a few rows at a time while
// the current one is being zoomed, so it stays smooth. Paused when off
// screen.
// Usage: <div data-mandelbrot-zoom style="height:360px"></div>
// Optional: data-x and data-y (the point to dive into, default Seahorse
// Valley, -0.743643887037151 and 0.131825904205330), data-depth (how many
// times to double the magnification before starting again, default 32),
// data-speed (seconds per doubling, default 2), data-palette ("violet",
// "fire" or "ocean", default violet).
var DEFAULTS = { x: -0.743643887037151, y: 0.13182590420533, depth: 32, speed: 2, palette: 'violet', start: 3.2, budget: 150000 };
var PALETTES = {
violet: ['#0b0420', '#3b0d6b', '#a21caf', '#f472b6', '#fde68a', '#ffffff', '#7dd3fc', '#1e3a8a'],
fire: ['#0a0202', '#5b0a0a', '#c2410c', '#f59e0b', '#fef3c7', '#fb923c', '#7f1d1d', '#1c0606'],
ocean: ['#020617', '#0c4a6e', '#0891b2', '#5eead4', '#f0fdfa', '#38bdf8', '#1e40af', '#0b1340']
};
var INSIDE = [11, 6, 20];
var css =
'[data-mandelbrot-zoom]{position:relative;isolation:isolate;overflow:hidden;background:#0b0614}' +
'.mbz-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var n = parseInt(hex.replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
// A smooth looping palette of 256 colours, packed as r, g, b.
function makePalette(stops) {
var cs = stops.map(rgb), out = new Uint8Array(256 * 3);
for (var i = 0; i < 256; i++) {
var f = i / 256 * cs.length, a = Math.floor(f), k = f - a;
k = k * k * (3 - 2 * k);
var c0 = cs[a % cs.length], c1 = cs[(a + 1) % cs.length];
for (var j = 0; j < 3; j++) out[i * 3 + j] = c0[j] + (c1[j] - c0[j]) * k;
}
return out;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'mbz-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var cx = d.x !== undefined ? +d.x : DEFAULTS.x, cy = d.y !== undefined ? +d.y : DEFAULTS.y;
var depth = Math.max(4, Math.min(38, +d.depth || DEFAULTS.depth));
var perStep = (+d.speed || DEFAULTS.speed) * 1000;
var pal = makePalette(PALETTES[d.palette] || PALETTES[DEFAULTS.palette]);
var w = 0, h = 0, dpr = 1, bw = 0, bh = 0;
// The view on screen (cur) and the next one, twice as close (nxt).
var cur = document.createElement('canvas'), nxt = document.createElement('canvas');
var img = null, row = 0, level = 0, p = 0, fading = false;
// Colour rows y0..y1 of the view `lvl` doublings deep into img.
function render(lvl, y0, y1) {
var W = DEFAULTS.start / Math.pow(2, lvl), sc = W / Math.min(bw, bh);
var maxIter = Math.min(1500, Math.round(140 + 42 * lvl));
// How close two visits must be to count as caught in a cycle.
var eps = Math.min(1e-10, sc * sc * 0.01);
var data = img.data;
for (var y = y0; y < y1; y++) {
var ci = cy + (y - bh / 2) * sc;
for (var x = 0; x < bw; x++) {
var cr = cx + (x - bw / 2) * sc;
var o = (y * bw + x) * 4;
// Inside the main cardioid or the big bulb: no need to iterate.
var q = (cr - 0.25) * (cr - 0.25) + ci * ci;
if (q * (q + cr - 0.25) < 0.25 * ci * ci || (cr + 1) * (cr + 1) + ci * ci < 0.0625) {
data[o] = INSIDE[0]; data[o + 1] = INSIDE[1]; data[o + 2] = INSIDE[2]; data[o + 3] = 255;
continue;
}
var zr = 0, zi = 0, zr2 = 0, zi2 = 0, i = 0, pr = 0, pi = 0, check = 8, inside = false;
while (i < maxIter) {
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
i++;
if (zr2 + zi2 > 256) break;
// Caught in a cycle: it never escapes.
if ((zr - pr) * (zr - pr) + (zi - pi) * (zi - pi) < eps) { inside = true; break; }
if (i === check) { pr = zr; pi = zi; check *= 2; }
}
if (inside || i >= maxIter) {
data[o] = INSIDE[0]; data[o + 1] = INSIDE[1]; data[o + 2] = INSIDE[2];
} else {
// Smooth escape count, so the bands blend.
var mu = i + 1 - Math.log(Math.log(zr2 + zi2) / 2 / Math.LN2) / Math.LN2;
var idx = (Math.floor(Math.sqrt(Math.max(0, mu)) * 26) & 255) * 3;
data[o] = pal[idx]; data[o + 1] = pal[idx + 1]; data[o + 2] = pal[idx + 2];
}
data[o + 3] = 255;
}
}
}
function sizeBuffers() {
var q = Math.min(dpr, Math.sqrt(DEFAULTS.budget / Math.max(1, w * h)));
bw = Math.max(16, Math.round(w * q));
bh = Math.max(16, Math.round(h * q));
cur.width = nxt.width = bw;
cur.height = nxt.height = bh;
img = cur.getContext('2d').createImageData(bw, bh);
}
// Draw the current view in one go, then start on the next.
function restart(lvl) {
level = lvl;
p = 0;
render(level, 0, bh);
cur.getContext('2d').putImageData(img, 0, 0);
row = 0;
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
if (!w || !h) return false;
sizeBuffers();
restart(level);
return true;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
var s = Math.pow(2, p);
ctx.drawImage(cur, w / 2 - w * s / 2, h / 2 - h * s / 2, w * s, h * s);
}
// Work on the next view for a few milliseconds each frame.
function work() {
if (row >= bh) return;
var start = performance.now();
while (row < bh && performance.now() - start < 8) {
var end = Math.min(bh, row + 2);
render(level + 1, row, end);
row = end;
}
if (row >= bh) nxt.getContext('2d').putImageData(img, 0, 0);
}
function step(dt) {
if (fading) return;
work();
p = Math.min(1, p + dt / perStep);
if (p < 1 || row < bh) return;
// Swap: the next view is exactly the current one at twice the size.
var tmp = cur; cur = nxt; nxt = tmp;
level++;
p = 0;
row = 0;
if (level >= depth) {
fading = true;
anime.animate(canvas, {
opacity: 0, duration: 700, ease: 'inOut(2)',
onComplete: function () {
restart(0);
draw();
anime.animate(canvas, { opacity: [0, 1], duration: 700, ease: 'inOut(2)', onComplete: function () { fading = false; } });
}
});
}
}
dpr = Math.min(2, window.devicePixelRatio || 1);
if (reduced()) {
// One still view, part of the way in.
level = 9;
resize();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
return;
}
resize();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
if (!img) return;
step(dt);
draw();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-mandelbrot-zoom]').forEach(function (el) {
if (el._mbz) return;
el._mbz = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-mandelbrot-zoom style="height: 360px"></div>
<!-- dive somewhere else: -->
<div data-mandelbrot-zoom data-x="-0.1011" data-y="0.9563" data-palette="fire"
style="height: 360px"></div>