手把手带你利用vue3.x绘制流程图

广告:宝塔服务器面板,一键全能部署及管理,送你10850元礼包,点我领取~~~

手把手带你利用vue3.x绘制流程图

利用Vue3.x怎么绘制流程图?下面本篇文章给大家分享基于 vue3.x 的流程图绘制方法,希望对大家有所帮助!

GitHub-workflow

https://github.com/554246839/component-test/tree/dev/src/components/workflow

这里主要是针对于工作流场景的流程图绘制及实现方式。(学习视频分享:vuejs视频教程)

下面是效果图:

整体结构布局:

需要实现的功能列表:

节点与连接线的可配置节点的拖拽与渲染及连接线的绘制节点与连接线的选择节点的样式调整节点移动时的吸附撤销和恢复节点与连接线的可配置节点配置信息
[  {    'id': '', // 每次渲染会生成一个新的id    'name': 'start', // 节点名称,也就是类型    'label': '开始', // 左侧列表节点的名称    'displayName': '开始', // 渲染节点的显示名称(可修改)    'className': 'icon-circle start', // 节点在渲染时候的class,可用于自定义节点的样式    'attr': { // 节点的属性      'x': 0, // 节点相对于画布的 x 位置      'y': 0, // 节点相对于画布的 y 位置      'w': 70, // 节点的初始宽度      'h': 70  // 节点的初始高度    },    'next': [], // 节点出度的线    'props': [] // 节点可配置的业务属性  },  // ...]
登录后复制连接线配置信息
// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]
登录后复制节点的属性配置结构
// props[  {    // 表单的字段    name: 'displayName',    // 表单的标签    label: '显示名称',    // 字段的值    value: '旅客运输',    // 编辑的类型    type: 'input',    // 属性的必填字段    required: true,    // 表单组件的其它属性    props: {        placeholder: 'xxx'    }  },  // ...]
登录后复制

对于下拉选择的数据,如果下拉的数据非常多,那么配置保存的数据量也会很大,所以可以把所有的下拉数据统一管理,在获取左侧的配置节点的信息时,将所有的下拉数据提取出来,以 props 的 name 值为 key 保存起来,在用的时候用 props.name 来取对应的下拉数据。

另外还需要配置连接线的属性,相对于节点的属性,每一个节点的属性都有可能不一样,但是连接线在没有节点的时候是没有的,所以我们要先准备好连接线的属性,在连接线生成的时候,在加到连接线的属性里去。当然我们可以把连接线的属性设置为一样的,也可以根据节点的不同来设置不同连接线的属性。

最后使用的方式:

<template>  <workflow    ref="workflowRef"    @component-change="getActiveComponent"    @line-change="getActiveLine"    main-height="calc(100vh - 160px)">  </workflow></template><script setup>import { ref } from 'vue'import Workflow from '@/components/workflow'import { commonRequest } from '@/utils/common'import { ElMessage, ElMessageBox } from 'element-plus'import { useRoute } from 'vue-router'const route = useRoute()const processId = route.query.processId // || 'testca08c433c34046e4bb2a8d3ce3ebc'const processType = route.query.processType// 切换的当前节点const getActiveComponent = (component: Record<string, any>) => {  console.log('active component', component)}// 切换的当前连接线const getActiveLine = (line: Record<string, any>) => {  console.log('active line', line)}const workflowRef = ref<InstanceType<typeof Workflow>>()// 获取配置的节点列表const getConfig = () => {  commonRequest(`/workflow/getWorkflowConfig?processType=${processType}`).then((res: Record<string, any>) => {    // 需要把所有的属性根据name转换成 key - value 形式    const props: Record<string, any> = {}    transferOptions(res.result.nodes, props)    // 设置左侧配置的节点数据    workflowRef.value?.setConfig(res.result)    getData(props)  })}// 获取之前已经配置好的数据const getData = (props: Record<string, any>) => {  commonRequest(`/workflow/getWfProcess/${processId}`).then((res: Record<string, any>) => {    // 调整属性,这里是为了当配置列表的节点或者属性有更新,从而更新已配置的节点的属性    adjustProps(props, res.result.processJson)    // 设置已配置好的数据,并渲染    workflowRef.value?.setData(res.result.processJson, res.result.type || 'add')  })}const init = () => {  if (!processId) {    ElMessageBox.alert('当前没有流程id')    return  }  getConfig()}init()const transferOptions = (nodes: Record<string, any>[], props: Record<string, any>) => {  nodes?.forEach((node: Record<string, any>) => {    props[node.name] = node.props  })}const adjustProps = (props: Record<string, any>, nodes: Record<string, any>[]) => {  nodes.forEach((node: Record<string, any>) => {    const oldProp: Record<string, any>[] = node.props    const res = transferKV(oldProp)    node.props = JSON.parse(JSON.stringify(props[node.name]))    node.props.forEach((prop: Record<string, any>) => {      prop.value = res[prop.name]    })  })}const transferKV = (props: Record<string, any>[]) => {  const res: Record<string, any> = {}  props.forEach((prop: Record<string, any>) => {    res[prop.name] = prop.value  })  return res}</script>
登录后复制节点的拖拽与渲染及连接线的绘制

关于节点的拖拽就不多说了,就是 drag 相关的用法,主要是渲染区域的节点和连接线的设计。

这里的渲染区域的思路是:以 canvas 元素作为画布背景,节点是以 div 的方式渲染拖拽进去的节点,拖拽的位置将是以 canvas 的相对位置来移动,大概的结构如下:

<template>    <!-- 渲染区域的祖先元素 -->    <div>        <!-- canvas 画布,绝对于父级元素定位, inset: 0; -->        <canvas></canvas>        <!-- 节点列表渲染的父级元素,绝对于父级元素定位, inset: 0; -->        <div>            <!-- 节点1,绝对于父级元素定位 -->            <div></div>            <!-- 节点2,绝对于父级元素定位 -->            <div></div>            <!-- 节点3,绝对于父级元素定位 -->            <div></div>            <!-- 节点4,绝对于父级元素定位 -->            <div></div>        </div>    </div></template>
登录后复制

而连接线的绘制是根据 next 字段的信息,查找到 targetComponentId 组件的位置,然后在canvas上做两点间的 线条绘制。

链接的类型分为3种: 直线,折线,曲线

直线

直线的绘制最为简单,取两个点连接就行。

// 绘制直线const drawStraightLine = (  ctx: CanvasRenderingContext2D,   points: [number, number][],   highlight?: boolean) => {  ctx.beginPath()  ctx.moveTo(points[0][0], points[0][1])  ctx.lineTo(points[1][0], points[1][1])  // 是否是当前选中的连接线,当前连接线高亮  shadowLine(ctx, highlight)  ctx.stroke()  ctx.restore()  ctx.closePath()}
登录后复制

折线

折线的方式比较复杂,因为折线需要尽可能的不要把连接线和节点重合,所以它要判断每一种连接线的场景,还有两个节点的宽度和高度也需要考虑计算。如下:

起始节点有四个方向,目标节点也有四个方向,还有目标节点相对于起始节点有四个象限,所以严格来说,总共有 4 * 4 * 4 = 64 种场景。这些场景中的折线点也不一样,最多的有 4 次, 最少的折 0 次,单求出这 64 种坐标点就用了 700 行代码。

最后的绘制方法与直线一样:

// 绘制折线const drawBrokenLine = ({ ctx, points }: WF.DrawLineType, highlight?: boolean) => {  ctx.beginPath()  ctx.moveTo(points[0][0], points[0][1])  for (let i = 1; i < points.length; i++) {    ctx.lineTo(points[i][0], points[i][1])  }  shadowLine(ctx, highlight)  ctx.stroke()  ctx.restore()  ctx.closePath()}
登录后复制曲线

曲线相对于折线来说,思路会简单很多,不需要考虑折线这么多场景。

这里的折线是用三阶的贝塞尔曲线来绘制的,固定的取四个点,两个起止点,两个控制点,其中两个起止点是固定的,我们只需要求出两个控制点的坐标即可。这里代码不多,可以直接贴出来:

/** * Description: 计算三阶贝塞尔曲线的坐标 */import WF from '../type'const coeff = 0.5export default function calcBezierPoints({ startDire, startx, starty, destDire, destx, desty }: WF.CalcBezierType,  points: [number, number][]) {  const p = Math.max(Math.abs(destx - startx), Math.abs(desty - starty)) * coeff  switch (startDire) {    case 'down':      points.push([startx, starty + p])      break    case 'up':      points.push([startx, starty - p])      break    case 'left':      points.push([startx - p, starty])      break    case 'right':      points.push([startx + p, starty])      break    // no default  }  switch (destDire) {    case 'down':      points.push([destx, desty + p])      break    case 'up':      points.push([destx, desty - p])      break    case 'left':      points.push([destx - p, desty])      break    case 'right':      points.push([destx + p, desty])      break    // no default  }}
登录后复制

简单一点来说,第一个控制点是根据起始点来算的,第二个控制点是跟根据结束点来算的。算的方式是根据当前点相对于节点的方向,继续往前算一段距离,而这段距离是根据起止两个点的最大相对距离的一半(可能有点绕...)。

绘制方法:

// 绘制贝塞尔曲线const drawBezier = ({ ctx, points }: WF.DrawLineType, highlight?: boolean) => {  ctx.beginPath()  ctx.moveTo(points[0][0], points[0][1])  ctx.bezierCurveTo(    points[1][0], points[1][1], points[2][0], points[2][1], points[3][0], points[3][1]  )  shadowLine(ctx, highlight)  ctx.stroke()  ctx.restore()  ctx.globalCompositeOperation = 'source-over'    //目标图像上显示源图像}
登录后复制节点与连接线的选择

节点是用 div 来渲染的,所以节点的选择可以忽略,然后就是连接点的选择,首先第一点是鼠标在移动的时候都要判断鼠标的当前位置下面是否有连接线,所以这里就有 3 种判断方法,呃... 严格来说是两种,因为折线是多条直线,所以是按直线的判断方法来。

// 判断当前鼠标位置是否有线export const isAboveLine = (offsetX: number, offsetY: number, points: WF.LineInfo[]) => {  for (let i = points.length - 1; i >= 0; --i) {    const innerPonints = points[i].points    let pre: [number, number], cur: [number, number]    // 非曲线判断方法    if (points[i].type !== 'bezier') {      for (let j = 1; j < innerPonints.length; j++) {        pre = innerPonints[j - 1]        cur = innerPonints[j]        if (getDistance([offsetX, offsetY], pre, cur) < 20) {          return points[i]        }      }    } else {      // 先用 x 求出对应的 t,用 t 求相应位置的 y,再比较得出的 y 与 offsetY 之间的差值      const tsx = getBezierT(innerPonints[0][0], innerPonints[1][0], innerPonints[2][0], innerPonints[3][0], offsetX)      for (let x = 0; x < 3; x++) {        if (tsx[x] <= 1 && tsx[x] >= 0) {          const ny = getThreeBezierPoint(tsx[x], innerPonints[0], innerPonints[1], innerPonints[2], innerPonints[3])          if (Math.abs(ny[1] - offsetY) < 8) {            return points[i]          }        }      }      // 如果上述没有结果,则用 y 求出对应的 t,再用 t 求出对应的 x,与 offsetX 进行匹配      const tsy = getBezierT(innerPonints[0][1], innerPonints[1][1], innerPonints[2][1], innerPonints[3][1], offsetY)      for (let y = 0; y < 3; y++) {        if (tsy[y] <= 1 && tsy[y] >= 0) {          const nx = getThreeBezierPoint(tsy[y], innerPonints[0], innerPonints[1], innerPonints[2], innerPonints[3])          if (Math.abs(nx[0] - offsetX) < 8) {            return points[i]          }        }      }    }  }  return false}
登录后复制

直线的判断方法是点到线段的距离:

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]0
登录后复制

关于曲线的判断方法比较复杂,这里就不多介绍, 想了解的可以去看这篇:如何判断一个坐标点是否在三阶贝塞尔曲线附近

连接线还有一个功能就是双击连接线后可以编辑这条连接线的备注信息。这个备注信息的位置是在当前连接线的中心点位置。所以我们需要求出中心点,这个相对简单。

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]1
登录后复制

曲线的中心点位置,可以直接拿三阶贝塞尔曲线公式求出

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]2
登录后复制

在算出每一条的中心点位置后,在目标位置添加备注信息即可:

节点的样式调整

节点的样式调整主要是位置及大小,而这些属性就是节点里面的 attr,在相应的事件下根据鼠标移动的方向及位置,来调整节点的样式。

还有批量操作也是同样,不过批量操作是要先计算出哪些节点的范围。

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]3
登录后复制

这个有个小功能没有做,就是在批量调整大小的时候,节点间的相对距离应该是不动的,这里忽略了。

节点移动时的吸附

这里的吸附功能其实是做了一个简单版的,就是 x 和 y 轴都只有一条校准线,且校准的优先级是从左至右,从上至下。

这里吸附的标准是节点的 6 个点:X 轴的左中右,Y 轴的上中下,当前节点在移动的时候,会用当前节点的 6 个点,一一去与其它节点的 6 个点做比较,在误差正负 2px 的情况,自动更新为0,即自定对齐。

因为移动当前节点时候,其它的节点是不动的,所以这里是做了一步预处理,即在鼠标按下去的时候,把其它的节点的 6 个点都线算出来,用 Set 结构保存,在移动的过程的比较中,计算量会相对较少。

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]4
登录后复制

判读是否有可吸附的点

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]5
登录后复制

最后更新状态。

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]6
登录后复制撤销和恢复

撤销和恢复的功能是比较简单的,其实就是用栈来保存每一次需要保存的配置结构,就是要考虑哪些操作是可以撤销和恢复的,就是像节点移动,节点的新增和删除,连接线的连接,连接线的备注新增和编辑等等,在相关的操作下面入栈即可。

// next[  {    // 连接线的id    'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b',    // 连接线的结束节点id    'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb',    // 连接线在起始节点的方向    'directionStart': 'right',    // 连接线在结束节点的方向    'directionEnd': 'left',    // 线的类型(直线、折线、曲线)    'lineType': 'straight',    // 显示在连接线中点的标识信息    'extra': '',    // 连接线在起始节点的id    'componentId': 'fde2a040-3795-4443-a57b-af412d06c023'  },  // ...]7
登录后复制

最后

这里主要的已经差不多都写了,其实最红还有一个挺有用的功能还没有做。就是改变已经绘制的连接线的起止点。

这里的思路是:先选中需要改变起止点的连接线,然后把鼠标移动到起止点的位置,将它从已经绘制的状态改为正在绘制的状态,然后再选择它的开始位置或者结束位置。这个后面看情况吧,有空就加上。

(学习视频分享:web前端开发、编程基础视频)

以上就是手把手带你利用vue3.x绘制流程图的详细内容,更多请关注9543建站博客其它相关文章!

9543建站博客
一个专注于网站开发、微信开发的技术类纯净博客。

标签: Vue

作者头像
admin创始人

肥猫,知名SEO博客站长,14年SEO经验。

上一篇:浅析UniApp跳转到其他页面的方法
下一篇:如何判断nodejs已经启动

发表评论

关闭广告
关闭广告