X6学习之实现一个简单的思维导图

898 阅读5分钟

最近在学习X6的使用,参考官方提供的思维导图案例,新加了一些功能,记录一下如何实现一个简单的思维导图。

自定义节点和边

  • 先自定义要用到的中心节点和子节点以及连接边的形状和样式,中心节点设置了左右各有一个图标可以进行两边节点的添加。
  • tools中添加node-editor对象即可提供节点上文本编辑功能。
//中心节点
Graph.registerNode(
  "topic",
  {
    inherit: "rect",
    width: 100,
    height: 40,
    markup: [
      // 指定了渲染节点/边时使用的 SVG/HTML 片段
      {
        tagName: "rect",
        selector: "body",
      },
      {
        tagName: "image",
        selector: "img1",
      },
      {
        tagName: "image",
        selector: "img2",
      },
      {
        tagName: "text",
        selector: "label",
      },
    ],
    attrs: {
      body: {
        rx: 6,
        ry: 6,
        stroke: "#5178C7",
        fill: "#5178C7",
        strokeWidth: 1,
      },
      img1: {
        ref: "body",
        refX: "100%",
        refY: "50%",
        refY2: -8,
        width: 18,
        height: 18,
        "xlink:href":
          "https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SYCuQ6HHs5cAAAAAAAAAAAAAARQnAQ",
        event: "add:topic:right",
        class: "topic-image",
      },
      img2: {
        ref: "body",
        refX: "0%",
        refY: "50%",
        refX2: "-18",
        refY2: -8,
        width: 18,
        height: 18,
        "xlink:href":
          "https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SYCuQ6HHs5cAAAAAAAAAAAAAARQnAQ",
        event: "add:topic:left",
        class: "topic-image",
      },
      label: {
        fontSize: 14,
        fill: "#ffffff",
      },
    },
    tools: [
      {
        name: "node-editor",
        args: {
          attrs: {
            backgroundColor: "#EFF4FF",
          },
        },
      },
    ],
  },
  true
);

//子节点
Graph.registerNode(
  "topic-child",
  {
    inherit: "rect",
    width: 100,
    height: 40,
    markup: [
      // 指定了渲染节点/边时使用的 SVG/HTML 片段
      {
        tagName: "rect",
        selector: "body",
      },
      {
        tagName: "image",
        selector: "img",
      },
      {
        tagName: "text",
        selector: "label",
      },
    ],
    attrs: {
      body: {
        rx: 6,
        ry: 6,
        stroke: "#5F95FF",
        fill: "#EFF4FF",
        strokeWidth: 1,
      },
      img: {
        ref: "body",
        // refX: "100%",
        refY: "50%",
        refY2: -8,
        width: 18,
        height: 18,
        "xlink:href":
          "https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SYCuQ6HHs5cAAAAAAAAAAAAAARQnAQ",
        event: "add:topic:right",
        class: "topic-image",
      },
      label: {
        fontSize: 14,
        fill: "#262626",
      },
    },
    tools: [
      {
        name: "node-editor",
        args: {
          attrs: {
            backgroundColor: "#EFF4FF",
          },
        },
      },
    ],
  },
  true
);

// 连接器
Graph.registerConnector(
  "mindmap",
  (sourcePoint, targetPoint, routerPoints, options) => {
    const midX = sourcePoint.x + 10;
    const midY = sourcePoint.y;
    const ctrX = (targetPoint.x - midX) / 5 + midX;
    const ctrY = targetPoint.y;
    const pathData = `
       M ${sourcePoint.x} ${sourcePoint.y}
       L ${midX} ${midY}
       Q ${ctrX} ${ctrY} ${targetPoint.x} ${targetPoint.y}
      `;
    return options.raw ? Path.parse(pathData) : pathData;
  },
  true
);

// 边
Graph.registerEdge(
  "mindmap-edge",
  {
    inherit: "edge",
    connector: {
      name: "mindmap",
    },
    attrs: {
      line: {
        targetMarker: "",
        stroke: "#A2B1C3",
        strokeWidth: 2,
      },
    },
    zIndex: 0,
  },
  true
);

初始化画布

    graph.current = new Graph({
      container: refContainer.current,
      connecting: { //通过配置 connecting 可以实现丰富的连线交互。
        connectionPoint: "anchor",
      },
      mousewheel: { //设置画布是否缩放
        enabled: true, 
      },
      panning: true, //设置画布是否可以平移
    });

初始化数据,在画布上展示中心节点

  • 利用@antv/hierarchy这个插件可以生成思维导图(Mind Map)类型的布局。
 //初始节点数据
  const data = {
    id: "center",
    type: "topic",
    label: "中心主题",
    width: 160,
    height: 50,
    children:[],
  }
 
   //初始化数据,生成思维导图(Mind Map)类型的布局。
  const init = () => {
    const result = Hierarchy.mindmap(data, {
      direction: "H", //水平布局
      getHeight(d) {
        return d.height;
      },
      getWidth(d) {
        return d.width;
      },
      getHGap() {
        //获取节点水平间距的函数
        return 40;
      },
      getVGap() {
        //获取节点垂直间距的函数
        return 20;
      },
      getSide: (e) => {
        //  子节点在父节点的左边还是右边
        return e.data.direction || "right";
      },
    });
    // console.log("result", result);
    const cells = [];

    const traverse = (hierarchyItem) => {
      if (hierarchyItem) {
        const { data, children, x, y } = hierarchyItem;
        // console.log("data", data);
        const node = addOneNode({
          ...data,
          x,
          y,
        });
        cells.push(node);
        if (children) {
          children.forEach((item) => {
            const { id, data } = item;
            const edge = addOneEdge(hierarchyItem.id, id, data.direction);
            cells.push(edge);
            traverse(item);
          });
        }
      }
    };

    traverse(result);
    graph.current.resetCells(cells); //resetCells方法用于清空画布并添加用指定的节点/边。
    graph.current.centerContent(); // 将画布中元素居中展示
  };

点击添加图标新增子节点

  • 添加的时候利用X6提供的addNode方法进行节点添加,利用addEdge方法进行节点的连接。
    //自定义节点的时候给添加图标设置了event事件名称,这边进通过名称进行关联
    graph.current.on("add:topic:left", (event) => {
      addNewNode(event, "left");
    });

    graph.current.on("add:topic:right", (event) => {
      addNewNode(event, "right");
    });
    
      //添加新节点
  const addNewNode = (event, direction) => {
    // console.log("addd", event.node);
    event.e.stopPropagation(); //阻止事件冒泡防止添加的时候出现节点的拉伸功能
    setResizingEnabled(false);
    const cur_node = event.node;
    const cur_node_pos = cur_node.position();
    const { id } = cur_node;
    const nodes = graph.current.getNodes(); //返回画布中所有节点和边的数量
    const edges = graph.current.getEdges(); //返回画布中所有节点。

    /**
     * 因为要在原有的基础上添加新的节点,左右两边添加节点的位置不一样,目前想到的方式是获取所有的子节点,
     * 判断是往哪个方向添加节点,先找到最远的那个子节点位置,然后在最远的那个下边添加新的节点。
     */
    const childNodes = edges
      .filter((edge) => edge.getSourceCellId() === id)
      .map((edge) => edge.getTargetCell());
    let p_x = -Infinity;
    let p_y = -Infinity;
    childNodes.forEach((node) => {
      const { x, y } = node.position();
      // console.log("x, y ", x, y);
      if (direction === "left" && x < 0) {
        p_x = Math.min(p_x, x);
        p_y = Math.max(p_y, y);
      } else if (direction === "right" && x > 0) {
        p_x = Math.max(p_x, x);
        p_y = Math.max(p_y, y);
      }
    });
    // console.log("位置", p_x, p_y);
    if (event.cell.isNode()) {
      const newNodeId = `new-${Date.now()}`;
      const init_x =
        direction === "left" ? cur_node_pos.x - 150 : cur_node_pos.x + 150;

      addOneNode({
        id: newNodeId,
        label: "输入文本",
        children: [],
        x: p_x === -Infinity ? init_x : p_x, // 设置新节点的位置
        y: p_y === -Infinity ? cur_node_pos.y + 5 : p_y + 60,
        type: "topic-child",
        direction,
      });

      addOneEdge(event.cell, newNodeId, direction);
    }
  };

  const addOneNode = (data) => {
    const { type, direction } = data;
    return graph.current.addNode({
      ...data,
      shape: type === "topic-child" ? "topic-child" : "topic",
      attrs:
        type === "topic-child"
          ? {
              img: {
                refX: direction === "left" ? "0%" : "100%",
                event:
                  direction === "left" ? "add:topic:left" : "add:topic:right",
                refX2: direction === "left" ? -18 : 0,
              },
            }
          : {},
    });
  };
  // 添加新边
  const addOneEdge = (source_id, target_id, direction) => {
    return graph.current.addEdge({
      shape: "mindmap-edge",
      source: {
        cell: source_id,
        anchor: {
          name: direction,
          args: direction === "left" ? {} : { dx: -16 },
        },
      },
      target: {
        cell: target_id,
        anchor: {
          name: direction === "left" ? "right" : "left",
        },
      },
    });
  };

选中节点通过删除键进行删除

  • 利用@antv/x6-plugin-keyboard这个插件来使用快捷键功能。
  • 删除的时候需要获取到所有的子节点,通过X6提供的removeNode方法遍历删除该节点和所有子节点。
    //绑定删除键的监听事件
    graph.current.bindKey(["backspace", "delete"], () => {
      const selectedNodes = graph.current
        .getSelectedCells()
        .filter((item) => item.isNode());
      if (selectedNodes.length) {
        const { id } = selectedNodes[0];
        if (id !== "center") {
          const allNode = getAllDescendants(id);
          allNode.forEach((data) => {
            graph.current.removeNode(data);
          });
        }
      }
    });
    
    
  // 获取某个节点的所有子节点
  const getAllDescendants = (nodeId) => {
    const visited = new Set();
    const descendants = new Set();

    const traverse = (currentNodeId) => {
      if (visited.has(currentNodeId)) {
        return;
      }

      visited.add(currentNodeId);
      const currentNode = graph.current.getCellById(currentNodeId);
      if (!currentNode) {
        return;
      }

      //getConnectedEdges方法获取与节点/边相连接的边。
      const edges = graph.current.getConnectedEdges(currentNode,{});
      edges.forEach((edge) => {
        const target = edge.getTargetCell();
        if (target && !descendants.has(target.id)) {
          descendants.add(target.id);
          traverse(target.id);
        }
      });
    };

    traverse(nodeId);

    return Array.from(descendants);
  };

实现节点可拉伸

  • 利用@antv/x6-plugin-transform插件实现节点的拉伸功能。
    graph.current.use(
      new Transform({
        resizing: resizingEnabled,
        // rotating: true,
      })
    );

实现撤销和恢复功能

  • 利用@antv/x6-plugin-history插件来实现撤销和恢复。
    graph.current.use(
      new History({
        enabled: true,
      })
    );
    
     //撤销某操作
  const onUndo = () => {
    graph.current.undo();
  };

  //恢复某操作
  const onRedo = () => {
    graph.current.redo();
  };

实现数据的导入和导出功能

  • 利用X6提供的toJSON来获取整个画布的数据,利用fromJSON方法按照指定的 JSON 数据渲染节点和边。
  //导出
  const onExport = () => {
    const data = graph.current.toJSON();
    console.log("data", data);
  };

  //导入
  const onImport = () => {
    graph.current.clearCells();
    graph.current.fromJSON(test_data);
    graph.current.centerContent();
  };

总结

目前实现的就是上述这些功能,完整代码放在github上了,后续如果有新增加的功能和完善的地方也会更新上去,有需要参考的可以去这里看看。有任何写的不对或者有更好方式实现的地方欢迎大家提出来呦!
github地址如下:github.com/winterping/…

image.png