Learning the Mandelbrot set with javascript
This is how I approached learning to visualize the Mandelbrot set. It is not optimized and the code is structured for readability rather than performance.
One dimension#
To make things easy, lets start our with rendering the factal in only one dimension.
To calculate if a number is part of the Mandelbrot set, we use the following function:
const mandelbrot = ({ z, c }) => z * z + c;
The function simply squares the value of z
and adds the number c
afterwards.
When the output of this function is fed back into itself many times, most numbers will eventually reach infinity. When doing these iterations, z
always starts at zero and we only alter c
to get different results.
To visualize this, let us define a function that iterates like this and prints the intermediate values:
function iterate({ c, limit = 5, z = 0, i = 0 }) {
const nextZ = mandelbrot({ z, c });
console.log(nextZ);
if (i !== limit) return iterate({ c, limit, z: nextZ, i: ++i });
}
iterate({ c: 1});
// will print values: 1, 2, 5, 26, 677, 458330
As seen, when iterating over the function with when c = 1
the numbers printed are quickly "escaping" to infinity.
However, some values never reach infinity. Like -1
or -0.5
for example:
iterate({ c: -1});
// will print values: -1, 0, -1, 0, -1, 0
iterate({ c: -0.5 }
// will print vlaues: -0.5, -0.25, -0.4375, -0.308.., -0.404.., -0.336..
All values for c
, that when passed to this iterative function never escapes to infinity are part of the mandelbrot set.
The number of iterations it takes for a given value c
to escape to infinity is called its escape velocity. Once the value of z
goes above 2, we know for certain that it will eventually escape. Let's modify the previous function to print the escape velocity of a given number:
function getEscapeVelocity({ c, limit = 100, z = 0, i = 0 }) {
const nextZ = mandelbrot({ z, c });
if (z < 2 && i !== limit)
return getEscapeVelocity({ c, limit, z: nextZ, i: ++i });
return i + 1;
}
console.log(getEscapeVelocity({ c: 0.8 }));
// will print "4"
When rendering pretty pictures of the Mandelbrot fractal, the escape velocity is what determines what color a given pixel will have.
Even through it will not look very interesting, let's visualize the mandelbrot set in one dimension using HTML5 canvas. To do that we first need to have a function that returns a color value based on an escape velocity. There are many different options for doing this. This is the HSV-version:
function getColor(escVel, limit = 100) {
// Hue should be a value between 0 and 360
const hue = (escVel * 360) / limit;
const sat = 255;
/* If the limit equals the escape velocity the number is within the set
and should be colored black. */
const light = escVel < limit ? 50 : 0;
return `hsl(${hue}, ${sat}%, ${light}%)`;
}
For brevity, we will not go into details on how exactly we are plotting the the set. Shortly put, we are putting a colored square for every value between -3 and 2 along a line on the center of the screen.
const bounds = [-3, 2];
const splits = 1000;
const increments = (bounds[1] - bounds[0]) / splits;
const marks = [...Array(splits)].map((x, i) => i * increments + bounds[0]);
function draw() {
for (i = 0; i < splits; i++) {
const segmentLength = canvas.width / splits;
const start = segmentLength * i;
const end = segmentLength * i + segmentLength;
const c = marks[i];
const escVel = getEscapeVelocity({ c });
const color = getColor(escVel);
ctx.fillStyle = color;
ctx.fillRect(start, canvas.height / 2, 10, 10);
}
}
draw();
The markup looks like this:
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>
<canvas id="myCanvas" width="200" height="100"></canvas>
<script src="1d.js"></script>
When running this in the browser, it should look like this:
This is the value of c
plotted for 1000 values between -3 and 2. Numbers that are within the set will be colored black. Numbers outside the set that eventually escape will have greater hue the slower they escape.
Two dimensions#
To turn this fairly boring line into the iconic Mandelbrot-shape we only need to replace the format if the c
value. To draw in 2d we need c
to be a pair of { x, y }
coordinates instead of a single number value. The way for multiplying these pairs are identical to multiplying complex numbers in mathematics.
So all we do is modify the mandelbrot function to handle { x, y }
values and update our draw()
function to plot a matrix instead. Here is the complete code:
function mandelbrot(z, c) {
const zSquared = { x: Math.pow(z.x, 2) - Math.pow(z.y, 2), y: 2 * z.x * z.y };
return { x: zSquared.x + c.x, y: zSquared.y + c.y };
}
function getEscVel({ z = { x: 0, y: 0 }, c, i = 0, limit = 100 }) {
const newZ = mandelbrot(z, c);
// this is used to control if Z is unbounded
const mod = Math.sqrt(Math.pow(z.x, 2) + Math.pow(z.y, 2));
return mod > 2 || i === limit ? i : getEscVel({ z: newZ, c, i: ++i, limit });
}
function getColor(escVel, limit = 100, mod = 200) {
const hue = (escVel * 360) / limit;
const sat = 255;
const light = escVel < limit ? 50 : 0;
return `hsl(${hue}, ${sat}%, ${light}%)`;
}
const canvas = document.getElementById('myCanvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const config = {
bounds: [-2.2, 1],
xSplits: 500,
limit: 200,
};
function draw(canvas, { bounds, xSplits, limit }) {
const ctx = canvas.getContext('2d');
// Calculate relative "pixels" since we want fullscreen
const segmentLength = canvas.width / xSplits;
const increments = (bounds[1] - bounds[0]) / xSplits;
const xMarks = [...Array(xSplits)].map((x, i) => i * increments + bounds[0]);
const ySplits = Math.ceil(canvas.height / segmentLength);
const yMarks = [...Array(ySplits)].map(
(x, i) => i * increments - (increments * ySplits) / 2
);
for (x = 0; x < xSplits; x++) {
for (y = 0; y < ySplits; y++) {
const xPos = segmentLength * x;
const yPos = segmentLength * y;
const c = { x: xMarks[x], y: yMarks[y] };
const escVel = getEscVel({ c, limit });
const color = getColor(escVel, limit);
ctx.fillStyle = color;
ctx.fillRect(xPos, yPos, segmentLength, segmentLength);
}
}
}
draw(canvas, config);