ANTV X6 流程图编辑器应用
- 唠唠叨叨
- 事发背景
- 实现效果
- 实现思路
- 总结
- 参考资料
唠唠叨叨
唉!最近感觉非常忙,要学的东西太多,要实现的方法变幻莫测,层出不穷,越学越觉得自己不会的实在太多。
项目本来中标了,后来又不知道发生了什么,临到签合同又黄了,整的我们开发的瑟瑟发抖,产品整个大改,恐怕要……
只能说还是抓紧时间好好学习,知识和技术是永远不会背叛自己的。
事发背景
俺滴组长(后端大佬)要我配合开发数据平台的任务流全局调度系统,想用流程图实现作业运行配置。
于是我在各种流程图的前端框架中选了ANTV X6来引入实现。官方文档天天在看,可这知识他就是不入脑子呀TvT。
终于有一天腌肉某人一拍脑门醍醐灌顶实现了功能,遂记录实现过程及困难解决方法。
在官方文档中给出了场景案例,恰好就有我所需要的流程图:
实现效果
经过一系列拼拼凑凑,修修改改,还有样式设计和调整,调度作业画布页面如下图所示:
实现思路
- 首先我们要了解系统的业务功能需求,以及业务流程的线路是怎样的;
如图是窗口的拆解,其中①作业树使用的是element UI的el-tree组件,是用于储存画布内容和画布回显更新的,②是选中节点树中的某项流程作业进行对应的任务操作,③和④才是实际应用AntV X6的画布插件实现的可拖拽、增、删、改、配置的流程图编辑器。
通过vue的父子组件我们创建一个index.vue的父组件,然后将这四个部分拆分为三个子组件的.vue文件,分别为DataTree.vue、DataButton.vue、FiliationGraph.vue(包含③、④两部分)今天记录的是FiliationGraph.vue内节点配置画布的实现。
2. 安装AntV X6
通过 npm 或 yarn 命令安装 X6。
# npm
$ npm install @antv/x6 --save
# yarn
$ yarn add @antv/x6
- 初始化画布,在html中建立节点库和画布div容器:
<-- 节点库 -->
<div class="app-stencil" ref="stencilContainer"></div>
<-- 画布 -->
<div class="app-content" id="flowContainer" ref="container"></div>
// 引入
import { Graph, Shape, Addon, FunctionExt } from "@antv/x6";
const { Stencil } = Addon; //节点库
const {graph} = Graph; //画布
const stencil = new Stencil({ //新建节点库
title: "数据集成",
target: graph,
search: false, // 搜索
collapsable: true,
stencilGraphWidth: 300, //容器宽度
stencilGraphHeight: 600, //容器长度
groups: [ //分组
{
name: "processLibrary",
title: "dataSource",
},
],
layoutOptions: {
dx: 30,
dy: 20,
columns: 1, //列数(行内节点数)
columnWidth: 130, //列宽
rowHeight: 100, //行高
},
});
proxy.$refs.stencilContainer.appendChild(stencil.container) //注册到div中
const graph = new Graph({ // 新建画布
container: document.getElementById('flowContainer'),
width: 800,
height: 600,
background: {
color: "#F2F7FA",
},
});
- 初始化节点、边:
初始化节点、边数据:
const nodeData = {
// 节点
nodes: [
],
// 边
edges: [
],
};
准备节点样式内容数据,其中节点的左上角图标image是自定义的svg文件,根据产品ui或自己设计来使用哟。
const imageShapes = [
{
body: {
fill: "rgba(102, 153, 204, 0.05)",
stroke: "rgb(102, 153, 204)",
},
label: {
text: state.collectLabel,
fill: 'rgb(102, 153, 204)',
},
image: require('/src/assets/Scheduler/DataCollect.svg'),
},
{
body: {
fill: "rgba(185, 147, 214, 0.05)",
stroke: "rgb(185, 147, 214)",
},
label: {
text: state.flinkLabel,
fill: 'rgb(185, 147, 214)',
},
image: require('/src/assets/Scheduler/DataFlink.svg'),
},
{
body: {
fill: "rgba(154, 184, 122, 0.05)",
stroke: "rgb(154, 184, 122)",
},
label: {
text: state.controlLabel,
fill: 'rgb(154, 184, 122)',
},
image: require('/src/assets/Scheduler/DataQc.svg'),
},
{
body: {
fill: "rgba(247, 178, 103, 0.05)",
stroke: "rgb(247, 178, 103)",
},
label: {
text: state.monitorLabel,
fill: 'rgb(247, 178, 103)',
},
image: require('/src/assets/Scheduler/DataWatch.svg'),
},
{
body: {
fill: "rgba(219, 127, 142, 0.05)",
stroke: "rgb(219, 127, 142)",
},
label: {
text: state.standerLabel,
fill: 'rgb(219, 127, 142)',
},
image: require('/src/assets/Scheduler/DataStandred.svg'),
},
]
- 左侧模型栏节点样式:
初始化链接桩
const ports = {
groups: {
in: {
position: 'top',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#108ee9',
strokeWidth: 2,
fill: '#fff',
style: {
visibility: "hidden",
},
}
}
},
out: {
position: 'bottom',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#31d0c6',
strokeWidth: 2,
fill: '#fff',
style: {
visibility: "hidden",
},
}
}
}
},
items: [
{
id: state.currentCode+ '_in',
group: 'in',
},
{
id: state.currentCode + '_out',
group: 'out',
},
],
}
根据样式数据,设计画布左侧节点样式
Graph.registerNode( //注册节点
'custom-node',
{
inherit: 'rect', //基础图形
width: 140,
height: 76,
attrs: { //自定义样式
body: { //节点主体
strokeWidth: 1,
rx: 30,
ry: 30,
},
image: { //图片
width: 20,
height: 20,
x: 12,
y: 3,
},
text: { //主题文本
refX: 40,
refY: 15,
fontSize: 15,
'text-anchor': 'start',
},
label: { //标签名
text: 'Please nominate this node',
id: 0, //自定义传给接口的数据
data: {}, //这里是我加入的标签内自定义储存的数据内容
refX: 10,
refY: 30,
fontSize: 12,
fill: 'rgba(0,0,0,0.6)',
'text-anchor': 'start',
textWrap: { //如果节点命名标签过长可以使用这个属性来定义文本内容
width: -10, // 宽度减少 10px
height: '70%', // 高度为参照元素高度的一半
ellipsis: true, // 文本超出显示范围时,自动添加省略号
breakWord: true, // 是否截断单词
}
},
},
markup: [ //组合
{
tagName: 'rect',
selector: 'body',
},
{
tagName: 'image',
selector: 'image',
},
{
tagName: 'text',
selector: 'text',
},
{
tagName: 'text',
selector: 'label',
},
],
data: {},
relation: {},
ports: { ...ports }, //链接桩定义
},
true,
)
const imageNodes = imageShapes.map((item) => //呈现画布节点数据的样式
graph.createNode({
shape: 'custom-node',
attrs: {
image: {
'xlink:href': item.image,
},
body: item.body,
text: item.label,
},
}),
)
stencil.load( //载入左侧节点模型库
imageNodes,
"processLibrary"
);
6.定义边的样式(节点连线)
Graph.registerConnector(
'algo-edge',
(source, target) => {
const offset = 4
const control = 80
const v1 = { x: source.x, y: source.y + offset + control }
const v2 = { x: target.x, y: target.y - offset - control }
return `M ${source.x} ${source.y}
L ${source.x} ${source.y + offset}
C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${target.x} ${target.y - offset}
L ${target.x} ${target.y}`
},
true,
)
以及可以在初始化画布graph = new graph({})方法中,定义连线规则
// 连线规则
connecting: {
snap: true, // 当 snap 设置为 true 时连线的过程中距离节点或者连接桩 50px 时会触发自动吸附
allowBlank: false, // 是否允许连接到画布空白位置的点,默认为 true
allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点,默认为 true
allowMulti: false, // 当设置为 false 时,在起始和终止节点之间只允许创建一条边
highlight: true, // 拖动边时,是否高亮显示所有可用的连接桩或节点,默认值为 false。
sourceAnchor: { // 当连接到节点时,通过 sourceAnchor 来指定源节点的锚点。
name: 'bottom',
args: {
dx: 0,
},
},
targetAnchor: { // 当连接到节点时,通过 targetAnchor 来指定目标节点的锚点。
name: 'top',
args: {
dx: 0,
},
},
connectionPoint: 'anchor', // 指定连接点,默认值为 boundary。
connector: 'algo-edge', // 连接器将起点、路由返回的点、终点加工为 元素的 d 属性,决定了边渲染到画布后的样式,默认值为 normal。
createEdge() {
return graph.createEdge({
attrs: {
line: {
strokeDasharray: '5 5',
stroke: '#808080',
strokeWidth: 1,
targetMarker: {
name: 'block',
args: {
size: '6',
},
},
},
},
})
},
validateMagnet({ magnet }) {
return magnet.getAttribute('port-group') !== 'in'
},
validateConnection({
sourceView,
targetView,
sourceMagnet,
targetMagnet
}) {
if (sourceView === targetView) {
return false;
}
if (!sourceMagnet) {
return false;
}
// 只能连接到输入链接桩
if (
!targetMagnet ||
targetMagnet.getAttribute("port-group") !== "in"
) {
return false;
}
return true;
},
// 当停止拖动边的时候根据 validateEdge 返回值来判断边是否生效,如果返回 false, 该边会被清除。
validateEdge({ edge }) {
const { source, target } = edge
return true
}
},
7.方法们
graph.toJSON() // 数据导出
graph.fromJSON() // 数据渲染
graph.isPannable() // 画布是否可以平移
graph.enablePanning() // 启用画布平移
graph.centerContent(); // 中心对称
8.绑定事件
graph.on('node:added', ({ node, cell }) => {}) // 节点移入画布事件
graph.on("cell:dblclick", ({ node, cell }) => {}); // 节点双击事件
graph.on("node:mouseenter", ({ node }) => { // 节点删除操作
// 鼠标 Hover 节点时添加删除按钮
node.addTools({
name: "button-remove",
args: {
x: 0,
y: 0,
offset: { x: 10, y: 10 },
markup: [ //自定义的删除按钮样式
{
tagName: 'circle',
selector: 'button',
attrs: {
r: 8,
stroke: '#F25C54',
strokeWidth: 1,
fill: 'rgba(214, 40, 40, 0.25)',
cursor: 'pointer',
},
},
{
tagName: 'text',
textContent: '✕',
selector: 'icon',
attrs: {
fill: '#F25C54',
fontSize: 7,
textAnchor: 'middle',
pointerEvents: 'none',
y: '0.3em',
},
},
],
},
});
});
graph.on("node:removed", ({ node, options }) => { // 删除节点事件
if (!options.ui) {
return;
}
});
graph.on("node:mouseleave", ({ node }) => {
// 鼠标移开节点时删除删除按钮
node.removeTools();
});
// 线删除操作
graph.on("edge:mouseenter", ({ edge }) => {
// 鼠标 Hover 边时添加删除按钮
edge.addTools([
"target-arrowhead",
{
name: "button-remove",
args: {
distance: -30,
},
},
]);
});
graph.on("edge:removed", ({ edge, options }) => { // 移除连线事件
if (!options.ui) {
return;
}
const cellId = edge.getTargetCellId()
const target = graph.getCellById(cellId)、
if (target) {
const id = target.ports.items[0].id
target && target.setPortProp(id, 'connected', false)
}else{
target && target.setPortProp(cellId+'_in', 'connected', false)
}
});
graph.on("edge:mouseleave", ({ edge }) => {
// 鼠标移开边时删除删除按钮
edge.removeTools();
});
graph.on('node:change:data', ({ node }) => { // 修改节点数据的实时响应事件
node.data = eachNodeData
})
graph.on("node:contextmenu", ({ cell, view }) => { // 节点文本
const oldText = cell.attr("text/textWrap/text");
const elem = view.container.querySelector(".x6-edit-text");
if (elem == null) {
return;
}
cell.attr("text/style/display", "none");
if (elem) {
elem.style.display = "";
elem.contentEditable = "true";
elem.innerText = oldText;
elem.focus();
}
const onBlur = () => {
cell.attr("text/textWrap/text", elem.innerText);
cell.attr("text/style/display", "");
elem.style.display = "none";
elem.contentEditable = "false";
};
elem.addEventListener("blur", () => {
onBlur();
elem.removeEventListener("blur", onBlur);
});
});
const container = document.getElementById("flowContainer");
graph.on("node:mouseenter",FunctionExt.debounce(() => { // 节点链接桩显隐
const ports = container.querySelectorAll(".x6-port-body");
showPorts(ports, true);
}),
500
);
graph.on("node:mouseleave", () => { // 节点链接桩显隐
const ports = container.querySelectorAll(".x6-port-body");
showPorts(ports, false);
});
graph.bindKey("backspace", () => { // 点按空格后跳出带有节点的画布区域
const cells = graph.getSelectedCells();
if (cells.length) {
graph.removeCells(cells);
}
});
其他有趣的用法
//设置指定路径上的属性值cell.attr('text/text', value) 其中label/text为自定义标题
cell.attr('label/text', state.flinkLabel)
cell.attr('label/data', j)
9.根据接口数据回显画布流程图
//就是通过这个方法来实现的
graph.fromJSON(nodeData) // 数据渲染,括号内填入回显的数组数据变量
//而nodeData在前面2.提出的数据准备中初始化定义了,我们在接口返回的数据里填充数据和样式就搞定回显了
总结
在vue项目中,使用AntV X6实现节点和画布、连线的配置,非常灵活,一旦会用后有相当多的方法可以使用,自由度和定制度高。还有许多用法可以实现,如点击执行指令显示流程图的动画运行流:
还有自定义的布局等
剩下的其他拓展方法等我下次整理了再发,今天这篇太长了,肝不动了TvT。
参考资料
- ANTV-X6 流程图
- ANTV-X6 快速上手