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

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");
                                });

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

  • 20
    点赞
  • 78
    收藏
    觉得还不错? 一键收藏
  • 60
    评论
基于Three.js开源项目有许多,其中一个知名的项目是Gio.js。Gio.js是一个基于Three.js的web 3D地球数据可视化的开源组件库。使用Gio.js,开发者可以快速地创建自定义的Web3D数据可视化模型,并将其作为一个组件整合到自己的应用中。通过在HTML页面中添加Three.js和Gio.js的依赖,可以开始基于Gio.js开发应用。您可以创建一个具有基础样式的Gio地球,具体代码如下所示: ``` <!DOCTYPE HTML> <html> <head> <!-- 引入 three.js --> <script src="three.min.js"></script> <!-- 引入 Gio.js --> <script src="gio.min.js"></script> </head> <body> <!-- 创建一个 div 作为 Gio 的绘制容器 --> <div id="globalArea"></div> </body> </html> ``` 除了Gio.js,还有其他基于Three.js开源项目,如harp.gl。harp.gl是一个用TypeScript编写的3D网络地渲染引擎,使用了Three.js。您可以在harp.gl的GitHub地址中找到更多相关信息。 github.com/syt123450/gio.js giojs.org/index_zh.html GitHub - heremaps/harp.gl: 3D web map rendering engine written in TypeScript using three.js<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* [[开源] Gio.js -- 一个基于 Three.js 的 Web3D 地球数据可视化库](https://blog.csdn.net/weixin_34129696/article/details/87961256)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] - *3* [基于ThreeJs的一些开源项目](https://blog.csdn.net/lg8883573/article/details/122596587)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论 60
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值