If you somehow don’t know what the Mandelbrot set is, here is a quick introduction. It’s defined as the set of all numbers for which the function doesn’t diverge to infinity when iterated starting at .
By mapping the values of to cartesian coordinates (meaning for example gets mapped to the point ) we can produce some cool images. You choose a color for the points that belong to the set and for others you pick the colors based on how fast they diverge (how many iterations were needed before the value crossed a certain threshold).
You can check out the final product here along with the source code here (NOTE: if it doesn’t work try disabling darkreader or any similar extension).
Try moving your cursor over it.
You can generate such images with a few lines of pretty much any reasonable programming language. Doing it in pure HTML+CSS presents some challenges. As we all know, CSS is clearly not Turing complete as there is no way to have infinite loops, so if it was then we would have a contradiction with the Halting Problem.
Actually… it is. Look I implemented Rule 110.
Nope. Using your approach the number of CSS rules would scale with the input AND it requires constant human interaction to function.
Ok, maybe not but still it requires the user to constantly press keys so you might as well just write the instructions for the human in natural language.
…
It’s not that simple and you’re welcome to do your own research. What is certain though, is that modern CSS has features (that we will explore in a second) that allow you to do cool shit and perform arithmetic.
As far as I’m aware there is no way to draw on the html canvas
with just CSS and we are gonna need something to represent our
drawing, so we’ll use individual divs to represent the
pixels. Let’s go for a
image for a total of
divs…
I get a weird feeling in my stomach when the size of my code scales with the size of the output, but hey, HTML is not a programming language, so it’s fine, right?
Ok 400i<div></div><CR><Esc>
done.
vim btw.
We’re gonna need some way to identify them to be able to calculate their colors later. That’s where variables come in. Let’s assign them their coordinates (starting with for the top left). Basically:
<div style="--x:1; --y:1;"></div>
<div style="--x:2; --y:1;"></div>
<div style="--x:3; --y:1;"></div>
...
I’m sure you could come up with a clever vim macro to do this but we’re gonna use python for code generation later any way so might as well…
rows, cols = 20, 20
for y in range(1, rows + 1):
for x in range(1, cols + 1):
print(f"<div style=\"--x:; --y:;\"></div>")
print()
We also want to display them in a
grid. So let’s wrap everything in a
<div id="mandelbrot>...</div>.
:root {
--s: 20;
}
#mandelbrot {
display: grid;
grid-template-columns: repeat(var(--s), 30px);
grid-auto-rows: 30px;
}
Notice we use a variable for the grid size as we’re going to use this value in calculations later.
To see variables in action let’s use our grid to display a simple pattern. For that we’ll need another key CSS feature – the calc function.
#mandelbrot div {
background-color: rgba(255, 255, 255,
calc(var(--x) * var(--y) / pow(var(--s), 2)));
}
It’s most
often used for its ability to combine different units. For
example if you wanted an element to take up 100% of the
width but with some room for margins:
width: calc(100% - 80px);. We’ll keep it simple though
and just use it in one place – to compute the alpha values of our
divs (as above).
Now for the core question – how do we actually compute the alpha values to make the Mandelbrot set appear?
As I explained at the beginning, the usual
approach is to count the number of iterations it takes the value to
cross a certain threshold, but the thing is, it’s not easy to get
CSS to do loops. So we’re gonna use a fixed number of iterations
(you’ll see how in a minute) and then map the magnitude (distance
from the origin) of a number to the alpha value of the corresponding
div. Intuition: if after our fixed number of iterations
will be large it means that it probably diverges quickly, so we’ll
map it to a low alpha value and if
is small it’s probably in the set -> high alpha.
Let’s start simple – just one iteration. Meaning we apply
once starting with
and
being the point represented by a specific div. Then we
just need to get the magnitude:
.
We’ll use python to generate the calc call to use as
the alpha value in CSS.
def sq(num):
return f"pow(,2)"
def var(name):
return f"var(--)"
x = var('x')
y = var('y')
def gen_iters():
return f" + "
print(gen_iters())
This gets us pow(var(--x),2) + pow(var(--y),2) and
pasting it for the alpha value of our divs’s
background-color… All turned white.
That’s unsuprising. The alpha in rgba is in range
0..1 and all our values are
.
It seems we have to apply some mappings.
Ok, no more rainbows…
First: the Mandelbrot set tastes best when served in roughly range. Right now we have so pretty far from ideal. As a general problem statement: we have a value and want to map it to be in .
This is a very common problem and I go through this process all the time.
Voilà. Some python follows.
def scale(x, a_to, b_to, a_from=1, b_from=20):
"""
Maps x that is in [a_from, b_from] to be in [a_to, b_to]
"""
return f" + ( - )*( - )/( - )"
If you look closely you might notice that I put whitespace around
+ and - but not around * and
/. The code we’ll generate later is going to get a
little large so I wanted to at least save up on some whitespace. The
spaces around + and - are
necessary though as CSS uses them to distinguish from unary
+ and - (for example
calc(1-2) is interpreted as 1 followed by
-2).
Thanks to CSS’s lovely error handling this is a fact that I will remember for a long time.
Second: Right now our outputted alpha values are in and we need them to be in . Our previous method doesn’t digest infinities well so we need something different. There are many different approaches but I personally like the sigmoid.
It maps the nonnegative values to so we’ll take which also makes it so that magnitudes close to (in the set) get an alpha close to (that’s just so it plays nicely with the site’s darkmode).
Integrating it back to our little script.
x = scale(var('x'), var('xa'), var('xb'))
y = scale(var('y'), var('ya'), var('yb'))
# ...
print(mapping(gen_iters(n)))
I thought it might be a good idea to keep the values as CSS variables for easy access.
#mandelbrot {
/* ... */
--xa: var(-2.0);
--xb: var(1.0);
--ya: var(-1.5);
--yb: var(1.5);
}
Nice! It seems that now all we need is…
We want to apply a total of times (which I will denote ) starting at and being the number represented by specific coordinates and then calculate . To get we need to compute the real and imaginary parts of .
Denote and say that after iterations we got for some and so
Grouping the real and imaginary parts we get
This equation translates literally to a simple recursive function:
def gen_iters(n):
"""
|z|^2 after n iterations
"""
def rec(num, i):
if i == 0:
return num
(re, im) = rec(num, i-1)
return (f" - + ", f"2*()*() + ")
(re, im) = rec((x, y), n-1)
return f" + "
I find that works well, anything more might be too much.
n = 6
print(mapping(gen_iters(n)))
The picture we got looks… ok but with only 400 pixels it’s hard to see much details. A cool effect we can add is zooming in on an area around the cursor.
If you think about what it means to zoom in in our case
it’s just changing the bounds (for example with changing
will zoom in 2x around the origin).
The plan is as follows:
As for the first step we’ll use the CSS’s :has() pseudoclass. Let me show you the snippet first:
#mandelbrot:has(div[style*="--x:3;"]:hover) {
/* TODO: change bounds */
}
We want to select the container div because that’s
where the variables controlling the bounds live. The styles defined
here will execute if our container div has a
div with a style attribute containing
"--x:3;" that is currently hovered.
Copying this for all other values of --x and
--y we effectively know the cursor’s position.
This approach makes it harder to increase the size of our grid as we’ll also have to add move styles now. But at least it’s a linear relation and not quadratic as before…
Now for steps 2 and 3. We’ll use the same formula to map the cursor position
that is initially in
(--s: 20;) to be in
.
Then just add
and we’re done.
#mandelbrot:has(div[style*="--x:3;"]:hover) {
--xa: calc(var(--def-xa) + (3 - 1)*(var(--def-xb) - var(--def-xa))/(var(--s) - 1) - 0.5);
--xb: calc(var(--def-xa) + (3 - 1)*(var(--def-xb) - var(--def-xa))/(var(--s) - 1) + 0.5);
}
Since we’re using those variables in our alpha calculation the image will change automatically.
Check out the demo and source code. If you have any improvements or spotted a mistake feel free to drop an issue, same goes for this post here.
Here are some additional ideas to implement: