如何让思维导图关联线的端点跟随鼠标移动

600 阅读11分钟

在流程图或思维导图中通常都会使用三次贝塞尔曲线来连接两个节点,为了体验更好,一般都支持拖拽贝塞尔曲线的控制点来调整曲线,更进一步,曲线在节点上的连接点也会随着控制点的位置而改变,就像下面这样:

a1.gif

那么这种效果是如何实现的呢,本文我们就来一探究竟。

准备工作

为了减少绘图工作量,我们选择Konva这个库来绘制图形,它是一个Canvas 2d图形库。

首先初始化一下图层:

<div class="container" ref="container"></div>
import { onMounted, ref } from 'vue'
import Konva from 'konva'

const container = ref(null)
let layer = null

// 初始化
const init = () => {
  const { width, height } = container.value.getBoundingClientRect()
  // 创建舞台
  const stage = new Konva.Stage({
    container: container.value,
    width,
    height
  })
  // 创建图层
  layer = new Konva.Layer()
  // 图层添加到舞台
  stage.add(layer)
}

后面所有图形都会添加到图层layer上。

我们需要绘制如下这些图形:

let rect1, // 矩形1
  rect2, // 矩形2
  line, // 连接两个矩形的贝塞尔曲线
  point1, // 矩形1的控制点
  point2, // 矩形2的控制点
  line1, // 控制点1到矩形1的连线
  line1StartPoint, // line1在矩形1上的连接点坐标
  line2, // 控制点2到矩形2的连线
  line2StartPoint // line2在矩形2上的连接点坐标
// 创建矩形
const renderRects = () => {
  // 创建两个矩形
  rect1 = new Konva.Rect({
    x: 400,
    y: 200,
    width: 100,
    height: 100,
    fill: '#fbfbfb',
    stroke: '#f56c6c',
    strokeWidth: 4
  })
  rect2 = new Konva.Rect({
    x: 800,
    y: 600,
    width: 100,
    height: 100,
    fill: '#fbfbfb',
    stroke: '#409eff',
    strokeWidth: 4
  })
  // 矩形添加到图层
  layer.add(rect1)
  layer.add(rect2)
}

随便定义两个矩形添加到画布。我们可以通过矩形对象的x()y()width()height()方法来获取其在画布里实时的坐标和宽高。

接下来创建两个矩形的连接线,即三次贝塞尔曲线,我们假设起始点为矩形1的右侧矩形2的左侧,那么初始的连接点为:

line1StartPoint = {
    x: rect1.x() + rect1.width(),
    y: rect1.y() + rect1.height() / 2
}
line2StartPoint = {
    x: rect2.x(),
    y: rect2.y() + rect1.height() / 2
}

三次贝塞尔曲线有两个控制点,初始的位置你可以根据你的喜好来计算,这里直接让它们的 x坐标为两个矩形距离的中点,y坐标和各自矩形的垂直中心点一致,那么可以写出如下的方法:

// 计算贝塞尔曲线的控制点
const computeCubicBezierPathPoints = (x1, y1, x2, y2) => {
    const cx = x1 + (x2 - x1) / 2
    reutrn [
        {
            x: cx,
            y: y1
        },
        {
            x: cx,
            y: y2
        }
    ]
}

当然,这个计算非常粗糙,很多特殊情况都没有考虑。

然后我们就可以创建一个曲线:

const createLine = () => {
    // ...
    // 根据起始点计算控制点
    const controlPoints = computeCubicBezierPathPoints(
        line1StartPoint.x,
        line1StartPoint.y,
        line2StartPoint.x,
        line2StartPoint.y
    )
    // 根据控制点拼接贝塞尔曲线路径
    const path = joinCubicBezierPath(
        line1StartPoint,
        line2StartPoint,
        controlPoints[0],
        controlPoints[1]
    )
    line = new Konva.Path({
        data: path,
        stroke: '#e6a23c',
        strokeWidth: 2
    })
    layer.add(line)
}

拼接路径的方法如下:

// 拼接三次贝塞尔曲线路径
const joinCubicBezierPath = (startPoint, endPoint, point1, point2) => {
  return `M ${startPoint.x},${startPoint.y} C ${point1.x},${point1.y} ${point2.x},${point2.y} ${endPoint.x},${endPoint.y}`
}

接下来创建两个圆作为控制点:

const createLine = () => {
    // ...
    // 控制点
    point1 = new Konva.Circle({
        x: controlPoints[0].x,
        y: controlPoints[0].y,
        radius: 10,
        fill: '#f56c6c',
        draggable: true
    })
    layer.add(point1)
    
    point2 = new Konva.Circle({
        x: controlPoints[1].x,
        y: controlPoints[1].y,
        radius: 10,
        fill: '#409eff',
        draggable: true
    })
    layer.add(point2)
}

我们设置了draggable:true让其可拖动。

最后,通过线段连接控制点和矩形,也就是贝塞尔曲线的起始点:

const createLine = () => {
    // ...
    // 控制点和起始点的连线
    line1 = new Konva.Line({
        points: [controlPoints[0].x, controlPoints[0].y, line1StartPoint.x, line1StartPoint.y],
        stroke: '#f56c6c',
        strokeWidth: 2
    })
    layer.add(line1)
    line2 = new Konva.Line({
        points: [controlPoints[1].x, controlPoints[1].y, line2StartPoint.x, line2StartPoint.y],
        stroke: '#409eff',
        strokeWidth: 2
    })
    layer.add(line2)
}

到这里,所有图形就创建完成了,看一下现在的效果:

image-20240722113229360.png

拖动控制点更新连线

接下来我们实现一下拖动控制点更新连线,要监听图形的拖动可以通过dragmove事件,然后获取控制点当前的位置,更新其和起始点的连线,及贝塞尔曲线:

const bindEvent = () => {
    // 控制点1的拖动事件
    point1.on('dragmove', () => {
        line1.points([point1.x(), point1.y(), line1StartPoint.x, line1StartPoint.y])
    	updateCubicBezierPath()
    }
}

目前贝塞尔的起始点还不会变化:

// 更新贝塞尔曲线
const updateCubicBezierPath = () => {
  const path = joinCubicBezierPath(
    line1StartPoint,
    line2StartPoint,
    {
      x: point1.x(),
      y: point1.y()
    },
    {
      x: point2.x(),
      y: point2.y()
    }
  )
  line.data(path)
}

控制点2的逻辑和控制1完全一致,这里就不重复了。

效果如下:

a2.gif

计算新的连接点

怎么根据控制点的位置来计算连接点的位置呢,核心就是通过角度,控制点和图形中心点会形成一条直线,我们可以通过Math.atan2()函数计算出这条直线和x轴的倾斜角,针对矩形,它有四个顶点,我们同样可以计算出这四个顶点到中心点连线的倾斜角,然后可以将角度的比值转换成长度的比值,最后换算成坐标,具体计算步骤如下:

1.计算出矩形四个顶点和中心点连线的倾斜角列表;

2.计算出控制点到中心点连线的倾斜角;

3.找出角度所在边的方向:上、右、下、左;

4.计算出控制点到中心点连线的倾斜角在该方向上倾斜角范围内所占的比例;

5.根据第4步计算出来的比例转换为边长比例,然后根据方向更新连接点的x或y坐标;

image-20240722144024488.png

如图所示,假设当前控制点所在方向为右侧,右侧总的角度范围和范围比值都是可以计算出来的,那么只要转换成矩形的高度的比值,在右上角顶点坐标的基础上累加,就可以计算出连接点的坐标,其他方向同理。

接下来我们通过代码来实现。

第一步:计算出矩形四个顶点和中心点连线的倾斜角

函数如下:

// 获取一个矩形各个顶点到中心点连线的倾斜角列表
// 从左上角开始
const getRectAtan2EdgeList = rect => {
  // 中心点
  const center = getRectCenterPoint(rect)
  // 四个顶点坐标
  const pointList = [
    [rect.x(), rect.y()],
    [rect.x() + rect.width(), rect.y()],
    [rect.x() + rect.width(), rect.y() + rect.height()],
    [rect.x(), rect.y() + rect.height()]
  ]
  return pointList.map(item => {
    return radToDeg(Math.atan2(item[1] - center.y, item[0] - center.x))
  })
}

// 获取一个矩形的中心点
const getRectCenterPoint = rect => {
  return {
    x: rect.x() + rect.width() / 2,
    y: rect.y() + rect.height() / 2
  }
}

// 弧度转角度
const radToDeg = r => {
  return (r * 180) / Math.PI
}

很简单,列出矩形四个顶点的坐标,依次和中心点坐标相减,然后调用Math.atan2方法计算角度。

因为atan2函数计算出来的是弧度值,为了方便观察我们转换成角度值返回。

第二步:计算出控制点到中心点连线的倾斜角

const bindEvent = () => {
    const rect1Atan2EdgeList = getRectAtan2EdgeList(rect1)

    point1.on('dragmove', () => {
        const rect1CenterPoint = getRectCenterPoint(rect1)
        const deg = radToDeg(
            Math.atan2(
                point1.y() - rect1CenterPoint.y,
                point1.x() - rect1CenterPoint.x
            )
        )
    })
}

同样转成角度值。

第三步:找出角度所在边的方向:上、右、下、左

atan2函数计算出来的是和x轴方向的夹角,向右为正方向,逆时针为负,顺时针为正,那么显然角度范围如下:

image-20240722151828478.png

所以要计算控制点在哪个方向,只要简单遍历一下进行比较就行了:

const bindEvent = () => {
    const rect1Atan2EdgeList = getRectAtan2EdgeList(rect1)
    
    point1.on('dragmove', () => {
        const rect1CenterPoint = getRectCenterPoint(rect1)
        const deg = radToDeg(
          Math.atan2(
            point1.y() - rect1CenterPoint.y,
            point1.x() - rect1CenterPoint.x
          )
        )
        const dir = getDegDir(deg, rect1Atan2EdgeList)// ++
    })
}

// 获取角度所在矩形的方向
const getDegDir = (deg, degList) => {
  if (deg >= degList[0] && deg < degList[1]) {
    return 'top'
  } else if (deg >= degList[1] && deg < degList[2]) {
    return 'right'
  } else if (deg >= degList[2] && deg < degList[3]) {
    return 'bottom'
  } else {
    return 'left'
  }
}

第四步:计算出控制点到中心点连线的倾斜角在该方向上倾斜角范围内所占的比例

这个也很简单,遍历一下角度列表,计算出各个方向的角度范围,然后计算比例:

// 获取角度在所在方向角度范围内的比例,相当于起始角度
const getDegRatio = (deg, dir, degList) => {
  switch (dir) {
    case 'top':
      return (deg - degList[0]) / (degList[1] - degList[0])
    case 'right':
      return (deg - degList[1]) / (degList[2] - degList[1])
    case 'bottom':
      return (deg - degList[2]) / (degList[3] - degList[2])
    case 'left':
      // 因为该范围包含起点终点,所以要判断一下
      const range = 180 - degList[3] + (180 + degList[0])
      const offset = deg > 0 ? deg - degList[3] : 180 + deg + 180 - degList[3]
      return offset / range
  }
}

唯一需要特殊考虑的就是最后一段横跨起终点的范围:

image-20240722162338532.png

第五步:根据第4步计算出来的比例转换为边长比例,然后根据方向更新连接点的x或y坐标

现在各个方向的比例也计算出来了,最后只要转换为坐标就可以了:

// 根据方向和比例计算新的坐标
const getPointBy = (rect, dir, ratio) => {
  switch (dir) {
    case 'top':
      return {
        x: rect.x() + rect.width() * ratio,
        y: rect.y()
      }
    case 'right':
      return {
        x: rect.x() + rect.width(),
        y: rect.y() + rect.height() * ratio
      }
    case 'bottom':
      return {
        x: rect.x() + rect.width() * (1 - ratio),
        y: rect.y() + rect.height()
      }
    case 'left':
      return {
        x: rect.x(),
        y: rect.y() + rect.height() * (1 - ratio)
      }
  }
}

函数都有了,只要在拖拽事件中调用并更新连接点即可:

const bindEvent = () => {
    // 顶点的倾斜角列表
    const rect1Atan2EdgeList = getRectAtan2EdgeList(rect1)
    
    point1.on('dragmove', () => {
        // 中心点
        const rect1CenterPoint = getRectCenterPoint(rect1)
        // 控制点的倾斜角
        const deg = radToDeg(
          Math.atan2(
            point1.y() - rect1CenterPoint.y,
            point1.x() - rect1CenterPoint.x
          )
        )
        // 所在方向
        const dir = getDegDir(deg, rect1Atan2EdgeList)
        // 所在方向的比例
        const ratio = getDegRatio(deg, dir, rect1Atan2EdgeList)
        // 计算连接点坐标
        line1StartPoint = getPointBy(rect1, dir, ratio)
        // 更新连线
        line1.points([point1.x(), point1.y(), line1StartPoint.x, line1StartPoint.y])
        updateCubicBezierPath()
    })
}

不出意外,现在连接点已经会跟着控制点动起来了:

a3.gif

奇形怪状

可能你发现了开头的动图里图形并不是矩形,两侧是圆弧,按照我们前面的做法是做不到让连接点贴着圆弧的,毕竟一个奇形怪状的图形你都无法区分出上下左右,那么扩展到任意图形就更不可能了,那么怎么才能做到连接点沿着任何不规则图形的边缘走呢,这就需要使用路径了。

对路径不熟悉的朋友可以先了解一下:path

首先我们绘制图形都改为通过路径,然后通常我们是可以获取到路径的总长度的,同时还能获取路径指定长度位置的坐标点,那么只要将角度的比例作为成路径长度的比例,再获取到该长度所在的坐标就是连接点的坐标。

接下来将前面的矩形2改为路径的方式。

先来绘制一个心型的路径:

const renderRects = () => {
    // ...
    rect2 = new Konva.Path({
        x: 800,
        y: 600,
        data: 'M60,30 a30,30 0 0,1 0,60 L0,90 0,30 a30,30 0 0,1 60,0',
        stroke: '#409eff',
        strokeWidth: 4
    })
}

路径元素调用它的width()、height()方法获取到的宽高为0,所以贝塞尔曲线的终点我们改为路径长度为0的地方的坐标:

// 路径长度为0处的坐标
const p = rect2.getPointAtLength(0)
// 因为getPointAtLength方法获取到的坐标是相对于路径而不是画布,所以要加上rect2本身在画布上的坐标
line2StartPoint = {
    x: p.x + rect2.x(),
    y: p.y + rect2.y()
}

效果如下:

image-20240723110843312.png

前面说了无法直接获取路径元素的宽高,那么中心点的计算方式也需要修改一下,先获取到路径的包围框数据:

// 获取路径的包围框数据
const boundingRect = rect2.getSelfRect()
// 路径的中心点,坐标同样是相对于路径本身,所以需要加上rect2自身的坐标
let rect2CenterPoint = {
    x: boundingRect.x + boundingRect.width / 2 + rect2.x(),
    y: boundingRect.y + boundingRect.height / 2 + rect2.y()
}

这样在dragmove事件中直接使用即可:

point2.on('dragmove', () => {
    // 控制点的倾斜角
    let deg = radToDeg(
        Math.atan2(
            point2.y() - rect2CenterPoint.y,
            point2.x() - rect2CenterPoint.x
        )
    )
}

为了方便观察,同时绘制了两个辅助图形:

image-20240723111559229.png

接下来关键的地方来了,计算角度比例,不同于前面矩形是计算在每条边上的比例,这里我们是计算整条路径长度的比例,所以总的角度范围为360度,然后控制点的角度是相对于起始控制点,所以要先计算并保存一下初始控制点的角度:

let line2ControlPointStartDeg = radToDeg(
    Math.atan2(line2StartPoint.y - rect2CenterPoint.y, line2StartPoint.x - rect2CenterPoint.x)
)

目前的角度范围是这样的:

image-20240722151828478.png

计算不是很方便,所以小于0的角度我们都给它加上360,转换成如下的范围:

image-20240723112253221.png

当然还是要考虑初始控制点角度比当前控制点角度大的问题:

point2.on('dragmove', () => {
    let deg = radToDeg(
        Math.atan2(
            point2.y() - rect2CenterPoint.y,
            point2.x() - rect2CenterPoint.x
        )
    )
    // 统一+360
    if (line2ControlPointStartDeg < 0) {
      line2ControlPointStartDeg += 360
    }
    if (deg < 0) {
      deg += 360
    }
    // 如果比例小于0,说明初始角度大于当前角度,那么加上一个周期,转为正数
    let ratio = (deg - line2ControlPointStartDeg) / 360
    if (ratio < 0) {
      ratio = 1 + ratio
    }
}

角度比例得到了,最后只要转换成路径长度比例,再获取到该长度所在的坐标就行了:

point2.on('dragmove', () => {
    // ...
    const length = rect2.getLength()
    const p = rect2.getPointAtLength(length * ratio)
    line2StartPoint = {
        x: p.x + rect2.x(),
        y: p.y + rect2.y()
    }
    // 更新连线
    line2.points([point2.x(), point2.y(), line2StartPoint.x, line2StartPoint.y])
    updateCubicBezierPath()
}

最终效果如下:

a4.gif

到这里就实现了任意图形的连接点跟随了,不过有个小问题,就是一开始拖动连接点发生了跳动,一个解决方法是,曲线的连接点根据初始的控制点位置再计算一下,有兴趣的可以自行尝试,也可以直接查看demo的源码,完整的源码你可以在这里找到:github.com/wanglin2/en…