动态渲染 Antv-X6 Pipeline DAG(计算坐标)

1,342 阅读5分钟

前言

最近在开发一款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流水线那,如下图

flowModel.png

开发前的思考

对照X6能识别的数据(也就是标准数据) 和 现有的数据,我们还需要提供什么属性,很显然,节点坐标x轴, y轴一定要给,节点的端口ports={id:null, group:'方向'} 要给。

边线数据 edges=[{source:{cell: '', port: ''}, target:{cell: '', port: ''}}] 要生成出来,一共有多少条线,明确出,每条线从哪个节点的哪个端口 --> 指向哪个节点的哪个端口。

深度思考

  1. id: 可取到 = refId
  2. shape: 可设置节点='dag-node', 边线='dag-edge'
  3. x, y 坐标:
    1. 先算出第一个节点的坐标,以第一个节点为基点
      • 先算出数据中最高的层数
      • 算出中线的值=(节点数 * 节点高度)+ (节点数 - 1)* 间距
      • 第一个节点的Y轴 = 中线
    2. 设计一个二维矩阵,存每一列的节点
      • 最终结构 [[1],[2],[3,4],[5]]
      • 遍历每一个节点
      • 如果当前Id的上游群,与当前列中元素有交集,则换列,否则,加入当前列
    3. 提取一个单独获取当前数据是第几列、第几个元素的方法
    4. 计算每一个元素的坐标
      • 以第一个元素为根基,每一个元素的 X 坐标值=当前列数 * X间距 + 列下标 * 节点宽度 / 2
      • 每一个元素的 Y 坐标值=中线值 / 列数 + Y间距 * 当前列的节点下标数 +当前列的节点下标数 * 节点高度 / 列数
  4. 端口:
    • 第一个节点没有左端口,最后一个节点没有右端口,其余的节点都有左右端口
  5. 边线
    • 从第二个节点开始遍历,根据上游数据,设计边

一张方法流转图

x6-flow.png

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];

渲染效果

我们看下最终数据渲染出的效果

endView.png

endView1.png

endView2.png