Working on canvas: Hit-testing with JavaScript and HTML5


Objects drawn on a HTML canvas have a spec for implementing hit regions but unfortunately current browsers do not support this feature. There is also no way to add listeners, or include "onclick" events and so on, to objects drawn on a canvas in current browsers. This means that when drawn objects are interacted with by the user, the only information we have is the event data for the canvas itself.

Fortunately this event data provides us with the ability to retrieve co-ordinates, either relative to the current scroll position, the screen or the webpage, which in turn enables us to work out if an object within the canvas has been clicked on.

Note: see here for a list of event properties on the Mozilla developer site.

Step 1: Stay Organised

In order to identify if an object is clicked on, it is important that there is a way for us to know where an object is positioned. It makes most sense if this information is stored at creation time. We need the information to create the objects, so why not structure our code so that this array not only stores the details but is also used to draw the objects?

Following this logic it is straightforward to start with a rectangle.
// create an array to store objects
var objectArray = [];
// add rectangle to array
objectArray.push({"rectangle":[50,50,100,150], "id":"green rectangle", "color":"green"});
It will be drawn on the canvas at point x: 50, y: 50 and be 100 px wide and 150 px high and it has an id of "green rectangle". But at the moment it is simply a dictionary, it will be the code that enables it to become an identifiable rectangle.

Step 2: Setting up the canvas

A canvas in HTML is similar to most other objects, and can be created like this:
<canvas id="square" width="200px" height="200px" style="border:dotted 1px; -webkit-tap-highlight-color:rgba(0,0,0,0);" onclick="hitTest(event)"></canvas>
Here it has been assigned a width, height, border style and an onclick() event. To obtain a reference to the canvas and its 2d drawing context we begin our JavaScript like this:
var canv = document.getElementById("square");
var context = canv.getContext("2d");
Next it will be necessary to create a drawing function for the rectangle:
function drawRect(x,y,w,h,color) {
context.fillStyle=color;
context.fillRect(x,y,w,h);
}
Creating a function will mean two things: (1) it can be reused, (2) it will keep other areas of the code clear from clutter.

Step 3: Retrieving object details

So now we're ready to retrieve our object details from the array:
for (i=0;i<=objectArray.length-1; i++) {
var keys = Object.keys(objectArray[i]);
var objType = keys[0];
var objDimensions = objectArray[i][objType];
var color = objectArray[i]["color"];
}
And to trigger the drawing based on object type I'll use a switch statement:
switch(objType) {
case "rectangle":
drawRect(objDimensions[0],objDimensions[1],objDimensions[2],objDimensions[3],color);
break;
default:
console.log("undefined");}
This will be added inside the code braces of the above code.

Step 4: Putting it all together

What we now have is the following:
<canvas id="square" width="200px" height="200px" style="border:dotted 1px; -webkit-tap-highlight-color:rgba(0,0,0,0);" onclick="hitTest(event)""></canvas>

<script>

var objectArray = [];

// add rectangle to array
objectArray.push({"rectangle":[50,50,100,150], "id":"green", "color":"green"});

// retrieve canvas and context
var canv = document.getElementById("square");
var context = canv.getContext("2d");

// rectangle drawing function
function drawRect(x,y,w,h,color) {
context.fillStyle=color;
context.fillRect(x,y,w,h);
}

// retrieve object properties
for (i=0;i<=objectArray.length-1; i++) {
var keys = Object.keys(objectArray[i]);
var objType = keys[0];
var objDimensions = objectArray[i][objType];
var color = objectArray[i]["color"];


// draw object based on type
switch(objType) {
case "rectangle":
drawRect(objDimensions[0],objDimensions[1],objDimensions[2],objDimensions[3],color);
break;
default:
console.log("undefined");
}
}

</script>

Step 5: Adding the hit-test function

For the result to be displayed first we add this piece of html beneath the canvas tags:
<div id="result">&nbsp;</div>
Now to test the position of the user's click and return information about whether or not our rectangle was clicked on.
<script>function hitTest(e)
{
// first retrieve event target (i.e. canvas) - this could be tested for equality to canvas if multiple canvases exist
var object = e.target;
// get test position of hit relative to what's currently in the browser and where the canvas is positioned on the page
var rectObject = object.getBoundingClientRect();
var x=e.clientX - rectObject.left;
var y=e.clientY - rectObject.top;

// detect object clicked, starting with last in array because this will be uppermost
for (i=objectArray.length-1; i>=0; i--) {
var obj = objectArray[i];
var keys = Object.keys(objectArray[i]);
var objType = keys[0];
var objDimensions = obj[objType];
// test for rectangle
if (x >= objDimensions[0] && x <= objDimensions[2] + objDimensions[0] && y >= objDimensions[1] && y <= objDimensions[3] + objDimensions[1]) {
document.getElementById("result").innerHTML="You hit: " + obj["id"];
// break once object has been found
break;
}
else {
document.getElementById("result").innerHTML="Co-ordinates: "+x+", "+y;
}
}
}</script>
The maths here is straightforward. If a hit is registered at a point that is greater than the x position of the left side and less than the width added to that x position, while at the same time being greater than the y origin and less than the height added to the y origin, it will be registered as positive and the id displayed. Else the co-ordinates of the hit will be displayed.

The array is worked through backwards, because the uppermost object (the object added last) is always uppermost in the canvas.

Note: While the same result would occur iterating forwards (if the "break" command was removed), it would increase the number of cycles through the array (when more than one object is drawn on the canvas).

Step 6: Demo

After these steps you should have something that behaves like this:


 

You'll notice I've taken the liberty of adding an additional rectangle by creating a new one in the array. This is a simple addition if you wish to do the same:
objectArray.push({"rectangle":[100,150,100,50], "color":"red","id":"red rectangle"});
It means that the code can be tested for the case where two rectangles overlap. Here it behaves as expected: i.e. it acknowledges the click on the uppermost rectangle.

Conclusion

This post has demonstrated one of the simplest cases of hit testing to cover the basic ground, but at the same time shown a method of organisation that makes additions to the canvas simple.

In real world use it is likely that drawn objects would be divided into static and interactive elements. This would enable the array against which hits are tested to remain small, even in situations with lots of additional drawing.

Note: Adding and testing circles will be addressed in a future post.

Further reading on Canvas

'Canvas Tutorial' (Mozilla developer)

'Drawing Shapes with Canvas' (Mozilla developer)

'Basic Animations' (Mozilla developer)

Further reading on Native Canvas Hit Regions (currently unsupported by browsers)

'Chrome does not support HTML5 Canvas Hit Testing' (code.google.com)

'An Example of Hit Regions (Graph)

'New Canvas Features' (blogs.adobe.com)

Endorse on Coderwall

Comments