Skip to content

使用 x6 实现简单的 flow 并支持节点 展开/收起

效果演示

我们的效果如下图所示:

定义数据

我们的数据结构和测试数据如下所示:

ts
interface Tree {
  id: string;
  label: string;
  children?: Tree[];
}

const data: Tree = {
  id: "1",
  label: "第1项",
  children: [
    {
      id: "1.1",
      label: "第1.1项",
      children: [
        {
          id: "1.1.1",
          label: "第1.1.1项",
        },
      ],
    },
    {
      id: "1.2",
      label: "第1.2项",
      children: [
        {
          id: "1.2.1",
          label: "第1.2.1项",
        },
      ],
    },
    {
      id: "1.3",
      label: "第1.3项",
    },
  ],
};
interface Tree {
  id: string;
  label: string;
  children?: Tree[];
}

const data: Tree = {
  id: "1",
  label: "第1项",
  children: [
    {
      id: "1.1",
      label: "第1.1项",
      children: [
        {
          id: "1.1.1",
          label: "第1.1.1项",
        },
      ],
    },
    {
      id: "1.2",
      label: "第1.2项",
      children: [
        {
          id: "1.2.1",
          label: "第1.2.1项",
        },
      ],
    },
    {
      id: "1.3",
      label: "第1.3项",
    },
  ],
};

自定义节点组件

src\components\CustomNode\index.module.css

css
.custom_node {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  border-radius: 4px;
  color: #262626;
  background: rgba(240, 245, 255, 0.5);
  border: 1px solid #597ef7;
}

.mr_1 {
  margin-right: 4px;
}
.custom_node {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  border-radius: 4px;
  color: #262626;
  background: rgba(240, 245, 255, 0.5);
  border: 1px solid #597ef7;
}

.mr_1 {
  margin-right: 4px;
}

src\components\CustomNode\index.tsx

ts
import { Node } from "@antv/x6";
import { useState } from "react";
import expandIcon from "../../assets/expand.png";
import retractIcon from "../../assets/retract.png";
import styles from "./index.module.css";

export default ({ node }: { node: Node }) => {
  // node 为当前节点实例,node.prop 获取节点上的某个属性
  const label = node.prop("label");
  const [expand, setExpand] = useState(true);

  const icon = expand ? retractIcon : expandIcon;

  const handleClick = () => {
    const result = !expand;
    setExpand(result);
  };

  return (
    <div className={styles.custom_node}>
      <span className={styles.mr_1}>{label}</span>
      <img style={{ width: "18px" }} onClick={handleClick} src={icon} />
    </div>
  );
};
import { Node } from "@antv/x6";
import { useState } from "react";
import expandIcon from "../../assets/expand.png";
import retractIcon from "../../assets/retract.png";
import styles from "./index.module.css";

export default ({ node }: { node: Node }) => {
  // node 为当前节点实例,node.prop 获取节点上的某个属性
  const label = node.prop("label");
  const [expand, setExpand] = useState(true);

  const icon = expand ? retractIcon : expandIcon;

  const handleClick = () => {
    const result = !expand;
    setExpand(result);
  };

  return (
    <div className={styles.custom_node}>
      <span className={styles.mr_1}>{label}</span>
      <img style={{ width: "18px" }} onClick={handleClick} src={icon} />
    </div>
  );
};

测试 CustomNode:组件中的 node prop 由 x6 提供,为了方便测试,我们可以自己传入 node

src\App.tsx

ts
import CustomNode from "./components/CustomNode";

const node: any = {
  prop() {
    return "标签一";
  },
};

export default () => {
  return (
    <div style={{ padding: "20px" }}>
      <div style={{ width: 172, height: 32 }}>
        <CustomNode node={node} />
      </div>
    </div>
  );
};
import CustomNode from "./components/CustomNode";

const node: any = {
  prop() {
    return "标签一";
  },
};

export default () => {
  return (
    <div style={{ padding: "20px" }}>
      <div style={{ width: 172, height: 32 }}>
        <CustomNode node={node} />
      </div>
    </div>
  );
};

效果如下所示: test.png

安装依赖

shell
npm install @antv/x6 @antv/x6-react-shape dagre @types/dagre  --save
npm install @antv/x6 @antv/x6-react-shape dagre @types/dagre  --save

其中 x6x6-react-shape 提供 flow 组件,dagre 提供自动布局

编写工具类函数

转换数据结构为 x6 支持的格式(formatData)

src\utils\index.tsx

ts
interface Tree {
  id: string;
  label: string;
  children?: Tree[];
}

const formatData = (
  data: Tree[] = [],
  resultData: any = { nodes: [], edges: [] },
  parentNode?: { id: string; label: string; children?: string[] }
): any => {
  const { nodes, edges } = resultData;
  data.forEach((item) => {
    const { label, id, children = [] } = item || {};

    // 节点配置,具体参见文档
    const node = {
      shape: "custom-node",
      label,
      id,
      children: [...children.map(({ id }) => id)],
      movable: false,
    };

    nodes.push(node);
    if (parentNode != null) {
      // 连接线配置,具体参见文档
      const edge = {
        shape: "dag-edge",
        source: parentNode.id,
        target: id,
        connector: "rounded",
      };
      edges.push(edge);
    }
    formatData(children, resultData, node);
  });

  return resultData;
};
export { formatData };
interface Tree {
  id: string;
  label: string;
  children?: Tree[];
}

const formatData = (
  data: Tree[] = [],
  resultData: any = { nodes: [], edges: [] },
  parentNode?: { id: string; label: string; children?: string[] }
): any => {
  const { nodes, edges } = resultData;
  data.forEach((item) => {
    const { label, id, children = [] } = item || {};

    // 节点配置,具体参见文档
    const node = {
      shape: "custom-node",
      label,
      id,
      children: [...children.map(({ id }) => id)],
      movable: false,
    };

    nodes.push(node);
    if (parentNode != null) {
      // 连接线配置,具体参见文档
      const edge = {
        shape: "dag-edge",
        source: parentNode.id,
        target: id,
        connector: "rounded",
      };
      edges.push(edge);
    }
    formatData(children, resultData, node);
  });

  return resultData;
};
export { formatData };

自动布局(layout)

src\utils\index.tsx 中添加以下代码:

ts
import Dagre from "dagre";

// 布局风格,默认 TB
type Dir = "LR" | "RL" | "TB" | "BT";

function layout(graph?: Graph, dir: Dir = "TB") {
  if (!graph) return;
  const nodes = graph.getNodes();
  const edges = graph.getEdges();
  const dagre: any = Dagre;
  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: dir, nodesep: 16, ranksep: 16 });
  g.setDefaultEdgeLabel(() => ({}));

  const width = 200;
  const height = 60;
  nodes.forEach((node) => {
    g.setNode(node.id, { width, height });
  });

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

  dagre.layout(g);

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

  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([]);
    }
  });
}

export { formatData, layout };
import Dagre from "dagre";

// 布局风格,默认 TB
type Dir = "LR" | "RL" | "TB" | "BT";

function layout(graph?: Graph, dir: Dir = "TB") {
  if (!graph) return;
  const nodes = graph.getNodes();
  const edges = graph.getEdges();
  const dagre: any = Dagre;
  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: dir, nodesep: 16, ranksep: 16 });
  g.setDefaultEdgeLabel(() => ({}));

  const width = 200;
  const height = 60;
  nodes.forEach((node) => {
    g.setNode(node.id, { width, height });
  });

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

  dagre.layout(g);

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

  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([]);
    }
  });
}

export { formatData, layout };

DataFlow 组件

增加 DataFlow 组件:

src\components\DataFlow\index.tsx

ts
import { Graph } from "@antv/x6";
import { register } from "@antv/x6-react-shape";
import { useEffect, useMemo, useRef } from "react";
import { formatData, layout, Tree } from "../../utils";
import CustomNode from "../CustomNode";

//注册自定义组件
register({
  shape: "custom-node",
  width: 172,
  height: 32,
  component: CustomNode,
});

//修改连接线样式
Graph.registerEdge(
  "dag-edge",
  {
    inherit: "edge",
    attrs: {
      line: {
        stroke: "#CFD2DC",
        strokeWidth: 1,
        targetMarker: null,
      },
    },
  },
  true
);

export default (props: { value?: Tree }) => {
  const { value } = props;
  //画布渲染的div元素
  const divRef = useRef<any>();
  // Graph 实例
  const graphRef = useRef<Graph>();

  const showData = useMemo(() => {
    if (!value) return [];
    const result = formatData([value]);
    return result;
  }, [value]);

  const fromJSON = () => {
    //更改数据
    graphRef.current?.fromJSON(showData);
    //重新布局
    layout(graphRef.current);
    // 修改缩放使内容完全展示在画布当中
    graphRef.current?.zoomToFit({ maxScale: 1 });
  };

  const init = () => {
    if (!divRef.current) return;
    // 初始化 Graph 实例,具体配置可参见文档
    const graph = new Graph({
      container: divRef.current,
      height: 400,
      panning: true,
      mousewheel: true,
      background: {
        color: "#fafafa",
      },
      grid: {
        visible: true,
        type: "dot",
        size: 7,
        args: {
          color: "#e6e6e6", // 网点颜色
          thickness: 1, // 网点大小
        },
      },
      translating: {
        restrict: true,
      },
      interacting: false,
    });
    graphRef.current = graph;
    fromJSON();
  };

  useEffect(fromJSON, [showData]);

  useEffect(() => {
    init();
    return () => {
      graphRef.current?.dispose();
    };
  }, []);

  return (
    <div className="w-full h-full relative">
      <div id="container" ref={divRef}></div>
    </div>
  );
};
import { Graph } from "@antv/x6";
import { register } from "@antv/x6-react-shape";
import { useEffect, useMemo, useRef } from "react";
import { formatData, layout, Tree } from "../../utils";
import CustomNode from "../CustomNode";

//注册自定义组件
register({
  shape: "custom-node",
  width: 172,
  height: 32,
  component: CustomNode,
});

//修改连接线样式
Graph.registerEdge(
  "dag-edge",
  {
    inherit: "edge",
    attrs: {
      line: {
        stroke: "#CFD2DC",
        strokeWidth: 1,
        targetMarker: null,
      },
    },
  },
  true
);

export default (props: { value?: Tree }) => {
  const { value } = props;
  //画布渲染的div元素
  const divRef = useRef<any>();
  // Graph 实例
  const graphRef = useRef<Graph>();

  const showData = useMemo(() => {
    if (!value) return [];
    const result = formatData([value]);
    return result;
  }, [value]);

  const fromJSON = () => {
    //更改数据
    graphRef.current?.fromJSON(showData);
    //重新布局
    layout(graphRef.current);
    // 修改缩放使内容完全展示在画布当中
    graphRef.current?.zoomToFit({ maxScale: 1 });
  };

  const init = () => {
    if (!divRef.current) return;
    // 初始化 Graph 实例,具体配置可参见文档
    const graph = new Graph({
      container: divRef.current,
      height: 400,
      panning: true,
      mousewheel: true,
      background: {
        color: "#fafafa",
      },
      grid: {
        visible: true,
        type: "dot",
        size: 7,
        args: {
          color: "#e6e6e6", // 网点颜色
          thickness: 1, // 网点大小
        },
      },
      translating: {
        restrict: true,
      },
      interacting: false,
    });
    graphRef.current = graph;
    fromJSON();
  };

  useEffect(fromJSON, [showData]);

  useEffect(() => {
    init();
    return () => {
      graphRef.current?.dispose();
    };
  }, []);

  return (
    <div className="w-full h-full relative">
      <div id="container" ref={divRef}></div>
    </div>
  );
};

使用 DataFlow 组件

回到 src\App.tsx 中,修改以下代码:

ts
import DataFlow from "./components/DataFlow";
import { Tree } from "./utils";

export default () => {
  // 省略 data 定义,可复制定义数据中的 data 数据
  // ...
  return (
    <div style={{ padding: "20px" }}>
      <div style={{ width: "500px", height: "500px" }}>
        <DataFlow value={data} />
      </div>
    </div>
  );
};
import DataFlow from "./components/DataFlow";
import { Tree } from "./utils";

export default () => {
  // 省略 data 定义,可复制定义数据中的 data 数据
  // ...
  return (
    <div style={{ padding: "20px" }}>
      <div style={{ width: "500px", height: "500px" }}>
        <DataFlow value={data} />
      </div>
    </div>
  );
};

效果如下所示: test2.png

展开和收起

现在的基本浏览功能已经实现,但是点击的时候并不能进行展开和收起,因为我们还没有做任何相关的逻辑处理。接下来,添加这些逻辑

回到 src\utils\index.tsx 中,添加 setVisible 函数

ts
import { Cell, Graph } from "@antv/x6";

const setVisible = (nodes: Cell<Cell.Properties>[], value = true) => {
  nodes?.forEach((child) => {
    if (child.children) {
      setVisible(child.children, value);
    }
    child.setVisible(value);
  });
};

export { formatData, layout, setVisible };
import { Cell, Graph } from "@antv/x6";

const setVisible = (nodes: Cell<Cell.Properties>[], value = true) => {
  nodes?.forEach((child) => {
    if (child.children) {
      setVisible(child.children, value);
    }
    child.setVisible(value);
  });
};

export { formatData, layout, setVisible };

src\components\CustomNode\index.tsx 中使用该函数处理节点的展开和收起

ts
import { setVisible } from "../../utils";
// 省略...
const handleClick = () => {
  const result = !expand;
  setVisible(node.children ?? [], result);
  setExpand(result);
};
// 省略...
import { setVisible } from "../../utils";
// 省略...
const handleClick = () => {
  const result = !expand;
  setVisible(node.children ?? [], result);
  setExpand(result);
};
// 省略...

再次回到页面当中,展开/收起功能已经可以正常使用了