DEV Community

Cover image for Let's build a rainbow on a canvas from scratch! πŸŒˆπŸ“
Pascal Thormeier
Pascal Thormeier

Posted on • Updated on

Let's build a rainbow on a canvas from scratch! πŸŒˆπŸ“

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":

HLS color cylinder. the bottom part is black, the top part is white, the colors wrap around the cylinder.

(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:

Two color gradients describing lightness and saturation

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:

Color wheel with angles

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>
Enter fullscreen mode Exit fullscreen mode

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)
    )

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

y=mx+a y = mx + a

Where m is the slope of the line and a describes the offset on the Y axis.

Desmos can illustrate that pretty well:

Desmos showing a single line.

Now, to create a gradient, we can gradually increase the Y axis offset and start to color the lines differently:

Desmos showing a linear gradient.

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:

a=yβˆ’mβˆ—x a = y - m * x

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
Enter fullscreen mode Exit fullscreen mode

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

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Buy me a coffee button

(Cover image by Flickr user Ivan, released under CC by 2.0, no changes made to the image)

Discussion (8)

Collapse
siddharthshyniben profile image
Siddharth

Nice! I love it when a post has the math.

Can you tell me what math tool you were using in the photos?

Collapse
thormeier profile image
Pascal Thormeier Author

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

Collapse
siddharthshyniben profile image
Siddharth

Ah, thanks. I watch 3blue1brown too.

Collapse
valeriavg profile image
Valeria

I find it much easier to make gradients with WebGL. You should give it a try too :-)

Collapse
thormeier profile image
Pascal Thormeier Author

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 πŸ˜…

Collapse
valeriavg profile image
Valeria

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 πŸ˜ƒ

Thread Thread
thormeier profile image
Pascal Thormeier Author

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!

Collapse
doekenorg profile image
Doeke Norg

Hah, you're very welcome @thormeier ! :-D