【已开源】基于jsPlumb.js的模仿sqlFlow数据血缘图的前端页面

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

一、概况

接到了数据血缘的需求,前端要求效果类似sqlflow。通过大佬的类似demo发现了jsplumb这个连线库。然后看文档和github一些demo捣鼓出来了。基本效果如下:
连线样式为贝塞尔曲线的表现:
【已开源】基于jsPlumb.js的模仿sqlFlow数据血缘图的前端页面_第1张图片
连线样式为状态机的表现:
【已开源】基于jsPlumb.js的模仿sqlFlow数据血缘图的前端页面_第2张图片

项目地址 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,即不显示临时表。

【已开源】基于jsPlumb.js的模仿sqlFlow数据血缘图的前端页面_第3张图片

所以我先根据后端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 = $("
  • col_replace
  • "
    ); //修改每个列名所在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");
                                    });
    

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

    • html2canvas截图模糊问题
    • jsplumb清空画布解决方案

    你可能感兴趣的:(数据血缘,javascript,前端,数据血缘,jsPlumb)