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


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

    请大家动动小手,给我一个免费的 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源码

    示例地址

  • 相关阅读:
    ArcGIS Map Sdk for unity使用
    C语言青蛙爬井(ZZULIOJ1072:青蛙爬井)
    Web学习笔记-React(Redux)
    W5300-TOE Arduino 网络服务器
    【HTTP协议】HTTP协议初体验,深入浅出网络协议
    【细读经典】springBoot源码(一)创建SpringApplication
    OpenCV 4基础篇| OpenCV图像的拆分和合并
    什么是TCP粘包?如何避免TCP粘包?
    【.NET全栈】ASP.NET开发Web应用——站点导航技术
    老照片怎么修复清晰?轻松几步让图片焕发新生
  • 原文地址:https://www.cnblogs.com/xachary/p/18248511