接到了数据血缘的需求,前端要求效果类似sqlflow。通过大佬的类似demo发现了jsplumb这个连线库。然后看文档和github一些demo捣鼓出来了。基本效果如下:
连线样式为贝塞尔曲线的表现:
连线样式为状态机的表现:
https://github.com/mizuhokaga/jsplumb-dataLineage
(后端示例json项目附带,后端项目待开源。可参考格式,需注意github中提到的json的node对象的属性id不能带特殊符号和数字!)
目前已实现效果:
设计思想参考无临时表的sqlflow。在没有临时表的情况下,数据血缘只有两种表,起源表和目标(结果)表。起源表在画布左边,仅需要右边的锚点(锚点是jsPlumb的概念,参考jsplumb中文文档)目标表在画布右边仅需要左边的锚点。设计目标类似下图,注意我关闭了show intermediate recordset
,即不显示临时表。
所以我先根据后端json数据依靠模板渲染出不同类型的节点(节点就是起源表和目标表)设置好锚点,再利用jsplumb连线、绑定事件。
<!-- 起源表-->
<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>
function main() {
jsPlumb.setContainer('bg');
// 请求接口血缘json
$.get(requestURL, function (res, status) {
if (status === "success") {
jsonData = res;
DataDraw.draw(jsonData)
}
}, 'json');
// 或使用本地数据
// DataDraw.draw(json);
}
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");
});