为什么我们需要它?
在画图时我们常常喜欢用箭头指向某个东西。在如何在HTML5 Canvas 上使用arcTo() 的教程里,我展示了怎样用 arcTo
绘制一个漂亮的箭头,但是当时我作弊了,写的示例是在一条水平线上的箭头。在本教程中我们将进行拓展,介绍如何在任意角度的直线上添加箭头,然后运用我们学到的知识实现在弧线的末端添加箭头。继续看下去,也许你会注意到本文的图表中带箭头的弧线和直线,它们一开始是没有的,是我后来重新回去在每个地方用新函数drawArcedArrow()
和drawArrow()
绘制的,耶!
注意:如果你了解一点基础的三角函数,那么这篇文章你将很好理解。如果不了解的话,可能就会感到晦涩难懂。如果你正在做相关的工作并且不了解这些知识,那就学习它吧。我推荐:www.khanacademy.org/math/trigon…。如果觉得这个太难了,可以从Khan Academy的基础代数开始学习。
我们要尝试完成的需求有哪些?
首先,我们将绘制带箭头的直线。我们希望:
- 箭头能被添加在直线的起点、终点或者两边都添加
- 我们希望能够指定直线末端到箭头侧边线的角度θ,并且要有一个有效的默认值
- 我们希望能够指定箭头的长度,并且也要有有效的默认值
- 我们希望可以选择箭头填充或者不填充,甚至让用户有机会通过一个自定义的函数来绘制箭头。我们将创建一个弯背填充箭头作为默认值。
如果你只需要绘制一个普通的箭头,那么只需指定箭头的起点和终点,其他的所有内容都将是默认的。
函数的签名是什么?
drawArrow( x1,y1,x2,y2,style,which,angle,length)
- x1,y1 – 箭柄线的起点 (译者:箭柄就是带箭头的直线中的那条直线)
- x2,y2 – 箭柄线的终点
- style – 要绘制的箭头的类型, 默认值是 3
-
- 0 – arcTo 绘制的弯背填充箭头
- 1 – 直背填充箭头
- 2 – 描边箭头
- 3 – quadraticCurveTo 绘制的弯背填充箭头
- 4 – bezierCurveTo 绘制的弯背填充箭头
- function(ctx,x0,y0,x1,y1,x2,y2,style) – 用户提供的绘制箭头的函数。点(x1, y1)是直线的端点,(x0, y0)和(x2, y2)是两个后角点。参数 style 是 函数的 this。文档的后面会展示一个只在每个箭头的角点上绘制了小圆圈的例子。
- which – 在箭柄线的哪一端添加箭头,默认值为 1(在终点添加箭头)
-
- 0 – 都不添加
- 1 – x2,y2 的那一端
- 2 – x1,y1 的那一端
- 3 – (即 1+2) 两个端点都添加、
- angle – 从箭杆线到箭头一侧边线的角度θ – 默认值为 π/8 (22 1/2°,45°的一半)
- length – 从箭头点向后沿着箭杆线到箭头背部的距离 d (以像素为单位)- 默认值为 10px
若要指定默认值请省略参数。
让我们先考虑直线的终点
我不会教你三角函数!
我们要绘制一条直线,然后画出边与它成一定角度的箭头。为此我们需要知道直线的角度。计算角度将会用到一些基础的三角函数,我不会讲解相关的知识,我会假设你都了解,如果你需要简单复习一下可以查看这个网址:www.khanacademy.org/math/trigon…
线的斜率的反正切是我们要求的角度
所以,atan(dy/dx)或者atan((y2-y1)/(x2-x1))可以计算出一条直线线的角度。如果我们用了这种方法,当两个点的x坐标相同时,必须小心除以0的情况,并且 当我们处在第二和第三象限时,必须搞清楚具体在哪个象限,并在角度上加上π。
atan2为我们完成了所有的工作
幸运的是,在Math中还有另一个JS方法为我们完成了所有的这些工作,它就是Math.atan2(y,x)
。
如果角α在一二象限,它则返回负角(-π <= α <= 0);如果角α在三四象限,它就返回正角(0 < α <= π)。
转向,转向!
直线从(x0,y0) 到 (x1,y1),atan2(y1-y0,x2-x0) 帮我们计算出了它的角度,但箭头的边线在相反的方向。为了弄清楚它的角度,我们需要将θ与α的反角相加。α的反角是π + α (单位为弧度)。所以箭头上边线的角度就是π + α + θ , 箭头下边线的角度就是π + α – θ。
(译者:α的反角的意思是直线反方向的角度,每条直线方向不同角度就不同,这两个角度相差180°)
我们箭头的长度错了!
我们已经有了箭头每条侧边线的角度和 d 的长度 ,但如果我们有 h (直角三角形的斜边),我们就能简单地计算出箭头倒钩背面的两个角的x、y坐标。
从 cos(θ) = d/h ,我们可以可以推导出 h=d/cos(θ)。现在,因为d是一个长度,所以它总是一个正数,而 cos() 的值则取决于角度,所以它的值可能是正的也可能是负的。我们希望直角三角形的斜边 h 也是一个长度,所以我们要取它的绝对值。
Math.abs(d / Math.cos(angle))
一旦我们有了斜边的长度,那么使用三角函数就可以容易的得到箭头顶部后角点的x、y坐标值。从点 (x2,y2) 开始沿着角度 angle1 前进 h距离,点 (topx1,topy1) 就等于:
( x2 + Math.cos(angle1) * h , y2 + Math.sin(angle1) * h )
同样地,给定箭头底部的角度(angle2),箭头底部后角(botx,boty)的x,y 值是:
( x2 + Math.cos(angle2) * h , y2 + Math.sin(angle2) * h )
最后,我们要绘制一些箭头
// 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))
计算线的角度,以便我们能用它得到箭头的上边线、下边线的角度,并用它来计算它们的端点的 (x,y) 位置 并绘制它们。
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
首先,正如我们上面所讨论的,我们通过将线的角度加上Math.PI
从而得到他的反角,然后我们将箭头倒钩的夹角加上反角值。之后,我们就可以通过三角函数简单的得到箭头倒钩角点的 (x,y)坐标。
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)
}
底角的坐标用相同的方法得到,然后我们调用另一个方法来实际绘制箭头的头部,给这个方法传递三个角点并告诉它样式。
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);
}
类似地,我们编写在另一端添加箭头的代码,计算点坐标 并将它们传递给头部绘制程序。主要的区别是我们无需给 lineangle加上 Math.PI
,因为它已经与箭头两侧的直线是相同的方向。
怎样设置默认值以及 toDrawHead来自哪里!
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
每个参数都有默认值,我们检查它们是否设置。如果设置了则使用它们的值,如果没有则将它们设置为默认值。
此外,对于参数style,我们会核验它是否是一个函数。如果是就用它作为绘制箭头的函数,否则就使用我们的函数drawHead。我不会讲解 drawHead 函数 ,因为它只是canvas绘图程序的简单应用。但你可以自己看看,他在这里 canvasutilities.js 。相反,我将向你展示如何编写一个你自己的箭头渲染函数并传入。
传入一个自定义的箭头渲染函数
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()
}
这个函数没什么可说的,它只是在每个点的位置绘制一个圆圈。你可以像这样使用它:
drawArrow(x1,y1,x2,y2,headDrawer)
(假设参数which、length、angle使用默认值)
你可以在下面的动图中看到它的使用效果。如果你看到了一个巨大的黑色物体,那是因为此箭头尺寸被设置为随机值,而这个值随机变得非常大。箭头和箭杆线之间的随机角度可能大于90度。
在弧线上实现同样的效果
弧线实现同样的效果几乎没有不同,我们已经解决了所有的问题,只需要搞清楚要传给箭头绘制方法的参数。为了正确的指定这些参数,我们需要知道弧线端点所形成的角度。那是曲线在那个点上的瞬时斜率。如果你已经学过第一学期的微积分,你就知道你是从圆方程的一阶导数中得到的。以 (a,b) 为中心的圆上的每个点都满足方程 (x-a)2 + (y-b)2 = r2。
通过隐式微分我们得到:2(x-a)+2(y-b)dy/dx=0。
化简之后我们得到:dy/dx=(a-x)/(y-b)。请注意,这个公式中带有x的部分是作为分子的,虽然我们通常认为在一条线上它的斜率是y的增量除以x的增量。但它是对的,数学没有骗人。之后我们将调用 atan2
去获取角度,并且我们还要将这些通过微积分得到的值传递给它。谁说没人需要微积分!
lineangle = Math.atan2(x - sx, sy - y)
在这个例子中,(x,y) 将作为圆心,并且 (sx,sy) 将作为弧线上的端点。atan2
返回 弧线在 (sx,sy)点切线的角度。
因此,给定一个弧线,如果我们能够搞清楚弧线的端点坐标,那么我就能很容易搞清楚箭头的方向。
我们将得到这样的弧线:
drawArcedArrow(ctx,x,y,r,startangle,endangle,anticlockwise,style,which)
- ctx – 2d绘图上下文
- x,y – 弧线所在圆的圆心
- r – 圆的半径
- startangle – 弧线的开始角
- endangle – 弧线的结束角
- anticlockwise – 如果弧线按逆时针方向绘制则值为
true
- style – 箭头的样式,详情请参考上面的
drawArrow
方法 - which – 弧线的哪一端得到箭头,详情请参考上面的
drawArrow
方法
这是一种策略,通过调用drawArraw() 方法实现代码复用
在绘制带箭头的弧线时,我们将会通过调用之前编写的绘制箭头的函数,从而实现代码的复用。此外我们还要做这些事情:计算弧线端点切线的角度,从弧线的端点开始向后绘制一条10像素的线,这条线上添加一个10像素长的箭头。为了确保这条线是不可见的,我们将绘制这条线时这样设置strokeStyle:
ctx.strokeStyle = 'rgba(0,0,0,0)'
这是drawArcedArrow()的代码
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
我们设置参数的默认值
ctx.save()
ctx.beginPath()
ctx.arc(x, y, r, startangle, endangle, anticlockwise)
ctx.stroke()
绘制弧线
var sx, sy, lineangle, destx, desty
ctx.strokeStyle = 'rgba(0,0,0,0)' // don't show the shaft
var origwhich = which
让我们的箭杆隐藏,并且记录要添加箭头的端点。我们之所以要记录,是因为无论在哪一端添加要执行的drawArrow()函数是相同的。我们的箭杆线总是从弧线的端点沿着切线方向往回绘制,所以我们希望绘制的起点是要添加箭头的端点。这是两种情况之一:
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)
}
如上所述,我们算出了端点 (sx,sy) ,并用它算出了切线的角度lineangle,然后我们再算出一个十像素外的点。
最后,我们绘制了一条箭头线,这条线从弧线末端到切线上10像素外的点,同时确保 drawArrow() 要让 箭头指向来的方向。
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()
}
这与另一端的代码是相同的,唯一的不同是我们用endangle代替开始角去计算弧线的端点坐标,以及我们所得到的切线上的点的方向是相反的。
致谢
感谢 Ceason 指出了一个问题,即 drawArrow的参数可能是一个看起来像数字的数值字符串。感谢 Ryan Cook ,他指出 x1 = parseInt(x1)
应该改为x1 = parseInt(x1,10)
,这样以0开头的数值字符串就不会被解析为一个八进制的数了。
有人真的知道现在几点了吗?
这是一个JavaScript应用,它使用了 Date
、drawArrow()
和 其他的一些canvas绘图命令。它并不复杂,但和许多的canvas应用一样冗长且无聊。如果你想了解更多可以看这个页面的源,函数就在的底部。clock函数被调用了一次,并且通过setInterval
去每秒一次的调用 drawclock()
。drawclock
每秒绘制时钟。
我喜欢每当秒针到达12时分针和时针都会移动的这种感觉。它看起来真的有一种机械美。canvasutilities.js文件中是一个略微改动的版本。整个内容都封装到了一个对象当中,你可以实例化并调用开始方法。查看我的主页获取它的使用示例。
译者的话
我在学习canvas的过程中需要绘制箭头。为此我查阅了网络上的一些文章,我发现它们许多都参考了的这篇英文博客,于是逐渐萌生了翻译它的想法。其实我的英文水平很差,之前也没有翻译的经验,但是一方面正是因为我的英文不好,所以我很能体会看不懂英文博客的痛苦,我希望自己的翻译可以帮助到更多的人;另一方面自然也是希望借助这个机会可以锻炼一下自己的英文水平。这是我翻译的第一篇文章,受限于本人的英文水平,许多地方都还翻译的很粗糙,还希望大家可以多多包涵。
原文链接:https://juejin.cn/post/7347300843001004041 作者:Carlos_sam