JavaScript + SVG实现Web前端WorkFlow工作流DAG有向无环图

时间:2024-03-05 08:26:47

一、效果图展示及说明

  

(图一)

 

(图二)

附注说明:

1. 图例都是DAG有向无环图的展现效果。两张图的区别为第二张图包含了多个分段关系。放置展示图片效果主要是为了说明该例子支持多段关系的展现(当前也包括单独的节点展现,图例没有展示)

2.图例中的圆形和曲线均使用的是SVG绘制。之前考虑了三种方式,一种是html5的canvas,一种是原始的html DOM,再有就是SVG。不过canvas对事件的支持不是很好(记得之前看过一篇文章主要是通过计算鼠标定位是否在canvas上的某个区域来触发事件机制,比较不适用工作流节点上的各种事件触发机制),另外原始的 DOM虽然对事件的处理比canvas要方便,但是从编码和绘制dom上则会过度的耗费资源,尤其是曲线的绘制,毕竟过多的dom操作都会影响性能拖慢响应速度,所以综合考虑使用SVG,它提供了绘制圆形,多边形,路径等操作,尤其是path的使用对于我们这种不会画曲线 的人太方便了。并且svg的dom对事件的支持和处理也很好。

二、有向无环图分析

Okay,了解了支持的展现效果和使用的技术,下面开始分析开发workflow的dag有向无环图吧(透露一下,有向无环图最重要的是计算每个节点的最大步长了,最大步长也就是该节点在这一段关系中,距离根节点的最远距离,网上有一些计算的算法什么的,不过本人不会,搞不通算法)。本例的 核心技术其实是 递归 。就是用递归就算每个节点的最大步长。还不了解 递归 是什么的童鞋们先了解一下递归。

  1. 理dag关系,剥离不存关系的节点和存在关系的节点,并找到每段关系的根节点

(图三)

附注说明:

上图是一个DAG有向无环图的关系链展示。(不要认为dag有向无环图单单指的是上图的中间部分,我们完全可以将上图理解为一个完整的dag关系。因为考虑问题要全面嘛!不是所有的节点只存在一段关系中,也不是所有的节点就一定和其它节点有关系。所以当然会出现上图的展示情况。当然,上图的任何一段单提取出来也是一段完整的dag关系。不过,为了后续的讲解和dag插件通用性,举了一个存在多种情况的dag关系的例子,之后的讲解也会按照这个图片来说明)。

方法思路:

  前提:已知当前dag图中的所有节点和所有关系链。(注意节点间的关系链是有向的)

1. 遍历所有的节点,逐个节点判定当前遍历的节点是否在关系链中,若不存在,则把当前节点作为一个独立的节点存储到一个数组中,我们就叫它 indiviual。(单独的节点其实跟后续的操作没有过多的关系,我们     只是考虑到这样特殊的情况,把它们都单独提取出来,展示到页面上即可。)

2. 同第1步一样,不过是取存在在关系链中的所有的节点,把它们push到另一个数组中,叫它 refNodes。

3. 获取了所有的再关系链中的节点数组 refNodes,遍历refNodes,并对照关系链,查找当前遍历的节点是否有作为输入节点类型对应的输出节点,如果有,表示当前遍历的节点不是根节点,若没有,怎该节点为     根节点,把它们push到一个叫 rootNodes的数组中。(因为是有向无环图,所以关系链是有向的,比如A-->B-->C,A作为第一次遍历的节点,查找是否存在 ?-->A的这种关系链 ,也就是A作为输入点找它上级的输出点,如果遍历完所有关系链都没有发现这种情况,则A是一个根节点。同理,遍历到B的时候,就会找到 A-->B这种情况,所以B不是根节点)。

*注意*:为了保证程序的正确性,数组中不会出现重复的节点,一定要在存储数组前执行以下去重操作。

 a). 可以定义一个javascript对象来存储dag中用的数据信息

    //工作流对象
    var relation={
        links:[],      //当前工作流中所有的关系链集合
        individual:[],  //存放所有没有关系的节点
        refNodes:[],    //存放有关系的节点
        rootNodes:[],   //存放关系中的根节点
    };

  b). 查找根节点示例代码,记得数组去重,可以使用jquery 的工具函数inArray。(links数组中存储的是所有的关系链对象link.具体的依照个人开发习惯定义,这里只是为了方便读者可以理解部分代码给出我使用的示例)

/**
  links中的关系对象存储示例
  var relation={
    links:[
      {

        output:{
          nodeId:A,  //输出节点的id
          pointName:A_1  //输出接线点名称
        },
        input:{
          nodeId:B,  //输入节点的id
          pointName:B_1  //输入接线点的名称
        }
      }
    ]
  }
**/

//
查找根节点 function findRootNodes(){ var len=relation.refNodes.length; for(var i=0;i<len;i++){ var node=relation.refNodes[i]; var isRootNode=true; $.each(relation.links,function(l,link){ var in_node=link.input.nodeId; //当前节点只要有作为输入点就不是根节点 if(node==in_node){ isRootNode=false; } }); if(isRootNode){ if($.inArray(node,relation.rootNodes)==-1){ relation.rootNodes.push(node); } } } }

2. 根据所有的根节点和有关系的节点及所有的关系链找到每个节点的最大步长 

                (图四)

循环所有的节点和根节点,每个节点的步长查找都要从根节点开始计算,如图四所示,以查找C节点的最大步长为例,遍历到C节点上时,查到第一个根节点A开始的关系网,第一次找到A,这时的步长是1,然后逐级向下查找,第二次找到B,步长计数为2,第三次找到C和D,步长为3,第四次找到C和E,步长为4,第五次已经遍历完当前根节点开始的一段关系,所以,上图上的无和步长5其实是没有的。同理,因为有可能存在多个根节点,所以都要遍历。第二个根节点为F,遍历后找不到C,所以不记录步长。

注意两方面:第一:取节点的最大步长

      第二:遍历步长为递归方式,每次从根节点查找(根节点以集合方式存储),取得下一级别的节点集合作为开始,每一个级别为一个步长计数,如此反复,直到集合为空为止。

关键代码如下:(节点的步长实际上是为了计算节点的横向排列位置用的,所以下面的代码用了一个nodeLevel对象来记录每个节点的最大步长)

//根据根节点和所有有关系的节点及关系链找到每个节点的最大步长
function setNodeMaxStep(){
    var len=relation.refNodes.length;
    for(var i=0;i<len;i++){
        var search_node=relation.refNodes[i];  //每次需要判定最大步长的节点

        //每次从根节点开始查找
        for(var k=0;k<relation.rootNodes.length;k++){
            var root_node=relation.rootNodes[k];    //获取当前根节点
            var node_arr=new Array();   //存放依次遍历的同级节点,首次放入根节点,逐步查找下一级别
            node_arr.push(root_node);

            var stepCount=1;    //从根节点级别时步长计数器归零

            //设置根节点的级别,根节点的步长为零
            nodeLevel[root_node]={};
            nodeLevel[root_node].breadth=stepCount;

            //递归查找search_node的最大步长
            recordNodeStep(node_arr,search_node,stepCount);

        }

    }
}

function recordNodeStep(arr,search_node,stepCount){
    if(arr!=null && arr!=undefined && arr.length>0){
        var temp_node_arr=new Array();  //临时存储下一级别节点的数组
        stepCount++;    //逐级增加步长,级别的判定就是arr数组的出现频次
        for(var n=0;n<arr.length;n++){
            var temp_node=arr[n];   //作为输出节点去查找输入点(即查找下一级节点)
            $.each(relation.links,function(l,link){
                var in_node=link.input.nodeId;
                var out_node=link.output.nodeId;
                if(temp_node==out_node){    //查找到输入点
                    if($.inArray(in_node,temp_node_arr)==-1){
                        temp_node_arr.push(in_node);
                    }
                    //节点作为输出点时找到对应的输入点,若输入点等于需要判定步长的节点怎记录步长信息
                    if(in_node==search_node){  //找到当前节点则记录当前步长
                        if(nodeLevel[in_node]==undefined){
                            nodeLevel[in_node]={};
                        }
                        //考虑到被判定步长的节点有可能存在多个根节点的关系链中且每次切换根节点计算步长都会将步长计数器归零,因此需要保留最大步长数
                        if(nodeLevel[in_node].breadth!=undefined && nodeLevel[in_node].breadth!=null){
                            var last_breadth=nodeLevel[in_node].breadth;
                            if(stepCount>last_breadth){
                                nodeLevel[in_node].breadth=stepCount;
                            }
                        }else{
                            nodeLevel[in_node].breadth=stepCount;
                        }
                    }
                }
            });
        }
        arr=temp_node_arr;
        recordNodeStep(arr,search_node,stepCount);


    }
}

3. 根据每个节点的最大步长计算节点的深度级别,这样最终可以通过坐标的方式定位节点的位置

 可以遍历每个节点的步长Map对象,然后以步长做为key,初始化每个步长的深度级别为0。然后再次遍历节点的步长Map对象,取得当前步长的深度数,遇到同步长的节点深度+1即可。这样,接线的纵向排列问题即可解决。

代码如下:

function setNodesDeepth(){
    var deepthLevel={};
    $.each(nodeLevel,function(i,node){
        var breadth=node.breadth;
        if(deepthLevel[breadth]==undefined && deepthLevel[breadth]==null){
            deepthLevel[breadth]=0;
        }
    });

    $.each(nodeLevel,function(i,node){
        var breadth=node.breadth;
        deepthLevel[breadth]+=1;
        node.deepth=deepthLevel[breadth];
    });


}

Okay,Dag最关键的核心步长定位解决了。不过我们之前还有一个individual的数组用来存放单独的节点,这个就简单啦,完全可以将它们全部横向展示在svg画布上的顶端。可以直接遍历这个数组,每个节点的横向步长逐个+1即可,纵向级别可固定为1。然后计算节点的X,Y坐标位置放置到svg画布上即可。

三、关于接线点和绘制和曲线的绘制说明

  1. 接线点

因为本例用的是圆形的节点,接线点也是在圆形的边界上,所以还是以圆形节点为例。计算方式其实就是使用的JavaScript的Math对象的sin和cos函数来确定接线点的位置的。(不会使用的小伙伴可以上网上搜一下,好多的例子,不再赘述了。)

  2.曲线

曲线的绘制时通过svg的path路径绘制的,看了网上的例子,只要确定起止点的x和y坐标即可。

例子如下:起始点坐标(354,164) 终止点坐标(762,80),然后结合例子看一下就知道怎么放置位置了吧。

<path d="M354,164C762,164,354,80,762,80" stroke-width="3" fill="none" stroke="#dddddd"></path>

 

结束语

本文主要介绍了一下在不会算法的情况下,如何使用递归获取有向无环图中各个节点的最大步长。以此来设置各节点的位置信息来实现dag的布局。通过此方法,我们只需要知道节点和节点的关系即可绘制出一幅dag有向无环图了。如果希望用户交互和体验更好些,可以实现svg缩放效果和移动效果。可以使用svg的scale和translate方法来实现。

 第一次写文章,如果有欠缺和不足的地方,欢迎大家指正探讨,不尽详细,感谢阅读。