Flexible Canvas Grid (without blurred lines)
I’ve been playing around with the HTML5 Canvas object recently for a project (I know I’m behind the times); but I love it and thought I’d share a small part in grid creation using canvas. I am using Vue (and you should too), but most of my code is pure javascript so don’t stop reading if you’re still living in the past.
I am also assuming you know how to use the HTML5 Canvas element; but if you don’t check out w3schools.com.
The surrounding code
OK, to kick off (and I promise after this it’s just pure JS), the template (or HTML with bound width and height properties), script (or JS to feed bound values and manipulate the canvas object), and style (or CSS):
<template>
<canvas class="gridCanvas"
:width="width"
:height="height"
></canvas>
</template><script>
export default {
props: [ 'width', 'height' ],
methods: {
drawGrid () {}
},
mounted () {
this.drawGrid()
}
}
</script><style scoped>
.gridCanvas {
position: relative !important;
border: lightgrey 1px solid;
border-radius: 5px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.2);
}
</style>
I’m hoping the <template> and <style> sections are pretty self explanatory; however the <script> is also pretty simple. In Vue, this is the basic structure of components which can be used in other components, and all this code says is: This component has two properties you can set (width and height); it has one method (drawGrid) which is currently empty; and when it’s mounted (similar to onload) it should run that drawGrid method…
The logic
So objectives are: A grid canvas that has a margin around the grid; can be drawn by defining a square size; can be any width or height; and is not blurry (explained later)
To draw this, we have the following variable to find:
- Square size: s
- Padding Left, Right, Top, and Bottom: pL, pR, pT, pB
So if we’re starting with defining a square size (e.g 28 px width/height), we already know s (s = 28). For simplicity, let’s assume the padding (pL, pR, pT, pB) is also one square wide/high.
drawGrid () {
let ctx = this.$el.getContext('2d') let s = 28
let pL = s
let pT = s
let pR = s
let pB = s
ctx.strokeStyle = 'lightgrey'
ctx.beginPath()
for (var x = pL; x <= this.width - pR; x += s) {
ctx.moveTo(x, pT)
ctx.lineTo(x, this.height - pB)
}
for (var y = pT; y <= this.height - pB; y += s) {
ctx.moveTo(pL, y)
ctx.lineTo(this.width - pR, y)
}
ctx.stroke()
}
OK, I lied. One more Vue thing. To explain: That first line gets the context of the canvas object from the template, but you could also just give your canvas an id and use document.getElementById() to do the same thing. After that we define our variables, set the canvas context line (stroke) color, and tell it to beginPath(). Then we go through two line drawing loops which start and end at the respective padding points and finally are drawn to the canvas with stroke():
So there are two issues: 1) There are fractions (of squares) in the bottom row; and 2) if you look closely, some lines are blurred while others are crisp.
So fractions ‘ey… The problem here is when we set our square size (s), we (wrongly) assumed that s will perfectly divide into the width and height to give us a nice round number of squares in each direction. To fix this we need to do some more maths and include four more variables; Number of Squares that will fit and Total Padding for X & Y Axis (nX, nY, pX & pY):
let s = 28
let nX = Math.floor(this.width / s) - 2
let nY = Math.floor(this.height / s) - 2
let pX = this.width - nX * s
let pY = this.height - nY * s
let pL = pX / 2
let pR = pX / 2
let pT = pY / 2
let pB = pY / 2
Again, s is set by us. To get the number of squares that will fit, we can divide the width by s and subtract 2 (to account for the left and right padding “squares”) to give us the number of squares horizontally (nX); and do the same with height to get the number of squares vertically (nY). These numbers will also have to be whole, so we have to round(), floor(), or ceil() the results. The reason we use floor() is if we end up increasing the number of squares, we run the risk of the grid getting wider than the canvas size.
The problem now is that by only allowing whole numbers, we have extra space to manage that would have been fractions of squares. We can divide this and add it to the padding, or just work out our total X and Y padding (pX & pY) by total width or height minus the grid width (nX * s) or height (nY * s); and then halve them fro pL, pR and pT, pB respectively.
Now for the blurred lines
This is an annoying quirk of the Canvas element (and maybe drawing in general), but actually quite simple once you understand how it works:
So after a little reading, most solutions involved adding 0.5 to pixel positions but didn’t really explain why. This place did however, and in short: to turn your very first pixel on, you want a point at { 0.5, 0.5 }, rather than the more intuitive { 1, 1 }. So if you draw a vertical line with x = 0.5, you get a single pixel line, and if you use x = 1 you actually end up with a two pixel line (turning on pixels at positions 0.5 and 1.5)… If that’s not clear, check out that link for a neat diagram that explains it (I won’t steal it).
What this means for our project is that our top-left point of the grid MUST start at coordinates ending in 0.5, and then only increase in whole numbers. To enforce this on our top-left point, we alter our pL and pT to be a whole number and then subtract 0.5. I use Math.ceil() because we’re taking 0.5 away so this will end up evening the sides. pR and pB are consequently any space left over:
let pL = Math.ceil(pX / 2) - 0.5
let pT = Math.ceil(pY / 2) - 0.5
let pR = this.width - nX * s - pL
let pB = this.height - nY * s - pT
Thanks to s already being a whole number, all the lines drawn from this point will end in 0.5 and be nice and crisp.
Conclusion
As any good programmer will know, it’s not the number of lines of code, but how you use them. While this grid canvas is a simple task, it’s got a few pitfalls which can cost hours of fiddling to figure out what the problem is. You can check out the full code here, and can also see a bonus commented-out section if you want to start with defining the number of squares in the top row (nX) instead of the square size (s).