前端使用 Konva 实现可视化设计器(15)- 自定义连接点、连接优化

前面,本示例实现了折线连接线,简述了实现的思路和原理,也已知了一些缺陷。本章将处理一些缺陷的同时,实现支持连接点的自定义,一个节点可以定义多个连接点,最终可以满足类似图元接线的效果。

请大家动动小手,给我一个免费的 Star 吧~

大家如果发现了 Bug,欢迎来提 Issue 哟~

github源码

gitee源码

示例地址

一些调整

  • 把示例素材从 src 转移至 public 目录,拖入画布的素材改为异步加载
  • 移除部分示例素材
  • 一些开发过程中的测试用例可以在线加载

此前有些朋友说导入、导出有异常,估计是线上版本和线下版本的构建示例素材的文件 hash 后缀不一样,跨环境导入、导出无法加载图片导致的。现在调整后就应该正常了。

自定义连接点

先说明一下定义:

ts 复制代码
// src/Render/types.ts

export interface AssetInfoPoint {
  x: number
  y: number
  direction?: 'top' | 'bottom' | 'left' | 'right' // 人为定义连接点属于元素的什么方向
}

export interface AssetInfo {
  url: string
  points?: Array<AssetInfoPoint>
}
ts 复制代码
// src/Render/draws/LinkDraw.ts

// 连接点
export interface LinkDrawPoint {
  id: string
  groupId: string
  visible: boolean
  pairs: LinkDrawPair[]
  x: number
  y: number
  direction?: 'top' | 'bottom' | 'left' | 'right' // 人为定义连接点属于元素的什么方向
}

一个素材除了原来的 url 信息外,增加了一个 points 的连接点数组,每个 point 除了记录了它的相对于素材的位置 x、y,还有方向的定义,目的是说明该连接点出入口方向,例如:

做这个定义的原因是,连接方向不可以预知,是与图元的含义有关。

不设定 direction 的话,就代表连接线可以从上下左右4个方向进出,如:

最佳实践应该另外实现一个连接点定义工具(也许后面有机会实现一个),多多支持~

ts 复制代码
// src/App.vue

// 从 public 加载静态资源 + 自定义连接点
const assetsModules: Array<Types.AssetInfo> = [
  { "url": "./img/svg/ARRESTER_1.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/ARRESTER_2.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/ARRESTER_2_1.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/BREAKER_CLOSE.svg", points: [{ x: 100, y: 1, direction: 'top' }, { x: 100, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/BREAKER_OPEN.svg", points: [{ x: 100, y: 1, direction: 'top' }, { x: 100, y: 199, direction: 'bottom' }] },
  // 略
 ]

素材拖入之前,需要携带 points 信息:

ts 复制代码
// src/App.vue

function onDragstart(e: GlobalEventHandlersEventMap['dragstart'], item: Types.AssetInfo) {
  if (e.dataTransfer) {
    e.dataTransfer.setData('src', item.url)
    e.dataTransfer.setData('points', JSON.stringify(item.points)) // 传递连接点信息
    e.dataTransfer.setData('type', item.url.match(/([^./]+)\.([^./]+)$/)?.[2] ?? '')
  }
}

拖入之后,需要解析 points 信息:

ts 复制代码
// src/Render/handlers/DragOutsideHandlers.ts

      drop: (e: GlobalEventHandlersEventMap['drop']) => {
        const src = e.dataTransfer?.getData('src')

        // 接收连接点信息
        let morePoints: Types.AssetInfoPoint[] = []
        const morePointsTxt = e.dataTransfer?.getData('points') ?? '[]'

        try {
          morePoints = JSON.parse(morePointsTxt)
        } catch (e) {
          console.error(e)
        }

        // 略

              // 默认连接点
              let points: Types.AssetInfoPoint[] = [
                // 左
                { x: 0, y: group.height() / 2, direction: 'left' },
                // 右
                {
                  x: group.width(),
                  y: group.height() / 2,
                  direction: 'right'
                },
                // 上
                { x: group.width() / 2, y: 0, direction: 'top' },
                // 下
                {
                  x: group.width() / 2,
                  y: group.height(),
                  direction: 'bottom'
                }
              ]

              // 自定义连接点 覆盖 默认连接点
              if (Array.isArray(morePoints) && morePoints.length > 0) {
                points = morePoints
              }

              // 连接点信息
              group.setAttrs({
                points: points.map(
                  (o) =>
                    ({
                      ...o,
                      id: nanoid(),
                      groupId: group.id(),
                      visible: false,
                      pairs: [],
                      direction: o.direction // 补充信息
                    }) as LinkDrawPoint
                )
              })

              // 连接点(锚点)
              for (const point of group.getAttr('points') ?? []) {
                group.add(
                  new Konva.Circle({
                    name: 'link-anchor',
                    id: point.id,
                    x: point.x,
                    y: point.y,
                    radius: this.render.toStageValue(1),
                    stroke: 'rgba(0,0,255,1)',
                    strokeWidth: this.render.toStageValue(2),
                    visible: false,
                    direction: point.direction // 补充信息
                  })
                )
              }
              
              // 略
      }
        

如果没有自定义连接点,这里会给予之前一样的 4 个默认连接点。

出入口修改

原来的逻辑就不能用了,需要重写一个。目标是计算出:沿着当前连接点的方向 与 不可通过区域其中一边的相交点,上图:

关注的就是这个绿色点(出入口):

就算这个点,用的是三角函数:

这里边长称为 offset,角度为 rotate,计算大概如下:

ts 复制代码
const offset = gap * Math.atan(((90 - rotate) * Math.PI) / 180)

不同角度范围,计算略有不同,是根据多次测试得出的,有兴趣的朋友可以在优化精简一下。

完整方法有点长,四个角直接赋值,其余按不同角度范围计算:

ts 复制代码
  // 连接出入口(原来第二个参数是 最小区域,先改为 不可通过区域)
  getEntry(anchor: Konva.Node, groupForbiddenArea: Area, gap: number): Konva.Vector2d {
    // stage 状态
    const stageState = this.render.getStageState()

    const fromPos = anchor.absolutePosition()

    // 默认为 起点/终点 位置(无 direction 时的值)
    let x = fromPos.x - stageState.x,
      y = fromPos.y - stageState.y

    const direction = anchor.attrs.direction

    // 定义了 direction 的时候
    if (direction) {
      // 取整 连接点 锚点 旋转角度(保留 1 位小数点)
      const rotate = Math.round(anchor.getAbsoluteRotation() * 10) / 10

      // 利用三角函数,计算按 direction 方向与 不可通过区域 的相交点位置(即出/入口 entry)
      if (rotate === -45) {
        if (direction === 'top') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        } else if (direction === 'left') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        } else if (direction === 'right') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        }
      } else if (rotate === 45) {
        if (direction === 'top') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        } else if (direction === 'left') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        } else if (direction === 'right') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        }
      } else if (rotate === 135) {
        if (direction === 'top') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        } else if (direction === 'left') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        } else if (direction === 'right') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        }
      } else if (rotate === -135) {
        if (direction === 'top') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        } else if (direction === 'left') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        } else if (direction === 'right') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        }
      } else if (rotate > -45 && rotate < 45) {
        const offset = gap * Math.tan((rotate * Math.PI) / 180)
        if (direction === 'top') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y1
        } else if (direction === 'bottom') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y2
        } else if (direction === 'left') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y - offset
        } else if (direction === 'right') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y + offset
        }
      } else if (rotate > 45 && rotate < 135) {
        const offset = gap * Math.atan(((90 - rotate) * Math.PI) / 180)
        if (direction === 'top') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y - offset
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y + offset
        } else if (direction === 'left') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y1
        } else if (direction === 'right') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y2
        }
      } else if ((rotate > 135 && rotate <= 180) || (rotate >= -180 && rotate < -135)) {
        const offset = gap * Math.tan((rotate * Math.PI) / 180)
        if (direction === 'top') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y2
        } else if (direction === 'bottom') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y1
        } else if (direction === 'left') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y + offset
        } else if (direction === 'right') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y - offset
        }
      } else if (rotate > -135 && rotate < -45) {
        const offset = gap * Math.atan(((90 + rotate) * Math.PI) / 180)
        if (direction === 'top') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y - offset
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y + offset
        } else if (direction === 'left') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y2
        } else if (direction === 'right') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y1
        }
      }
    }

    return { x, y } as Konva.Vector2d
  }

原来的算法起点、终点 与 连接点一一对应,科室现在新的计算方法得出的出入口x、y坐标与连接点不再总是存在同一方向一致(因为被旋转),所以现在把算法的起点、终点改为出入口对应:

ts 复制代码
              // 出口、入口 -> 算法 起点、终点

              if (columns[x] === fromEntry.x && rows[y] === fromEntry.y) {
                matrix[y][x] = 1
                matrixStart = { x, y }
              }

              if (columns[x] === toEntry.x && rows[y] === toEntry.y) {
                matrix[y][x] = 1
                matrixEnd = { x, y }
              }

上面提到没有定义 direction 的连接点可以从不同方向出入,所以会进行下面处理:

ts 复制代码
              // 没有定义方向(给于十字可通过区域)
              // 如,从:
              // 1 1 1
              // 1 0 1
              // 1 1 1
              // 变成:
              // 1 0 1
              // 0 0 0
              // 1 0 1
              if (!fromAnchor.attrs.direction) {
                if (columns[x] === fromEntry.x || rows[y] === fromEntry.y) {
                  if (
                    x >= columnFromStart &&
                    x <= columnFromEnd &&
                    y >= rowFromStart &&
                    y <= rowFromEnd
                  ) {
                    matrix[y][x] = 1
                  }
                }
              }
              if (!toAnchor.attrs.direction) {
                if (columns[x] === toEntry.x || rows[y] === toEntry.y) {
                  if (x >= columnToStart && x <= columnToEnd && y >= rowToStart && y <= rowToEnd) {
                    matrix[y][x] = 1
                  }
                }
              }

最后在绘制连线的时候,补上连接点(起点、终点)即可:

ts 复制代码
            this.group.add(
              new Konva.Line({
                name: 'link-line',
                // 用于删除连接线
                groupId: fromGroup.id(),
                pointId: fromPoint.id,
                pairId: pair.id,
                //
                points: _.flatten([
                  [
                    this.render.toStageValue(fromAnchorPos.x),
                    this.render.toStageValue(fromAnchorPos.y)
                  ], // 补充 起点
                  ...way.map((o) => [
                    this.render.toStageValue(columns[o.x]),
                    this.render.toStageValue(rows[o.y])
                  ]),
                  [this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)] // 补充 终点
                ]),
                stroke: 'red',
                strokeWidth: 2
              })
            )

测试一下

已知缺陷

从 Issue 中得知,当节点进行说 transform rotate 旋转的时候,对齐就会出问题。大家多多支持,后面抽空研究处理一下(-_-)。。。

More Stars please!勾勾手指~

源码

gitee源码

示例地址

相关推荐
加班是不可能的,除非双倍日工资3 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi4 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip4 小时前
vite和webpack打包结构控制
前端·javascript
excel4 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国5 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼5 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy5 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT5 小时前
promise & async await总结
前端
Jerry说前后端5 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天5 小时前
A12预装app
linux·服务器·前端