「AntV」7.xflow自定义布局

1,836 阅读5分钟

注意:写文章的时候,我用的xflow版本是1.x的

image.png 像上面这种布局,xflow或者x6是没有的,最接近的也只有deg,但是不满足。

上面的图有几个特点

  1. 布局是左右的,相当于横过来的树形
  2. 箭头的方向不是固定的,负责人那里箭头是指向回来的
  3. 注意负责人指向国资委的那根线,箭头不是连接在国资委这个节点上,而是连接在中间的线段上的

1. 思路

思路来自x6文档的思维导图,这里面关于layoutconnector的使用很有帮助。

主要思路就是:

  1. 使用第三方布局算法,把节点的位置计算出来
  2. 配置connector连接器,来自定义箭头方向,以及线段的走向,比如直角走多远。
  3. 将位置信息写入graphData,渲染出来

2. 处理布局

有两种方式:

  1. 使用xflow文档上的自定义布局
<XFlow
    graphLayout={{
      customLayout: async (graphData: NsGraph.IGraphData) => {
        /** 自定义布局算法, 为每一个节点node赋予渲染所需的x,y属性 */
        return graphData
      }
    }}
>
</XFlow>

2. 自己处理了graphData后赋值

我用的这个方法,因为要处理后端来的数据顺便就做了个布局。

2.1 布局库

先介绍一个布局算法库hierarchy,这个是antv的东西,里面有个树形布局compact-box,恰好满足我们的需求,这是g6自带的,但是xflow好像不支持。x6也有示例,也是用的这个库。

hierarchy没几个api,可以直接参考g6的文档。

// 使用方法很简单
import Hierarchy from '@antv/hierarchy';
// 这里的treeData就是一个树形的数据
// {id:xx,children: [{id: xx, children: [{id: xxx}]}, {id: xx}]}
const compactBoxLayout = Hierarchy.compactBox(treeData, {、
   // 方向为水平
  direction: 'H',
  // 宽高为固定值
  getHeight(d: any) {
    return NODE_SIZE.height;
  },
  getWidth(d: any) {
    return NODE_SIZE.width;
  },
  // 水平间距
  getHGap() {
    return 80;
  },
  // 垂直间距
  getVGap() {
    return 5;
  },
  // 节点位置
  getSide: (node: any) => {
    return node.data.side;
  },
});

返回的就是一个包含节点x、y的对象

{
    id: xxx,
    x: x,
    y: x
    children: [
        {
            id: xxx,
            x: x,
            y: x
        }
    ]
}

2.2 转化为图数据

有了节点位置信息,就可以把图的数据组合出来。

const graphData: NsGraph.IGraphData = { nodes: [], edges: [] };
// 递归遍历树,组合数据
const traverse = (parentItem: any) => {
  if (parentItem) {
    // 父节点
    graphData.nodes.push({
      id: parentItem.data.id,
      renderKey: 'NODE',
      x: parentItem.x,
      y: parentItem.y,
      width: NODE_SIZE.width,
      height: NODE_SIZE.height,
      label: parentItem.data.label,
      nodeType: parentItem.data.nodeType,
      side: parentItem.data.side,
      children: parentItem.data.children,
      // 插桩配置
      ports: {
        // 定义插桩位置配置
        groups: {
          left: {
            position: 'left',
          },
          right: {
            position: 'right',
          },
        },
        // 
        items: [
          {
            id: 'left' + parentItem.data.id,
            group: 'left',
            attrs: {
              // 隐藏插桩圆圈
              circle: {
                r: 0,
              },
            },
          },
          {
            id: 'right' + parentItem.data.id,
            group: 'right',
            attrs: {
              circle: {
                r: 0,
              },
            },
          },
        ],
      },
    });

    // 如果有子节点,遍历子节点
    if (parentItem.children) {
      parentItem.children.forEach((child: any) => {
        let source = parentItem.id;
        let target = child.id;
        let sourcePortId = child.data.side + parentItem?.data.id;
        let targetPortId = (child.data.side === 'left' ? 'right' : 'left') + child.data.id;
        // 配置边
        graphData.edges.push({
          id: nanoid(),
          renderKey: 'EDGE',
          // 节点源和目标
          source,
          target,
          // 插桩源和目标
          sourcePortId,
          targetPortId,
          attrs: {
            line: {
              // 翻转连线的时候,正常连线的箭头应该去掉
              targetMarker: {
                name: child.data.filpEdge ? '' : 'block',
              },
            },
          },
          // 自定义连接器,这个后面解释
          connector: {
            name: 'mindmap',
          },
        });
    
        // 可以看到图中负责人那里是反过来的箭头
        // 反转连线的情况加一根线,反向连接
        // 为什么要翻转在连一根?
        // 因为我这个箭头是在线段中间,直接设置offset不好控制
        // 还不如再连一条线设置线段位置箭头就自然对了
        if (child.data.filpEdge) {
          // 翻转嘛,交换源和目标插桩
          [sourcePortId, targetPortId] = [targetPortId, sourcePortId];
          graphData.edges.push({
            id: nanoid(),
            source: child.id,
            /**
             * 本来目标节点就是父节点,但是负责人连接的时候不是直接连接在国资委节点上
             * 而是连接在线段上的
             * 所以这里需要判断一下
             * 
             * 判断的思路就是,如果连接目标是根节点,就连接线段而不是插桩
             * 
             * 当然这个思路是基于我的业务的,因为负责人这一个分支,只有负责人->国资委这个线段是这样连的
             */
            target: parentItem.data.nodeType === 'primary' ? graphData.edges[graphData.edges.length - 1].id : parentItem.id,
            sourcePortId,
            targetPortId,
            // 由于是连接在线段上,所以连接器也要切换成另外一种
            // 连接器后面解释
            connector: {
              name: parentItem.data.nodeType === 'primary' ? 'mindmap2' : 'mindmap',
            },
          });
        }
        traverse(child);
      });
    }
  }
};
traverse(compactBoxLayout);

// 最后的结果就是graphData保存的数据

这里面主要做了下面几件事:

  1. 遍历布局算法生成的布局树
  2. 根据节点关系,配置连线,并处理反向连接的情况

2.3. 自定义连接器connector

连接器x6里面的概念,和路由有点混。 他们都是控制边渲染方式的配置。

对于路由来说,它是控制路径点 vertices来改变边的渲染,也就是这条边经过哪些点,是平滑过度还是垂直变化。

而连接器,是控制path元素的d属性,也就是怎么画出这条线。

我选择连接器实现的原因有下面几点:

  1. 自带的路由不能实现功能,最多这样
// getGraphConfig
//...
    connecting: {
      router: {
        name: 'er',
        args: {
          min: 16,
          direction: 'H',
          offset: 'center',
        },
      },
    },
//...

image.png 这个箭头有点问题

  1. 自定义路由对于我来说不直观

当然你也可以参考文档自定义路由

2.3.1. 第一种连接器

第一种就是最普通的那种,父节点指向子节点。只不过弯折的时候是直角。

import { Graph } from '@antv/x6';
// 在createGraphConfig之前注册连接器
Graph.registerConnector(
  'mindmap', 
  function (sourcePoint, targetPoint, routerPoints, options) {
    // sourcePoint和this.sourceBBox有一点点区别
    // 前者是加上了插桩的尺寸,后者只有节点尺寸
    // 大部分情况下是一样的,只是看你自己需求
    const { x: targetX } = this.targetBBox;
    let { x: sourceX } = this.sourceBBox;
    const targetData = this.targetView?.cell.getData();
    // 间距就取的中间
    const gap = (targetX - sourceX) / 2;
    // d属性的值
    // 不懂的可以简单复习一下svg的语法
    const pathData = `
                       M ${sourceX} ${sourcePoint.y}
                       L ${sourceX + gap} ${sourcePoint.y}
                       L ${sourceX + gap} ${targetPoint.y}
                       L ${targetX} ${targetPoint.y}
                      `;
    return pathData;
  },
  true,
);

这个路径大概的意思就是

image.png

2.3.2. 第二种连接器

第二种和第一种大同小异,第二种是反向连接的时候用的

Graph.registerConnector(
  'mindmap2',
  function (sourcePoint, targetPoint, routerPoints, options) {
    const targetCell = this.targetView?.cell as X6Edge;
    const gap = ((targetCell.getSourcePoint().x ?? 0) - (targetCell.getTargetPoint().x ?? 0)) / 2;
    const pathData = `
                       M ${sourcePoint.x} ${sourcePoint.y}
                       L ${sourcePoint.x + gap} ${sourcePoint.y}
                      `;
    return pathData;
  },
  true,
);

image.png