... please wait

Jeremy Aaron Heminger

Systems Programmer / Web Developer Located in Southern California

The Mandelbrot Set

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();
    }

})();

Linux
Apache
MySQL
PHP
HTML5
CSS3
JavaScript
jQuery
Grunt
Sass
Git / GitHub
WordPress
Drupal
TypeScript
C#
Oracle SQL Developer
Visual Studio