蓝图是什么
这里的蓝图其实指的是使用图形化的技术来创建代码逻辑,像是虚幻引擎,建模软件 blender 等等都有这种功能,开发可以通过拖放节点,连线来决定代码执行顺序,可以设置参数,蓝图自带基础节点,比如流程类的循环,分支,数学类,判断等等
为什么选择X6
先分析下蓝图最基本的功能(图编辑)
- 需要一个画布进行节点的放置、缩放管理等
- 对各种类型的节点定义,拖拽到画布中,复制粘贴等
- 对节点进行位置更改,框选等
- 对节点连线,表示逻辑流程以及参数的设置等
如果要完全自己实现功能,需要的工作量还是很大的,所以肯定是要选择开源库的,我个人还是很喜欢 Antv 的,插件化,很容易定制和拓展,所以最后选了 X6 作为技术底座,来进行图编辑相关的功能,这是官网的介绍
X6 是基于 HTML 和 SVG 的图编辑引擎,提供低成本的定制能力和开箱即用的内置扩展,方便我们快速搭建 DAG 图、ER 图、流程图、血缘图等应用。
这样只需要关心事情就变成了
- 自定义节点格式以及对应图形
- 连线处理,参数设置
- 运行逻辑
最后技术选型选了 React + X6
实现
参考虚幻引擎的蓝图:
我们先要定义这个图形,然后区分节点类型,连接桩的布局可以使用 magnet,X6 里只要 dom 上带有 magnet 属性,就可以作为边的连接点,参数设置只需要有个类型对应的设置器就行了
初始化 X6 实例
这里初始化需要做的比较多,比如注册插件、注册节点 shape、注册连线的路由,定义合法节点等等
这里说一下缩放调整网格背景,如果不处理,缩小之后网格会比较难看
arduino
const gridSize = {
1: 16,
0.5: 16 * 2,
0.33: 16 * 3,
0.16: 16 * 6,
};
export function updateGridSize(graph: Graph, scale: number) {
if (scale <= 0.16) {
graph.setGridSize(gridSize[0.16]);
} else if (scale <= 0.33) {
graph.setGridSize(gridSize[0.33]);
} else if (scale <= 0.5) {
graph.setGridSize(gridSize[0.5]);
} else {
graph.setGridSize(gridSize[1]);
}
}
const graph: Graph = new Graph({
// 略
mousewheel: {
enabled: true,
guard(event: WheelEvent) {
const scale = graph.transform.getScale().sx;
if (scale >= 1 && event.deltaY < 80) {
if (event.ctrlKey) {
return true;
}
return false;
}
updateGridSize(graph, scale);
return true;
},
},
scaling: {
min: 0.13,
max: 2,
},
background: {
color: '#262626',
},
grid: {
visible: true,
type: 'doubleMesh',
size: 16,
args: [
{
color: '#353535', // 主网格线颜色
thickness: 2, // 主网格线宽度
},
{
color: '#161616', // 次网格线颜色
thickness: 2, // 次网格线宽度
factor: 8, // 主次网格线间隔
},
],
}
})
然后就是设置 allowPort 来判断边和连接点是否能够连接,比如 input 出来的边只能和 output 连接,并且类型需要一致,比如是否都为执行引脚或者是相同数值类型的参数,也可以联动 highlight 来做一些高亮等处理
定义基本图形
X6 也是支持自定义 shape,用的是 React 所以用 @antv/x6-react-shape 插件的 register,所以直接定义一个BaseNode 作为最基本的节点,头部 icon 以及 节点名称,加上内部的执行引脚和参数引脚
ini
import { register } from '@antv/x6-react-shape';
export enum ShapeName {
BASENODE = 'base-node',
}
function BaseNode(props) {
const data = props.node.getData();
const nodeWrapRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (nodeWrapRef.current) {
const boundRect = nodeWrapRef.current.getBoundingClientRect();
props.node.setSize(boundRect.width, boundRect.height);
}
}, []);
useLayoutEffect(() => {
const pos = props.node.getPosition();
props.node.setPosition(pos.x + 0.000000001, pos.y + 0.000000001);
}, []);
return (
<div className="custom-base-node" ref={nodeWrapRef}>
<div className="custom-base-node-top">{data.name}</div>
<div className="custom-base-node-content"></div>
</div>
);
}
register({
shape: ShapeName.BASENODE,
component: BaseNode,
});
这里第一个 useLayoutEffect 想要自适应节点的大小,所以设置了一次size,第二个则是因为 graph.fromJSON 渲染完发现边会出现错乱的情况,暂时用 setPosition hack了一下,还没去找真正的原因
这两点有更好的做法大家可以说一下
定义蓝图节点分类
因为流程总是由一个事件触发开始的,比如鼠标、键盘、或者是观察的事件等,然后根据执行引脚执行相关的流程,一般也是函数体,所以先枚举一个 NodeType 以及对应的 icon 以及 颜色 来区分下类型
vbnet
enum NodeType {
FUNCTION = 'Function',
EVENT = 'Event',
}
export const NodeTypeMeta = {
[NodeType.FUNCTION]: {
icon: SVGIcon.function,
color: '64, 109, 247',
},
[NodeType.EVENT]: {
icon: SVGIcon.event,
color: '180, 25, 25',
}
};
使用 JSON 描述节点
接下来就要想想如何使用 JSON 描述一个蓝图节点, X6 是推荐在 data 中去携带业务数据,并且提供 getData setData 等相关联的操作,所以我们额外的属性就放在 data 里即可,我们先简单定义一下需要的类型
typescript
export interface Pin {
// 引脚类型
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'exec';
// 设置器
setter?: string;
// 参数显示名称
displayName?: string;
// 参数名称
name?: string;
// 存储的类型 比如是 object 或者是 array
fields?: Pin | Pin[];
// 参数值
value?: unknown;
[key: string]: unknown;
}
export interface NodeDataItem {
//用于判断触发了哪一个节点
id: string;
//节点名称
name: string;
//节点类型
type: NodeType;
//节点的shape 默认是 base-node
nodeShape?: ShapeName;
//描述
description?: string;
//执行输入
execInput?: Pin[];
//执行输出
execOutput?: Pin[];
//参数输入
input?: Pin[];
//参数输出
output?: Pin[];
[key: string]: unknown;
}
假设是一个按钮 ,需要一个点击事件的节点描述, 让用户只需要这么写(这里 event 的 fields 参数太多, 就不写了):
json
{
"name": "onClick",
"displayName": "点击",
"output": [
{
"name": "event",
"displayName": "event",
"type": "object"
}
]
}
然后在 createNode 的时候添加一些默认的字段,比如 shape 直接用 ShapeName.BaseNode 和 type 为 NodeType.Event等字段,顺便存一个 originalData 来方便复制粘贴
kotlin
// data 为上面的 json 处理过的
const node = graph.createNode({
shape: data.nodeShape || ShapeName.BASENODE,
type: data.type,
width: 300,
height: 150,
...data.defaultX6Setting,
data: {
...data,
originalData: cloneDeep(data),
},
});
我们看看添加节点后展示的样子
如何执行流程
可以通过 X6 的 toJSON 把图的 Cells 全部存储起来,然后在代码运行的时候,通过执行边进行寻找下一个执行节点,参数也是通过边进行寻找,如果没有边则直接读取当前的 value,就可以了
效果展示
图相关的操作逻辑 X6 已经做的非常好了,所以只需要额外做一点事情就可以完成我们所需要的业务功能,这是最后在产品中集成的效果展示 点击链接查看.mp4
最后
在这个示例中的图表其实也是用的 Antv 的 G2,所以非常感谢 Antv 让我节省了很多时间,作为开源的使用者,所以在使用的过程中将遇到的问题解决后也会回馈到社区,在 issue 里或者群聊中解答疑问,虽然都是微不足道的小问题,但也算是做到从开源中来,到开源中去了
希望 Antv 越来越好,能够帮助到越来越多的人