Site icon Joshua Tzucker's Site

Canvas Hit Detection Methods – With or Without Tolerances

The HTML Canvas element often feels like a web development super power – you can render arbitrary content, take advantage of hardware acceleration (in some cases), and have full control over every pixel under your domain. However, all that power comes with some tradeoffs. One significant tradeoff is the inability to attach event listeners to visual content within the Canvas.

Unlike the regular DOM, where you can capture events at varying levels of granularity, with the Canvas you can only capture events on the Canvas element itself. For using the Canvas purely for output, this is usually fine, but if you need to add interactivity, things get more challenging.

Since it might be hard to visualize what I’m talking about, let’s start with a simple example: you are drawing a cloud on the screen, and when the user hovers over the cloud and/or the edge of it, you want to add some extra effects.

Pure HTML Approach

A refresher on event listeners and the non-canvas approach: in a pure HTML approach, without Canvas, we have per-element event listeners to make this easy:

As you can see, we can use these native event listeners even on SVG elements, like path.

Canvas

Pretty much all intersection calculations with Canvas start the same way. Since we can’t attach event listeners directly to areas of the canvas, we start by getting the coordinates of the mouse cursor / touch point, and then checking if it is inside a hit-box or path.

For checking if the mouse coordinates are inside our area of interest, you have a few different options:

The rest of this post will mostly focus on context.isPointInStroke, as our demo focuses on detecting intersection between the cursor and a line, rather than an entire shape.

Canvas – Getting Coordinates from a Mouse Event

The first complexity involved with Canvas point methods is that they want coordinates relative to the canvas element (distance from canvas top left), not to the DOM. This is an issue because rarely is it the case that your <canvas> element is going to be taking up 100% of the viewport, so you can’t simply use MouseEvent.clientX, MouseEvent.clientY as inputs into context methods.

Instead, you need to translate those coordinates each time, to adjust for the offset of the canvas element, and optionally for scaling as well.

Here is what that might look like:

/**
 * For a given mouse coordinate, translate to corresponding coordinate within original canvas element
 * @param mouseEvent {{clientX: number, clientY: number}}
 * @param canvasElem {HTMLCanvasElement}
 */
function getCanvasCoords(mouseEvent, canvasElem) {
    const canvasBoundingRect = canvasElem.getBoundingClientRect();
    const scale = {
        x: canvasElem.width / canvasBoundingRect.width,
        y: canvasElem.height / canvasBoundingRect.height
    };
    return {
        x: (mouseEvent.clientX - canvasBoundingRect.left) * scale.x,
        y: (mouseEvent.clientY - canvasBoundingRect.top) * scale.y
    };
}

Demo – Without Padding

Here is a demo without padding / increased hit-box:

You will likely notice that it is kind of hard to get the code to detect your mouse cursor over the cloud edge – the tip of the cursor has to be pretty much exactly over the line. Read on for how to add padding to our mouse collision detection so that our tolerance doesn’t have to be so tight.

Canvas Hit Detection – With a Tolerance Allowance

A minor issue with the demo above, and a common issue with detecting mouse interaction with a path, is that if the stroke of our path is not very wide, it can be hard for a user to actually get their mouse cursor directly over the path. In many applications, there is a tolerance involved with calculating overlap, so that you can still click on or mouseover elements that have very small regions.

To implement this for canvas, many forums and answer sites (like StackOverflow) will give (in my opinion) misleading and overly-complicated recommendations. For example, they might insist that you need to stroke your paths with a larger width before render, but this changes the visual output. Or an answer might involve complex transformation matrices and custom algorithms, which seems like overkill for simple path point intersection detection.

If you want to add a tolerance to isPointIn detection without visually changing the output, here is one of the more simple approaches:

Note: This works for both isPointInStroke and isPointInPath

Here is an updated demo that uses a much larger lineWidth for mouse detection, but does not visually change what is shown in the Canvas:

isPointInPath vs isPointInStroke

In general, use isPointInStroke if you are trying to determine if a point exists directly on or inside the path itself (e.g., on the perimeter of a shape) and use isPointInPath if you are trying to determine if a point is enclosed by a given path (e.g., within a filled shape).

This also explains why the isPointInPath method has options for which fillRule to use for determination, but isPointInStroke does not.

If we wanted our demos to work when the cursor is anywhere inside or over the cloud, instead of only when it overlaps with the border of the cloud, we would need to update our code to use isPointInPath instead of isPointInStroke.

Other Approaches

As is frequently the case with Web Development, the only real limits here are your imagination. There are other ways I’ve seen to tackle this problem that have not been enumerated above, such as: