Drawing lines and arcs with arrow heads on HTML5 Canvas

by Patrick Horgan

(Back to canvas tutorials)

Why we need it

When drawing diagrams we often like to point at things with arrows. In the How to use arcTo() on an HTML5 canvas tutorial I showed how arcTo could make a nice arrow head, but cheated, and did the example on a nice horizontal line. In this tutorial we'll generalize it for lines at any angle, and then apply what we've learned to make arrow heads on the end of arcs too. When we go along, you'll notice arcs and lines with arrows in the diagrams. They didn't have them originally, but when I got to the end, I went back and used the new functions drawArcedArrow() and drawArrow() wherever seemed appropriate. Yay!

N.B. if you understand a tiny bit of basic trig this will be transparent to you. If you don't it will be obscure as. If you are doing this work and you don't understand basic trig learn it. I recommend: https://www.khanacademy.org/math/trigonometry. If that is too hard, start at Khan Academy's basic algebra.

So what are we trying to accomplish?

First we're going to deal with lines with arrow head. We want:

The normal user will just specify the source and destination points, and everything else will default.

What's the signature of the function?

drawArrow(x1,y1,x2,y2,style,which,angle,length)

To specify defaults leave the arguments out.

Let's think about the destination end first

I'm not teaching you trig. Fugedaboudit!

So we're going to draw a line, and then draw the arrow with the sides at some angle to it. To do that we need to know the angle of the line. To calculate the angle we're going to use some basic trig. I'm not going to teach you about trig, I assume you know it. If you need a basic review: https://www.khanacademy.org/math/trigonometry.

The arctangent of the slope of the line is the angle

So, the angle of a line is given atan(dy/dx), or atan((y2-y1)/(x2-x1)). If we really did it that way, we'd have to be careful about dividing by zero if the x's were the same, and we'd have to figure out which quadrant we were in and add π to the angle if we were in quadrant II or III.

atan2 does all the work for us

Lucky for us, there's another javascript method in Math that does all that for us, Math.atan2(y,x). It returns the angle α as negative angles (-π <= α <= 0) for quadrants I and II, and as positive angles (0 <- α <= π) for quadrants III and IV.

Turn around, turn around!

Consider the line from (x0,y0) to (x1,y1).

atan2(y1-y0,x2-x0) give us its angle, but the line for the arrow head comes back in the opposite direction. To figure out its angle, we need to add θ to the opposite of α. In radians, opposite of α is π + α. So the angle of the top side of the arrow head is π + α + θ and the angle of bottom line of the arrow head would be π + α - θ.

We've got the wrong length for the arrow head!

We have the angle of each side of the arrow head, and we have d, but if instead we had h (the hypotenuse) we could easily calcuate the x and y coordinates of the two corners of the back of the arrow barb.

Since the cos(θ) = d/h, then a little algebra tells us that h=d/cos(θ). Now, d is a length, so is always a positive number, the cosine, depending on the angle could be positive or negative. We want the hypotenuse to also be a length so we'll take the absolute value. Math.abs(d/Math.cos(angle)).

Once we have the length of the hypotenuse, then using basic trig, can get the x and y values of the back corner of the top of the arrow head pretty easily. Starting at the point (x2,y2) and going h (this is a different h at this point) distance at angle angle1, the point (topx1,topy1) is equal to

(x2+Math.cos(angle1)*h,y2+Math.sin(angle1)*h)
Similarly, given the angle of the bottom side of the arrow head (angle2), the x and y values of the back corner (botx,boty) of the bottom side of the arrow head are
(x2+Math.cos(angle2)*h,y2+Math.sin(angle2)*h)

Finally, we're going to draw some heads

// calculate the angle of the line var lineangle=Math.atan2(y2-y1,x2-x1); // h is the line length of a side of the arrow head var h=Math.abs(d/Math.cos(angle));

Calculate the angle of the line so that we can use it to find the angle of the top and bottom lines of the arrow head and use that to calculate the (x,y) positions of their ends and draw them.

if(which&1){ // handle head at far end var angle1=lineangle+Math.PI+angle; var topx=x2+Math.cos(angle1)*h; var topy=y2+Math.sin(angle1)*h;

First, as we discussed above, we find the angle of the line by adding Math.PI to the line's angle to get its opposite angle. Then we add the passed in angle of the barb to result. After that, we can easily find the (x,y) coordinates of the corner of the barb by basic trig.

var angle2=lineangle+Math.PI-angle; var botx=x2+Math.cos(angle2)*h; var botx=y2+Math.sin(angle2)*h; toDrawHead(ctx,topx,topy,x2,y2,botx,boty,style); }

The coordinates of the bottom corner are found just the same way, and then we call another method to actually draw the head, passing the three corners and telling it the style.

if(which&2){ // handle head at near end var angle1=lineangle+angle; var topx=x1+Math.cos(angle1)*h; var topy=y1+Math.sin(angle1)*h; var angle2=lineangle-angle; var botx=x1+Math.cos(angle2)*h; var boty=y1+Math.sin(angle2)*h; ctx.beginPath(); toDrawHead(ctx,topx,topy,x1,y1,botx,boty,style); }

Similary, we handle the code for the other end of the arrow, calculating the points and passing them to the head drawing routine. The main difference is that we don't have to add Math.PI to the lineangle, since it's already going the same way at the lines for the sides of the arrow head.

Here's how the defaults get set, (and where toDrawHead came from!)

var drawArrow=function(ctx,x1,y1,x2,y2,style,which,angle,d) { 'use strict'; if(typeof(x1)=='string') x1=parseInt(x1,10); if(typeof(y1)=='string') y1=parseInt(y1,10); if(typeof(x2)=='string') x2=parseInt(x2,10); if(typeof(y2)=='string') y2=parseInt(y2,10); which=typeof(which)!='undefined'? which:1; // end point gets arrow angle=typeof(angle)!='undefined'? angle:Math.PI/8; d =typeof(d) !='undefined'? d :10; style=typeof(style)!='undefined'? style:3; // default to using drawHead to draw the head, but if the style // argument is a function, use it instead var toDrawHead=typeof(style)!='function'?drawHead:style;

For each of the arguments that can have defaults, we check to see if they are set, and if so we use their values. If not, we set them to default values.

Additionally, for style, we check to see if its a function. If so, we use that for our function to draw heads, otherwise we use our function drawHead. I'm not going to talk about drawHead, since it's just simple applications of canvas drawing routines, but you can look at it for yourself, it's in canvasutilities.js Instead, I'm going to show you how to write your own head drawing routine to pass in.

Passing in a custom head drawing routine

var headDrawer=function(ctx,x0,y0,x1,y1,x2,y2,style) { var radius=3; var twoPI=2*Math.PI; ctx.save(); ctx.beginPath(); ctx.arc(x0,y0,radius,0,twoPI,false); ctx.stroke(); ctx.beginPath(); ctx.arc(x1,y1,radius,0,twoPI,false); ctx.stroke(); ctx.beginPath(); ctx.arc(x2,y2,radius,0,twoPI,false); ctx.stroke(); ctx.restore(); }

There's very little to say about this, it just draws a circle at each point. You would use it like drawArrow(x1,y1,x2,y2,headDrawer) (assuming you default the choices about which end, length and angle). You can see it in use in the silly moving diagram below. If you see big dark things it's because the size of the heads on the one with random values has randomly gotten really big. The random angle between the side of the head and the shaft might have gotten bigger than 90 degrees as well. If you wait it will randomly get smaller, or the angle will randomly get smaller, or you can refresh to get the smaller start value.

Doing the same with arcs

There's very little to do differently with arcs, we've solved all of the problems, and just need to figure out the arguments to pass to the head drawing method. To point it the right way, we need to know the angle that the end of an arc makes. That's the instantaneous slope of the curve at that point. If you've had first semester calculus, you know that you get that from the first derivative of the equation for the circle. Every point of a circle centered at (a,b) meets the equation (x-a)2 + (y-b)2 = r2.

Differentiating implicitly we get: 2(x-a)+2(y-b)dy/dx=0.

Simplifying we get dy/dx=(a-x)/(y-b). Notice that the part with the x goes on the top, even though we usually expect on a line that the slope is the change in y divided by the change in x. It's ok. The math doesn't lie. Later we'll call atan2 to get the angle, and we'll pass these values derived from this application of calculus to it. Who says no one needs calculus!

lineangle=Math.atan2(x-sx,sy-y)

In this case (x,y) will be the center, and (sx,sy) will be the end point on the arc. The atan2 returns the angle of the line tangent to the arc at (sx,sy).

So given an arc, if we can figure out the end points, we should pretty easily be able to figure out what direction to point the arrow head.

We'll be given an arc like this:

drawArcedArrow(ctx,x,y,r,startangle,endangle,anticlockwise,style,which)

Here's the strategy, code reuse by calling of drawArrow()

To draw the arc with the arrow head, we're going to reuse the code that we wrote to draw arrows by calling it. What we'll do is find the angle of the line tangent to the end of the arc, move back 10 pixels from the end and draw a 10 pixel line, with a 10 pixel head. To make sure the line doesn't show up, we set the strokeStyle used to draw lines like this:

strokeStyle='rgba(0,0,0,0)';

rgba lets us set the alpha, or opacity, of the line. The first three values, the r, g, and b, don't matter, because we set the fourth value to 0 which gives complete transparency, the line is invisible. In the diagram, I left the line so you can see that it lies upon the tangent line.

Here's the drawArcedArrow() code

var drawArcedArrow= function(ctx,x,y,r,startangle,endangle,anticlockwise,style,which,angle,d) { 'use strict'; style=typeof(style)!='undefined'? style:3; which=typeof(which)!='undefined'? which:1; // end point gets arrow angle=typeof(angle)!='undefined'? angle:Math.PI/8; d =typeof(d) !='undefined'? d :10;

We set up our defaults.

ctx.save(); ctx.beginPath(); ctx.arc(x,y,r,startangle,endangle,anticlockwise); ctx.stroke();

Draw the arc.

var sx,sy,lineangle,destx,desty; ctx.strokeStyle='rgba(0,0,0,0)'; // don't show the shaft var origwhich=which;

Make our arrow shafts invisible, and remember which end of the arc that we'll be adding heads to. The reason we remember, is that the which we pass to the drawArrow() routine from here is always the same. We always draw back along the tangent line from the end of the arc, so we want the source end to be the end that gets the arrow head. That's a which of 2.

if(origwhich&1){ // draw the destination end sx=Math.cos(startangle)*r+x; sy=Math.sin(startangle)*r+y; lineangle=Math.atan2(x-sx,sy-y); if(anticlockwise){ destx=sx+10*Math.cos(lineangle); desty=sy+10*Math.sin(lineangle); }else{ destx=sx-10*Math.cos(lineangle); desty=sy-10*Math.sin(lineangle); } drawArrow(ctx,sx,sy,destx,desty,style,2,angle,d); }

Just as discussed above, we figure out the end point, (sx,sy), we used that to figure out the angle of the tangent line, lineangle, and then we figure out a point 10 pixels away.

Finally, we draw an arrowed line from the end of the arc to the point 10 pixels away on the tangent line, making sure to tell drawArrow() to point the arrow at the end we came from.

if(origwhich&2){ // draw the origination end sx=Math.cos(endangle)*r+x; sy=Math.sin(endangle)*r+y; lineangle=Math.atan2(x-sx,sy-y); if(anticlockwise){ destx=sx-10*Math.cos(lineangle); desty=sy-10*Math.sin(lineangle); }else{ destx=sx+10*Math.cos(lineangle); desty=sy+10*Math.sin(lineangle); } drawArrow(ctx,sx,sy,destx,desty,style,2,angle,d); } ctx.restore(); }

This works just the same as the code for the other end, the only difference is that we use the endangle instead of the start angle to find the end point of the arc, and direction we go to find the point on the tangent line is reversed.

Kudos

Thanks to Ceason who pointed to a problem where an argument to drawArrow could be a string that only looked like a number. An addition would instead concatenate and the result would be then used as a REALLY big number:) Thanks Ceason! Thanks to Ryan Cook who pointed out that x1=parseInt(x1); should be x1=parseInt(x1,10) so that a leading zero doesn't get the string parsed as octal.

Does anyone really know what time it is?

This is a javascript application that uses Date, our drawArrow(), and some other canvas drawing commands. It's not complicated, but it is, like many canvas applications are, tedious and boring;p If you want to know more, just look at the source for this page. The routine is down at the bottom. The clock function gets called once and calls setInterval to call drawclock() once a second. drawclock draws the clock every second. Just read it. It's all pretty obvious. I like the way that each time the second hand hits 12 the minute and hour hands move. It looks really mechanical. There's a slightly improved version in the canvasutilities.js file. It wraps the whole thing in an object you can instantiate and call the start method on. Look at my home page for an example of its use.

(Back to canvas tutorials)