Vue版本已开源,欢迎移步github,Vue版本的介绍文章链接点击这里

一、概况

接到了数据血缘的需求,前端要求效果类似sqlflow。通过大佬的类似demo发现了jsplumb这个连线库。然后看文档和github一些demo捣鼓出来了。基本效果如下:
连线样式为贝塞尔曲线的表现:
在这里插入图片描述
连线样式为状态机的表现:
在这里插入图片描述

项目地址 github:jsplumb-dataLineage

https://github.com/mizuhokaga/jsplumb-dataLineage

  • 项目代码更新过,这篇文章参考价值已不大~
  • 项目json中的坐标需要后端自行设计赋予,坐标我这边设计是由后端计算的,前端传显示区域的长和宽,后端用拓扑排序算法来计算生成,因为jsplumb 本身只管渲染,也不维护坐标等等,我这边拓扑算法参考这篇文章:https://www.dazhuanlan.com/tong08/topics/982245

(后端示例json项目附带,后端项目待开源。可参考格式,需注意github中提到的json的node对象的属性id不能带特殊符号和数字!)
目前已实现效果:

  • 流程图下载为png图片
  • 流程图下载为json数据
  • 流程图缩放
  • 流程图拖动
  • 选择连线,线两端的节点高亮

二、主流程

设计思想参考无临时表的sqlflow。在没有临时表的情况下,数据血缘只有两种表,起源表和目标(结果)表。起源表在画布左边,仅需要右边的锚点(锚点是jsPlumb的概念,参考jsplumb中文文档)目标表在画布右边仅需要左边的锚点。设计目标类似下图,注意我关闭了show intermediate recordset,即不显示临时表。

没有临时表情况下的sqlflow

所以我先根据后端json数据依靠模板渲染出不同类型的节点(节点就是起源表和目标表)设置好锚点,再利用jsplumb连线、绑定事件。

1.血缘里有两种表,起源表和目标表,所以我们需要两个js模板
   <!--    起源表-->
<script id="tpl-Origin" type="text/html">
    <div class="pa" id='{{id}}' style='top:{{top}}px;left:{{left}}px'>
        <div class="panel panel-node panel-node-origin" id='{{id}}-inner'>
            <div id='{{id}}-heading' data-id="{{id}}" class="table-header">{{name}}</div>

            <ul id='{{id}}-cols' class="col-group">
            </ul>
        </div>
    </div>
</script>
 <!--    目标表-->
<script id="tpl-RS" type="text/html">
    <div class="pa" id='{{id}}' style='top:{{top}}px;left:{{left}}px'>
        <div class="panel  panel-node panel-node-rs" d='{{id}}-inner'>
            <div id='{{id}}-heading' data-id="{{id}}" class="table-header"
                 style="background-color: #d26b58;color: white"> {{name}}
            </div>
            <ul id='{{id}}-cols' class="col-group">
            </ul>
        </div>
    </div>
</script>
2.发请求给接口获取血缘json数据
 function main() {
        jsPlumb.setContainer('bg');

        // 请求接口血缘json
        $.get(requestURL, function (res, status) {
            if (status === "success") {
                jsonData = res;
                DataDraw.draw(jsonData)
            }
        }, 'json');

        // 或使用本地数据
        // DataDraw.draw(json);
    }
3.根据后端传来的数据来渲染节点和连线
 var DataDraw = {
        // 核心方法
        draw: function (json) {
            var $container = $(areaId)
            var that = this
            //遍历渲染所有节点
            json.nodes.forEach(function (item, key) {
                var data = {
                    id: item.id,
                    name: item.id,
                    top: item.top,
                    left: item.left,
                };
                //根据不同类型的表获取各自的模板并填充数据
                var template = that.getTemplate(item);
                $container.append(Mustache.render(template, data));
                //根据json数据添加表的每个列
                //将类数组对象转换为真正数组避免前端报错 XX.forEach is not a function
                item.columns = Array.from(item.columns);
                //将该表的所有列
                item.columns.forEach(col => {
                    var ul = $('#' + item.id + '-cols');
                    //这里li标签的id应该和 addEndpointOfXXX方法里的保持一致 col-group-item
                    var li = $("<li id='id-col' class='panel-node-list' >col_replace</li>");

                    //修改每个列名所在li标签的id使其独一无二
                    li[0].id = item.name + '.' + col.name
                    //填充列名
                    li[0].innerText = col.name;
                    ul.append(li);
                });
                //根据节点类型找到不同模板各自的 添加端点 方法
                if (that['addEndpointOf' + item.type]) {
                    that['addEndpointOf' + item.type](item)
                }
            });
            //最后连线
            this.finalConnect(json.nodes, json.relations)
        },

根据不同类型的模板添加节点的方法:

   addEndpointOfOrigin: function (node) {
            //节点设置可拖拽
            addDraggable(node.id);
            node.columns = Array.from(node.columns);
            node.columns.forEach(function (col) {
                //这里的id应该和draw方法里设置的id保持一致
                setOriginPoint(node.id + '.' + col.name, 'Right')
            })
        },
        addEndpointOfRS: function (node) {
            addDraggable(node.id)
            node.columns = Array.from(node.columns);
            node.columns.forEach(function (col) {
                setRSPoint(node.id + '.' + col.name, 'Left')
            })
        },

连线的方法,注释地很详细:

  //根据节点类型找到对应的渲染方法
        finalConnect: function (nodes, relations) {
            var that = this;
            nodes.forEach(function (node) {
                //RS表要排除,
                if (node.id != 'RS' && node.type != 'RS') {
                    //遍历每个表的每个列
                    node.columns.forEach(col => {
                        relations.forEach(relation => {
                            var relName = relation.source.parentName + '.' + relation.source.column;
                            var nodeName = node.name + '.' + col.name;
                            //如果关系中的起始关系等于当前表节点的列,就构建连接
                            if (relName === nodeName) {
                                //这里sourceUUID、targetUUID应该和addEndpoint里设置的uuid一致
                                var sourceUUID = nodeName + "-OriginTable";
                                var targetUUID = relation.target.parentName + '.' + relation.target.column + '-RSTable';
                                that.connectEndpoint(sourceUUID, targetUUID);
                                //鼠标移动到连接线上后,两边的列高亮
                                jsPlumb.bind("mouseover", function (conn, originalEvent) {
                                    var src_name = conn.sourceId.split(".");
                                    var tar_name = conn.targetId.split(".");
                                    //注意 . 的转义,参考 https://blog.csdn.net/qq_44831907/article/details/120899676
                                    $("#" + src_name[0] + "-cols").find("#" + src_name[0] + "\\." + src_name[1]).css("background-color", "#faebd7");
                                    $("#" + tar_name[0] + "-cols").find("#" + tar_name[0] + "\\." + tar_name[1]).css("background-color", "#faebd7");
                                });
                                jsPlumb.bind("mouseout", function (conn, originalEvent) {
                                    var src_name = conn.sourceId.split(".");
                                    var tar_name = conn.targetId.split(".");
                                    $("#" + src_name[0] + "-cols").find("#" + src_name[0] + "\\." + src_name[1]).css("background-color", "#fff");
                                    $("#" + tar_name[0] + "-cols").find("#" + tar_name[0] + "\\." + tar_name[1]).css("background-color", "#fff");
                                });
                            }
                        });
                    });
                }
            })
        },
        //真正调用的方法还是jsplumb的连接方法
     connectEndpoint: function (from, to) {
            // 通过编码连接endPoint需要用到uuid
            jsPlumb.connect({uuids: [from, to]})
        },

获取模板的方法:

  getTemplate: function (node) {
            return $('#tpl-' + node.type).html();
        },

几个通用方法:


    // 获取基本配置
    function getBaseNodeConfig() {
        return Object.assign({}, visoConfig.baseStyle)
    };

    // 让元素可拖动
    function addDraggable(id) {
        jsPlumb.draggable(id, {
            containment: '#bg'
        })
    };

    // 设置起源表每一列的端点
    function setOriginPoint(id, position) {
        var config = getBaseNodeConfig()

        config.isSource = true
        //一个起源表的字段可能是多个RS字段的来源 这里-1不限制连线数
        config.maxConnections = -1


        jsPlumb.addEndpoint(id, {
            anchors: [position || 'Right',],
            uuid: id + '-OriginTable'
        }, config)
    };

    // 设置RS端点
    function setRSPoint(id, position) {
        var config = getBaseNodeConfig()

        config.isTarget = true
        //RS表一个字段可能是来自多个起源表字段 这里-1不限制连线数
        config.maxConnections = -1;
        jsPlumb.addEndpoint(id, {
            anchors: position || 'Left',
            uuid: id + '-RSTable'
        }, config)
    };

三、几个功能实现的记录

1.流程图下载为png图片
利用html2canvas这个js,由于jsplumb的线是svg无法被html2canvas识别,所以需要额外处理一下,参考这篇文章

 function download_png() {
        if (typeof html2canvas !== 'undefined') {
            var nodesToRecover = [];
            var nodesToRemove = [];
            var svgElem = $("#bg").find('svg');//注意修改选取的dom元素
            //将边(svg)转化了canvas的形式
            svgElem.each(function (index, node) {
                var parentNode = node.parentNode;
                var svg = node.outerHTML.trim();
                //canvas 容器
                var canvas = document.createElement('canvas');
                canvg(canvas, svg);
                if (node.style.position) {
                    canvas.style.position += node.style.position;
                    canvas.style.left += node.style.left;
                    canvas.style.top += node.style.top;
                }
                nodesToRecover.push({
                    parent: parentNode,
                    child: node
                });
                parentNode.removeChild(node);

                nodesToRemove.push({
                    parent: parentNode,
                    child: canvas
                });
                parentNode.appendChild(canvas);
            })
        }
        //scala属性解决生成的canvas模糊问题
        html2canvas($("#bg"), {taintTest: false, scale: 2}).then(canvas => {
            var a = document.createElement('a');
            //转换图片格式方法来自 https://blog.csdn.net/yzding1225/article/details/119215395
            var blob = this.dataURLToBlob(canvas.toDataURL('image/png'));
            //这块是保存图片操作  可以设置保存的图片的信息
            a.setAttribute('href', URL.createObjectURL(blob));
            //图片名称是当前 时间戳+uuid
            a.setAttribute('download', new Date().getTime() + this.getUUID() + '.png');
            a.click();
            URL.revokeObjectURL(blob);
            a.remove();
            //由于生成图片将svg转换了canvas导致边的hover事件失效,需要重新填入数据 or 刷新页面
            //TODO:目前直接刷新整个页面
            location.reload()
        });

    };

2.流程图下载为json
这里偷懒,直接把后端传过来的json下载了

   function download_json() {

        //如果血缘信息json是直接从后端请求过来的,直接下载接口数据
        var datastr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(jsonData));
        var a = document.createElement('a');
        a.setAttribute("href", datastr);
        a.setAttribute("download", new Date().getTime() + this.getUUID() + '.json');
        a.click();
        a.remove();
    };

3.流程图缩放
没什么好方法,暂时用的css的scala属性实现的

 //原始尺寸
    var baseZoom = 1;
    //重置缩放
    function reset() {
        if (this.baseZoom !== 1) {
            this.baseZoom = 1;
            const zoom = this.baseZoom;
            this.zoom(zoom);
            jsPlumb.setZoom(baseZoom);
        }
    }

    //缩放是整个画布及其内容一起缩放
    //参考 https://blog.csdn.net/KentKun/article/details/105230475
    function zoom(scale) {
        $("#bg").css({
            "-webkit-transform": `scale(${scale})`,
            "-moz-transform": `scale(${scale})`,
            "-ms-transform": `scale(${scale})`,
            "-o-transform": `scale(${scale})`,
            "transform": `scale(${scale})`,
            "transform-origin": "0% 0%"
        })
    };
//放大
    function zoomin() {
        this.baseZoom += 0.1;
        const zoom = this.baseZoom;
        this.zoom(zoom);
        jsPlumb.setZoom(zoom);
    };

    //缩小
    function zoomout() {
        this.baseZoom -= 0.1;
        const zoom = this.baseZoom;
        this.zoom(zoom);
        jsPlumb.setZoom(zoom);
    }

4.流程图拖动
本来想实现画布拖动,最后实现是把流程图中所有节点全部移动造成的假象,参考这里

   X = 0;
    Y = 0;
    bgX = $("#bg").width();
    bgY = $("#bg").height();
 //拖动功能不够完善又缺陷。参考 https://blog.csdn.net/join_null/article/details/80266993
    //松开鼠标右键
    function mouseup(event) {
        if (event.button == 2) {
            $("#bg").css("cursor", "Auto")
            this.flag = false;
        }
        // console.log(this.X+"|"+this.Y)
    }

    //按下鼠标右键
    function mousedown(event) {

        if (event.button == 2) {
            this.flag = true;
            $("#bg").css("cursor", "Grabbing");
            var bx = event.offsetX;
            var by = event.offsetY;
            this.X = bx;
            this.Y = by;
            // console.log(this.X + "|" + this.Y)
        }
    }

    //按住右键拖动,血缘关系图会在框架内移动
    function move(event) {
        if (flag && baseZoom===1) {
            //获取相对父元素的坐标
            var ax = event.offsetX;
            var ay = event.offsetY;
            var tmp_x = (ax - this.X), tmp_y = (ay - this.Y);
            // console.log(tmp_x + "t|" + tmp_y)
            if (this.flag) {
                $("#bg .pa").each(function (index, node) {
                    var a = tmp_x + $(node).position().left;
                    var b = tmp_y + $(node).position().top;
                    if (a >= bgX || a <= 0) a = bgX - $(node).width();
                    else if (b >= bgY || b <= 0) b = bgY - $(node).height();
                    else {
                        $(node).css('left', $(node).position().left+tmp_x/25);
                        $(node).css('top', $(node).position().top+tmp_y/25);
                    }
                });
                jsPlumb.repaintEverything();
            }
        }
    };

5.选择连线后线两端节点高亮
利用jsplumb的连线事件实现的

//连线
 that.connectEndpoint(sourceUUID, targetUUID);
//鼠标移动到连接线上后,两边的列高亮
jsPlumb.bind("mouseover", function (conn, originalEvent) {
         var src_name = conn.sourceId.split(".");
         var tar_name = conn.targetId.split(".");
                                    //注意 . 的转义,参考 https://blog.csdn.net/qq_44831907/article/details/120899676
      $("#" + src_name[0] + "-cols").find("#" + src_name[0] + "\\." + src_name[1]).css("background-color", "#faebd7");
      $("#" + tar_name[0] + "-cols").find("#" + tar_name[0] + "\\." + tar_name[1]).css("background-color", "#faebd7");
                                });
                                
jsPlumb.bind("mouseout", function (conn, originalEvent) {
       var src_name = conn.sourceId.split(".");
      var tar_name = conn.targetId.split(".");
       $("#" + src_name[0] + "-cols").find("#" + src_name[0] + "\\." + src_name[1]).css("background-color", "#fff");
      $("#" + tar_name[0] + "-cols").find("#" + tar_name[0] + "\\." + tar_name[1]).css("background-color", "#fff");
                                });

四 一些编写途中遇到的坑与解决方案记录

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐