前言
最近在开发一款CI/CD平台,在应用配置模块下有一个CD Pipeline流水线的模块,对日常、测试、预发等环境做模版化处理。技术选型是蚂蚁的Antv-X6,里面有一个DAG for AI Model(有向无环图)的示例很符合我们的期望。
但是,我看了一下示例中的data.json,里面的节点都是有坐标属性的, 如下
node-节点数据
[
{
"id": "1",
"shape": "dag-node",
"x": 290,
"y": 110,
"data": {
"label": "读数据",
"status": "success"
},
"ports": [
{
"id": "1-1",
"group": "bottom"
}
]
},
]
edge-边线数据
[
{
"id": "5",
"shape": "dag-edge",
"source": {
"cell": "1",
"port": "1-1"
},
"target": {
"cell": "2",
"port": "2-1"
},
"zIndex": 0
},
]
后端能提供的数据
我们来看下服务端可以提供的数据类型是什么样子的, yes, that's all, 针好。
[
{ "refId": "1", "requisiteStageRefIds": [] },
{ "refId": "2", "requisiteStageRefIds": ["1"] },
{ "refId": "3", "requisiteStageRefIds": ["2"] },
{ "refId": "4", "requisiteStageRefIds": ["2"] },
{ "refId": "5", "requisiteStageRefIds": ["2","3","4"] }
]
解释一下,数据中 refId 是当前节点的id,requisiteStageRefIds 是当前节点的上游 id 集合,也就是,都有哪些节点指向当前节点。可以看到 refId=1
的节点是起始节点,因为它的上游节点为requisiteStageRefIds=[]
空数组
在此数据基础上,我们期望渲染出什么样子的Pipeline流水线那,如下图
开发前的思考
对照X6能识别的数据(也就是标准数据) 和 现有的数据,我们还需要提供什么属性,很显然,节点坐标x轴, y轴
一定要给,节点的端口ports={id:null, group:'方向'}
要给。
边线数据 edges=[{source:{cell: '', port: ''}, target:{cell: '', port: ''}}]
要生成出来,一共有多少条线,明确出,每条线从哪个节点的哪个端口 --> 指向哪个节点的哪个端口。
深度思考
- id: 可取到 = refId
- shape: 可设置
节点='dag-node', 边线='dag-edge'
- x, y 坐标:
- 先算出第一个节点的坐标,以第一个节点为基点
- 先算出数据中最高的层数
- 算出中线的值=(节点数 * 节点高度)+ (节点数 - 1)* 间距
- 第一个节点的Y轴 = 中线
- 设计一个二维矩阵,存每一列的节点
- 最终结构
[[1],[2],[3,4],[5]]
- 遍历每一个节点
- 如果当前Id的上游群,与当前列中元素有交集,则换列,否则,加入当前列
- 最终结构
- 提取一个单独获取当前数据是第几列、第几个元素的方法
- 计算每一个元素的坐标
- 以第一个元素为根基,每一个元素的 X 坐标值=
当前列数 * X间距 + 列下标 * 节点宽度 / 2
- 每一个元素的 Y 坐标值=
中线值 / 列数 + Y间距 * 当前列的节点下标数 +当前列的节点下标数 * 节点高度 / 列数
- 以第一个元素为根基,每一个元素的 X 坐标值=
- 先算出第一个节点的坐标,以第一个节点为基点
- 端口:
- 第一个节点没有左端口,最后一个节点没有右端口,其余的节点都有左右端口
- 边线
- 从第二个节点开始遍历,根据上游数据,设计边
一张方法流转图
Write Code
1. 入口方法
后端数据转化为 --> X6可识别的数据
function identifiableOfX6(datas: Datas[]) {
// 先找到并行最高层数
const level = findHighestParallelLevel(datas);
const { height: nodeHeight } = findNodeSize();
const centerLine = getCenterline(level, nodeHeight);
const oneNodeLocation: Location = getFirstOneNodeLocation(0, centerLine);
const dataInColumns: string[][] = nodesOfColumns(datas);
const haveLocationData: Array<Location & Datas> = setEveryDataLocation(datas, oneNodeLocation, dataInColumns, centerLine);
// 添加端口属性
const havePortsData = addPorts(haveLocationData);
// 补全节点属性:shape、label、status..
const completedNodes = completionNodeAttrs(havePortsData);
// 根据节点,创建所有的边
const allEdges = createAllEdges(completedNodes);
// 合并所有数据
const allRenderableDatas = [...completedNodes, ...allEdges];
return allRenderableDatas;
};
2. findHighestParallelLevel
找到动态数据中最高的层数,也就是,某节点被指向最多的那一列
// 计算整体最高层数
function findHighestParallelLevel(datas: Datas[]) {
let levels: number[] = datas?.map(data => data?.requisiteStageRefIds?.length);
const max = levels?.sort()?.reverse()?.[0];
return max;
};
3. findNodeSize
获取节点的宽高值的方法,因为我们在初始化 X6-Graph 实例的时候,会先执行注册节点和边的方法
function registerNode(graphConstructor: typeof Graph) {
graphConstructor.registerNode(
'dag-node',
{
inherit: 'react-shape',
width: 180,
height: 36,
component: <AlgoNode />,
ports: {...}
}
...
)
}
所以在此可以通过 graph?.getNodes()
方法获取节点的尺寸值
// 获取节点尺寸
function findNodeSize():Size {
const nodes = graph?.getNodes();
const size: Size = nodes && nodes![0]?.size();
return size;
};
4. getCenterline
获取中心线 === 第一个节点的 y 坐标
/**
* (节点数 * 节点高度)+ (节点数 - 1)* 间距 / 2
*/
function getCenterline(maxLevel: number, nodeHeight: number): number {
return (maxLevel * nodeHeight + (maxLevel - 1) * 50) / 2;
};
5. getFirstOneNodeLocation
getFirstOneNodeLocation 只是返回了传入的值,在此返回第一个节点的坐标
function getFirstOneNodeLocation(x: number, y: number): Location {
return { x, y };
};
6. nodesOfColumns
当前 refId 的上游id集合里[...]
,是否有当前 columns 的值[[...],[...]]
function nodesOfColumns(datas: Datas[]): string[][] {
let columns: string[][] = [[]];
let currentColumns = 0;
for (let i = 0; i < datas?.length; i++) {
let currentId = datas[i]?.refId;
// 有同列, 这里的判断条件需要更严谨一些
if (rigorous(datas[i].requisiteStageRefIds, columns[currentColumns])) {
++currentColumns;
columns[currentColumns] = [];
};
// 没有换列
columns[currentColumns]?.push(currentId);
};
return columns;
};
function rigorous(pres: string[], columns: string[]) {
return pres?.some(pre => columns?.includes(pre));
};
7. setEveryDataLocation
设置每个节点的坐标值 { x: xx, y: xx }
const spaceX = 150;
const spaceY = 60;
function setEveryDataLocation(allData: Datas[], oneNodeLocation: Location, dataInColumns: string[][], centerLine: number) {
const { width: nodeWidth, height: nodeHeight } = findNodeSize();
// 从第二个元素开始,第一个元素已经确定
for(let i = 0; i < allData?.length; i++) {
if (i == 0) {
allData[i] = { ...allData[i], ...oneNodeLocation };
} else {
// 获取当前列的信息
const currentColumnsInfo: CurrentDataColumnsInfo = currentDataColumnsInfo(allData[i], dataInColumns)!;
console.log(`当前id=${allData[i].refId}, 属于第${currentColumnsInfo?.columnskey}列,该列有${currentColumnsInfo?.columnsNumber}个元素,该元素属于该列的第${currentColumnsInfo?.whichIndexInCurrentColumn}个元素`);
const location: Location = {
x: locationX(currentColumnsInfo, nodeWidth),
y: locationY(currentColumnsInfo, nodeHeight, centerLine)
};
console.log(`当前id=${allData[i].refId}的x=${location.x}, y=${location.y}`);
allData[i] = { ...allData[i], ...location };
};
};
return allData as Array<Location & Datas>;
};
计算节点X轴坐标值
function locationX(currentColumnsInfo:CurrentDataColumnsInfo, nodeWidth:number) {
const { columnskey } = currentColumnsInfo;
return Math.ceil(columnskey * spaceX + columnskey * nodeWidth / 2);
};
计算节点Y轴坐标值
function locationY(currentColumnsInfo:CurrentDataColumnsInfo, nodeHeight:number, centerLine: number) {
const { columnsNumber, whichIndexInCurrentColumn } = currentColumnsInfo;
return Math.ceil(
centerLine / columnsNumber +
spaceY * whichIndexInCurrentColumn +
whichIndexInCurrentColumn * nodeHeight / columnsNumber
);
};
8. addPorts
给每个节点添加完坐标
之后,还要给每个节点添加端口
属性,因为一个节点端口有不同的方向,每个方向也可以有不同数量的端口,所以端口也是个很重要属性,它决定边线是否准确的连接
function addPorts(allData: Array<Location & Datas>) {
// 首节点没有左端口,末节点没有右端口
return allData?.map((data: Location & Datas, index: number) => {
const first = index === 0;
const last = index + 1 === allData?.length;
if (first || last) {
return { ...data, ports: [{ id: `${data?.refId}-1`, group: first ? 'right': 'left' }] };
};
const leftPort = { id: `${data?.refId}-1`, group: 'left' };
const rightPort = { id: `${data?.refId}-2`, group: 'right' };
return {
...data,
ports: [leftPort, rightPort]
};
});
};
9. completionNodeAttrs
补全节点属性: shape、label、status..等
function completionNodeAttrs(datas: Array<Location & Datas>) {
return datas?.map((data: Location & Datas) => ({
...data,
id: data?.refId,
shape: 'dag-node',
data: {
label: data?.name,
status: data?.type
}
})) as Array<Location & Datas & OtherAttrs>;
};
10. createAllEdges
现在节点完备了,那根据节点创建所有的边
function createAllEdges(datas: Array<Location & Datas & OtherAttrs>) {
let nodeLastestId = datas?.length;
let allEdges:Edge[] = [];
// 注意: 从第二项开始,因为第一项没有上游
for(let i = 0; i < datas?.length; i++) {
// 每一个上游
for (let j = 0; j < datas[i]?.requisiteStageRefIds?.length; j++) {
++nodeLastestId
let currentIdCurrentPre = datas[i]?.requisiteStageRefIds[j];
let currentId = datas[i]?.refId;
allEdges?.push({
id: Number(nodeLastestId).toString(),
shape: 'dag-edge',
source: {
cell: currentIdCurrentPre,
port: `${currentIdCurrentPre}-${Number(currentIdCurrentPre) === 1 ? '1': '2' }`,
},
target: {
cell: currentId,
port: `${currentId}-1`,
},
zIndex: 0
});
};
};
return allEdges;
};
11. 合并所有数据
目前此数据可传入 X6-DAG 模型渲染出Pipeline流水线了
const allRenderableDatas = [...completedNodes, ...allEdges];
渲染效果
我们看下最终数据渲染出的效果