Bootstrap

基于乐吾乐meta2d从零实现可视化流程图编辑器(六)

概要

可视化编辑器已成为前端发展趋势,相关产品层出不穷,但是用户较难根据自身需求去完整实现一个功能较为全面的可视化编辑器,我将采用乐吾乐开源的meta2d.js可视化库来实现一个简单的流程图编辑器,通过这个案例来介绍meta2d的相关功能,并向读者展示如何用meta2d从零出发搭建一个较为完整的项目,让我们在实际项目中来体验meta2d的强大之处吧。

请添加图片描述

什么是乐吾乐meta2d.js

meta2d是乐吾乐开源的2D图元组成的可视化引擎,集实时数据展示、动态交互、数据管理等一体的全功能2D可视化引擎。能够快速实现数字孪生、大屏可视化、Web组态、SCADA等解决方案。具有实时监控、多样、变化、动态交互、高效、可扩展、支持自动算法、跨平台等特点,最大程度减少研发和运维的成本,并致力于普通业务人员 0 代码开发实现物联网、工业互联网、电力能源、水利工程、智慧农业、智慧医疗、智慧城市等可视化解决方案。

乐吾乐已将其meta2d核心库完全免费开源,本系列教程就是基于meta2d从零实现web端可视化流程图编辑器。

乐吾乐 meta2d开源项目地址:https://github.com/le5le-com/meta2d.js

乐吾乐 meta2d官方文档:https://doc.le5le.com/document/119359590

项目地址

此可视化流程图编辑器项目地址:github.com/Grnetsky/me…

在线体验地址: http://editor.xroot.top/

往期教程

  1. 基本环境搭建: juejin.cn/spost/72617…
  2. 主界面布局及其初始化: juejin.cn/post/726219…
  3. Meta2d核心库图元注册流程及相关概念: juejin.cn/spost/72629…
  4. 侧边栏功能开发:https://juejin.cn/post/7264414580776403003
  5. Nav组件功能实现:https://juejin.cn/post/7264951443344916517

6. nav组件扩展-添加工具栏

这一章,我们来讲解工具栏的开发,工具栏为用户提供了一系列快捷方法,扩展了我们原有菜单栏的功能,我们要添加的功能有:撤销、重做、线段起点样式、线段终点样式、连线样式、手动锚点功能、网格、标尺、另存为、缩放等、让我们一步一步来。

前置准备

好的功能设计能省去很多时间和精力,我们先将我们菜单栏的框架搭起来,老规矩,遵循开放封闭原则,我们依然像菜单栏一样将功能按钮抽取出去,另外在UI上写好循环遍历渲染我们的功能按钮。UI可以像下面这样写:

<div v-for="(item,index) in menu.right">
  <el-sub-menu v-if="item.children" :index="(index+1)+''">
    <template #title>
      <div class="title">
        <i v-if="item.icon" :title="item.name" class="t-icon font-size"
          :class="[item.icon,item.name?'format':'']"></i>{{item.name}}
      </div>
    </template>
    <el-menu-item v-for="(c,i) in item.children" :index="`${index+1}-${i+1}`"
      @click="dispatchFunc(c.action,c.value,c.icon)"><i :title="item.name" class="t-icon" :class="c.icon"
        style="margin-left: 20px;margin-right: 30px"></i>{{ c.name }}</el-menu-item>
  </el-sub-menu>
  <el-menu-item v-else @click="dispatchFunc(item.action,item.value,item.icon)" :index="index+1+''"><i v-if="item.icon"
      :title="item.name" class="t-icon font-size" :class="item.icon"></i>{{item.name }}</el-menu-item>

</div>
<el-sub-menu>
  <template #title>
    缩放
  </template>
  <el-menu-item index="100">
    <el-slider v-model="scaleValue" />
  </el-menu-item>
</el-sub-menu>

我们通过循环遍历menu.right数组(存放的工具栏信息)来实现加载工具栏,每个工具栏元数据中,都有name属性,用于记录菜单的名字,key属性用于唯一标识符,action属性用于事件回调处理函数,icon属性用于图标展示,还有children属性用于子菜单的加载。通过dispatchFunc函数来根据相关参数调用对应的回调函数。

眼睛细的朋友们能看到我把缩放单独拿出来了,原因是该功能的特殊性,与其他功能结合实现起来较为复杂,故在这里就单独拿出来做

UI布局好了,我们就开始配置我们的工具栏信息,如下:

// defaultsConfig.js
menu.right = [
  {
    key: "undo",
    name: "撤销",
    icon: "t-angle-left",
    action: "undo"
  },
  {
    key: "redo",
    name: "重做",
    icon: "t-angle-right",
    action: "redo"
  },
  {
    key: "start",
    name: "起点",
    icon: "t-line",
    children: [
      {
        name: "line",
        icon: "t-line",
        action: "start",
        value: "",
      },
      {
        name: "triangle",
        icon: "t-to-triangle",
        action: "start",
        value: "triangle",
      },
      {
        name: "diamond",
        icon: "t-to-diamond",
        action: "start",
        value: "diamond",
      },
      {
        name: "circle",
        icon: "t-to-circle",
        action: "start",
        value: "circle",
      },
      {
        name: "lineDown",
        icon: "t-to-lineDown",
        action: "start",
        value: "lineDown",
      },
      {
        name: "lineUp",
        icon: "t-to-lineUp",
        action: "start",
        value: "lineUp",
      },
      {
        name: "triangleSolid",
        icon: "t-to-triangleSolid",
        action: "start",
        value: "triangleSolid",
      },
      {
        name: "diamondSolid",
        icon: "t-to-diamondSolid",
        action: "start",
        value: "diamondSolid",
      },
      {
        name: "circleSolid",
        icon: "t-to-circleSolid",
        action: "start",
        value: "circleSolid",
      },
      {
        name: "lineArrow",
        icon: "t-to-line",
        action: "start",
        value: "line",
      },

    ]
  },
  {
    key: "end",
    name: "终点",
    icon: "t-line",
    children: [
      {
        name: "line",
        icon: "t-line",
        action: "end",
        value: "",
      },
      {
        name: "triangle",
        icon: "t-to-triangle",
        action: "end",
        value: "triangle",
      },
      {
        name: "diamond",
        icon: "t-to-diamond",
        action: "end",
        value: "diamond",
      },
      {
        name: "circle",
        icon: "t-to-circle",
        action: "end",
        value: "circle",
      },
      {
        name: "lineDown",
        icon: "t-to-lineDown",
        action: "end",
        value: "lineDown",
      },
      {
        name: "lineUp",
        icon: "t-to-lineUp",
        action: "end",
        value: "lineUp",
      },
      {
        name: "triangleSolid",
        icon: "t-to-triangleSolid",
        action: "end",
        value: "triangleSolid",
      },
      {
        name: "diamondSolid",
        icon: "t-to-diamondSolid",
        action: "end",
        value: "diamondSolid",
      },
      {
        name: "circleSolid",
        icon: "t-to-circleSolid",
        action: "end",
        value: "circleSolid",
      },
      {
        name: "lineArrow",
        icon: "t-to-line",
        action: "end",
        value: "line",
      },

    ]
  },
  {
    key: "line",
    name: "连线",
    icon: "t-line",
    children: [{
      name: "直线",
      icon: " t-line",
      action: "line",
      value: "line"
    }, {
      name: "曲线",
      icon: "t-curve2",
      action: "line",
      value: "curve"
    }, {
      name: "线段",
      icon: "t-polyline",
      action: "line",
      value: "polyline"
    }, {
      name: "脑图",
      icon: "t-mind",
      action: "line",
      value: "mind"
    }]
  },
  {
    key: "manual",
    name: "手动锚点",
    icon: "",
    action: "manual"
  },
  {
    key: "grid",
    name: "网格",
    icon: "",
    action: "grid"
  },
  {
    key: "rule",
    name: "标尺",
    icon: "",
    action: "rule"
  },
  {
    key: "saveAs",
    name: "保存为",
    icon: "",
    children: [
      {
        name: "svg",
        action: "saveAs",
        value: "svg"
      },
      {
        name: "png",
        action: "saveAs",
        value: "png"
      }
    ]
  },
]

我们还要把事件回调函数写好,用于执行业务逻辑,与菜单栏一样,我们也把回调函数编写在menuFunc对象中,函数写好后我们还要写谁来调用他,还记得上面提到的dispatchFunc函数吗,它用于接受参数来决定执行哪个函数,从而实现点击工具栏执行业务逻辑的功能,所以你只需要像下面这样编写代码:

export function dispatchFunc(act,value,...args){  
    // doSomething before  
    menuFunc[act](value,...args)  
}

我们在第二章提到过这个函数,在这里进行简单的扩充,新增了参数

基本配置信息编写好了,那我们从第一个需求开始。

撤销、重做功能开发

这个功能用于撤销和重做,其实这个功能的核心是调用了meta2d提供的undo和redo的API来实现这个功能的,所以实现起来特别简单:

// defaultsConfig.js  menuFunc
undo(){  
    meta2d.undo()  
},  
redo(){  
    meta2d.redo()  
}

来看看效果

撤销重做 000000000030gif

设置线段笔帽起点样式与终点样式

meta2d中的line的笔帽是能够选择样式的,meta2d提供了10种样式,分别为:line、triangle、diamond、circle、lineDown、lineUp、triangleSolid、diamondSolid、circleSolid、toLine。要改变笔帽起点样式需要修改line的fromArrow属性,也可以全局修改通过配置meta2d.store.data.fromArrow值来实现

有了这些知识基础后,那我们的线段起点样式就很好实现了,我们只需要将笔帽样式列表在前端渲染出来,然后用户点击哪个就在回调中将笔帽样式修改为选定值即可。

像下面这样:

// defaultsConfig.js menuFunc
start(value, icon){
  const a = menu.right.find((i => i.key === "start")) // 获取按钮元数据  
  a.icon = icon // 修改元数据的图标  
  meta2d.store.data.fromArrow = value // 修改meta2d的fromArrow样式值  
  if (meta2d.store.active) { // 循环遍历 修改激活图元fromArrow// 样式  
    meta2d.store.active.forEach((i) => {
      if (i.type === PenType.Line) {
        i.fromArrow = value
      }
    })
  }
  meta2d.render() //重新渲染  
}

元数据配置如下:

// menu.right
{
  key: "start",
  name: "起点",
  icon: "t-line",
  children: [
    {
      name: "line",
      icon: "t-line",
      action: "start",
      value: "",
    },
    {
      name: "triangle",
      icon: "t-to-triangle",
      action: "start",
      value: "triangle",
    },
    {
      name: "diamond",
      icon: "t-to-diamond",
      action: "start",
      value: "diamond",
    },
    {
      name: "circle",
      icon: "t-to-circle",
      action: "start",
      value: "circle",
    },
    {
      name: "lineDown",
      icon: "t-to-lineDown",
      action: "start",
      value: "lineDown",
    },
    {
      name: "lineUp",
      icon: "t-to-lineUp",
      action: "start",
      value: "lineUp",
    },
    {
      name: "triangleSolid",
      icon: "t-to-triangleSolid",
      action: "start",
      value: "triangleSolid",
    },
    {
      name: "diamondSolid",
      icon: "t-to-diamondSolid",
      action: "start",
      value: "diamondSolid",
    },
    {
      name: "circleSolid",
      icon: "t-to-circleSolid",
      action: "start",
      value: "circleSolid",
    },
    {
      name: "lineArrow",
      icon: "t-to-line",
      action: "start",
      value: "line",
    },

  ]
}

同理,设置线段终点样式也是一样的,
元数据:

{
  key: "end",
  name: "终点",
  icon: "t-line",
  children: [
    {
      name: "line",
      icon: "t-line",
      action: "end",
      value: "",
    },
    {
      name: "triangle",
      icon: "t-to-triangle",
      action: "end",
      value: "triangle",
    },
    {
      name: "diamond",
      icon: "t-to-diamond",
      action: "end",
      value: "diamond",
    },
    {
      name: "circle",
      icon: "t-to-circle",
      action: "end",
      value: "circle",
    },
    {
      name: "lineDown",
      icon: "t-to-lineDown",
      action: "end",
      value: "lineDown",
    },
    {
      name: "lineUp",
      icon: "t-to-lineUp",
      action: "end",
      value: "lineUp",
    },
    {
      name: "triangleSolid",
      icon: "t-to-triangleSolid",
      action: "end",
      value: "triangleSolid",
    },
    {
      name: "diamondSolid",
      icon: "t-to-diamondSolid",
      action: "end",
      value: "diamondSolid",
    },
    {
      name: "circleSolid",
      icon: "t-to-circleSolid",
      action: "end",
      value: "circleSolid",
    },
    {
      name: "lineArrow",
      icon: "t-to-line",
      action: "end",
      value: "line",
    },

  ]
},

回调函数:

end(value,icon){
  const a = menu.right.find((i=>i.key==="end"))
  a.icon = icon
  meta2d.store.data.toArrow = value
  if(meta2d.store.active){
      meta2d.store.active.forEach((i)=>{
          if(i.type === PenType.Line){
              i.toArrow = value
          }
      })
  }
  meta2d.render()
},

来看看实际效果

cap 000000000030gif

设置连线样式

meta2d提供了4个基础的连线样式,分别为line、polyline、curve、mind,meta2d同样提供了相关API供我们去修改他:

  • meta2d.canvas.drawingLineName 通过该属性可以查看并设置当前连线样式
  • meta2d.updateLineType(pen, value) 更新图元的连线样式
  • meta2d.store.options.drawingLineName 用于全局配置

有了这些api的支撑,那么连线样式的更改就变得很简单了,和线段笔帽的修改基本一致,来看看吧:

这是定义元数据

{
  key:"line",
  name:"连线",
  icon:"t-line",
  children:[{
      name:"直线",
      icon: " t-line",
      action: "line",
      value: "line"
  },{
      name: "曲线",
      icon: "t-curve2",
      action: "line",
      value: "curve"
  },{
      name: "线段",
      icon: "t-polyline",
      action: "line",
      value: "polyline"
  },{
      name: "脑图",
      icon: "t-mind",
      action: "line",
      value: "mind"
  }]
},

功能实现函数

line(value,icon){
  const a = menu.right.find((i=>i.key==="line")) //获取按钮对象
  a.icon = icon //修改图标
  meta2d.store.options.drawingLineName = value  // 修改全局连线样式配置
  meta2d.canvas.drawingLineName &&  
  (meta2d.canvas.drawingLineName = value);  // 修改当前连线样式
  meta2d.store.active?.forEach((pen) => {  // 修改已激活图元的连线样式
      meta2d.updateLineType(pen, value);  // meta2d的修改函数
  });
  meta2d.render()
}

来看看实际效果

连线style 000000000030gif

其实这里有个bug,就是当加载文件时,工具栏的icon图标不会随着工程文件的配置样式而实时更新,这里为了教程的由易到难,暂时不处理这个问题,后面集中处理。

手动锚点

我们都知道,每个图元都有几个锚点信息,他们大多都是在注册图元的同时注册的,虽然大多数场景这些锚点已经够用了,但是面对少量的情景依然存在锚点位置不精确、可配置性较差的问题,而且学习难度还不小,为了解决这个问题,meta2d提供了手动锚点的功能,即想在哪里加锚点只需要点击一下即可,给了用户最大的自由去定义锚点位置,并且学习难度几乎为0,先看看官网怎么说的。

简单直接,我们要使用这个功能直接调用该函数即可,所以我们应该这么写

manual(){
    meta2d.toggleAnchorMode()
}

元数据

{  
    key:"manual",  
    name:"手动锚点",  
    icon:"",  
    action: "manual"  
}

来看看实际效果

hand锚点 000000000030gif

使用快捷键A 也能快速打开手动锚点功能

网格

网格为精准布局提供辅助视觉支持,meta2d中提供了网格功能的API,先看看官方文档,根据官方文档,打开网络功能一共有两种方式,一种是直接修改配置项进行打开,另一种是使用setGrid函数打开,第一种方法灵活性更高,可以动态更改网格属性,而第二种方法封装度更高也更方便,并且满足大多数应用场景,所以在这里使用第二种方法进行设置。

我们的逻辑函数很简单,像下面这样:

grid(){
    if(meta2d.store.data.grid){  // 判断网格是否已经打开
        meta2d.setGrid({
            grid: false  // 关闭网格
        })
    }else{
        meta2d.setGrid({   // 设置网格 并设置属性
            grid: true,
            gridColor: '#e2e2e2',
            gridSize: 10,
            gridRotate: 0
        });
    }
    meta2d.render()  // 重新渲染

}

上面的逻辑很简单,通过一个meta2d.store.data.grid方法来判断是否已经开启网格,然后再执行相应的逻辑,setGrid方法可以配置网格的颜色,宽度,旋转角度等,用户可以根据实际情况自定义即可。将grid属性设置为true则打开网格,设置为false则关闭网格

来看看效果

grid 000000000030gif

标尺

标尺也是一样的,用于让用户实时查看图元尺寸大小,meta2d同样提供了标尺的相关函数,通过meta2d.setRule()函数进行操作,很简单,像这样:

rule() {
    if (meta2d.store.data.rule) {  // 查看是否已经打开rule
        meta2d.setRule({  // 关闭rule
            rule: false,
        });
    } else {
        meta2d.setRule({  // 开启rule
            rule: true,    
            ruleColor: '#414141'  // 设置颜色
        });
    }
    meta2d.render()  // 重新渲染 
}

来看效果

rule 000000000030gif

保存为

保存为功能为用户提供图纸导出的效果,在这里我们暂且只实现导出为svg和png两种格式。导出为png主要依赖于meta2d提供的downloadPng函数:

  • meta2d.downloadPng(name) 将图纸导出为png,name为导出文件名

导出为svg需要我们使用第三方库canvas2svg配合meta2d提供的renderPenRaw函数来实现

  • canvas2svg库github地址:https://github.com/gliffy/canvas2svg

我们先看看代码怎么写:

saveAs(value){
    // 选择导出格式
    switch (value) {
        case "png":
            let name =meta2d.store.data.name
            if(name){
                name += '.png'
            }
            meta2d.downloadPng(name)  // 导出为png
            break
        case "svg":
            downloadSvg()  // 导出为svg
            break
    }
}

上面的函数很好理解,根据不同选项来调用不同导出方法而已,而且导出为png具体实现也不需要我们关心,核心库已经封装好了,重点是导出为svg是怎么实现的需要我们好好讨论下,代码如下:

// 遍历子节点
function isShowChild(pen, store) {
    let selfPen = pen;
    while (selfPen && selfPen.parentId) {
        const oldPen = selfPen;
        selfPen = store.pens[selfPen.parentId];
        const showChildIndex = selfPen?.calculative?.showChild;
        if (showChildIndex != undefined) {
            const showChildId = selfPen.children[showChildIndex];
            if (showChildId !== oldPen.id) {
                return false;
            }
        }
    }
    return true;
}


// 下载canvas为svg格式
const downloadSvg = () => {
    const rect= meta2d.getRect();  // 获取图纸区域
    rect.x -= 10;
    rect.y -= 10;
    const ctx = new C2S(rect.width + 20, rect.height + 20);  // 创建canvas2svg对象  
    ctx.textBaseline = 'middle';
    for (const pen of meta2d.store.data.pens) {
        if (pen.visible == false || !isShowChild(pen, meta2d.store)) {  // 是否有子节点
            continue;
        }
        meta2d.renderPenRaw(ctx, pen, rect);  // 使用canvas2svg对象 绘画图元
    }

    let mySerializedSVG = ctx.getSerializedSvg();  // 序列化为svg
    if (meta2d.store.data.background) {
        mySerializedSVG = mySerializedSVG.replace('{{bk}}', '');  // 添加背景
        mySerializedSVG = mySerializedSVG.replace(
            '{{bkRect}}',
            `<rect x="0" y="0" width="100%" height="100%" fill="${meta2d.store.data.background}"></rect>`
        );
    } else {
        mySerializedSVG = mySerializedSVG.replace('{{bk}}', '');
        mySerializedSVG = mySerializedSVG.replace('{{bkRect}}', '');
    }

    mySerializedSVG = mySerializedSVG.replace(/--le5le--/g, '&#x');

    // 下载生成的svg文件 
    const urlObject = window.URL || window;
    const export_blob = new Blob([mySerializedSVG]);
    const url = urlObject.createObjectURL(export_blob);

    const a = document.createElement('a');
    a.setAttribute(
        'download',
        `${meta2d.store.data.name || 'le5le.meta2d'}.svg`
    );
    a.setAttribute('href', url);
    const evt = document.createEvent('MouseEvents');
    evt.initEvent('click', true, true);
    a.dispatchEvent(evt);
};

其实核心还是在canvas2svg这个库里,该库创建了一个模拟的2d canvas上下文,当执行canvas方法时,该库同时会执行相应的svg指令创建一样的场景图,即我们只需要通过canvas2svg提供的对象来绘画一编我们要导出的canvas的图像,就可以拿到svg版的同样的图形,然后我们再拿到文件对象,通过a链接的download属性来下载到本地即可,如果想了解他是如何将canvas方法转换为svg的,具体查看canavs2svg的源码吧,这里不做探讨,也不深入研究了。

最后,让我们来看看实际效果吧。

saveas 000000000030gif

缩放

终于到最后一个功能的讲解了,缩放。其实缩放功能的实现也是完全依赖于meta2d的scale方法,官方文档说明见下:

我们只要向scale函数传递缩放值即可,需要说明的是,scale有最大值和最小值,可以通过meta2d.store.option.maxScale和meta2d.store.option.minScale来设置或者通过setOption方法来设置,见官网,默认为0.1-10。在这里暂时使用这个默认值来操作。

在UI上我们使用slider组件来实现,见下:

<el-sub-menu>
  <template #title>
    缩放
  </template>
  <el-menu-item index="100">
    <el-slider v-model="scaleValue" @input="scaleView"/>
  </el-menu-item>
</el-sub-menu>

通过监听input事件来执行scaleView函数来动态进行缩放,代码如下:

function scaleView(val){
    // 缩放
    meta2d.scale((meta2d.store.options.maxScale -  meta2d.store.options.minScale) / 100 *val)
    // 将图元移动到图纸中央
    meta2d.centerView()
  }

另外,我们还可以通过鼠标滚轮来实现缩放,为了滚轮缩放和slider值的一致性,在这里还要监听meta2d的scale事件进行数据同步操作。

meta2d.on("scale",(data)=>{  
   scaleValue.value = +(data.toFixed(1)*(meta2d.store.options.maxScale - meta2d.store.options.minScale)).toFixed()
})

来看看实现效果

缩放 000000000030gif

总结

到这里,我们的工具栏的开发就告一段落了,其实写了这么多功能后,你会发现,无论是什么功能最终都离不开meta2d核心库的支撑,很多功能他都已经为我们封装好,我们需要做的就是仔细阅读官方文档,结合官方案例进行学习开发,meta2d提供的API远远不止这些,读者可以结合自己实际需求配合meta2d去探索、去创新实现更多的功能。
在下一章,我们开始讲解我们的最后一个部分,也是最复杂,最多样的setting组件部分(或者叫props部分),准备好和我进一步探索meta2d吧。

Meta2d.js 开源地址

给大家推荐一下 Meta2d.js是一个实时数据响应和交互的2d引擎,可用于Web组态,物联网,数字孪生等场景。

Github:https://github.com/le5le-com/meta2d.js

Gitee: https://gitee.com/le5le/meta2d.js

大家一起去为这个国产的开源产品star一下吧,毕竟优秀的国产可视化开源项目不多。

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;