在vue项目里用antv-X6生成一张版本分支图 (一)

2,328 阅读7分钟

官方文档:x6.antv.vision/zh/docs/tut…

Ps:需求驱动式阅读文档

What —— antv-X6是什么

antv-x6是蚂蚁集团数据可视化团队开发维护的antv旗下的一款图编辑引擎,提供了一系列开箱即用的交互组件和简单易用的节点定制能力,方便我们快速搭建DAG 图、ER 图、流程图等应用。

其在画布中具有平移、缩放、对齐、居中、小地图等功能,基于 SVG SMIL Animation 实现了元素动画,可以精准定义元素的运动路径,X6 完全复用了 G6 的成熟布局能力,并做了数据格式的适配,在 X6 也可以很方便的使用布局功能。 X6 是图编辑引擎,特点是节点、边、等元素的定制能力非常强,经常用来构建流程图、ER图、DAG图、脑图等应用。G6 和 X6 是孪生兄弟,G6 更擅长于图可视化和图分析领域。

拓展:X6 与 G6 的区别

前端图可视化引擎antv的g6和x6区别是什么,如何选择?

区别主要体现在以下几个方面

  1. 应用场景
  2. 数据量大小
  3. 定制能力和上手成本
  4. 是否需要支持移动端/小程序
  5. 是否需要统计图表节点

Why —— 为什么要使用antv-X6

功能需求:绘制一张展示产品版本分支之间关系的分支树图,可以灵活移动各部分,版本分支展示动画流向,可以保存移动后的布局。 示例如下图:

时序图.png

antv-X6可提供:画布平移、缩放;节点移动;Dagre布局;正交路由;动画 ...

How —— 如何在vue中使用antv-X6

安装

通过 npm 命令分别安装 X6和布局算法

$ npm install @antv/x6 --save 
$ npm install @antv/layout --save

PS:使用该命令可能会报无法识别 @...的错误,此时需要给其添加引号'@...'

开始使用

1. 创建容器

在页面中创建一个用于容纳 X6 绘图的容器,可以是一个 div 标签

<div id="container"></div>

2. 准备节点数据

X6 支持 JSON 格式数据,该对象中需要有节点 nodes 和边 edges 字段,分别用数组表示:

nodeInfo:{ 
    // 节点
    nodes: [
           {
               id: 'node1', // String,可选,节点的唯一标识
               label: 'hello', // String,节点标签 
           },
           {   id: 'node2', // String,节点的唯一标识
               label: 'world', // String,节点标签 
           }, 
           ], 
    // 边 
    edges: [ 
            { source: 'node1', // String,必须,起始节点 id
              target: 'node2', // String,必须,目标节点 id 
              lineType: 'solid'//Sting,可选,用来定义线的样式 
            }, 
           ], 
}


PS:在项目实际应用中是通过接口请求获取节点相关信息

3. 画布初始化及渲染

创建一个 Graph 对象,并为其指定一个页面上的绘图容器,并初始化画布

import { GraphVector } from "@antv/x6";
import { DagreLayout } from "@antv/layout";
//画布初始化
init() {
      //创建一个 Graph 对象
      this.graph = new Graph({
        container: document.getElementById("container"),
        width: 800, //画布容器宽度
        height: 600, //画布容器高度
        mousewheel: true, //滚轮缩放
        panning: true //支持平移拖拽
      });
      //定义层次布局Dagre
      const dagreLayout = new DagreLayout({
        type: "dagre", //布局类型
        rankdir: "LR", //布局的方向。T:top(上);B:bottom(下);L:left(左);R:right(右)
        align: "UL", //节点对齐方式。U:upper(上);D:down(下);L:left(左);R:right(右);undefined (居中)
        ranksep: 50, //层间距(px)。在 rankdir 为 TB 或 BT 时是竖直方向相邻层间距;在 rankdir 为 LR 或 RL 时代表水平方向相邻层间距
        nodesep: 40, //节点间距(px)。在 rankdir 为 TB 或 BT 时是节点的水平间距;在 rankdir 为 LR 或 RL 时代表节点的竖直方向间距
        controlPoints: true, //是否保留布局连线的控制点
      });
      //分别动态添加节点和边的样式
      this.nodeInfo.nodes.map((item) => {
        Object.assign(item, { width: 130, height: 40 });
      });
      this.nodeInfo.edges.map((item) => {
        if (item.lineType === "solid") {
          Object.assign(item, {
            //路由将边的路径点 vertices 做进一步转换处理
            router: {
              name: "manhattan", //智能正交路由
              args: {
                startDirections: ["right"], // 支持从哪些方向开始路由
                endDirections: ["left"], // 支持从哪些方向结束路由
              },
            },
            //定义实线样式
            attrs: {
              line: { stroke: "#0e639c" }, // line 指代的元素代表了边的主体
            },
          });
        } else if (item.lineType === "dotted") {
          Object.assign(item, {
            router: {
              name: "manhattan", //智能正交路由,由水平或垂直的正交线段组成,并自动避开路径上的其他节点(障碍)
              args: {
                startDirections: ["bottom"], // 支持从哪些方向开始路由
                endDirections: ["left"], // 支持从哪些方向结束路由
              },
            },
            //定义虚线样式
            attrs: {
              line: { strokeDasharray: "5 5", stroke: "#51ff51" }, // line 指代的 元素代表了边的主体
            },
          });
        }
      });
      //判断是否需要dagre布局(通过控制dagreLayout字段)
      if (this.isDagreLayOut) {
        //应用dagre布局
        const model = dagreLayout.layout(this.nodeInfo);
        //渲染画布
        this.graph.fromJSON(model);
      } else {
        this.graph.fromJSON(this.nodeInfo);
      }
    }

其中布局使用的是Dagre布局,路由使用的是manhattan智能正交路由,由水平或垂直的正交线段组成,并自动避开路径上的其他节点(障碍)

渲染出来的效果:

image.png

4.给节点设置定时高亮

flash(cell){ 
    //根据节点/边ID或实例查找对应的视图 
    const cellView = this.graph.findViewByCell(cell) 
    if(cellView){ 
        //高亮滤镜,高亮指定的元素 
        cellView.highlight()
        //取消高亮指定的元素 
        window.setTimeout(function () {
          cellView.unhighlight();
        }, 300);
    } 
}

5. 实现动画效果(点击节点后从父节点流动到该节点)

animation(){ 
    //监听节点的点击事件 
    this.graph.on('node:mousedown',({cell}) => { 
    //trigger触发signer事件 
    this.graph.trigger('signer',cell) 
    })
    //监听signer事件 
    this.graph.on("signal", (cell) => { 
        if (cell.isEdge()) { 
        //根据节点/边ID或实例查找对应的视图 
            const view = this.graph.findViewByCell(cell);
            if (view) { 
                // Vector.create创建一个Vector对象 
                const token = Vector.create("circle", { r: 6, fill: "#feb662" }); // 获取边的起始节点/边 ⭐
                const source= cell.getSourceCell(); //根据提供的选择器,获取指定根元素的第一个匹配的后代元素
                const path = view.findOne('path') 
                if(path){ 
                    setTimeout(() => { 
                        if(source){ 
                            //触发一个沿 SVGPathElement 路径元素运动的动画 
                            token.animateAlongPath( 
                                { 
                                    dur: '4s', 
                                    repeatCount: 'indefinite' 
                                },
                                path 
                            )
                            token.appendTo(path.parentNode) 
                            this.graph.trigger('signal',source) 
                         } 
                     }, 300); 
                } 
            }else { 
                //给被点击的节点设置定时高亮 
                this.flash(cell); //获取与节点/边相连接的边 
                const edges = graph.model.getConnectedEdges(cell, { 
                    // outgoing: true 
                    incoming: true //返回输入边 
                });
                edges.forEach((edge) => graph.trigger("signal", edge)); 
            }
    });
}

PS:使用animateAlongPath的时候需要注意antv/x6的版本不能是2.x以上的

"@antv/x6": "^1.34.3",

动画实现效果: 1672388542406.gif

该动画效果使用的animateAlongPath沿路径运动的动画 | X6 (antv.vision),还可以考虑通过sendToken实现 沿边运动的动画 | X6 (antv.vision)


2.x版本中,动画相关的一些api被删除了,升级后的文档中还没有提供

image.png 但是由于一些原因,项目中的antv-X6版本需要升级到2.x,本来是想创建一个html元素来实现圆球的动画,考虑到图形的缩放和平移后发现使用svg元素更合适, 因此动画的实现方式需要采用其他方式(创建svg元素 +CSS属性 offset-path

PS:参考新 CSS 属性 offset-path 使元素沿着不规则路径运动

将路由调整为地铁路由metro (曼哈顿路由 manhattan 的一个变种),去掉了args配置,让箭头指向可以随节点位置变化而变化,还去掉了消除的高亮的定时器

更新后的 animation()方法:

    animation() {
      //监听节点的点击事件
      this.graph.on("node:dblclick", ({ node }) => {
        console.log("点击节点");
        //trigger触发signer事件
        this.graph.trigger("signal", node);
      });
      //监听signer事件
      this.graph.on("signal", (cell) => {
        console.log("cell", cell);
        if (cell.isEdge()) {
          //根据节点/边ID或实例查找对应的视图
          const view = this.graph.findViewByCell(cell);
          console.log("view", view);
          if (view) {
            //创建g元素
            let g = document.getElementsByClassName("x6-graph-svg-viewport")[0];
            let circle = document.createElementNS(
              "http://www.w3.org/2000/svg",
              "circle"
            );
            circle.setAttribute("fill", "#feb662");
            circle.setAttribute("id", "circle");
            g.appendChild(circle);
            const source = cell.getSourceCell(); //根据提供的选择器,获取指定根元素的第一个匹配的后代元素
            const path = view.findOne("path");
            if (path) {
              setTimeout(() => {
                if (source) {
                  console.log("path", path);
                  let s = new XMLSerializer();
                  let str = s.serializeToString(path);
                  let d = str.split('d="')[1].split('"')[0];
                  circle.setAttribute("r", "5");
                  circle.style.offsetPath = `path("${d}")`;
                  circle.style.animation = "move 1s linear infinite";
                  this.graph.trigger("signal", source);
                }
              }, 1000);
            }
          }
        } else {
          //给被点击的节点设置定时高亮
          this.flash(cell); //获取与节点/边相连接的边
          const edges = this.graph.model.getConnectedEdges(cell, {
            // outgoing: true
            incoming: true, //返回输入边
          });

          edges.forEach((edge) => this.graph.trigger("signal", edge));
        }
      });
    },

替换后的效果: 版本分支树动画 (2).gif

结束了吗?其实还没有

还需要考虑到动画的暂停等问题 需求也发生了变更并且产生了一些优化需求

【续集】:在vue项目里用antv-X6来生成一张版本分支图(二)

【项目地址】:antv-X6实现版本分支图 (gitee.com)

【布局报错处理】Vue自定义的组件使用DagreLayout布局,报错TypeError: Cannot read properties of undefined (reading 'width') · Issue #2389 · antvis/X6

2ba6cff43e0a925a8c9ba410e83f4313.png