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

konva · 浏览次数 : 0

小编点评

本文主要介绍了折线连接线的实现思路、原理以及一些缺陷的处理方法,并新增了自定义连接点的功能。 1. **实现思路**:本文首先实现了折线连接线的基本功能,包括连接点的定义、连接线的绘制等。同时,指出了已知的缺陷,如连接方向的不确定性、导入导出异常等问题。 2. **自定义连接点**:为了更好地满足实际需求,本文新增了自定义连接点的功能。用户可以为每个节点定义多个连接点,从而实现类似图元接线的效果。自定义连接点包括连接点的定义、属性等信息。 3. **连接点与出入口计算**:本文对原有的连接点计算方法进行了改进,使其能够更准确地计算出连接点的坐标。同时,针对不同角度范围的连接点,提供了相应的计算公式。 4. **连接线绘制**:本文在绘制连接线时,补充了连接点的信息,以便更准确地绘制出连接线。此外,还提供了连接线的删除功能。 5. **测试与优化**:本文通过测试用例验证了自定义连接点的功能正确性,并对原有算法进行了优化,以提高连接点的准确性和稳定性。 总之,本文通过对折线连接线的实现思路、原理及缺陷的处理方法的介绍,以及对自定义连接点的功能实现,使折线连接线更加灵活和实用。

正文

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

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

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

github源码

gitee源码

示例地址

一些调整

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

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

自定义连接点

先说明一下定义:

// src/Render/types.ts

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

export interface AssetInfo {
  url: string
  points?: Array<AssetInfoPoint>
}
// 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,还有方向的定义,目的是说明该连接点出入口方向,例如:

image

做这个定义的原因是,连接方向不可以预知,是与图元的含义有关。
不设定 direction 的话,就代表连接线可以从上下左右4个方向进出,如:

image

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

// 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 信息:

// 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 信息:

// 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 个默认连接点。

出入口修改

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

image

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

image

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

image

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

const offset = gap * Math.atan(((90 - rotate) * Math.PI) / 180)

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

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

  // 连接出入口(原来第二个参数是 最小区域,先改为 不可通过区域)
  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坐标与连接点不再总是存在同一方向一致(因为被旋转),所以现在把算法的起点、终点改为出入口对应:

              // 出口、入口 -> 算法 起点、终点

              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 的连接点可以从不同方向出入,所以会进行下面处理:

              // 没有定义方向(给于十字可通过区域)
              // 如,从:
              // 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
                  }
                }
              }

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

            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
              })
            )

测试一下

image

已知缺陷

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

More Stars please!勾勾手指~

源码

gitee源码

示例地址

与前端使用 Konva 实现可视化设计器(15)- 自定义连接点、连接优化相似的内容:

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

本章将处理一些缺陷的同时,实现支持连接点的自定义,一个节点可以定义多个连接点,最终可以满足类似图元接线的效果。

前端使用 Konva 实现可视化设计器(18)- 素材嵌套 - 加载阶段

本章主要实现素材的嵌套(加载阶段)这意味着可以拖入画布的对象,不只是图片素材,还可以是嵌套的图片和图形。

前端使用 Konva 实现可视化设计器(16)- 旋转对齐、触摸板操作的优化

这一章解决两个缺陷,一是调整一些快捷键,使得 Mac 触摸板可以正常操作;二是修复一个 Issue,使得即使素材节点即使被旋转之后,也能正常触发磁贴对齐效果,有个小坑需要注意。

前端使用 Konva 实现可视化设计器(14)- 折线 - 最优路径应用【代码篇】

话接上回[《前端使用 Konva 实现可视化设计器(13)- 折线 - 最优路径应用【思路篇】》](https://www.cnblogs.com/xachary/p/18238704),这一章继续说说相关的代码如何构思的,如何一步步构建数据模型可供 AStar 算法进行路径规划,最终画出节点之间的...

前端使用 Konva 实现可视化设计器(13)- 折线 - 最优路径应用【思路篇】

这一章把直线连接改为折线连接,沿用原来连接点的关系信息。关于折线的计算,使用的是开源的 AStar 算法进行路径规划,启发方式为 曼哈顿距离,且不允许对角线移动。 请大家动动小手,给我一个免费的 Star 吧~ 大家如果发现了 Bug,欢迎来提 Issue 哟~ github源码 gitee源码 示

前端使用 Konva 实现可视化设计器(12)- 连接线 - 直线

这一章实现的连接线,目前仅支持直线连接,为了能够不影响原有的其它功能,尝试了2、3个实现思路,最终实测这个实现方式目前来说最为合适了。 请大家动动小手,给我一个免费的 Star 吧~ 大家如果发现了 Bug,欢迎来提 Issue 哟~ github源码 gitee源码 示例地址 相关定义 连接点 记

前端使用 Konva 实现可视化设计器(11)- 对齐效果

这一章补充一个效果,在多选的情况下,对目标进行对齐。基于多选整体区域对齐的基础上,还支持基于其中一个节点进行对齐。

前端使用 Konva 实现可视化设计器(10)- 对齐线

前端使用 Konva 实现可视化设计器,这次实现对齐线的交互功能,单个、多个、多选都可以对齐,同时还能磁贴。

前端使用 Konva 实现可视化设计器(9)- 另存为SVG

请大家动动小手,给我一个免费的 Star 吧~ 大家如果发现了 Bug,欢迎来提 Issue 哟~ github源码 gitee源码 示例地址 另存为SVG 这一章增强了另存为的能力,实现“另存为SVG”,大概是全网唯一的实例分享了吧。 灵感来源:react-konva-custom-context

前端使用 Konva 实现可视化设计器(8)- 预览框

请大家动动小手,给我一个免费的 Star 吧~ 大家如果发现了明显的 Bug,可以提 Issue 哟~ 这一章我们实现一个预览框,实时、可交互定位的。 github源码 gitee源码 示例地址 预览框 定位方法 移动画布,将传入 x,y 作为画布中心: // 更新中心位置 updateCenter