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

本章响应小伙伴的反馈,除了算法自动画连接线(仍需优化完善),实现了可以手动绘制直线、折线连接线功能。

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

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

github源码

gitee源码

示例地址

模式切换

前置工作

连接线 模式种类

复制代码
// src/Render/types.ts
export enum LinkType {
  'auto' = 'auto',
  'straight' = 'straight', // 直线
  'manual' = 'manual' // 手动折线
}

连接线 模式状态

复制代码
// src/Render/draws/LinkDraw.ts
​
// 连接线(临时)
export interface LinkDrawState {
  // 略
  linkType: Types.LinkType // 连接线类型
  linkManualing: boolean // 是否 正在操作拐点
}

连接线 模式切换方法

复制代码
// src/Render/draws/LinkDraw.ts
​
  /**
   * 修改当前连接线类型
   * @param linkType Types.LinkType
   */
  changeLinkType(linkType: Types.LinkType) {
    this.state.linkType = linkType
    this.render.config?.on?.linkTypeChange?.(this.state.linkType)
  }

连接线 模式切换按钮

复制代码
<!-- src/App.vue -->
​
<button @click="onLinkTypeChange(Types.LinkType.auto)"
        :disabled="currentLinkType === Types.LinkType.auto">连接线:自动</button>
<button @click="onLinkTypeChange(Types.LinkType.straight)"
        :disabled="currentLinkType === Types.LinkType.straight">连接线:直线</button>
<button @click="onLinkTypeChange(Types.LinkType.manual)"
        :disabled="currentLinkType === Types.LinkType.manual">连接线:手动</button>

连接线 模式切换事件

复制代码
// src/App.vue
const currentLinkType = ref(Types.LinkType.auto)
​
function onLinkTypeChange(linkType: Types.LinkType) {
  (render?.draws[Draws.LinkDraw.name] as Draws.LinkDraw).changeLinkType(linkType)
}

当前 连接对(pair) 记录当前 连接线 模式

复制代码
// src/Render/draws/LinkDraw.ts
​
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略
  override draw() {
    // 略
  
    // 连接点
    for (const point of points) {
      // 略
    
      // 非 选择中
      if (group && !group.getAttr('selected')) {
        // 略
        const anchor = this.render.layer.findOne(`#${point.id}`)
​
        if (anchor) {
          // 略
          circle.on('mouseup', () => {
            if (this.state.linkingLine) {
              // 略
              
              // 不同连接点
              if (line.circle.id() !== circle.id()) {
                // 略
                if (toGroup) {
                  // 略
                  if (fromPoint) {
                    // 略
                    if (toPoint) {
                      if (Array.isArray(fromPoint.pairs)) {
                        fromPoint.pairs = [
                          ...fromPoint.pairs,
                          {
                            // 略
                            
                            linkType: this.state.linkType // 记录 连接线 类型
                          }
                        ]
                      }
                      // 略
                    }
                  }
                }
              }
              // 略
            }
          })
          // 略
        }
      }
    }
  }
}

直线

绘制直线相对简单,通过判断 连接对(pair)记录的 连接线 模式,从起点绘制一条 Line 到终点即可:

复制代码
// src/Render/draws/LinkDraw.ts
​
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略
  override draw() {
    // 略
  
    // 连接线
    for (const pair of pairs) {
        if (pair.linkType === Types.LinkType.manual) {
          // 略,手动折线
        } else if (pair.linkType === Types.LinkType.straight) {
          // 直线
​
          if (fromGroup && toGroup && fromPoint && toPoint) {
            const fromAnchor = fromGroup.findOne(`#${fromPoint.id}`)
            const toAnchor = toGroup.findOne(`#${toPoint.id}`)
​
            // 锚点信息
            const fromAnchorPos = this.getAnchorPos(fromAnchor)
            const toAnchorPos = this.getAnchorPos(toAnchor)
​
            const linkLine = new Konva.Line({
              name: 'link-line',
              // 用于删除连接线
              groupId: fromGroup.id(),
              pointId: fromPoint.id,
              pairId: pair.id,
              linkType: pair.linkType,
​
              points: _.flatten([
                [
                  this.render.toStageValue(fromAnchorPos.x),
                  this.render.toStageValue(fromAnchorPos.y)
                ],
                [this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)]
              ]),
              stroke: 'red',
              strokeWidth: 2
            })
​
            this.group.add(linkLine)
          }
        } else {
          // 略,原算法画连接线逻辑
        }
    }
  }
}

折线

绘制折线,先人为定义 3 种"点": 1、连接点,就是原来就有的。 2、拐点(待拐),蓝色的,从未拖动过的,一旦拖动,会新增拐点记录。 3、拐点(已拐),绿色的,已经拖动过的,依然可以拖动,但不会新增拐点记录。

请留意下方代码的注释,关键:

  • fromGroup 会记录 拐点 manualPoints。
  • 连接线 的绘制是从 起点 -> 拐点(们)-> 终点(linkPoints)。
  • 拐点正在拖动时,绘制临时的虚线 Line。
  • 分别处理 拐点(待拐)和 拐点(已拐)两种情况。

处理 拐点(待拐)和 拐点(已拐)主要区别是:

  • 处理 拐点(待拐),遍历 linkPoints 的时候,是成对遍历的。

  • 处理 拐点(已拐),遍历 linkPoints 的时候,是跳过 起点 和 终点 的。

  • 拖动 拐点(待拐),会新增拐点记录。

  • 拖动 拐点(已拐),不会新增拐点记录。

    // src/Render/draws/LinkDraw.ts

    export class LinkDraw extends Types.BaseDraw implements Types.Draw {
    // 略
    override draw() {
    // 略

    复制代码
      // 连接线
      for (const pair of pairs) {
          if (pair.linkType === Types.LinkType.manual) {
            // 手动折线


    if (fromGroup && toGroup && fromPoint && toPoint) {
    const fromAnchor = fromGroup.findOne(#${fromPoint.id})
    const toAnchor = toGroup.findOne(#${toPoint.id})

    // 锚点信息
    const fromAnchorPos = this.getAnchorPos(fromAnchor)
    const toAnchorPos = this.getAnchorPos(toAnchor)

    // 拐点(已拐)记录
    const manualPoints: Array<{ x: number; y: number }> = Array.isArray(
    fromGroup.getAttr('manualPoints')
    )
    ? fromGroup.getAttr('manualPoints')
    : []

    // 连接点 + 拐点
    const linkPoints = [
    [
    this.render.toStageValue(fromAnchorPos.x),
    this.render.toStageValue(fromAnchorPos.y)
    ],
    ...manualPoints.map((o) => [o.x, o.y]),
    [this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)]
    ]

    // 连接线
    const linkLine = new Konva.Line({
    name: 'link-line',
    // 用于删除连接线
    groupId: fromGroup.id(),
    pointId: fromPoint.id,
    pairId: pair.id,
    linkType: pair.linkType,

    points: .flatten(linkPoints),
    stroke: 'red',
    strokeWidth: 2
    })

    this.group.add(linkLine)

    // 正在拖动效果
    const manualingLine = new Konva.Line({
    stroke: '#ff0000',
    strokeWidth: 2,
    points: [],
    dash: [4, 4]
    })
    this.group.add(manualingLine)

    // 拐点

    // 拐点(待拐)
    for (let i = 0; i < linkPoints.length - 1; i++) {
    const circle = new Konva.Circle({
    id: nanoid(),
    pairId: pair.id,
    x: (linkPoints[i][0] + linkPoints[i + 1][0]) / 2,
    y: (linkPoints[i][1] + linkPoints[i + 1][1]) / 2,
    radius: this.render.toStageValue(this.render.bgSize / 2),
    stroke: 'rgba(0,0,255,0.1)',
    strokeWidth: this.render.toStageValue(1),
    name: 'link-manual-point',
    // opacity: 0,
    linkManualIndex: i // 当前拐点位置
    })

    // hover 效果
    circle.on('mouseenter', () => {
    circle.stroke('rgba(0,0,255,0.8)')
    document.body.style.cursor = 'pointer'
    })
    circle.on('mouseleave', () => {
    if (!circle.attrs.dragStart) {
    circle.stroke('rgba(0,0,255,0.1)')
    document.body.style.cursor = 'default'
    }
    })

    // 拐点操作
    circle.on('mousedown', () => {
    const pos = circle.getAbsolutePosition()

    // 记录操作开始状态
    circle.setAttrs({
    // 开始坐标
    dragStartX: pos.x,
    dragStartY: pos.y,
    // 正在操作
    dragStart: true
    })

    // 标记状态 - 正在操作拐点
    this.state.linkManualing = true
    })
    this.render.stage.on('mousemove', () => {
    if (circle.attrs.dragStart) {
    // 正在操作
    const pos = this.render.stage.getPointerPosition()
    if (pos) {
    // 磁贴
    const { pos: transformerPos } = this.render.attractTool.attract({
    x: pos.x,
    y: pos.y,
    width: 1,
    height: 1
    })

    // 移动拐点
    circle.setAbsolutePosition(transformerPos)

    // 正在拖动效果
    const tempPoints = [...linkPoints]
    tempPoints.splice(circle.attrs.linkManualIndex + 1, 0, [
    this.render.toStageValue(transformerPos.x - stageState.x),
    this.render.toStageValue(transformerPos.y - stageState.y)
    ])
    manualingLine.points(
    .flatten(tempPoints))
    }
    }
    })
    circle.on('mouseup', () => {
    const pos = circle.getAbsolutePosition()

    if (
    Math.abs(pos.x - circle.attrs.dragStartX) > this.option.size ||
    Math.abs(pos.y - circle.attrs.dragStartY) > this.option.size
    ) {
    // 操作移动距离达到阈值

    // stage 状态
    const stageState = this.render.getStageState()

    // 记录(插入)拐点
    manualPoints.splice(circle.attrs.linkManualIndex, 0, {
    x: this.render.toStageValue(pos.x - stageState.x),
    y: this.render.toStageValue(pos.y - stageState.y)
    })
    fromGroup.setAttr('manualPoints', manualPoints)
    }

    // 操作结束
    circle.setAttrs({
    dragStart: false
    })

    // state 操作结束
    this.state.linkManualing = false

    // 销毁
    circle.destroy()
    manualingLine.destroy()

    // 更新历史
    this.render.updateHistory()

    // 重绘
    this.render.redraw()
    })

    this.group.add(circle)
    }

    // 拐点(已拐)
    for (let i = 1; i < linkPoints.length - 1; i++) {
    const circle = new Konva.Circle({
    id: nanoid(),
    pairId: pair.id,
    x: linkPoints[i][0],
    y: linkPoints[i][1],
    radius: this.render.toStageValue(this.render.bgSize / 2),
    stroke: 'rgba(0,100,0,0.1)',
    strokeWidth: this.render.toStageValue(1),
    name: 'link-manual-point',
    // opacity: 0,
    linkManualIndex: i // 当前拐点位置
    })

    // hover 效果
    circle.on('mouseenter', () => {
    circle.stroke('rgba(0,100,0,1)')
    document.body.style.cursor = 'pointer'
    })
    circle.on('mouseleave', () => {
    if (!circle.attrs.dragStart) {
    circle.stroke('rgba(0,100,0,0.1)')
    document.body.style.cursor = 'default'
    }
    })

    // 拐点操作
    circle.on('mousedown', () => {
    const pos = circle.getAbsolutePosition()

    // 记录操作开始状态
    circle.setAttrs({
    dragStartX: pos.x,
    dragStartY: pos.y,
    dragStart: true
    })

    // 标记状态 - 正在操作拐点
    this.state.linkManualing = true
    })
    this.render.stage.on('mousemove', () => {
    if (circle.attrs.dragStart) {
    // 正在操作
    const pos = this.render.stage.getPointerPosition()
    if (pos) {
    // 磁贴
    const { pos: transformerPos } = this.render.attractTool.attract({
    x: pos.x,
    y: pos.y,
    width: 1,
    height: 1
    })

    // 移动拐点
    circle.setAbsolutePosition(transformerPos)

    // 正在拖动效果
    const tempPoints = [...linkPoints]
    tempPoints[circle.attrs.linkManualIndex] = [
    this.render.toStageValue(transformerPos.x - stageState.x),
    this.render.toStageValue(transformerPos.y - stageState.y)
    ]
    manualingLine.points(_.flatten(tempPoints))
    }
    }
    })
    circle.on('mouseup', () => {
    const pos = circle.getAbsolutePosition()

    if (
    Math.abs(pos.x - circle.attrs.dragStartX) > this.option.size ||
    Math.abs(pos.y - circle.attrs.dragStartY) > this.option.size
    ) {
    // 操作移动距离达到阈值

    // stage 状态
    const stageState = this.render.getStageState()

    // 记录(更新)拐点
    manualPoints[circle.attrs.linkManualIndex - 1] = {
    x: this.render.toStageValue(pos.x - stageState.x),
    y: this.render.toStageValue(pos.y - stageState.y)
    }
    fromGroup.setAttr('manualPoints', manualPoints)
    }

    // 操作结束
    circle.setAttrs({
    dragStart: false
    })

    // state 操作结束
    this.state.linkManualing = false

    // 销毁
    circle.destroy()
    manualingLine.destroy()

    // 更新历史
    this.render.updateHistory()

    // 重绘
    this.render.redraw()
    })

    this.group.add(circle)
    }
    }
    } else if (pair.linkType === Types.LinkType.straight) {
    // 略,直线
    } else {
    // 略,原算法画连接线逻辑
    }
    }
    }
    }

最后,关于 linkManualing 状态,会用在 2 个地方,避免和其它交互产生冲突:

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

// 略

export class DragHandlers implements Types.Handler {
  // 略  
  handlers = {
    stage: {
      mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        // 拐点操作中,防止异常拖动
        if (!(this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state.linkManualing) {
          // 略
        }
      },
      // 略
    }
  }
}

// src/Render/tools/LinkTool.ts

// 略
export class LinkTool {
  // 略

  pointsVisible(visible: boolean, group?: Konva.Group) {
    // 略

    // 拐点操作中,此处不重绘
    if (!(this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state.linkManualing) {
      // 重绘
      this.render.redraw()
    }
  }
  // 略
}

Done!

More Stars please!勾勾手指~

源码

gitee源码

示例地址

相关推荐
kyriewen21 小时前
写组件文档写到吐?我用AI自动生成Storybook,同事以后直接抄
前端·javascript·面试
五点六六六1 天前
你敢信这是非Native页面写出来的渐变效果吗🌝(底层原理解析
前端·javascript·面试
吃西瓜的年年1 天前
TypeScript
javascript·ubuntu·typescript
熊猫_豆豆1 天前
一个模拟四轴飞行器在随机气流扰动下悬停飞行的交互式3D仿真网页,包含飞行器建模与PID控制算法
javascript·3d·html·四轴无人机模拟飞行
来恩10031 天前
jQuery选择器
前端·javascript·jquery
前端繁华如梦1 天前
树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南
前端·javascript
CDwenhuohuo1 天前
优惠券组件直接用 uview plus
前端·javascript·vue.js
川冰ICE1 天前
TypeScript装饰器与元编程实战
前端·javascript·typescript
AI砖家1 天前
Vue3组件传参大全,各种传参方式的对比
前端·javascript·vue.js
希望永不加班1 天前
var局部变量类型推断的利弊
java·服务器·前端·javascript·html