nielssp.dk

Animated line of chevrons on HTML canvas

I wanted an animation to indicate movement between two points on a map. I had this picture in my mind of a line of chevrons (or arrow heads) that I was sure I'd seen multiple times before but wasn't entirely sure what to call. I think a chevron conveyor sort of has the look I'm going for, although it moves in the opposite direction of what I had in mind.

Click on the canvas to toggle the animation.

Drawing a line between two points

If you already know how to draw a line on a canvas you can probably skip this section. The reason I've included this section is because this is how I initially approached the problem of drawing a line of chevrons.

The goal is to draw a line of chevrons moving from a point (x0, y0) to another point (x1, y1). We'll start by defining those two points globally:

const x0 = 50;
const y0 = 50;
const x1 = 250;
const y1 = 150;

The exact values obviously don't matter and just serve to get something onto the screen.

Next we'll add a canvas to the HTML and give it an id (so we can access it from JavaScript) as well as a width and a height:

<canvas id="canvas" width="300" height="200"></canvas>

We'll use document.getElementById to access the canvas element, then getContext to get the drawing context of the canvas:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

Next we set the line color and line width:

ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;

Finally we draw our line segment by moving the pen to (x0, y0) then drawing a line to (x1, y1):

ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();

Not the most interesting result, but to me it was important to see the line on the canvas before I could start thinking about how to actually draw the chevrons.

You can change the parameters below (this will update all the other examples on this page as well):

Drawing chevrons

To draw chevrons instead of a boring old line we need to know the angle of the line. We can calculate this angle with the atan2 function. To know how many chevrons we need to draw, we'll also need to know its length.

// Angle between our line and the x-axis in radians
const angle = Math.atan2(y1 - y0, x1 - x0);
// Length of our line (Euclidean distance)
const length = Math.sqrt((x0 - x1) * (x0 - x1) + (y0 - y1) * (y0 - y1));
Diagram showing relationship between a line and the X-axis on a canvas
`atan2` computes the angle between the x-axis and the line.

Next we'll define some constants that define the shape and size of the chevrons we'll be drawing:

// Distance between individual chevrons
const chevronSpace = 7;
// Angle between our main line and the leg of a chevron in radians
const chevronAngle = 0.7 * Math.PI; // 126°
// Length of a chevron leg
const chevronLength = 10;

We can think of a chevron as a path consisting of two line segments meeting in a point. We'll refer to this point as the center point.

Diagram showing relationship between a line and a chevron on the line
The three points of a single chevron.

The center point is on the main line (that we drew in the previous section) and will be pretty easy to find. The two other points have a constant offset from the center point. We can calculate those offsets by subtracting and adding chevronAngle from the angle of our main line, then converting the resulting angles to two vectors of length chevronLength:

// Offset of the first point of a chevron relative to its center point
// calculated by subtracting the chevron angle from the line angle
const startX = Math.cos(angle - chevronAngle) * chevronLength;
const startY = Math.sin(angle - chevronAngle) * chevronLength;

// Offset of the third point of a chevron relative to its center point
// calculated by adding the chevron angle to the line angle
const endX = Math.cos(angle + chevronAngle) * chevronLength;
const endY = Math.sin(angle + chevronAngle) * chevronLength;

Finally we can loop through all the chevrons and draw them using the above offsets:

// Number of chevrons to draw based on length of line
const n = Math.floor(length / chevronSpace);
for (let i = 0; i < n; i++) {
  // The position of the center point of the i'th chevron
  const x = x0 + (x1 - x0) * i / n;
  const y = y0 + (y1 - y0) * i / n;
  ctx.beginPath();
  ctx.moveTo(x + startX, y + startY); // First point
  ctx.lineTo(x, y);                   // Second point
  ctx.lineTo(x + endX, y + endY);     // Third point
  ctx.stroke();
}

Animating the chevrons

To animate the chevrons we'll use requestAnimationFrame to repeatedly call our rendering code, each time adding a small offset to the position of the chevrons. The speed at which we do this will be defined by the following constant:

// Number of pixels to move the chevrons each second
const pixelsPerSecond = 20;

The amount by which we change the offset at each frame can then be calculated using the timestamp provided to us by requestAnimationFrame. To apply the offset to our chevrons we convert it to a vector using the angle of the main line.

// Previous timestamp for calculating delta
let prevTimestamp;
// Animation offset of chevrons in pixels
let offset = 0;

function render(timestamp) {
  // Calculate elapsed time (delta) since previous frame
  const delta = prevTimestamp ? timestamp - prevTimestamp : 0;
  prevTimestamp = timestamp;

  const n = Math.floor(length / chevronSpace);

  // Calculate coordinate offset using the line angle
  const offsetX = offset * Math.cos(angle);
  const offsetY = offset * Math.sin(angle);

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.lineWidth = lineWidth;

  for (let i = 0; i < n; i++) {
    // Apply previously calculated offset to chevron
    const x = x0 + (x1 - x0) * i / n + offsetX;
    const y = y0 + (y1 - y0) * i / n + offsetY;
    ctx.beginPath();
    ctx.moveTo(x + startX, y + startY);
    ctx.lineTo(x, y);
    ctx.lineTo(x + endX, y + endY);
    ctx.stroke();
  }

  // Add offset based on elapsed time
  offset += pixelsPerSecond * delta / 1000;
  // Roll over to the start
  if (offset >= chevronSpace) {
    offset %= chevronSpace;
  }

  // Render the next frame when the browser is ready for it
  requestAnimationFrame(render);
}

requestAnimationFrame(render);

If you cover up both ends of the above line with your fingers, it pretty much looks the way it should. We just need to do something about the chevrons popping in and out of existence.

Fading

It turns out the jerkiness of the above animation is fairly easy to fix. All we need to do is to slowly fade in the chevrons at the start of line and slowly fade out the chevrons at the end of the line. We'll define one additional constant that sets the number of pixels that will be used for fading chevrons in and out:

// Number of pixels it takes for a chevron to fade in at the start of the line
// and fade out at the end
let fadeLength = 30;

To use it we'll add the following code inside the loop before we draw the path (before the call to ctx.stroke()):

// The opacity depends on how close we are to the start (0) or end
// (n * chevronSpace) of the line
const dist = Math.min(i * chevronSpace + offset,
  n * chevronSpace - (i * chevronSpace + offset));
const opacity = Math.min(1, dist / fadeLength);
ctx.strokeStyle = 'rgba(0, 0, 0, ' + opacity + ')';

This results in a 30 pixel gradient from white to black at both ends of the line:

This is the final chevron animation. You can go back and change the other parameters to see what effects they have on the final result.

Conclusion

I'm not sure how useful this is, but I just wanted to share it since it wasn't completely obvious to me at first how to do this, and I haven't been able to find any other mentions of this type of effect. That might be down to the fact that I don't even really know what the effect is called though.

All the JavaScript used on this page is available here.

Comments