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:

  • Manual tracking
    • If you know exactly which pixels an object occupies (or can determine easily), you can check if the touch point is inside those pixels.
    • This approach really only works well and is scalable for bounding-box situations, and does not lend itself well to curved paths, circles, etc.
  • Canvas point overlap check methods (isPointIn___):

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

  • Step 1: For the path that you want to perform hit detection on, convert methods that operate directly on context, to those that construct a path instance outside of it
    • For example:
      // Before
      const ctx = canvas.getContext('2d');
      ctx.rect(10, 10, 30, 30);
      ctx.stroke();
      
      // After
      const myRectPath = new Path2D();
      myRectPath.rect(10, 10, 30, 30);
      ctx.stroke(myRectPath);
      // We still have access to `myRectPath` after it has been stroked
      
  • Step 2: Pass the path object to isPointIn___ methods, instead of relying on the current path
    • Example: replace ctx.isPointInPath(10, 10) with ctx.isPointInPath(myRectPath, 10, 10)
  • Step 3: Instead of padding the path with strokeStyle or lineWidth before drawing it onto the canvas, only pad the path right before calling isPointIn___ and then reset it right after
    • Example:
      /*
      * ======== Before =======
      */
      ctx.lineWidth = 1;
      
      const myRectPath = new Path2D();
      myRectPath.rect(10, 10, 30, 30);
      ctx.lineWidth = 10; // Pad for bigger hit-target
      ctx.stroke(myRectPath);
      
      console.log(
          ctx.isPointInStroke(myRectPath, 12, 11)
      );
      
      /*
      * ======== After =======
      */
      ctx.lineWidth = 1;
      
      const myRectPath = new Path2D();
      myRectPath.rect(10, 10, 30, 30);
      ctx.stroke(myRectPath);
      
      ctx.lineWidth = 10; // Pad for bigger hit-target
      console.log(
          ctx.isPointInStroke(myRectPath, 12, 11)
      );
      ctx.lineWidth = 1; // reset
      

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:

  • Stacking fixed position elements on top of the canvas
    • Let’s say that you want to track clicks on a button that should appear as part of the UI being presented by the Canvas. Instead of rendering this as part of the Canvas, you could render it in the DOM, as a regular <button> element, and then position it over the Canvas element, which would let you attach event listeners much more easily
  • Use a full-fledge Canvas library with interaction support
    • Some Canvas libraries, like fabric.js, provide flexible interaction tracking and event listeners out-of-the-box

Leave a Reply

Your email address will not be published.