Interactive Hexagon Grid Tutorial Part 3 - Resizing the Canvas
Greetings, friends! We finally have an interactive hexagon grid working, but why stop there? Up until now, the canvas has been set to the dimensions of 500px by 500px. In this tutorial, we'll learn how to make the hexagon grid adapt to the screen size changes. In particular, we'll look at how to redraw the canvas when the width of the canvas element changes. Let's begin!
Changing the Canvas Width
Instead of hardcoding a value of 500px
for the width inside our index.html
file, we'll let the browser extend the canvas's width to fit the page.
Let's create a new file called style.css
and add the following contents:
canvas {
border: 1px solid black;
width: 100%;
height: 200px;
}
Then, we'll replace the contents of the index.html
with the following code:
<!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>
<!-- Add the stylesheet -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Canvas API Tutorial</h1>
<canvas id="canvas"></canvas>
<script src="canvas.js"></script>
</body>
</html>
Remember! The width
attribute of the canvas element is not the same as the width
CSS property. The former adjusts the width of the drawn canvas, and the latter adjusts the width of the DOM element. By setting the CSS property, width
to 100%
, we are telling the browser to stretch the <canvas>
element to fill the width of the page.
Let's add the JavaScript code we created in the last tutorial inside canvas.js
:
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById("canvas")
const ctx = canvas.getContext("2d")
const canvasRect = canvas.getBoundingClientRect();
const r = 50 // radius
const hexagons = []
let mouseX = null
let mouseY = null
document.addEventListener("mousemove", (e) => {
mouseX = e.clientX - canvasRect.left
mouseY = e.clientY - canvasRect.top
})
function lerp(color1, color2, percent) {
// Clamp the percentage between zero and one
const t = Math.max(0, Math.min(1, percent));
// Convert hexadecimal colors to RGB values
const r1 = Number.parseInt(color1.substring(1, 3), 16)
const g1 = Number.parseInt(color1.substring(3, 5), 16)
const b1 = Number.parseInt(color1.substring(5, 7), 16)
const r2 = Number.parseInt(color2.substring(1, 3), 16)
const g2 = Number.parseInt(color2.substring(3, 5), 16)
const b2 = Number.parseInt(color2.substring(5, 7), 16)
// Linearly Interpolate RGB values
const r = Math.round(r1 + (r2 - r1) * t)
const g = Math.round(g1 + (g2 - g1) * t)
const b = Math.round(b1 + (b2 - b1) * t)
// Convert interpolated values back to hexadecimal
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
class Hexagon {
constructor(a, b) {
this.a = a
this.b = b
this.originalColor = '#ffffff'
this.hoverColor = '#48bb78'
this.targetColor = '#93e0f0'
this.currentColor = this.originalColor
this.isHovered = false
this.isTouched = false
this.lerpValue = 0
}
draw() {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
ctx.lineTo(
this.a + r * Math.cos((i * Math.PI) / 3),
this.b + r * Math.sin((i * Math.PI) / 3)
)
}
ctx.closePath()
ctx.stroke()
ctx.fillStyle = this.currentColor
ctx.fill()
}
checkHover() {
if (mouseX == null || mouseY == null)
return
this.isHovered = ctx.isPointInPath(mouseX, mouseY)
if (this.isHovered)
this.isTouched = true
}
update() {
if (this.isHovered) {
this.currentColor = this.hoverColor
this.lerpValue = 0
}
if (this.isTouched) {
this.lerpValue += 0.02
this.currentColor = lerp(
this.hoverColor,
this.targetColor,
this.lerpValue
)
}
}
}
function setup() {
const sizeX = 8
const sizeY = 13
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)
hexagons.push(new Hexagon(x, y))
}
}
}
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (const hexagon of hexagons) {
hexagon.draw()
hexagon.checkHover()
hexagon.update()
}
requestAnimationFrame(render)
}
setup()
render()
The hexagon grid should now appear stretched. That is because the default width
and height
attributes of the <canvas>
element are 300px and 150px, respectively, and we told the browser to make the canvas element's box stretch to fit the page.
The DOM's coordinate system is not the same as the canvas's coordinate system built inside the canvas element. Therefore, we need to use JavaScript to set the canvas's true width and height, so the coordinate systems match.
At the top of the setup
function in canvas.js
, we'll add the following code:
function setup() {
// Dynamically set the size of the canvas based on the DOM element's width and height
canvas.width = canvasRect.width
canvas.height = canvasRect.height
// ...rest of the setup function code...
}
With this change, the hexagon grid should look normal again! But, if we resize the browser window, the canvas still appears distorted...Let's fix that!
Adding a Resize Event Listener
Since we set the canvas's width and height attributes in the setup
function instead of the render
, the size is only set when your app initially loads or when you refresh the page. Now we could update the canvas's width and height inside the render
function, but then it would get resized many times per second. Instead, it's better to use a resize event handler.
We'll create a new event listener that detects if the browser window has been resized or not. When a resize event occurs, we'll clear the canvas, reset the hexagon state, retrieve new data about the canvas's dimensions, and re-run the setup
function.
window.addEventListener('resize', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
hexagons = []
canvasRect = canvas.getBoundingClientRect()
setup()
})
Don't forget to update the const
variables to let
, since we're now changing them later in our code:
let canvasRect = canvas.getBoundingClientRect();
let hexagons = []
The hexagon grid should look the same as we resize the browser window! But, now we see that the hexagons don't fill up the entire grid. That is because we are still hardcoding a value for both sizeX
and sizeY
inside the setup
function. It's time to make these dynamically change based on the width and height of the canvas.
Filling Hexagons Grids of Dynamic Size
Alright, it's time to go back to some hexagon geometry and math. As mentioned toward the end of Part 1 of this tutorial series, we took the approach of adding points in a rectangular grid pattern. Please see the image below.
We need to figure out how to determine the correct values of sizeX
and sizeY
such that our canvas is completely filled of hexagons. Along the edges, some hexagons may spill over, but that's okay. We want the canvas to be packed full of hexagons, so that the magical hover effect we made in the previous tutorial is seamless across the whole canvas. Any large gaps may take away from the experience.
A simple way to determine sizeX
, the max iteration count in our nested for-loop for drawing the hexagon grid along the x-direction, is to use the following equation:
const sizeX = Math.ceil(canvas.width / (1.5 * r)) + 1
From the image above, we can see that the horizontal distance between each point is equal to 1.5 * r
. We therefore divide the canvas width by 1.5 * r
, take the ceiling of the number. We then add one to the result because our nested for loop uses i < sizeX
instead of i <= sizeX
, but that's an easy fix.
For determining sizeY
, we can perform a similar calculation:
const sizeY = Math.ceil(canvas.height / (0.5 * r * Math.sqrt(3))) + 1
The vertical distance between points in the image above is equal to 0.5 * r * Math.sqrt(3)
. We then take the ceiling of this number and add one, similar to sizeX
.
To remove the + 1
at the end of the sizeX
and sizeY
equations, we can simply update <
to <=
:
function setup() {
canvas.width = canvasRect.width
canvas.height = canvasRect.height
const sizeX = Math.ceil((canvas.width) / (1.5 * r))
const sizeY = Math.ceil((canvas.height) / (0.5 * r * Math.sqrt(3)))
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)
hexagons.push(new Hexagon(x, y))
}
}
}
}
With these changes implemented, we should have the following code:
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById("canvas")
const ctx = canvas.getContext("2d")
let canvasRect = canvas.getBoundingClientRect();
const r = 50 // radius
let hexagons = []
let mouseX = null
let mouseY = null
document.addEventListener("mousemove", (e) => {
mouseX = e.clientX - canvasRect.left
mouseY = e.clientY - canvasRect.top
})
window.addEventListener('resize', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
hexagons = []
canvasRect = canvas.getBoundingClientRect()
setup()
})
function lerp(color1, color2, percent) {
// Clamp the percentage between zero and one
const t = Math.max(0, Math.min(1, percent));
// Convert hexadecimal colors to RGB values
const r1 = Number.parseInt(color1.substring(1, 3), 16)
const g1 = Number.parseInt(color1.substring(3, 5), 16)
const b1 = Number.parseInt(color1.substring(5, 7), 16)
const r2 = Number.parseInt(color2.substring(1, 3), 16)
const g2 = Number.parseInt(color2.substring(3, 5), 16)
const b2 = Number.parseInt(color2.substring(5, 7), 16)
// Linearly Interpolate RGB values
const r = Math.round(r1 + (r2 - r1) * t)
const g = Math.round(g1 + (g2 - g1) * t)
const b = Math.round(b1 + (b2 - b1) * t)
// Convert interpolated values back to hexadecimal
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
class Hexagon {
constructor(a, b) {
this.a = a
this.b = b
this.originalColor = '#ffffff'
this.hoverColor = '#48bb78'
this.targetColor = '#93e0f0'
this.currentColor = this.originalColor
this.isHovered = false
this.isTouched = false
this.lerpValue = 0
}
draw() {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
ctx.lineTo(
this.a + r * Math.cos((i * Math.PI) / 3),
this.b + r * Math.sin((i * Math.PI) / 3)
)
}
ctx.closePath()
ctx.stroke()
ctx.fillStyle = this.currentColor
ctx.fill()
}
checkHover() {
if (mouseX == null || mouseY == null)
return
this.isHovered = ctx.isPointInPath(mouseX, mouseY)
if (this.isHovered)
this.isTouched = true
}
update() {
if (this.isHovered) {
this.currentColor = this.hoverColor
this.lerpValue = 0
}
if (this.isTouched) {
this.lerpValue += 0.02
this.currentColor = lerp(
this.hoverColor,
this.targetColor,
this.lerpValue
)
}
}
}
function setup() {
canvas.width = canvasRect.width
canvas.height = canvasRect.height
const sizeX = Math.ceil(canvas.width / (1.5 * r))
const sizeY = Math.ceil(canvas.height / (0.5 * r * Math.sqrt(3)))
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)
hexagons.push(new Hexagon(x, y))
}
}
}
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (const hexagon of hexagons) {
hexagon.draw()
hexagon.checkHover()
hexagon.update()
}
requestAnimationFrame(render)
}
setup()
render()
Identifying Completely Touched Canvases
When I was making the interactive hexagon grid for the front page of my website, I had to consider having too many hexagons past the edges of the canvas. If too many hexagons spill over the edges, then the user might not be able to see them. If your goal is to simply show an interactive grid of hexagons, then you really don't need to be concerned about that. In fact, you're basically done! You can stop here, or you can continue reading for fun 🙂
On my website, I play a special animation when the user hovers over all the hexagons. Therefore, I need to make sure the user can actually see and touch all the hexagons that are visible in the canvas. If there are secret hexagons outside the canvas, it may get annoying, or the user may think nothing special happens, and my hexagons are lame 😅
In order to see some of the issues I'm referring to, let's make our canvas smaller to really zoom in on the details. Inside our style.css
file, we can temporarily change the width
of the canvas to 240px
.
canvas {
border: 1px solid black;
width: 240px;
height: 200px;
}
Then, we'll add the following a console log at the very end of the setup
function to display how many hexagons have been drawn.
function setup() {
// ...rest of the setup function code...
console.log('Total hexagons: ', hexagons.length)
}
We'll also add a circle to represent the center hexagon points, similar to what we did in Part 1 of this tutorial series. Create a new method inside the Hexagon
class called drawCenter
with the following contents:
class Hexagon {
// ...rest of the Hexagon code...
drawCenter() {
ctx.beginPath();
ctx.arc(this.a, this.b, r * 0.1, 0, 2 * Math.PI);
ctx.fillStyle = "#000000";
ctx.fill();
}
}
Then, we'll update the render
function to invoke every hexagon's drawCenter
method.
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const hexagon of hexagons) {
hexagon.draw();
hexagon.checkHover();
hexagon.update();
hexagon.drawCenter(); // <-- draw center points of hexagons
}
requestAnimationFrame(render);
}
Our canvas.js
file should now look like the following:
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById("canvas")
const ctx = canvas.getContext("2d")
let canvasRect = canvas.getBoundingClientRect();
const r = 50 // radius
let hexagons = []
let mouseX = null
let mouseY = null
document.addEventListener("mousemove", (e) => {
mouseX = e.clientX - canvasRect.left
mouseY = e.clientY - canvasRect.top
})
window.addEventListener('resize', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
hexagons = []
canvasRect = canvas.getBoundingClientRect()
setup()
})
function lerp(color1, color2, percent) {
// Clamp the percentage between zero and one
const t = Math.max(0, Math.min(1, percent));
// Convert hexadecimal colors to RGB values
const r1 = Number.parseInt(color1.substring(1, 3), 16)
const g1 = Number.parseInt(color1.substring(3, 5), 16)
const b1 = Number.parseInt(color1.substring(5, 7), 16)
const r2 = Number.parseInt(color2.substring(1, 3), 16)
const g2 = Number.parseInt(color2.substring(3, 5), 16)
const b2 = Number.parseInt(color2.substring(5, 7), 16)
// Linearly Interpolate RGB values
const r = Math.round(r1 + (r2 - r1) * t)
const g = Math.round(g1 + (g2 - g1) * t)
const b = Math.round(b1 + (b2 - b1) * t)
// Convert interpolated values back to hexadecimal
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
class Hexagon {
constructor(a, b) {
this.a = a
this.b = b
this.originalColor = '#ffffff'
this.hoverColor = '#48bb78'
this.targetColor = '#93e0f0'
this.currentColor = this.originalColor
this.isHovered = false
this.isTouched = false
this.lerpValue = 0
}
draw() {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
ctx.lineTo(
this.a + r * Math.cos((i * Math.PI) / 3),
this.b + r * Math.sin((i * Math.PI) / 3)
)
}
ctx.closePath()
ctx.stroke()
ctx.fillStyle = this.currentColor
ctx.fill()
}
checkHover() {
if (mouseX == null || mouseY == null)
return
this.isHovered = ctx.isPointInPath(mouseX, mouseY)
if (this.isHovered)
this.isTouched = true
}
update() {
if (this.isHovered) {
this.currentColor = this.hoverColor
this.lerpValue = 0
}
if (this.isTouched) {
this.lerpValue += 0.02
this.currentColor = lerp(
this.hoverColor,
this.targetColor,
this.lerpValue
)
}
}
drawCenter() {
ctx.beginPath();
ctx.arc(this.a, this.b, r * 0.1, 0, 2 * Math.PI);
ctx.fillStyle = "#000000";
ctx.fill();
}
}
function setup() {
canvas.width = canvasRect.width
canvas.height = canvasRect.height
const sizeX = Math.ceil(canvas.width / (1.5 * r))
const sizeY = Math.ceil(canvas.height / (0.5 * r * Math.sqrt(3)))
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)
hexagons.push(new Hexagon(x, y))
}
}
}
console.log('Total hexagons: ', hexagons.length)
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const hexagon of hexagons) {
hexagon.draw();
hexagon.checkHover();
hexagon.update();
hexagon.drawCenter();
}
requestAnimationFrame(render);
}
setup()
render()
After refreshing the page, we should see that our canvas looks like the following:
If we count the hexagons in the canvas, we'll find that there are 12 hexagons visible. However, our console log is reporting 15 hexagons. Why? This is due to how we constructed the mathematical expressions for the sizeX
and sizeY
variables in our setup
function. Let's inspect it more closely.
function setup() {
canvas.width = canvasRect.width
canvas.height = canvasRect.height
const sizeX = Math.ceil(canvas.width / (1.5 * r))
const sizeY = Math.ceil(canvas.height / (0.5 * r * Math.sqrt(3)))
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)
hexagons.push(new Hexagon(x, y))
}
}
}
console.log('Total hexagons: ', hexagons.length)
}
Let's calculate the values for sizeX
and sizeY
given a canvas width of 240px
, height of 200px
, and a radius of 50
.
const r = 50
const width = 240
const height = 200
const sizeX = Math.ceil(width / (1.5 * r))
const sizeY = Math.ceil(height / (0.5 * r * Math.sqrt(3)))
console.log(sizeX) // 4
console.log(sizeY) // 5
We can see that sizeX
equals 4
and sizeY
equals 5
, but what do these values tell us? If we look at the nested for loop that draws the hexagon grid, we can see that our code looks like the following:
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)
hexagons.push(new Hexagon(x, y))
}
}
}
Remember, in the previous tutorials, we were using i < size
and y < sizeY
, but now we're using i <= sizeX
and j <= sizeY
because it let us avoid adding a + 1
add the end of sizeX
and sizeY
.
As the code stands right now, if sizeX
equals 4
, then we'd expect the outer loop to run 5 times. If sizeY
equals 5
, then we'd expect the inner loop to run 6 times.
The image below should help visualize what's going on. In the image, each coordinate represents the center points of each hexagon relative to the i
and j
indices. The blue box represents the visible portion of our canvas. The red box indicates the invisible portion of the canvas.
In our nested for-loop, we only draw hexagons when both i
and j
are even or when both of them are odd. That is, we only care about drawing hexagons when (i, j)
lands on the center of the hexagon, not on the edges.
Notice how there are completely full hexagons being drawn outside of the canvas. Since sizeX
goes up to 4
(inclusive), then we are drawing three extra hexagons to the right of the canvas. This is why we end up with 15 hexagons instead of 12.
Since our mousemove
event listener was added to the document
instead of the canvas, the user can still technically hover over these "invisible" hexagons even if they're not visible, but we want the interactive hexagon grid experience to be perfect, or at least have the appearance of perfection 😉
In order to resolve our issue, we need to make some changes to how we calculate sizeX
and sizeY
. There are multiple ways we can solve this problem. If we constrained the width and height, then it's trivial to make calculations to ensure all hexagons are both visible and interactable. It's tricky when the canvas can change to any size.
I'll discuss an implementation that seems to work for most use cases. My use case is a bit fine-tuned for my website, but I'll explain some approaches that can help. Feel free to explore other techniques as well. In my implementation, I keep the very first hexagon (the one at top-left corner) at (0, 0)
.
I don't shift the canvas to the right or left at all. I only cutoff the canvas on the right side and draw as many hexagons as are visible in the canvas. I also keep the height static and change the radius of the hexagons when the width decreases past a certain threshold.
The main thing to pay attention to is the part of the hexagon where it has a slope along the boundary between two hexagons. These small edges can cause problems for identifying how many hexagons are truly visible in the canvas.
The trick is to divide the canvas width by 1.5 * r
like before, but we're going to extract both the integer and fractional part of this result within the setup
function.
const sizeXSlice = canvas.width / (1.5 * r)
const sizeXInt = Math.floor(sizeXSlice)
const sizeXFrac = sizeXSlice % 1
Looking at the image above, the distance between the center point of a hexagon and the part of the hexagon that starts sloping is equal to 0.5 * r
. This is equal to one third of 1.5 * r
. Remember, we are dividing the whole canvas by 1.5 * r
. The remainder tells us how many leftover hexagons we should draw. Therefore, our sizeX
value should change depending on whether the right edge of the canvas is less than 1/3
or greater than 1/3
.
const sizeXSlice = canvas.width / (1.5 * r)
const sizeXInt = Math.floor(sizeXSlice)
const sizeXFrac = sizeXSlice % 1
let sizeX = sizeXInt
if (sizeXFrac > 1 / 3) {
sizeX = sizeXInt + 1
}
To provide a bit more buffer, I find that 0.35
works a bit better (and also results in one less computation we have to do).
const sizeXSlice = canvas.width / (1.5 * r)
const sizeXInt = Math.floor(sizeXSlice)
const sizeXFrac = sizeXSlice % 1
let sizeX = sizeXInt
if (sizeXFrac > 0.35) {
sizeX = sizeXInt + 1
}
If we run our code with our new sizeX
value, then we should now see that the console is logging 12
total hexagons instead of 15
. Success!
When resizing the canvas, you may notice there are other occasions where the hexagons might not be visible. For instance, suppose we set the width
of the canvas is set to 190px
, the height is set to 180px
, and the radius is set to 50
.
The canvas will look like the following:
Our total hexagon count will mistakenly say 12
, but only 11
hexagons are visible. That is because the hexagon in the bottom-right corner of the hexagon grid is cutoff from view.
As we can see in the image above, the red box represents the part of the hexagon grid invisible to the user. The hexagon on the bottom-right corner is missing from view. When originally designing my interactive hexagon grid, the bottom-right corner hexagon gave me the most trouble. We could use some math to prevent them from being drawn. I use the following trick in the setup
function:
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);
// If the very last hexagon is hard to see on even-numbered columns (due to the canvas border), then exclude the hexagon from the list
if (canvas.width - (x - r) < 20 && j === sizeY) continue;
hexagons.push(new Hexagon(x, y));
}
}
}
The leftmost point on a hexagon is equal to x - r
where x
is the x-coordinate of the center point of a hexagon. When the last hexagon is hard to see on even-numbered columns, we can check if the leftmost point is a certain distance away from the canvas edge. If so, then we skip drawing the hexagon.
I also prefer to keep the height at a constant size for my website, and I change the radius of the hexagons depending on the screen size. I found a height where hexagons are least likely to be hidden for most canvas widths.
We can make the radius, r
, a let
variable instead of const
at the top of our canvas.js
file, and the insert the following code into our setup
function.
function setup() {
if (window.innerWidth < 640)
r = 65
else
r = 50
// ...rest of the setup function code...
}
Once your hexagon grid is fine-tuned to your liking, you can now count the number of hexagons and do something special when the user has highlighted over all the hexagons. Yay! 🎉
Here's a small function called checkHexagonStatus
for counting all the hexagons in the canvas:
function checkHexagonStatus() {
return hexagons.every((hexagon) => hexagon.isTouched);
}
We can make this check at the end of the render
function. We'll simply console log a celebratory message, but feel free to play fun animations or whatever you'd like! 🙂
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let hexagon of hexagons) {
hexagon.draw();
hexagon.checkHover();
hexagon.update();
hexagon.drawCenter();
}
if (checkHexagonStatus()) {
console.log('All hexagons found! Yay! 🎉🎉🎉');
}
requestAnimationFrame(render);
}
On my website, I make a check every frame to see if all hexagons have an isTouched
property set to true
. If so, then I make party poppers appear and make it look like confetti shoots out of them. If you want to see how I created fun confetti animations, please see my CodePen! It contains all the tricks I mentioned in this tutorial for making sure the users can always see a message appear when they've highlighted all the hexagons regardless of the canvas size. I hopefully covered all edge cases 😅
Finished Code
Make sure to change your canvas's width back to 100%
in the style.css
file.
canvas {
border: 1px solid black;
width: 100%;
height: 200px;
}
Please see the finished code for canvas.js
below:
/** @type {HTMLCanvasElement} */
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let canvasRect = canvas.getBoundingClientRect();
let r = 50; // radius
let hexagons = [];
let mouseX = null;
let mouseY = null;
document.addEventListener("mousemove", (e) => {
mouseX = e.clientX - canvasRect.left;
mouseY = e.clientY - canvasRect.top;
});
window.addEventListener("resize", () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
hexagons = [];
canvasRect = canvas.getBoundingClientRect();
setup();
});
function lerp(color1, color2, percent) {
// Clamp the percentage between zero and one
const t = Math.max(0, Math.min(1, percent));
// Convert hexadecimal colors to RGB values
const r1 = Number.parseInt(color1.substring(1, 3), 16);
const g1 = Number.parseInt(color1.substring(3, 5), 16);
const b1 = Number.parseInt(color1.substring(5, 7), 16);
const r2 = Number.parseInt(color2.substring(1, 3), 16);
const g2 = Number.parseInt(color2.substring(3, 5), 16);
const b2 = Number.parseInt(color2.substring(5, 7), 16);
// Linearly Interpolate RGB values
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
// Convert interpolated values back to hexadecimal
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
class Hexagon {
constructor(a, b) {
this.a = a;
this.b = b;
this.originalColor = "#ffffff";
this.hoverColor = "#48bb78";
this.targetColor = "#93e0f0";
this.currentColor = this.originalColor;
this.isHovered = false;
this.isTouched = false;
this.lerpValue = 0;
}
draw() {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
ctx.lineTo(
this.a + r * Math.cos((i * Math.PI) / 3),
this.b + r * Math.sin((i * Math.PI) / 3)
);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = this.currentColor;
ctx.fill();
}
drawCenter() {
ctx.beginPath();
ctx.arc(this.a, this.b, r * 0.1, 0, 2 * Math.PI);
ctx.fillStyle = "#000000";
ctx.fill();
}
checkHover() {
if (mouseX == null || mouseY == null) return;
this.isHovered = ctx.isPointInPath(mouseX, mouseY);
if (this.isHovered) this.isTouched = true;
}
update() {
if (this.isHovered) {
this.currentColor = this.hoverColor;
this.lerpValue = 0;
}
if (this.isTouched) {
this.lerpValue += 0.02;
this.currentColor = lerp(
this.hoverColor,
this.targetColor,
this.lerpValue
);
}
}
}
function setup() {
canvas.width = canvasRect.width;
canvas.height = canvasRect.height;
if (window.innerWidth < 640) r = 65;
else r = 50;
const sizeXSlice = canvas.width / (1.5 * r);
const sizeXInt = Math.floor(sizeXSlice);
const sizeXFrac = sizeXSlice % 1;
let sizeX = sizeXInt;
if (sizeXFrac > 1 / 3) {
sizeX = sizeXInt + 1;
}
const sizeY = Math.ceil(canvas.height / (0.5 * r * Math.sqrt(3)));
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);
if (canvas.width - (x - r) < 20 && j === sizeY) continue;
hexagons.push(new Hexagon(x, y));
}
}
}
console.log("Total hexagons: ", hexagons.length);
}
function checkHexagonStatus() {
return hexagons.every((hexagon) => hexagon.isTouched);
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let hexagon of hexagons) {
hexagon.draw();
hexagon.checkHover();
hexagon.update();
// hexagon.drawCenter(); // uncomment this line to see hexagon center points
}
if (checkHexagonStatus()) {
console.log('All hexagons found! Yay! 🎉🎉🎉');
}
requestAnimationFrame(render);
}
setup();
render();
Conclusion
That's it, friends! You did it! You can now make an interactive hexagon grid that plays a special event, animation, or whatever you want when the user hovers over every hexagon 🎉
But, what if I told you we could make the interactive hexagon grid faster and more powerful? 🤯
In the next tutorial, we'll take an entirely different approach to drawing hexagons by creating a fragment shader inside a WebGL canvas instead of drawing to a 2D canvas. Together with Three.js, we'll create a brand new interactive hexagon grid using WebGL and render textures. Go beyond! Plus ultra! ⭐