Let's build a rainbow on a canvas from scratch! 🌈📐
Tags: webdev, tutorial, javascript, mathematics
Published: 2021-09-25 12:10:40

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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a1q9x5gmxo0vgg2xre3h.png)

*(Image by Wikimedia user SharkD, released under the [CC BY-SA 3.0](https://creativecommons.org/licenses/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](https://stackoverflow.com/a/43795837) describes it very well:

![Two color gradients describing lightness and saturation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/blvhm8nc0pll9causdwf.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vkym0i9p4aqw1d6pp1mu.png)

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.

```html
<!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.

```javascript
/**
 * 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`.

```javascript
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:

{%katex%}
y = mx + a
{%endkatex%}

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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2lpxku3lf7078092emve.png)

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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bcsy2iaecli0b7e0o2d6.png)

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:

{%katex%}
a = y - m * x
{%endkatex%}

We can define this as a JS function right away:

```javascript
/**
 * 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:

```javascript
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:

```javascript
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:

```javascript
/**
 * 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:

```javascript
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:

```javascript
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:

{% codepen https://codepen.io/thormeier/pen/yLXREoX %}

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* ☕](http://buymeacoffee.com/pthormeier) *or* [*follow me on Twitter* 🐦](https://twitter.com/pthormeier)*!* *You can also support me directly via [Paypal](https://www.paypal.me/pthormeier)!*

[![Buy me a coffee button](https://dev-to-uploads.s3.amazonaws.com/i/x55hp6jopwyy161d8e2u.png)](http://buymeacoffee.com/pthormeier)

*(Cover image by Flickr user Ivan, released under [CC by 2.0](https://creativecommons.org/licenses/by/2.0/), no changes made to the image)*