【22-Antv X6实现流程图】自定义react节点实现流程图-bysking

1,250 阅读4分钟

一、背景介绍

大家好,今天分享下这个流程图效果的实现,帮助有需要的同学,依赖框架 - antv x6, 功能如下

  • 流程图框架,x6
  • 流动线效果,自定义线样式
  • 自定义react节点
  • x6的布局库

image.png


二、demo页面

既然能看这篇文档,说明大家都有点前端依赖安装基础的,依赖请务必自行安装,参考版本如下:

   "@ant-design/icons": "^5.3.7",
    "@antv/x6": "^2.18.1",
    "@antv/x6-plugin-dnd": "^2.1.1",
    "@antv/x6-plugin-history": "^2.2.4",
    "@antv/x6-plugin-minimap": "^2.0.7",
    "@antv/x6-plugin-scroller": "^2.0.10",
    "@antv/x6-plugin-selection": "^2.2.2",
    "@antv/x6-plugin-snapline": "^2.1.7",
    "@antv/x6-react-components": "^2.0.8",
    "@antv/x6-react-shape": "^2.2.3",
    "@umijs/max": "^4.2.5",
    "antd": "^5.17.4",
    "dagre": "^0.8.5",
import { ProCard } from '@ant-design/pro-components';
import { useState } from 'react';
import GraphPanel from './graph-panel';

const dataLayout = {
  nodes: [
    {
      id: '1',
      shape: 'rect',
      label: '开始',
      data: {
        status: 'success',
      },
    },
    {
      id: '2',
      shape: 'rect',
      label: '处理',
      data: {
        status: 'success',
      },
    },
    {
      id: '3',
      shape: 'rect',
      label: '老王复核',
      data: {
        status: 'success',
      },
    },
    {
      id: '4',
      shape: 'rect',
      label: '老李复核',
      data: {
        status: 'success',
      },
    },
    {
      id: '5',
      shape: 'rect',
      label: '抽查',
      data: {
        status: 'processing',
      },
    },
    {
      id: '6',
      shape: 'rect',
      label: '审批',
      data: {
        status: 'processing',
      },
    },
    {
      id: '7',
      shape: 'rect',
      label: '完成',
      data: {
        status: 'init',
      },
    },
  ],
  edges: [
    {
      shape: 'edge',
      source: '1',
      target: '2',
    },
    {
      shape: 'edge',
      source: '2',
      target: '3',
    },
    {
      shape: 'edge',
      source: '2',
      target: '4',
    },
    {
      shape: 'edge',
      source: '2',
      target: '5',
    },
    {
      shape: 'edge',
      source: '3',
      target: '6',
    },
    {
      shape: 'edge',
      source: '4',
      target: '6',
    },
    {
      shape: 'edge',
      source: '6',
      target: '7',
    },
    {
      shape: 'edge',
      source: '5',
      target: '7',
    },
  ],
};
const nodeMap: Record<string, any> = {};
dataLayout.nodes = dataLayout.nodes.map((nodeItem) => {
  // 记录节点id到节点的映射
  nodeMap[nodeItem.id] = nodeItem;

  // 处理节点的宽度,根据节点的名字匹配暂定单字符16单位宽度
  let width = nodeItem.label?.length * 16;
  if (nodeItem.label?.length <= 2) {
    width = 40;
  }
  return {
    ...nodeItem,
    width: width,
    height: 16,
    shape: 'custom-react-node', // 增加shape用于渲染自定义react组件
  };
});

dataLayout.edges = dataLayout.edges.map((item) => {
  const sourceNode = nodeMap[item.source];
  const targetNode = nodeMap[item.target];

  if (!sourceNode || !targetNode) {
    return item;
  }
  let lineStyle = {
    stroke: 'green', // 线条颜色
    strokeWidth: 1, // 线条宽度
  };

  if (targetNode.data.status === 'processing') {
    lineStyle = {
      stroke: 'brown', // 线条颜色
      strokeWidth: 1, // 线条宽度
      strokeDasharray: '2 2', // 虚线样式设置
      style: {
        animation: 'flowing-line 10s infinite linear', // 应用动画
      },
    };
  }

  if (targetNode.data.status === 'init') {
    // 边的结束节点,如果是处理中,则这条边是虚线样式
    lineStyle = {
      ...lineStyle,
      stroke: '#b7b9c1',
    };
  }

  return {
    ...item,
    attrs: {
      line: {
        ...lineStyle,
        targetMarker: {
          name: 'classic', // 箭头类型,可以是 'block', 'classic' 等
          size: 4, // 调整箭头大小,默认是 10
          width: 4, // 箭头宽度
          height: 6, // 箭头高度
        },
      },
    },
  };
});

const Progress = () => {
  const [nodes] = useState(dataLayout.nodes);
  const [edges] = useState(dataLayout.edges);
  return (
    <div>
      <ProCard>
        <div>流程进度bysking</div>
        <div>
          <GraphPanel nodes={nodes} edges={edges} />
        </div>
      </ProCard>
    </div>
  );
};

export default Progress;


三、graph-panel

import { layoutGraph } from '@/pages/Table/graph-tool';
import { Graph } from '@antv/x6';
import { Scroller } from '@antv/x6-plugin-scroller';
import { Snapline } from '@antv/x6-plugin-snapline';
import { register } from '@antv/x6-react-shape';
import { useEffect, useRef } from 'react';
import CustomReactNode from './custom-react-node';
import './index.less';

type typeProps = {
  nodes: any;
  edges: any;
};

register({
  shape: 'custom-react-node',
  effect: ['data'],
  component: CustomReactNode,
});

const GraphPanel = (props: typeProps) => {
  const refContainer = useRef();
  const graphIns = useRef<Graph>();
  const scrollerIns = useRef<Scroller>();
  const { nodes, edges } = props;

  const initPlugins = (graph: Graph) => {
    graph.use(
      new Snapline({
        enabled: true,
      }),
    );

    scrollerIns.current?.dispose();
    // scrollerIns.current = new Scroller({
    //   enabled: true,
    //   // pageVisible: false,
    //   pageBreak: true,
    //   pannable: false,
    //   autoResize: true,
    //   autoResizeOptions: {
    //     border: 200,
    //   },
    // });

    // graph.use(scrollerIns.current);
  };

  const init = () => {
    graphIns.current = new Graph({
      interacting: false, // 禁用所有交互
      container: refContainer.current,
      autoResize: true,
      panning: false,
      virtual: false,
      mousewheel: false,
      // 设置画布背景颜色
      background: {
        // color: '#F2F7FA',
      },

      // 设置画布缩放阈值
      //   scaling: {
      //     min: 0.1,
      //     max: 3,
      //   },
      grid: {
        visible: false,
        type: 'doubleMesh',
        args: [
          {
            color: '#eee', // 主网格线颜色
            thickness: 1, // 主网格线宽度
          },
          {
            color: '#ddd', // 次网格线颜色
            thickness: 1, // 次网格线宽度
            factor: 2, // 主次网格线间隔
          },
        ],
      },
      connecting: {
        connectionPoint: {
          name: 'boundary',
          args: {
            offset: 2,
          },
        },
        router: {
          name: 'manhattan',
          args: {
            args: {
              padding: 20,
              step: 10,
              startDirections: ['right'],
              endDirections: ['left'],
            },
          },
        },
        connector: {
          name: 'rounded',
          args: {
            radius: 10,
          },
        },
      },
    });

    initPlugins(graphIns.current);
    graphIns.current?.fromJSON({
      nodes,
      edges,
    }); // 渲染默认节点数据

    setTimeout(() => {
      // 自动左右布局
      layoutGraph(graphIns.current as Graph, 'LR', 150, 30);
      resizeGraph();
    }, 100);
  };

  const resizeGraph = () => {
    // 更新画布大小
    const container = document.getElementById('container');
    if (!container || !graphIns.current) {
      return;
    }
    graphIns.current.resize(container.clientWidth, container.clientHeight);
    // 重新缩放适应
    graphIns.current.zoomToFit({ padding: 30 });
  };

  useEffect(() => {
    init();
  }, [props.edges, props.nodes]);

  useEffect(() => {
    // 监听窗口大小变化
    window.addEventListener('resize', () => {
      resizeGraph();
    });
  }, []);

  return (
    <div style={{ border: '1px solid rgba(5,5,5,0.05)' }}>
      <div style={{ height: '300px' }} id="container">
        <div ref={refContainer} />
      </div>
    </div>
  );
};

export default GraphPanel;

graph-panel/tool.ts

import { Graph } from '@antv/x6';
import dagre from 'dagre';

export const layoutGraph = (
  graph: Graph,
  dir: 'LR' | 'RL' | 'TB' | 'BT' = 'LR',
  width = 140,
  height = 30,
) => {
  const nodes = graph.getNodes();
  const edges = graph.getEdges();
  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: dir, nodesep: 16, ranksep: 16 });
  g.setDefaultEdgeLabel(() => ({}));

  nodes.forEach((node) => {
    g.setNode(node.id, {
      width: node.width || width,
      height: node.height || height,
    });
  });

  edges.forEach((edge) => {
    const source = edge.getSource();
    const target = edge.getTarget();
    g.setEdge(source.cell, target.cell);
  });

  dagre.layout(g);

  g.nodes().forEach((id) => {
    const node = graph.getCellById(id) as Node;
    if (node) {
      const pos = g.node(id);
      node.position(pos.x, pos.y);
    }
  });

  return; // 暂时不用下面的布局 todo

  edges.forEach((edge) => {
    const source = edge.getSourceNode()!;
    const target = edge.getTargetNode()!;
    const sourceBBox = source.getBBox();
    const targetBBox = target.getBBox();

    if ((dir === 'LR' || dir === 'RL') && sourceBBox.y !== targetBBox.y) {
      const gap =
        dir === 'LR'
          ? targetBBox.x - sourceBBox.x - sourceBBox.width
          : -sourceBBox.x + targetBBox.x + targetBBox.width;
      const fix = dir === 'LR' ? sourceBBox.width : 0;
      const x = sourceBBox.x + fix + gap / 2;
      edge.setVertices([
        { x, y: sourceBBox.center.y },
        { x, y: targetBBox.center.y },
      ]);
    } else if (
      (dir === 'TB' || dir === 'BT') &&
      sourceBBox.x !== targetBBox.x
    ) {
      const gap =
        dir === 'TB'
          ? targetBBox.y - sourceBBox.y - sourceBBox.height
          : -sourceBBox.y + targetBBox.y + targetBBox.height;
      const fix = dir === 'TB' ? sourceBBox.height : 0;
      const y = sourceBBox.y + fix + gap / 2;
      edge.setVertices([
        { x: sourceBBox.center.x, y },
        { x: targetBBox.center.x, y },
      ]);
    } else {
      edge.setVertices([]);
    }
  });
};

index.less

@keyframes ant-line {
    to {
        stroke-dashoffset: -1000
    }
  }

custome-react-node.tsx

import {
  CheckCircleTwoTone,
  Loading3QuartersOutlined,
} from '@ant-design/icons';
import { Node } from '@antv/x6';
import { useEffect, useState } from 'react';

const CustomReactNode = ({ node, ...rr }: { node: Node }) => {
  const label = node?.store?.data?.label;
  const [nodeData, setData] = useState(node.getData() || {});

  const onDataChanged = () => {
    setData(node.getData());
  };

  useEffect(() => {
    /** 监听节点变化事件 */
    node.on('change:data', onDataChanged);
    return () => {
      /** 销毁事件 */
      node.off('change:data', onDataChanged);
    };
  }, []);

  const renderStatus = () => {
    let iconMap = {
      success: (
        <CheckCircleTwoTone
          size={12}
          color="#694093"
          style={{ color: '#694093' }}
        />
      ),
      processing: (
        <Loading3QuartersOutlined
          spin
          style={{
            color: '#694093',
          }}
        />
      ),
      init: (
        <div
          style={{
            width: '16px',
            height: '16px',
            border: '1px solid #b7b9c1',
            borderRadius: '50%',
          }}
        ></div>
      ),
    };
    return (
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          height: '100%',
        }}
      >
        {iconMap[nodeData?.status]}
      </div>
    );
  };

  let bColor = nodeData?.status === 'processing' ? 'green' : 'rgba(5,5,5,0.2)';
  let isInit = nodeData?.status === 'init';

  return (
    <div
      style={{
        // border: `1px solid ${bColor}`,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'left',
        borderRadius: '4px',
        gap: '4px',
      }}
    >
      {renderStatus()}
      <div
        style={{
          color: isInit ? '#757a89' : '',
          fontSize: '10px',
        }}
      >
        {label}
      </div>
    </div>
  );
};

export default CustomReactNode;

结语

路过不加个关注再走?持续分享本人亲身经历的业务场景的一些实践,希望对你有帮助