Interactive Hexagon Grid Tutorial Part 1 - Intro

Published: Friday, June 21, 2024

Greetings, friends! If you've visited the front page of my website, you may have seen a cool interactive hexagon grid made by yours truly. Hexagons are the bestagons afterall 😃

Follow along to see how I made the magic happen!

Introduction

Welcome to my amazing tutorial series on how to make an interactive hexagon grid that glows whenever you move the mouse over the hexagons. Have you tried hovering over all the hexagons on the front page of my website to see if anything happens? 🧐

This tutorial series will be quite special. There's nothing like it on the Internet! What makes it so special? Well, I'll be showing everyone two techniques for implementing the same hexagon grid. The first way will utilize the HTML canvas API via JavaScript. The second way will utilize shaders built using GLSL. We'll use Shadertoy to design fragment shaders and then port them to WebGL via Three.js to make our "interactive hexagon grid" hardware accelerated 😲

This is Part 1 of the interactive hexagon grid tutorial series and will focus on creating hexagons using the HTML canvas API. Let's get started!

HTML Canvas API Review

Let's quickly review fundamentals of the HTML Canvas API. For a full tutorial, please visit my HTML Canvas API tutorial series. Part 3 is the most relevant for creating polygons such as hexagons.

Let's create an HTML file with the following contents:

index.html
Copied! ⭐️
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Interactive Hexagon Grid</title>
</head>
<body>
  <h1>Canvas API Tutorial</h1>
  <canvas id="canvas" width="500" height="500" style="border: 1px solid black;"></canvas>
  <script src="canvas.js"></script>
</body>
</html>

Then, we'll create a JavaScript file called canvas.js that will identify the canvas element and create a 2D context.

canvas.js
Copied! ⭐️
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

If you're using a code editor that supports JSDoc such as VS Code, we can add a JSDoc comment before the first line.

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

Now, IntelliSense should be available within our code editor, so we can see hints on what methods are available on the canvas and ctx objects.

Let's review how the coordinate system works in a 2D HTML canvas.

Coordinate system for 2D HTML Canvas

The origin point, (0, 0), is located on the top-left corner of the canvas. The x-axis increases as you go right, and the y-axis increases as you go down. The y-axis is opposite to what you might see in math textbooks. We'll see later than in WebGL, the y-axis goes back to normal and increases as you go up instead of down.

If we wanted to draw a square like in the above picture, then we could use the following code:

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#3056ff';
ctx.fillRect(200, 200, 50, 50); // The parameters are x, y, width, height, respectively

2D HTML canvas with a blue square drawn at position (200, 200).

Alternatively, we could have used line segments to draw the square. We first begin a new path, use either ctx.moveTo or ctx.lineTo to initialize a starting point, and then close the path. Once the path is closed, the shape we drew can have a stroke and/or fill applied. Let's look at an example.

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.beginPath();
ctx.lineTo(200, 200); // ctx.moveTo(200, 200) also could have been used here
ctx.lineTo(250, 200);
ctx.lineTo(250, 250);
ctx.lineTo(200, 250);
ctx.closePath();

ctx.fillStyle = '#3056ff';
ctx.fill();

We will be using lines to draw our hexagons for this tutorial, but first, we need to learn hexagon geometry.

Hexagon Geometry

In this tutorial, we'll be creating a grid of regular hexagons. That is, we'll create hexagons where each side of the hexagon is the same length and each angle between line segments is the same. Regular hexagons have an angle of 60° (60 degrees) between each line segment. 60° is equivalent to 2π/6 radians or π/3 radians.

Hexagons have a special property where they are cyclic, meaning that we can circumscribe a circle around the hexagon and each vertex of the hexagon touches a point on the circle. By circumscribing the hexagon with a unit circle, we can clearly see how each hexagon vertex relates to the center origin point.

Unit circle with a hexagon circumscribed by the circle
Unit circle, by Wikipedia, circumscribing a Hexagon

In the image above, the origin has a coordinate of (0, 0) on a standard graph. Keep in mind that this image shows points plotted on a typical x-y plane, with the y-axis increasing as you go up.

There are six points of interest that we care about in the image. Those are the vertices of the hexagon, located at the following angles (in counter-clockwise order):

  • 0° = 360° = 0 radians = 2π radians
  • 60° = 2π/6 radians = π/3 radians
  • 120° = 4π/6 radians = 2π/3 radians
  • 180° = 6π/6 radians = 3π/3 radians = π radians
  • 240° = 8π/6 radians = 4π/3 radians
  • 300° = 10π/6 radians = 5π/3 radians

These angles correspond to the following points on the unit circle (in counter-clockwise order):

  • (1, 0)
  • (1/2, √3/2)
  • (-1/2, √3/2)
  • (-1, 0)
  • (-1/2, -√3/2)
  • (1/2, -√3/2)

How were the values inside each coordinate obtained? Through trigonometry of course!

Suppose we drew a right triangle inside the hexagon such that the hypotenuse extends from the origin to the top-right point of the hexagon. We can determine that the x-coordinate is equal to r * cos(θ) where r is the radius and θ (theta) equals the angle between the x-axis and a hexagon vertex (point on the unit circle). The y-coordinate is equal to r * sin(θ). In a unit circle, the radius equals one.

A right triangle inside the hexagon where the hypotenuse extends from the origin to the top-right corner of the hexagon. The hypotenuse equals one. The bottom leg of the triangle equals cosine of 60 degrees. The right leg of the triangle equals sine of 60 degrees. The angle between the x-axis and the hypotenuse equals 60 degrees.

We can therefore represent the x-coordinate and y-coordinate of any point on the unit circle relative to the center origin point at (0, 0) using mathematical expressions.

js
Copied! ⭐️
// theta = angle with respect to the x-axis
// r = radius of the circle/hexagon
x = r * cos(theta)
y = r * sin(theta)

If we want to move/translate our origin away from (0, 0), then we need to add values, a and b, for the x-axis and y-axis, respectively.

js
Copied! ⭐️
x = a + r * cos(theta)
y = b + r * sin(theta)

By shifting the origin, we can shift the whole hexagon and unit circle anywhere we want. I have created a Desmos graph illustrating how this works. Try playing around with the values of r, a, and b. Notice how all points on the hexagon move relative to the origin point defined by the coordinate, (a, b).

Desmos graph containing six points on a graph. Each point represents a vertex of a hexagon.

Drawing Hexagons

Now that we can find all points of a hexagon relative to a single point, let's start drawing hexagons using the HTML canvas API. We will use the ctx.lineTo method six times for each vertex of a hexagon. After beginning a new path, the first call to ctx.lineTo initializes a starting point, similar to ctx.moveTo. Then, the next calls to ctx.lineTo will draw lines from one point to another. After we create the full path, we close it and call ctx.stroke and/or ctx.fill to visualize the shape we just drew.

Let's see how this is implemented in JavaScript code:

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const r = 50; // radius
const a = 200; // x-coordinate of origin point
const b = 200; // y-coordinate of origin point

ctx.beginPath();
ctx.lineTo(a + r * Math.cos(0 * Math.PI / 3), b + r * Math.sin(0 * Math.PI / 3));
ctx.lineTo(a + r * Math.cos(1 * Math.PI / 3), b + r * Math.sin(1 * Math.PI / 3));
ctx.lineTo(a + r * Math.cos(2 * Math.PI / 3), b + r * Math.sin(2 * Math.PI / 3));
ctx.lineTo(a + r * Math.cos(3 * Math.PI / 3), b + r * Math.sin(3 * Math.PI / 3));
ctx.lineTo(a + r * Math.cos(4 * Math.PI / 3), b + r * Math.sin(4 * Math.PI / 3));
ctx.lineTo(a + r * Math.cos(5 * Math.PI / 3), b + r * Math.sin(5 * Math.PI / 3));
ctx.closePath();
ctx.stroke();

When we run the code, we should see a hexagon appear! Yay! ⬣

Hexagon drawn to the HTML canvas with an origin point located at (200, 200).

You may have noticed a pattern in the code above. We are simply adding π/3 to our angle every time we move to the next point in our hexagon. Let's make a function that can draw a hexagon at any point we want.

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const r = 50; // radius

function drawHexagon(a, b) {
  ctx.beginPath();
  for (let i = 0; i < 6; i++) {
    ctx.lineTo(
      a + r * Math.cos((i * Math.PI) / 3),
      b + r * Math.sin((i * Math.PI) / 3)
    );
  }
  ctx.closePath();
  ctx.stroke();
}

drawHexagon(200, 200);

When this code is run, we should see the hexagon drawn to the same spot, but now we have control of where we draw the hexagon.

tip
Note that we drew the hexagon in a counter-clockwise direction in Desmos, but the y-axis is flipped in the HTML canvas. Therefore, our hexagon will get drawn in the clockwise direction instead.

Let's now add a small circle where our origin point is located, so we can clearly see where the center of the hexagon is located. As mentioned in Part 2 of my HTML canvas API series, we can make a circle using the following code:

js
Copied! ⭐️
ctx.beginPath();
ctx.arc(a, b, r, 0, 2 * Math.PI);
ctx.fill();

Where (a, b) is the coordinate for the circle's origin and r is the circle's radius. If you need help understanding the ctx.arc method, please try out my Canvas Playground app and select "Circle" from the dropdown menu.

Adding this to our code, we get the following:

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const r = 50; // radius

function drawHexagon(a, b) {
  ctx.beginPath();
  for (let i = 0; i < 6; i++) {
    ctx.lineTo(
      a + r * Math.cos((i * Math.PI) / 3),
      b + r * Math.sin((i * Math.PI) / 3)
    );
  }
  ctx.closePath();
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(a, b, r * 0.1, 0, 2 * Math.PI);
  ctx.fill();
}

drawHexagon(200, 200);

In the code above, I opted to make the circle have 10% of the hexagon's radius. That way, we can still see the point regardless of the hexagon size.

Drawing a Hexagon Grid

Using our new drawHexagon function, we can now start creating a grid of hexagons, but first, we need to learn a bit more theory about how hexagons fit together. We want to arrange hexagons in such a way that there's no gaps in between them. The image below demonstrates how the grid should be laid out. Notice that the canvas origin is located at the top-left corner of the hexagon grid at the coordinate, (0, 0).

Illustration of a possible hexagon grid.

In order to draw this grid, we need to figure out where to place the center point of each hexagon. They will all have a coordinate that is relative to the origin, (0, 0). Let's zoom in on the top-left corner of the graph to calculate the distance between each hexagon's center point.

Zoomed in version of the top-left corner of a possible hexagon grid.

In a regular hexagon, the radius, r, of a hexagon is equal to the length of its side. Therefore, we can determine that the distance between the top and middle hexagon along the x-direction is equal to 1.5 * r. The distance along the y-direction is equal to 0.5 * h where h is the height of the hexagon. Recall from the unit circle mentioned earlier in this tutorial that half the height of a hexagon is simply r * sin(60°), which is equal to r * √3/2 or 0.5 * r * √3.

Illustration of a possible hexagon grid. The distance between hexagon centers along the x-axis is 1.5 times the radius. The distance between hexagon centers along the y-axis is half the height of a hexagon times the radius.

The image below shows the coordinates of a possible hexagon grid layout. Observe this image carefully. Each column has a very noticeable pattern: the x-coordinate is the same for each hexagon of the same column. However, each "row" of the hexagon grid forms a zigzag pattern and doesn't line up in a straight line. The hexagons alternate their y-coordinate by an offset of 0.5 * h.

Illustration of a possible hexagon grid and every hexagon origin point is labelled relative a hexagon's width and height.

You may be thinking, "How am I going to code this? Is it still possible to create a nested for-loop that draws a grid of hexagons?" The answer is "Yes, it is!" But, it requires identifying some interesting patterns. Let's start drawing the first "zigzag" row of hexagons to see if we can notice a pattern.

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const r = 50; // radius

function drawHexagon(a, b) {
  ctx.beginPath();
  for (let i = 0; i < 6; i++) {
    ctx.lineTo(
      a + r * Math.cos((i * Math.PI) / 3),
      b + r * Math.sin((i * Math.PI) / 3)
    );
  }
  ctx.closePath();
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(a, b, r * 0.1, 0, 2 * Math.PI);
  ctx.fill();
}

drawHexagon(0, 0);
drawHexagon(1.5 * r, 0.5 * r * Math.sqrt(3));
drawHexagon(3 * r, 0);
drawHexagon(4.5 * r, 0.5 * r * Math.sqrt(3));
drawHexagon(6 * r, 0);
drawHexagon(7.5 * r, 0.5 * r * Math.sqrt(3));
drawHexagon(9 * r, 0);
drawHexagon(10.5 * r, 0.5 * r * Math.sqrt(3));

Running this code should create a result similar to the following image:

Single zigzag row of hexagons drawn to the HTML canvas.

If we look back at our code, we can see that the x-coordinate increases by 1.5 * r as we move to the right. The y-coordinate alternates between zero and 0.5 * r * Math.sqrt(3) as we move to the right as well. We can easily write this code as a for-loop by checking whether our iteration variable, i, is odd or even.

js
Copied! ⭐️
for (let i = 0; i < 8; i++) {
  const x = 1.5 * r * i;
  const y = i % 2 === 0 ? 0 : 0.5 * r * Math.sqrt(3);
  drawHexagon(x, y);
}

If we replace all the drawHexagon calls with this for-loop, then we should see the same result. Next, we need to draw the second row. Let's try drawing both the first and second zigzag rows without using any for-loops to see if we can notice another pattern.

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const r = 50; // radius

function drawHexagon(a, b) {
  ctx.beginPath();
  for (let i = 0; i < 6; i++) {
    ctx.lineTo(
      a + r * Math.cos((i * Math.PI) / 3),
      b + r * Math.sin((i * Math.PI) / 3)
    );
  }
  ctx.closePath();
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(a, b, r * 0.1, 0, 2 * Math.PI);
  ctx.fill();
}

// First zigzag row
drawHexagon(0, 0);
drawHexagon(1.5 * r, 0.5 * r * Math.sqrt(3));
drawHexagon(3 * r, 0);
drawHexagon(4.5 * r, 0.5 * r * Math.sqrt(3));
drawHexagon(6 * r, 0);
drawHexagon(7.5 * r, 0.5 * r * Math.sqrt(3));
drawHexagon(9 * r, 0);
drawHexagon(10.5 * r, 0.5 * r * Math.sqrt(3));

// Second zigzag row
drawHexagon(0, r * Math.sqrt(3));
drawHexagon(1.5 * r, 1.5 * r * Math.sqrt(3));
drawHexagon(3 * r, r * Math.sqrt(3));
drawHexagon(4.5 * r, 1.5 * r * Math.sqrt(3));
drawHexagon(6 * r, r * Math.sqrt(3));
drawHexagon(7.5 * r, 1.5 * r * Math.sqrt(3));
drawHexagon(9 * r, r * Math.sqrt(3));
drawHexagon(10.5 * r, 1.5 * r * Math.sqrt(3));

Notice the pattern yet? The second zigzag row is the same as the first row except that the y-coordinate now fluctuates between r * Math.sqrt(3) and 1.5 * r * Math.sqrt(3). Each hexagon in a zigzag row still fluctuates by a value of 0.5 * r * Math.sqrt(3). Refer to the unit circle image shown earlier in this tutorial if you need to refresh on how the vertical spacing between hexagon centers was determined.

Given this knowledge, we should have enough info for creating an entire hexagon grid using a nested for-loop. Let's see how we can implement this in code:

js
Copied! ⭐️
for (let i = 0; i < 8; i++) {
  for (let j = 0; j < 8; j++) {
    const x = i * 1.5 * r;
    const y = i % 2 === 0 ? 
      j * r * Math.sqrt(3) :
      j * r * Math.sqrt(3) + 0.5 * r * Math.sqrt(3);
    drawHexagon(x, y);
  }
}

Using some math, we can simplify this for-loop even further:

text
Copied! ⭐️
j * r * Math.sqrt(3) + 0.5 * r * Math.sqrt(3)
  = (j + 0.5) * r * Math.sqrt(3)

Our for-loop then becomes the following:

js
Copied! ⭐️
for (let i = 0; i < 8; i++) {
  for (let j = 0; j < 8; j++) {
    const x = i * 1.5 * r;
    const y = i % 2 === 0 ? 
      j * r * Math.sqrt(3) :
      (j + 0.5) * r * Math.sqrt(3);
    drawHexagon(x, y);
  }
}

We can then turn this nested for-loop into a function called drawHexagonGrid. Our finished code should look like the following:

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const r = 50; // radius

function drawHexagon(a, b) {
  ctx.beginPath();
  for (let i = 0; i < 6; i++) {
    ctx.lineTo(
      a + r * Math.cos((i * Math.PI) / 3),
      b + r * Math.sin((i * Math.PI) / 3)
    );
  }
  ctx.closePath();
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(a, b, r * 0.1, 0, 2 * Math.PI);
  ctx.fill();
}

function drawHexagonGrid(sizeX, sizeY) {
  for (let i = 0; i < sizeX; i++) {
    for (let j = 0; j < sizeY; j++) {
      const x = i * 1.5 * r;
      const y = i % 2 === 0 ? 
        j * r * Math.sqrt(3) :
        (j + 0.5) * r * Math.sqrt(3);
      drawHexagon(x, y);
    }
  }
}

drawHexagonGrid(8, 8);

After running this code, we should see a complete hexagon grid inside the canvas! Yay! 🎉

Grid of hexagons drawn to the HTML canvas.

If you're satisfied with this method, then feel free to use it, but there is actually another way we can create our nested for-loop, one which gives us a bit more control over how the hexagons are drawn.

Drawing Straight Rows in Hexagonal Grids

In the previous section, we learned how to draw a hexagonal grid by drawing hexagons in a zigzag pattern, but this was just one way of filling up the HTML canvas with a bunch of hexagons. Depending on our use case, we may want to draw hexagons in a straight row instead. In fact, I use this strategy for my interactive hexagon canvas on the home page of this website.

When users hover over all the hexagons in the canvas, I play a special animation. The tricky part is making sure all the hexagons stay within view and don't reach outside the canvas. When we make a zigzag row of hexagons, the last row likely will extend outside the canvas. Therefore, we need to make some changes to our nested for-loop.

In our current implementation of drawing a hexagon grid, we are only concerned with the hexagon centers and how they relate to the i and j iteration variables. However, we can add more points to our hexagon grid to make the points form a rectangular grid-like pattern, so the points have the same width between each other. See the image below to visualize how we can make every point relative to the i and j values of our nested for-loop.

Illustration of a possible hexagon grid with points marked at every hexagon's center point and on the top and bottom edges of the hexagons within the canvas. Each point has an x-coordinate and y-coordinate relative to the i and j for-loop iteration variables.

Notice a pattern? A hexagon center is located at every index where i and j are both even or both odd. Therefore, we can redesign our drawHexagonGrid function to have the following nested for-loop:

js
Copied! ⭐️
function drawHexagonGrid(sizeX, sizeY) {
  for (let i = 0; i < sizeX; i++) {
    for (let j = 0; j < sizeY; j++) {
      if (i % 2 === j % 2) {
        const x = i * 1.5 * r;
        const y = j * 0.5 * r * Math.sqrt(3);
        drawHexagon(x, y);
      }
    }
  }
}

drawHexagonGrid(8, 13);

Now, we have the advantage of being able to draw straight rows of hexagons and can prevent too many hexagons being drawn to the canvas. The code looks a bit cleaner too. Notice that the sizeY parameter we pass to the drawHexagonGrid function is now 13 instead of 8. That is because we have added more points to our grid and no longer draw complete zigzag rows. Try playing around with the sizeX and sizeY parameters to see how the hexagons are drawn.

If we wanted to draw only the first row of hexagons in a straight line, we could pass a value of 1 to the sizeY variable in our drawHexagon function.

canvas.js
Copied! ⭐️
drawHexagonGrid(8, 1);

Then, we should see the following image.

Single straight row of hexagons drawn to the HTML canvas.

Let's change our sizeY parameter back to 13 to make a full hexagon grid again. Our completed code should look like the following:

canvas.js
Copied! ⭐️
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const r = 50; // radius

function drawHexagon(a, b) {
  ctx.beginPath();
  for (let i = 0; i < 6; i++) {
    ctx.lineTo(
      a + r * Math.cos(i * Math.PI / 3),
      b + r * Math.sin(i * Math.PI / 3)
    );
  }
  ctx.closePath();
  ctx.stroke();
  
  ctx.beginPath();
  ctx.arc(a, b, r * 0.1, 0, 2 * Math.PI);
  ctx.fill();
}

function drawHexagonGrid(sizeX, sizeY) {
  for (let i = 0; i < sizeX; i++) {
    for (let j = 0; j < sizeY; j++) {
      if (i % 2 === j % 2) {
        const x = i * 1.5 * r;
        const y = j * 0.5 * r * Math.sqrt(3);
        drawHexagon(x, y);
      }
    }
  }
}

drawHexagonGrid(8, 13);

Conclusion

In this tutorial, we learned how to draw hexagons using the HTML canvas API and learned two different ways of drawing hexagon grids. In the next tutorial, we'll learn how to add interactions to our hexagon grid, so the hexagons light up when a user hovers their mouse over them ✨. See you there!

Resources