翻译:在HTML5 Canvas 当中绘制带有箭头的直线和圆弧

为什么我们需要它?

在画图时我们常常喜欢用箭头指向某个东西。在如何在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 也是一个长度,所以我们要取它的绝对值。

JavaScript 复制代码
Math.abs(d / Math.cos(angle))

一旦我们有了斜边的长度,那么使用三角函数就可以容易的得到箭头顶部后角点的x、y坐标值。从点 (x2,y2) 开始沿着角度 angle1 前进 h距离,点 (topx1,topy1) 就等于:

JavaScript 复制代码
( x2 + Math.cos(angle1) * h , y2 + Math.sin(angle1) * h )

同样地,给定箭头底部的角度(angle2),箭头底部后角(botx,boty)的x,y 值是:

JavaScript 复制代码
( x2 + Math.cos(angle2) * h , y2 + Math.sin(angle2) * h )

最后,我们要绘制一些箭头

JavaScript 复制代码
// 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) 位置 并绘制它们。

JavaScript 复制代码
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)坐标。

JavaScript 复制代码
  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)
}

底角的坐标用相同的方法得到,然后我们调用另一个方法来实际绘制箭头的头部,给这个方法传递三个角点并告诉它样式。

JavaScript 复制代码
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来自哪里!

JavaScript 复制代码
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 。相反,我将向你展示如何编写一个你自己的箭头渲染函数并传入。

传入一个自定义的箭头渲染函数

JavaScript 复制代码
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 去获取角度,并且我们还要将这些通过微积分得到的值传递给它。谁说没人需要微积分!

JavaScript 复制代码
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:

JavaScript 复制代码
  ctx.strokeStyle = 'rgba(0,0,0,0)' 

这是drawArcedArrow()的代码

JavaScript 复制代码
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

我们设置参数的默认值

JavaScript 复制代码
  ctx.save()
  ctx.beginPath()
  ctx.arc(x, y, r, startangle, endangle, anticlockwise)
  ctx.stroke()

绘制弧线

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

让我们的箭杆隐藏,并且记录要添加箭头的端点。我们之所以要记录,是因为无论在哪一端添加要执行的drawArrow()函数是相同的。我们的箭杆线总是从弧线的端点沿着切线方向往回绘制,所以我们希望绘制的起点是要添加箭头的端点。这是两种情况之一:

JavaScript 复制代码
  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() 要让 箭头指向来的方向。

JavaScript 复制代码
  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应用,它使用了 DatedrawArrow() 和 其他的一些canvas绘图命令。它并不复杂,但和许多的canvas应用一样冗长且无聊。如果你想了解更多可以看这个页面的源,函数就在的底部。clock函数被调用了一次,并且通过setInterval 去每秒一次的调用 drawclock()drawclock每秒绘制时钟。

我喜欢每当秒针到达12时分针和时针都会移动的这种感觉。它看起来真的有一种机械美。canvasutilities.js文件中是一个略微改动的版本。整个内容都封装到了一个对象当中,你可以实例化并调用开始方法。查看我的主页获取它的使用示例。


译者的话

我在学习canvas的过程中需要绘制箭头。为此我查阅了网络上的一些文章,我发现它们许多都参考了的这篇英文博客,于是逐渐萌生了翻译它的想法。其实我的英文水平很差,之前也没有翻译的经验,但是一方面正是因为我的英文不好,所以我很能体会看不懂英文博客的痛苦,我希望自己的翻译可以帮助到更多的人;另一方面自然也是希望借助这个机会可以锻炼一下自己的英文水平。这是我翻译的第一篇文章,受限于本人的英文水平,许多地方都还翻译的很粗糙,还希望大家可以多多包涵。

相关推荐
m0_748247551 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255022 小时前
前端常用算法集合
前端·算法
真的很上进2 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203982 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2343 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1233 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~4 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语4 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport4 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg4 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全