A port of the Mandelbrot set: https://en.wikipedia.org/wiki/Mandelbrot_set
I coded a slow version of this in Game Maker a long time ago. I never colored it and it was very slow.
(() => {
'use strict';
const WH = 700;
class Mandelbrot {
constructor(target, min = -2.5, max = 1.5) {
this.maxIterations = 150;
this.centerX = (min + max) / 2;
this.centerY = 0;
this.span = max - min;
this.renderId = 0;
this.frameId = null;
this.canvas = document.createElement('canvas');
this.canvas.width = WH;
this.canvas.height = WH;
this.canvas.style.cssText = `
display: block;
width: 100%;
max-width: ${WH}px;
height: auto;
cursor: grab;
touch-action: none;
`;
target.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d', {
alpha: false
});
this.setupEvents();
this.render();
}
reset(min, max) {
if (!Number.isFinite(min) ||
!Number.isFinite(max) ||
min >= max) return;
this.centerX = (min + max) / 2;
this.centerY = 0;
this.span = max - min;
this.render();
}
// Convert canvas coordinates to complex coordinates
pixelToComplex(x, y) {
return {
re: this.centerX + (x - WH / 2) * this.span / WH,
im: this.centerY + (y - WH / 2) * this.span / WH
};
}
// Calculate escape iterations
inSet(cr, ci) {
let zr = 0;
let zi = 0;
let n = 0;
while (n < this.maxIterations) {
const zr2 = zr * zr;
const zi2 = zi * zi;
if (zr2 + zi2 > 4) break;
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
n++;
}
if (n === this.maxIterations) return null;
// Smooth iteration count
const magnitude = Math.sqrt(zr * zr + zi * zi);
return n + 1 - Math.log2(Math.log2(magnitude));
}
// Smooth RGB color palette
getColor(n) {
if (n === null) return [0, 0, 0];
const t = n * 0.17;
return [
Math.round(127.5 + 127.5 * Math.sin(t)),
Math.round(127.5 + 127.5 * Math.sin(t + 2.094)),
Math.round(127.5 + 127.5 * Math.sin(t + 4.189))
];
}
render() {
// Cancel any unfinished render
const id = ++this.renderId;
if (this.frameId !== null) {
cancelAnimationFrame(this.frameId);
}
const w = WH;
const h = WH;
const image = this.ctx.createImageData(w, h);
const pixels = image.data;
const scale = this.span / w;
const left = this.centerX - this.span / 2;
const top = this.centerY - this.span / 2;
let y = 0;
const drawChunk = () => {
if (id !== this.renderId) return;
const startRow = y;
const deadline = performance.now() + 12;
// Process a small batch without blocking the UI
while (y < h &&
(y === startRow || performance.now() < deadline)) {
const ci = top + y * scale;
for (let x = 0; x < w; x++) {
const cr = left + x * scale;
const n = this.inSet(cr, ci);
const [r, g, b] = this.getColor(n);
const i = (y * w + x) * 4;
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
pixels[i + 3] = 255;
}
y++;
}
this.ctx.putImageData(
image, 0, 0,
0, startRow, w, y - startRow
);
if (y < h) {
this.frameId = requestAnimationFrame(drawChunk);
} else {
this.frameId = null;
}
};
this.ctx.clearRect(0, 0, w, h);
this.frameId = requestAnimationFrame(drawChunk);
}
setupEvents() {
const canvas = this.canvas;
// Zoom toward mouse position
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) *
WH / rect.width;
const y = (e.clientY - rect.top) *
WH / rect.height;
const before = this.pixelToComplex(x, y);
const factor = Math.exp(Math.sign(e.deltaY) * 0.2);
this.span = Math.max(
1e-12,
Math.min(1e4, this.span * factor)
);
// Keep the point under the cursor stationary
this.centerX = before.re -
(x - WH / 2) * this.span / WH;
this.centerY = before.im -
(y - WH / 2) * this.span / WH;
this.render();
}, { passive: false });
// Drag to pan
let drag = null;
canvas.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
drag = {
x: e.clientX,
y: e.clientY,
centerX: this.centerX,
centerY: this.centerY,
span: this.span
};
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('pointermove', (e) => {
if (!drag) return;
const rect = canvas.getBoundingClientRect();
const dx = (e.clientX - drag.x) *
WH / rect.width;
const dy = (e.clientY - drag.y) *
WH / rect.height;
this.centerX = drag.centerX -
dx * drag.span / WH;
this.centerY = drag.centerY -
dy * drag.span / WH;
this.render();
});
const endDrag = () => {
drag = null;
canvas.style.cursor = 'grab';
};
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
}
}
// Initialize without overwriting other load handlers
function initialize() {
const target = document.getElementById('target');
if (!target) return;
const minInput = document.getElementById('min');
const maxInput = document.getElementById('max');
const getMin = () => Number(minInput?.value ?? -2.5);
const getMax = () => Number(maxInput?.value ?? 1.5);
const m = new Mandelbrot(target, getMin(), getMax());
const updateRange = () => {
const minv = document.getElementById('minv');
const maxv = document.getElementById('maxv');
if (minv) minv.textContent = getMin();
if (maxv) maxv.textContent = getMax();
m.reset(getMin(), getMax());
};
minInput?.addEventListener('change', updateRange);
maxInput?.addEventListener('change', updateRange);
// Optional iteration control
const iterations = document.getElementById('iterations');
iterations?.addEventListener('input', () => {
m.maxIterations = Number(iterations.value);
m.render();
});
// Optional reset button
document.getElementById('reset')?.addEventListener(
'click',
() => m.reset(getMin(), getMax())
);
}
// Works whether the script loads before or after DOMContentLoaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize, {
once: true
});
} else {
initialize();
}
})();
