这篇文章要解决一个问题,就是给定 HTML 中任意一个点(起点)和另一个点(终点),绘制一条带箭头的曲线。

废话不多说,直奔主题。

我们只有两个点的相对偏移量(offset),思路就是以这两个点作为对角,创建一个绝对定位的 Canvas,然后在两点中绘制一条曲线(Curve),最后在终点处绘制箭头(Arrow)。

因此分为 3 步:

  1. 创建适当的 Canvas
  2. 绘制曲线
  3. 绘制箭头

创建适当的 Canvas

先确定 Canvas 的绝对定位偏移量,因为是任意两点,所以对角可能是左上加右下,也可能是左下加右上,不论是哪一种,它的左偏移量一定是两个点的左偏移量的最小值,同理,上偏移量也是两个点的上偏移量的最小值。

再确定 Canvas 的宽和高,宽等于两点左偏移量之差的模,长等于两点上偏移量之差的模。

// 随机的起始点和终点,这里不考虑边缘情况,实际生产环境下,相近的两点应该很少会有加指向性箭头的需求
const sp = { left: Math.floor(window.innerWidth * Math.random()), top: Math.floor(window.innerHeight * Math.random()) };
const ep = { left: Math.floor(window.innerWidth * Math.random()), top: Math.floor(window.innerHeight * Math.random()) };

const canvas = document.createElement('canvas');
canvas.style.position = 'absolute'; // 设置绝对定位
canvas.style.left = Math.min(sp.left, ep.left) + 'px'; // 设置左偏移量
canvas.style.top = Math.min(sp.top, ep.top) + 'px'; // 设置右偏移量
canvas.width = Math.abs(sp.left - ep.left); // 设置宽度
canvas.height = Math.abs(sp.top - ep.top); // 设置高度

// 顺便为 Canvas 加个红色的边框,方便 debug
canvas.style.border = '1px solid red';

// 把 Canvas 放到 body 中
document.body.appendChild(canvas);

绘制曲线

Canvas 中绘制曲线很简单,API 中已经提供了贝塞尔曲线(Bezier Curve)的绘制方法。

而控制点的掌握…全靠经验 :)

这里提供一个很简单,很好算的控制点,绘制出的曲线效果也非常好。

const ctx = canvas.getContext('2d'); // 获取 Canvas 上下文

// 下面求各点在 Canvas 中的坐标
sp.x = sp.left - Math.min(sp.left, ep.left);
sp.y = sp.top - Math.min(sp.top, ep.top);
ep.x = ep.left - Math.min(sp.left, ep.left);
ep.y = ep.top - Math.min(sp.top, ep.top);

// 算贝塞尔曲线的控制点坐标,很简单,只需要把起始点和终点的 x 相加除以 3, y 永远和起始点的 y 一致
// 这样向左和向右的箭头不会是一样的曲线,显得不那么死板
const cp = {
    x: (sp.x + ep.x) / 3,
    y: sp.y
};

ctx.beginPath();
ctx.moveTo(sp.x, sp.y);
ctx.quadraticCurveTo(cp.x, cp.y, ep.x, ep.y);
ctx.strokeStyle = '#FB9845';
ctx.lineWidth = '3';
ctx.stroke();
ctx.closePath();

// 绘制出控制点到终点的连线,方便 debug
ctx.beginPath();
ctx.moveTo(cp.x, cp.y);
ctx.lineTo(ep.x, ep.y);
ctx.strokeStyle = 'red';
ctx.lineWidth = '1';
ctx.stroke();
ctx.closePath();

绘制箭头

绘制箭头的步骤稍微复杂一点点,因为涉及到数学运算。

本人对贝塞尔曲线并没有深入的研究,但是通过观察发现控制点到终点的连线近似曲线在终点处的切线,可以作为箭头的中线来使用。

所以问题被转化为求控制点顺时针和逆时针旋转特定角度后的坐标。这个角度我们取 20,别问,问就是好看。

涉及到旋转,就要理解参照系,为了简化计算,我们把终点作为原点,那么终点在不同的角上,我们所使用的坐标系是不同的,因此需要有坐标转换的方法。

// 把 Canvas 坐标转换成旋转计算所使用的坐标,接收 1 个参数,需要转换的点 p
function coordEx(p) {
    const result = {};
    if (ep.x < sp.x && ep.y < sp.y) {
        result.x = p.x;
        result.y = p.y;
    } else if (ep.x < sp.x && ep.y > sp.y) {
        result.x = p.x;
        result.y = Math.abs(sp.top - ep.top) - p.y;
    } else if (ep.x > sp.x && ep.y < sp.y) {
        result.x = Math.abs(sp.left - ep.left) - p.x;
        result.y = p.y;
    } else if (ep.x > sp.x && ep.y > sp.y) {
        result.x = Math.abs(sp.left - ep.left) - p.x;
        result.y = Math.abs(sp.top - ep.top) - p.y;
    }

    return result;
}

// 把旋转计算用的坐标转换回 Canvas 坐标,用于绘图
function coordRe(p) {
    const result = {};
    if (ep.x < sp.x && ep.y < sp.y) {
        result.x = p.x;
        result.y = p.y;
    } else if (ep.x < sp.x && ep.y > sp.y) {
        result.x = p.x;
        result.y = Math.abs(sp.top - ep.top) - p.y;
    } else if (ep.x > sp.x && ep.y < sp.y) {
        result.x = Math.abs(sp.left - ep.left) - p.x;
        result.y = p.y;
    } else if (ep.x > sp.x && ep.y > sp.y) {
        result.x = Math.abs(sp.left - ep.left) - p.x;
        result.y = Math.abs(sp.top - ep.top) - p.y;
    }

    return result;
}

有了转换后的坐标就可以开始计算了,向量关于原点的逆时针旋转计算公式:

向量关于原点的逆时针旋转计算公式

向量关于原点的顺时针旋转计算公式:

向量关于原点的顺时针旋转计算公式

const CURVE_ARROW_ANGLE = 20; // 旋转的角度
const CURVE_ARROW_LENGTH = 26; // 绘制箭头线段的长度
const ncp = coordEx(cp); // 转换控制点坐标

// 计算逆时针旋转后的坐标
const nlp = {
    x: ncp.x * Math.cos((CURVE_ARROW_ANGLE * Math.PI) / 180) - ncp.y * Math.sin((CURVE_ARROW_ANGLE * Math.PI) / 180),
    y: ncp.x * Math.sin((CURVE_ARROW_ANGLE * Math.PI) / 180) + ncp.y * Math.cos((CURVE_ARROW_ANGLE * Math.PI) / 180)
};

// 计算箭头线段长度和 nlp 到原点距离的比值,用于计算绘制箭头的坐标
const lRate = CURVE_ARROW_LENGTH / Math.sqrt(nlp.x * nlp.x + nlp.y * nlp.y);

// 把 nlp 的坐标转换为绘制箭头的坐标
nlp.x = nlp.x * lRate;
nlp.y = nlp.y * lRate;

// 把绘制箭头的坐标转换回 Canvas 坐标
const lArrowPoint = coordRe(nlp);
ctx.beginPath();
ctx.moveTo(lArrowPoint.x, lArrowPoint.y);
ctx.lineTo(ep.x, ep.y);
ctx.strokeStyle = '#FB9845';
ctx.lineWidth = '3';
ctx.stroke();

以上是绘制逆时针箭头的方法,同样的方法可以绘制顺时针箭头,如果顺利的话,现在你看到的图像应该是这样的:

如果顺利的话

也有可能不顺利…

如果不顺利的话

这是绘制超出了 canvas 的范围,因此需要为 canvas 添加 padding。

添加 padding

可以通过增加 canvas 的长宽,同时调用 tranlate 方法来解决。

const PADDING = 20;

// 修改上面设置 canvas 宽高的代码
canvas.width = Math.abs(sp.left - ep.left) + PADDING * 2; // 设置宽度
canvas.height = Math.abs(sp.top - ep.top) + PADDING * 2; // 设置高度

// 修改上面设置 canvas 偏移量的代码
canvas.style.left = Math.min(sp.left, ep.left) - PADDING + 'px'; // 设置左偏移量
canvas.style.top = Math.min(sp.top, ep.top) - PADDING + 'px'; // 设置右偏移量

// 并在获取 canvas 上下文之后,设置 tranlate
ctx.tranlate(PADDING, PADDING);

最后

注释掉辅助线代码,即可获得一条完美的带箭头的曲线。

源码