绘制二次贝塞尔曲线(二次贝兹曲线)等距线:让 IE 支持 canvas接口 isPointInPath

一、背景:

在使用 canvas 做知识图谱的时,实体关系使用线宽为 1px 的线绘制, 用户必须点在线上, 才能正常拾取到点击的边。 边关系,有些是直线边,有些是二次贝塞尔曲线。产品提议,线不能加粗, 否则图谱展示大量数据时, 有碍美观。直线边扩展已经完成, 曲线边相对麻烦一些。 IE 浏览器不支持 canvas 判断点击事件源是否在路径上的接口 sPointInstroke ; 而对 isPointInPath 的支持,仅限于中心线封闭出来的路径, 中心线外侧的部分能通过 isPointInPath 判断是无效的,也就是说, 加宽线宽在 IE 浏览器是无法正常使用 isPointInPath 接口的。

二、方案:通过数学运算, 实现 isPointInPath 接口底层逻辑

在二次贝兹曲线的外侧绘制两条等距曲线,然后封闭路径。判断点击事件源坐标是否在该路径内,如果是,则判定该曲线被点中; 否则该曲线未被点中。以下是图示:
在这里插入图片描述
如图, 请看 a<0 下面的图片。

途中浅蓝色的二次曲线一共有三条, 中间的一条是图谱上已经绘制的,记为 lineA , 上方和下方的两条曲线分别记为 lineBlineC , 可以看到 lineBlineClineA 包围, 如果将 lineBlineC 两端连接, 则形成了一个封闭的图形, 正好包裹住中间的曲线。

三、实现:

1. 数学几何知识提要

  1. 直线可以用方程来表示 : y = ax + b, 其中 a 为斜率, b 为常数项;
  2. 斜率的计算方法:a = (deltaY1 - deltaY2) / (deltaX1 / detalX2);
  3. 平行的直线斜率相同, 常数项不同, 即 a 相等, b 不相等;
  4. 垂直的直线斜率的乘积是 -1;
  5. 直角三角形、勾股定理、相似三角形的角和边关系以及三角函数 sincos 请自行百度。

2. 注意事项

  1. 计算机屏幕坐标系原点 (0,0) 为屏幕的左上角, 向右为 x 轴正方向, 向下为 y 轴正方向。 所以在不偏移画布的前提下, 是没有负数的坐标的。
  2. “起点-控制点”连线形成的直线, 上图中 AH , “终点-控制点” 连线形成的直线 HK , 若这两条直线的斜率正负号相同, 是一种情形; 这两条直线的斜率正负号不同,又是另一种情形。详见后边的代码。

3. 实现代码:

  1. 为了能用最小的开销实现预览代码效果, 我把代码都摘出来放到一个 html 文档中了;
  2. 为了能更好的表达点、线的所属关系, 命名有点冗长了。倒也情有可原,原因嘛,一是现代编译工具可以帮我打包压缩,因此也无所顾忌; 二是如果不好好命名,过一段时间我自己也睁眼瞎了。
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <canvas id="canvas" width="1980" height="1000"></canvas>
  <script>

    const run = (start, controlPoint, end) => {
      const canvas = document.querySelector('#canvas')
      const context = canvas.getContext('2d');
      const RANGE = 10; // 拾取扩展范围
      /*
        求出贝塞尔曲线“起点-控制点”和“终点-控制点”的直线方程
        @params controlPoint 控制点
        @params controlPoint 起点
        @params controlPoint 终点
      */
      function getBaseEquation(controlPoint, start, end) {
        const slopeOfLineControlPointToStart = (controlPoint.y - start.y) / (controlPoint.x - start.x) //   控制点-起点斜率
        const slopeOfLineControlPointToEnd = (controlPoint.y - end.y) / (controlPoint.x - end.x) //  控制点-终点斜率
        const absoluteTermOfLineToStart = start.y - slopeOfLineControlPointToStart * start.x //  控制点-起点直线方程常数项
        const absoluteTermOfLineToEnd = end.y - slopeOfLineControlPointToEnd * end.x // 控制点-终点直线方程常数项
        const lineControlPointToStart = x => slopeOfLineControlPointToStart * x + absoluteTermOfLineToStart // 控制点-起点直线方程
        const lineControlPointToEnd = x => slopeOfLineControlPointToEnd * x + absoluteTermOfLineToEnd // 控制点-终点直线方程
        return {
          lineControlPointToStart,
          lineControlPointToEnd,
          slopeOfLineControlPointToStart,
          slopeOfLineControlPointToEnd,
          absoluteTermOfLineToStart,
          absoluteTermOfLineToEnd
        }
      }
      const { lineControlPointToStart,
        lineControlPointToEnd,
        slopeOfLineControlPointToStart,
        slopeOfLineControlPointToEnd,
        absoluteTermOfLineToStart,
        absoluteTermOfLineToEnd } = getBaseEquation(controlPoint, start, end)

      /*
        根据贝塞尔曲线“起点-控制点”和“终点-控制点”的直线方程,求出扩展后的上下四条平行线的方程
        * 
      */
      function getShiftedEquation(controlPoint, start, end, range) {
        const hypotenuseOfStart = Math.sqrt(Math.pow(controlPoint.x - start.x, 2) + Math.pow(controlPoint.y - start.y, 2)) // 控制点-起点弦长
        const RANGE_OF_START = RANGE * hypotenuseOfStart / Math.abs(controlPoint.x - start.x) // 与 “控制点-起点”直线平行的两条直线的 y 轴偏移量
        const hypotenuseOfEnd = Math.sqrt(Math.pow(controlPoint.x - end.x, 2) + Math.pow(controlPoint.y - end.y, 2)) // 控制点-终点弦长
        const RANGE_OF_END = RANGE * hypotenuseOfEnd / Math.abs(controlPoint.x - end.x)// 与 “控制点-终点”直线平行的两条直线的 y 轴偏移量
        const lineLeftAbove = x => (lineControlPointToStart(x) + RANGE_OF_START) // 起点-控制点向上偏移方程
        const lineLeftBellow = x => (lineControlPointToStart(x) - RANGE_OF_START) // 起点-控制点向下偏移方程
        const lineRightAbove = x => (lineControlPointToEnd(x) + RANGE_OF_END) // 终点-控制点向上偏移方程
        const lineRightBellow = x => (lineControlPointToEnd(x) - RANGE_OF_END) // 终点-控制点向下偏移方程
        return {
          RANGE_OF_START,
          RANGE_OF_END,
          lineLeftAbove,
          lineLeftBellow,
          lineRightAbove,
          lineRightBellow
        }
      }

      const {
        RANGE_OF_START,
        RANGE_OF_END,
        lineLeftAbove,
        lineLeftBellow,
        lineRightAbove,
        lineRightBellow
      } = getShiftedEquation(controlPoint, start, end, RANGE)

      /***
       * 求出偏移后的控制点, 偏移直线的交点即为偏移后的控制点
      */
      function getShiftedControlPoint() {

        let controlPonitAboveX = (absoluteTermOfLineToEnd + RANGE_OF_END - absoluteTermOfLineToStart - RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
        if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {
          controlPonitAboveX = (absoluteTermOfLineToEnd - RANGE_OF_END - absoluteTermOfLineToStart - RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
        }
        const controlPointAbove = {
          x: controlPonitAboveX,
          y: lineLeftAbove(controlPonitAboveX)
        }
        let controlPonitBelloweX = (absoluteTermOfLineToEnd - RANGE_OF_END - absoluteTermOfLineToStart + RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
        if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {
          controlPonitBelloweX = (absoluteTermOfLineToEnd + RANGE_OF_END - absoluteTermOfLineToStart + RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
        }
        const controlPointBellow = {
          x: controlPonitBelloweX,
          // slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0 时, 线要变
          y: lineLeftBellow(controlPonitBelloweX)
        }

        return {
          controlPointAbove,
          controlPointBellow
        }
      }

      const { controlPointAbove, controlPointBellow } = getShiftedControlPoint()

      /**
       * 求出经过左、右侧端点的垂线方程
       * 求出偏移后的两条贝塞尔曲线的起点和终点坐标
      */
      function getShiftedStartAndEndPoints() {
        const absoluteTermOfPerpendicularLineControlPointToStart = start.y + 1 / slopeOfLineControlPointToStart * start.x  // “控制点-起点”偏移方程的常数项
        const absoluteTermOfPerpendicularLineControlPointToEnd = end.y + 1 / slopeOfLineControlPointToEnd * end.x // “控制点-终点”偏移方程的常数项
        const perpendicularLineControlPointToStart = x => -(1 / slopeOfLineControlPointToStart) * x + absoluteTermOfPerpendicularLineControlPointToStart  // 垂线方程
        const perpendicularLineControlPointToEnd = x => -(1 / slopeOfLineControlPointToEnd) * x + absoluteTermOfPerpendicularLineControlPointToEnd // 垂线方程

        // 5. 求出4个垂足的坐标
        // 5.1 上边曲线起点
        // y = slopeOfLineControlPointToStart * x + absoluteTermOfLineToStart + RANGE_OF_START
        // y = -(1/slopeOfLineControlPointToStart) * x + absoluteTermOfPerpendicularLineControlPointToStart
        // 0 = (slopeOfLineControlPointToStart + 1/slopeOfLineControlPointToStart) * x + absoluteTermOfLineToStart + RANGE_OF_START -absoluteTermOfPerpendicularLineControlPointToStart
        const leftAboveStartX = (absoluteTermOfPerpendicularLineControlPointToStart - absoluteTermOfLineToStart - RANGE_OF_START) / (slopeOfLineControlPointToStart + 1 / slopeOfLineControlPointToStart)
        const leftAboveStart = {
          x: leftAboveStartX,
          y: lineLeftAbove(leftAboveStartX)
        }
        // 5.2 上边曲线终点
        const rightAboveEndX = (absoluteTermOfPerpendicularLineControlPointToEnd - absoluteTermOfLineToEnd - RANGE_OF_END) / (slopeOfLineControlPointToEnd + 1 / slopeOfLineControlPointToEnd)
        const rightAboveEnd = {
          x: rightAboveEndX,
          y: lineRightAbove(rightAboveEndX)
        }

        // 5.3 下边曲线起点
        // y = slopeOfLineControlPointToStart * x + absoluteTermOfLineToStart - RANGE_OF_START
        // y = -(1/slopeOfLineControlPointToStart) * x + absoluteTermOfPerpendicularLineControlPointToStart
        // 0 = (slopeOfLineControlPointToStart + 1/slopeOfLineControlPointToStart) * x + absoluteTermOfLineToStart - RANGE_OF_START - absoluteTermOfPerpendicularLineControlPointToStart
        const leftBellowStartX = (absoluteTermOfPerpendicularLineControlPointToStart + RANGE_OF_START - absoluteTermOfLineToStart) / (slopeOfLineControlPointToStart + 1 / slopeOfLineControlPointToStart)
        const leftBellowStart = {
          x: leftBellowStartX,
          y: lineLeftBellow(leftBellowStartX)
        }

        // 5.4 下边曲线终点
        const rightBellowX = (absoluteTermOfPerpendicularLineControlPointToEnd + RANGE_OF_END - absoluteTermOfLineToEnd) / (slopeOfLineControlPointToEnd + 1 / slopeOfLineControlPointToEnd)
        const rightBellowEnd = {
          x: rightBellowX,
          y: lineRightBellow(rightBellowX)
        }


        return {
          leftAboveStart,
          rightAboveEnd,
          leftBellowStart,
          rightBellowEnd,
        }

      }

      const { leftAboveStart,
        rightAboveEnd,
        leftBellowStart,
        rightBellowEnd
      } = getShiftedStartAndEndPoints()



      // 6. 画圆环
      if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {

        context.save();
        context.beginPath();
        context.moveTo(leftAboveStart.x, leftAboveStart.y);
        context.quadraticCurveTo(controlPointAbove.x, controlPointAbove.y, rightBellowEnd.x, rightBellowEnd.y);
        context.lineTo(rightAboveEnd.x, rightAboveEnd.y);
        context.quadraticCurveTo(controlPointBellow.x, controlPointBellow.y, leftBellowStart.x, leftBellowStart.y);
        context.lineTo(leftAboveStart.x, leftAboveStart.y);
        context.stroke();
        context.restore();
      } else {
        // 6. 画圆环
        context.save();
        context.moveTo(leftAboveStart.x, leftAboveStart.y);
        context.quadraticCurveTo(controlPointAbove.x, controlPointAbove.y, rightAboveEnd.x, rightAboveEnd.y);
        context.lineTo(rightBellowEnd.x, rightBellowEnd.y);
        context.quadraticCurveTo(controlPointBellow.x, controlPointBellow.y, leftBellowStart.x, leftBellowStart.y);
        context.lineTo(leftAboveStart.x, leftAboveStart.y);
        context.stroke();
        context.restore();

      }




      // 1. 以下是辅助作图, 对实际作图无影响, 只是便于理解
      // 2. 以下是辅助作图, 对实际作图无影响, 只是便于理解
      // 3. 以下是辅助作图, 对实际作图无影响, 只是便于理解

      // 画中心贝塞尔曲线
      context.save();
      context.beginPath();
      context.beginPath();
      context.moveTo(start.x, start.y);
      context.quadraticCurveTo(controlPoint.x, controlPoint.y, end.x, end.y);
      context.stroke();
      context.restore();

      const drawCircle = (x, y, color) => {
        context.save()
        context.beginPath();
        context.moveTo(x, y);
        context.strokeStyle = color;
        context.fillStyle = color;
        context.arc(x, y, 5, 0, Math.PI * 2);
        context.fill();
        context.restore();

      }

      drawCircle(controlPoint.x, controlPoint.y, 'red')
      drawCircle(controlPointAbove.x, controlPointAbove.y, 'green')
      drawCircle(controlPointBellow.x, controlPointBellow.y, 'blue')
      drawCircle(leftAboveStart.x, leftAboveStart.y, 'black')
      drawCircle(leftBellowStart.x, leftBellowStart.y, 'black')



      const drawLines = (x1, y1, x2, y2, x3, y3, color) => {
        context.save();
        context.beginPath();
        context.strokeStyle = color || "#000";
        context.moveTo(x1, y1);
        context.lineTo(x2, y2);
        context.lineTo(x3, y3);
        context.stroke();
        context.restore();
      }
      // if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {
      //   // 高线起点---高线控制点的线---高线终点
      //   drawLines(leftAboveStart.x, leftAboveStart.y, controlPointAbove.x, controlPointAbove.y, rightBellowEnd.x, rightBellowEnd.y)
      //   drawLines(leftBellowStart.x, leftBellowStart.y, controlPointBellow.x, controlPointBellow.y, rightAboveEnd.x, rightAboveEnd.y)
      // } else {
      //   // 高线起点---高线控制点的线---高线终点
      //   drawLines(leftAboveStart.x, leftAboveStart.y, controlPointAbove.x, controlPointAbove.y, rightAboveEnd.x, rightAboveEnd.y)
      //   drawLines(leftBellowStart.x, leftBellowStart.y, controlPointBellow.x, controlPointBellow.y, rightBellowEnd.x, rightBellowEnd.y)
      // }
    }

    const line1 = run(
      { x: 321, y: 743 },
      { x: 345, y: 632 },
      { x: 324, y: 126 }
    )
    const line2 = run(
      { x: 621, y: 743 },
      { x: 587, y: 632 },
      { x: 612, y: 126 }
    )
    const line3 = run(
      { x: 1432, y: 200 },
      { x: 1342, y: 600 },
      { x: 800, y: 800 }
    )
    const line4 = run(
      { x: 900, y: 200 },
      { x: 800, y: 600 },
      { x: 700, y: 800 }
    )

  </script>



</body>

</html>

四、效果预览:

如下图, 一共有四条曲线, 每条曲线都被两条曲线包围, 并且两端封闭。我们只需要判断点击事件源坐标是否在封闭图形内部, 即可判定封闭路径内部的曲线是否被点中, 这样就可以实现在不加宽线宽的前提下,扩展点击范围的目的了。几点说明:

  1. 黑色的点是扩展曲线的起点;
  2. 红色点点是中间曲线的控制点;
  3. 蓝色的点和绿色的点是两条扩展曲线的控制点。

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值