It's raining since a few days at my place. And even though it actually just stopped raining as I'm writing this post, the sun hardly comes out anymore. It's autumn on the northern hemisphere. The chances of seeing what is probably nature's most colorful phenomenon this year are close to zero. What a pity.
But there's a remedy: Let's just build our own rainbow with JavaScript, some HTML and some mathematics! And no, we're not using any built-in linear gradient functions or CSS today.
But first, I'd like to thank @doekenorg for supporting me via Buy Me A Coffee! Your support is highly appreciated and the coffee was delicious, just the right thing on a cold autumn morning! Thank you!
No built-in linear gradient? How are we going to do this?
With mathematics and a color scheme called HLS. With a few parameters, namely the width and height of the canvas, the angle of the rainbow, which color to start with and which color to end with, we can construct an algorithm that will tell us the exact color of every pixel.
The nice thing: We can also do other things than painting with the result. For example coloring a monospaced text in a rainbow pattern!
HLS? What's that?
Good question! Most people that worked with CSS have seen RGB values before. RGB stands for "Red, Green, Blue". All colors are mixed by telling the machine the amount of red, green and blue. This is an additive color model (all colors together end of in white), red green and yellow on the other hand, is a subtractive color model (all colors together end up black).
HLS is a bit different. Instead of setting the amount of different colors, we describe the color on a cylinder. HLS stands for "hue, lightness, saturation":
(Image by Wikimedia user SharkD, released under the CC BY-SA 3.0, no changes made to the image)
The lightness determines how bright the color is. 0% always means black, 100% means white. The saturation describes how intense the color is. 0% would mean gray-scale, 100% means the colors are very rich. This image I found on Stackoverflow describes it very well:
Now, the hue part is what's interesting to us. It describes the actual color on a scale from 0 degrees to 360 degrees. For better understanding, the Stackoverflow post I mentioned above also has a very nice illustration for that:
If we want to make a rainbow with HLS, we set the colors as always mid-brightness (not black nor white), full saturation (the colors should be visible and rich) and go around the circle, so from 0 to 360 degrees.
Let's get started then!
So first, we start with the usual boilerplating: A canvas and a script linking to the rainbow.
<!DOCTYPE html>
<html>
<head></head>
<body>
<canvas id="canvas" width="400" height="400"></canvas>
<script src="./rainbow.js"></script>
</body>
</html>
In there, I start with an array of arrays the same size as the canvas. I want to make this as generic as possible so I can also use it without the canvas or for any other gradient.
/**
* Creates an array of arrays containing a gradient at a given angle.
* @param valueFrom
* @param valueTo
* @param width
* @param height
* @param angle
* @returns {any[][]}
*/
const createGradientMatrix = (valueFrom, valueTo, width, height, angle) => {
let grid = Array(height)
.fill()
.map(
() => Array(width).fill(null)
)
// ...
}
I also normalize valueTo
, so I can use percentages to determine which value I want. For example, 50% is should be halfway between valueFrom
and valueTo
.
const normalizedValueTo = valueTo - valueFrom
Determining the color of a pixel
This is where the mathematics come in. In a gradient, all pixels lie on parallel lines. All pixels on the same line have the same colors. A line is defined as follows:
Where m
is the slope of the line and a
describes the offset on the Y axis.
Desmos can illustrate that pretty well:
Now, to create a gradient, we can gradually increase the Y axis offset and start to color the lines differently:
Now, how can we use this to determine the color of each and every pixel?
We need to figure out which line it is on. The only difference between all the lines of the gradient shown with Desmos is the Y axis offset a
. We know the coordinates X
and Y
of the pixel and we know the slope (given by the angle), so we can determine the Y axis offset like this:
We can define this as a JS function right away:
/**
* Determines the a of `y = mx + a`
* @param x
* @param y
* @param m
* @returns {number}
*/
const getYOffset = (x, y, m) => y - m * x
Now we know the line the pixel is on. Next, we need to figure out which color the line has. Remember how we normalized the valueTo
in order to figure out a value with percentages? We can dos something similar here:
const createGradientMatrix = (valueFrom, valueTo, width, height, angle) => {
// ...
// Some trigonometry to figure out the slope from an angle.
let m = 1 / Math.tan(angle * Math.PI / 180)
if (Math.abs(m) === Infinity) {
m = Number.MAX_SAFE_INTEGER
}
const minYOffset = getYOffset(width - 1, 0, m)
const maxYOffset = getYOffset(0, height - 1, m)
const normalizedMaxYOffset = maxYOffset - minYOffset
// ...
}
By plugging in the maximum X value (width - 1
) and the maximum Y value (height - 1
) we can find the range of Y offsets that will occur in this gradient. Now, if we know the X and Y coordinates of a pixel, we can determine it's value like so:
const yOffset = getYOffset(x, y, m)
const normalizedYOffset = maxYOffset - yOffset
const percentageOfMaxYOffset = normalizedYOffset / normalizedMaxYOffset
grid[y][x] = percentageOfMaxYOffset * normalizedValueTo
So, this is what's happening now, step by step:
- Transform the angle of all lines into the slope of all lines
- Do some failover (
if (Math.abs(m) === Infinity) ...
) to not run into divisions by zero etc. - Determine the maximum Y axis offset we'll encounter
- Determine the minimum Y axis offset we'll encounter
- Normalize the maximum Y axis offset, so we don't have to deal with negatives
- Figure out the Y axis offset of the line that goes through X and Y
- Normalize that calculated Y axis offset as well
- Figure out how far (in %) this line is in the gradient
- Use the calculated % to figure out the color value of the line
- Assign the color value to the pixel
Let's do that for every pixel of the grid:
/**
* Determines the a of `y = mx + a`
* @param x
* @param y
* @param m
* @returns {number}
*/
const getYOffset = (x, y, m) => y - m * x
/**
* Creates an array of arrays containing a gradient at a given angle.
* @param valueFrom
* @param valueTo
* @param width
* @param height
* @param angle
* @returns {any[][]}
*/
const createGradientMatrix = (valueFrom, valueTo, width, height, angle) => {
let grid = Array(height)
.fill()
.map(
() => Array(width).fill(null)
)
// Some trigonometry to figure out the slope from an angle.
let m = 1 / Math.tan(angle * Math.PI / 180)
if (Math.abs(m) === Infinity) {
m = Number.MAX_SAFE_INTEGER
}
const minYOffset = getYOffset(width - 1, 0, m)
const maxYOffset = getYOffset(0, height - 1, m)
const normalizedMaxYOffset = maxYOffset - minYOffset
const normalizedValueTo = valueTo - valueFrom
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const yOffset = getYOffset(x, y, m)
const normalizedYOffset = maxYOffset - yOffset
const percentageOfMaxYOffset = normalizedYOffset / normalizedMaxYOffset
grid[y][x] = percentageOfMaxYOffset * normalizedValueTo
}
}
return grid
}
This will yield an array of arrays the size of the canvas with values for each cell between valueFrom
and valueTo
.
Creating the actual rainbow
Let's use this to create a rainbow:
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d')
const grid = createGradientMatrix(0, 360, 400, 400, 65)
grid.forEach((row, y) => row.forEach((cellValue, x) => {
context.fillStyle = 'hsl('+cellValue+', 100%, 50%)'
context.fillRect(x, y, 1, 1)
}))
You can now see that the gradient matrix we've created isn't necessarily for canvasses only. We could also use this to create colored text:
const loremIpsum = 'Lorem ipsum ...' // Really long text here.
const lines = loremIpsum.substring(0, 400).match(/.{1,20}/g)
const loremMatrix = lines.map(l => l.split(''))
const textColorGrid = createGradientMatrix(0, 360, 20, 20, 65)
for (let x = 0; x < 20; x++) {
for (let y = 0; y < 20; y++) {
loremMatrix[y][x] = `
<span class="letter" style="color: hsl(${textColorGrid[y][x]}, 100%, 50%);">
${loremMatrix[y][x]}
</span>`
}
}
const coloredText = loremMatrix.map(l => l.join('')).join('')
document.querySelector('#text').innerHTML = coloredText
The result
And here's the result:
Awesome! And it just started raining again...
I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a β€οΈ or a π¦! I write tech articles in my free time and like to drink coffee every once in a while.
If you want to support my efforts, buy me a coffee β or follow me on Twitter π¦! You can also support me directly via Paypal!
(Cover image by Flickr user Ivan, released under CC by 2.0, no changes made to the image)
Top comments (8)
Nice! I love it when a post has the math.
Can you tell me what math tool you were using in the photos?
Glad you liked it! That's Desmos, you can find it here: desmos.com/calculator?lang=en
Got it from the YT channel "3blue1brown", I love that tool.
I do however admit that I cheated a bit with the gradient in Desmos. I created each linear function twice with different colors and changed the opacity to make it look like a gradient. It does the trick, though :D
Ah, thanks. I watch 3blue1brown too.
I find it much easier to make gradients with WebGL. You should give it a try too :-)
Agreed, there's specialized tools for this that are much simpler to handle!
I wanted to do it from scratch entirely and show how to approach this problem from a mathematical point of view with the most basic tools at hand. This post was meant to be more about thinking patterns, I could've emphasized that more π
I didn't mean it as an offence in any way. I enjoy your posts and this one is not an exception π
So let me rephrase: those looking to draw heavily optimized 2D graphics, especially gradients should take a look at WebGL, where the computation is performed on GPU directly. It's not as scary as it sounds π
No offence taken! Sorry if my answer came over as too defensive, I think I misunderstood your original comment a bit. I'm really glad you enjoy my articles, thank you so much π
I should really dive deeper into WebGL, I've seen amazing things built with it. You're absolutely right!
Hah, you're very welcome @thormeier ! :-D