• antv g6实现系统拓扑图


    1 背景

    为例描述各个服务、redis、mysql等之间的联系及其健康状态,构建系统拓扑图,考虑 g6 更适合处理大量数据之间的关系,所以我们采用g6来绘制前端的图形。

    g6提供的支持:

    • 节点/边类型多样,同样支持自定义
    • 对于节点的样式可以直接配置化处理
    • 丰富的事件体系,包括对节点/边/画布,以及时机事件的监听
    • 多种布局算法
    • 节点/边的数据,都是可以配置化的json对象

    在线工具:g6示例

    2 功能列表

    节点:

    • 添加节点:除了id、style、type外,还包括一些业务需要的数据
    • 删除节点:除了删除该节点相对于画布的id外,还包括与之相关的业务数据
    • 节点状态:比如错误节点需要标红;非活跃节点需要标灰

    边:

    • 添加边:除了id、style、type外,还包括一些业务需要的数据
    • 删除变:除了删除该边相对于画布的id外,还包括与之相关的业务数据
    • 修改边:主要是修改边所代表的业务信息,如果没有业务信息的话,这条边应该被删除

    画布:

    • 用户自定义布局,比如需要保存用户拖拽节点后的节点位置坐标信息
    • dagre层次布局
    • 工具栏
    • 图例
    • 小地图
    • 触摸板放大缩小
    • 节点搜索

    在这里插入图片描述

    3 节点

    3.1 渲染节点

    渲染节点,包括自定义节点类型和样式。

    自定义节点,该节点由rect和image组成,类似于矩形里面有icon:

    // 其实可以不用自定义节点,可以使用circle类型的icon字段。但是这种方式,点击节点的时候,里面的icon会存在闪缩的情况
    // https://g6.antv.antgroup.com/manual/middle/elements/nodes/built-in/circle#%E5%9B%BE%E6%A0%87-icon
    G6.registerNode(
      'drag-inner-image-node',
      {
        afterDraw(cfg, group) {
          const size = cfg?.size as number[];
          const width = size[0] - 20;
          const height = size[1] - 20;
          const imageShape = group?.addShape('image', {
            attrs: {
              x: -width / 2,
              y: -height / 2,
              width,
              height,
              img: cfg?.img,
              cursor: 'move',
            },
            // must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
            name: 'image-shape',
          });
          // 启用拖拽
          imageShape?.set('draggable', true);
        },
      },
      'circle',
    );
    

    节点样式:

    const DefaultNodeSelectedStyle = {
      lineWidth: 8,
      'text-shape': {
        // 点击后的文本样式,保持点击前一致
        fontWeight: 400,
      },
    };
    
    export const NodeStyleMap = {
      default: {
        // 正常节点 - 样式设置
        style: {
          fill: GlobalLightBlueColor,
          stroke: GlobalBlueColor,
          lineWidth: 1,
        },
        // 状态样式,比如 selected点击状态
        stateStyles: {
          selected: {
            stroke: GlobalBlueColor,
            fill: GlobalLightBlueColor,
            shadowColor: GlobalBlueColor,
            ...DefaultNodeSelectedStyle,
          },
        },
      },
      error: {
        // 异常节点
        style: {
          stroke: GlobalRedColor,
          fill: GlobalLightRedColor,
          lineWidth: 1,
        },
        stateStyles: {
          selected: {
            stroke: GlobalRedColor,
            fill: GlobalLightRedColor,
            shadowColor: GlobalRedColor,
            ...DefaultNodeSelectedStyle,
          },
        },
      },
    };
    

    获取节点的渲染数据:

    export const formatNodes = (nodes: MttkArchitectureNode[] = []) => {
      return nodes?.map((node) => {
        const { component, has_error, coordinates } = node;
        // 业务逻辑
        const middlewareType = getMiddlewareType(component) as MttkComponentType;
    
        const { id, label, wholeLabelName } = getNodeId(node);
    
        // 样式和icon
        const nodeStyle = NodeStyleMap[has_error ? 'error' : 'default'];
        const img = has_error ? ErrorIconImageMap[middlewareType] : IconImageMap[middlewareType];
    
        return {
          ...node,
          img,
          middlewareType,
          label,
          wholeLabelName, // 仅前端展示使用
          ...nodeStyle,
          id, // 仅前端展示使用
          x: coordinates?.x, // 节点的位置坐标
          y: coordinates?.y, // 节点的位置坐标
        };
      });
    };
    

    3.2 删除节点

    表现方式:

    • 右键菜单选择删除
    • 键盘backsapce健删除

    实现方式:

    graph.current.removeItem(node);
    

    我们选择键盘快捷键删除的方式:

    • 监听键盘事件
    • 判断是否为叶子节点 > 二次确认删除
    • 否则弹窗显示用户不可删除非叶子节点
    useEffect(()=>{
        // 按下键盘键变化
        const onChangeKeydown = (event: any) => {
          // 检查按下的键是否是 Backspace 键
          if (event.key === 'Backspace' && !tooltipOpenRef.current) {
            // 弹窗有打开的情况下,不能进行删除节点操作
            // 获取当前选中的节点
            const selectedNodes = graph.current.findAllByState('node', 'selected');
            // 删除选中的叶子节点
            if (selectedNodes && selectedNodes.length > 0) {
              selectedNodes.forEach((node: any) => {
                // 获取节点的出边数量
                const outEdges = node.getOutEdges();
                const nodeModel = node.getModel();
    
                if (outEdges.length === 0) {
                  // 叶子节点,允许删除,二次确认
                  Modal.confirm({
                    title: `Are you sure to delete the ${nodeModel.label}?`,
                    cancelText: 'Cancel',
                    okText: 'OK',
                    centered: true,
                    onOk: () => {
                      graph.current.removeItem(node);
    
                      // 更新节点数据
                      setCurrentNodes(currentNodes.filter((n) => n.id !== nodeModel.id));
                      setSearchValue(undefined);
                    },
                    onCancel: () => {},
                  });
                } else {
                  Modal.warning({
                    title: `${nodeModel.label} can't allow to delete.`,
                    onOk() {},
                    centered: true,
                    content: 'Please make sure the node you want to delete is a leaf node.',
                  });
                }
              });
            }
          }
        };
        // 监听键盘按下事件
        document.addEventListener('keydown', onChangeKeydown);
    
        return () => {
          document.removeEventListener('keydown', onChangeKeydown);
        };
    },[])
    

    在这里插入图片描述

    3.3 添加节点

    参考 切换模式添加边和节点,考虑单击画布有可能有其他操作(比如隐藏添加节点/边的弹窗),所以最终考虑 双击空白画布新增节点 的方式来实现:

    • 双击空白画布显示添加节点的弹窗
    • 选择节点的信息
    • 点击确认后,画布上生成对应的节点
    • 选择取消或者点击空白画布,弹窗隐藏,不再进行添加节点的操作

    实现:

    • 监听画布canvas:dblclick事件
    • graph.current.addItem('node', { ...values, ...nodeTooltipPoint?.node });添加
    • 涉及到弹窗位置的问题,以及新节点位置的问题

    添加节点的弹窗比较了一下官方提供的tooltip和menu context,最终考虑使用 G6 中渲染 React 组件 的方式,主要还是样式和交互可以自定义,包括数据联动。

      // tooltip
      const [nodeTooltipPoint, setNodeTooltipPoint] = useState<{ tooltip: Point; node: Point }>();
      const [isShowNodeTooltip, setIsShowNodeTooltip] = useState<boolean>(false);
      // 弹窗是否打开
      const tooltipOpenRef = useRef<boolean>(true);
      // 表单数据是否发生变化
      const formDataChangedRef = useRef(false);
    
      // 双击空白画布,添加新的节点
      graph.current.on('canvas:dblclick', (e: any) => {
        // 双击的画布位置
        const { canvasX, canvasY } = e;
        // 获取画布宽高
        const canvasWidth = graph.current.getWidth();
        const canvasHeight = graph.current.getHeight();
        // tooltip容器的宽高
        const { width: tooltipWidth, height: tooltipHeight } =
          TooltipHeightAndWidthMap[MttkArchitectureGraphTooltip.ADD_NODE];
    
        // tooltip容器的偏移量
        let tooltipX = canvasX;
        let tooltipY = canvasY;
        // icon的位置
        let placement = MttkArchitectureGraphPlacement.TOPLEFT;
    
        if (canvasX + tooltipWidth > canvasWidth) {
          // 靠右点击
          tooltipX = canvasX - tooltipWidth;
          placement = MttkArchitectureGraphPlacement.TOPRIGHT;
        }
        if (canvasY + tooltipHeight > canvasHeight) {
          // 靠下点击
          tooltipY = canvasY - tooltipHeight;
          placement =
            placement === MttkArchitectureGraphPlacement.TOPRIGHT
              ? MttkArchitectureGraphPlacement.BOTTOMRIGHT
              : MttkArchitectureGraphPlacement.BOTTOMLEFT;
        }
    
        setNodeTooltipPoint({ tooltip: { x: tooltipX, y: tooltipY, placement }, node: { x: e.x, y: e.y } });
        setIsShowNodeTooltip(true);
        tooltipOpenRef.current = true;
      });
      
      const handleAddNode = (values: Record<string, any>) => {
        console.log('=== NodeTooltip values:', values);
    
        // 更新节点的坐标
        graph.current.addItem('node', { ...values, ...nodeTooltipPoint?.node });
        setIsShowNodeTooltip(false);
        formDataChangedRef.current = false;
        tooltipOpenRef.current = false;
    
        // 存储最新节点数据,同时可以回显到serch输入框
        setCurrentNodes([...currentNodes, values] as NodeConfig[]);
        handleSearchInputChange(values?.id);
      };
    
      // react自定义组件
      {isShowNodeTooltip && (
        <NodeTooltip
          position={nodeTooltipPoint?.tooltip} // 弹窗的位置,防止弹窗超出窗口视图被截断
          addNode={handleAddNode} // 确认按钮后的回调函数
          originGraphData={getCurrentGraphData()} // 永远获取当前画布最新的数据,新节点可以跟搜索节点的输入框联动
          cancel={handleCancelAllTooltip}
          setFormDataChanged={handleFromDataChanged} // 添加弹窗的表单数据是否发生改变,如果发生改变,则不允许用户通过 点击空白画布 或者 esc键盘快捷键 的方式隐藏弹窗
        />
      )}
    

    在这里插入图片描述

    3.4 节点位置

    参考 4.4 边控制点,节点位置为x和y,在渲染的时候,需要监听afterlayout事件,对节点和边的位置信息手动updateItem。

    3.5 节点可搜索

    对于复杂业务场景,可能存在节点有几十个,这个时候希望能对节点进行搜索,快速定位和筛选。

    交互:

    • 节点被选中,搜索框应该显示该节点
    • 搜索框输入某节点,则该节点应该处于被选中的状态
    • 新建节点,该节点应该被选中,同时搜索框显示该节点
    • 删除节点,搜索框列表也需要删除该节点
    // 当前最新的nodes数据,便于可以回显到serch输入框
    const [currentNodes, setCurrentNodes] = useState<NodeConfig[]>(nodes || []);
    const currentNodesRef = useRef<NodeConfig[]>(nodes || []);
    
    // 节点列表是动态变化的,比如新建了新节点,则该列表也需要更新;删除也是同理
    const NodeIdOptions = useMemo(() => {
      currentNodesRef.current = currentNodes;
      return (
        currentNodes?.map(({ id, wholeLabelName, middlewareType }) => ({
          value: id,
          label: (
            <Row justify="start" align="middle" wrap={false} className="architecture-graph-search-input-container">
              <img
                src={IconImageMap[middlewareType as MttkComponentType]}
                style={{ height: 12, width: 12, marginRight: 5 }}
              />
              <p title={wholeLabelName as string}>{wholeLabelName}</p>
            </Row>
          ),
          text: wholeLabelName,
        })) || []
      );
    }, [currentNodes]);
    
    // 清除图上所有节点的 selected 状态及相应样式
    const clearSelectedNodeState = () => {
      const focusNodes = graph.current.findAllByState('node', 'selected');
      focusNodes.forEach((fnode: any) => {
        graph.current.setItemState(fnode, 'selected', false);
      });
    };
    // 清除图上所有边的 selected 状态及相应样式
    const clearSelectedEdgeState = () => {
      const focusEdges = graph.current.findAllByState('edge', 'selected');
      focusEdges.forEach((fedge: any) => {
        graph.current.setItemState(fedge, 'selected', false);
      });
    };
    
    const clearSelectedItemState = () => {
      clearSelectedNodeState();
      clearSelectedEdgeState();
    };
    
    const handleSetSelectedItem = (id: string) => {
      // 清除所有元素的状态
      clearSelectedItemState();
      // 重新为当前元素设置 选中 状态
      const item = graph.current.findById(id);
      item?.setState('selected', true);
    };
    
    const handleSearchInputChange = (value: string) => {
      setSearchValue(value);
      handleSetSelectedItem(value);
    };
    
    <Row justify="end" style={{ marginTop: 10 }}>
      <Col style={{ marginRight: 'auto' }}>
        <Select
          options={NodeIdOptions}
          style={{ width: 300 }}
          placeholder="Search Node"
          showSearch
          filterOption={filterOption}
          value={searchValue}
          onChange={handleSearchInputChange}
          allowClear
        />
      </Col>
      <LegendRow />
    </Row>
    

    4 边

    4.1 渲染边

    边的样式:

    const DefaultEdgeSelectedStyle = {
      lineWidth: 4,
      shadowBlur: 10, // 阴影的模糊级别,数值越大越模糊
    };
    
    export const EdgeStyleMap = {
      default: {
        // 正常边 - 样式设置
        style: {
          stroke: GlobalBlueColor,
          lineWidth: 1,
          lineDash: [0], // 如果[0]表示直线,需要覆盖一下创建边之后的虚线样式
        },
        // 状态样式,比如 selected点击状态
        stateStyles: {
          selected: {
            stroke: GlobalBlueColor,
            shadowColor: GlobalBlueColor,
            ...DefaultEdgeSelectedStyle,
          },
        },
      },
      error: {
        // 异常边
        style: {
          stroke: GlobalRedColor,
          lineWidth: 1,
        },
        stateStyles: {
          selected: {
            stroke: GlobalRedColor,
            shadowColor: GlobalRedColor,
            ...DefaultEdgeSelectedStyle,
          },
        },
      },
    };
    

    边的渲染数据:

    export const formatEdges = (edges: MttkArchitectureEdge[] = [], nodes: MttkArchitectureNode[] = []) => {
      return edges?.map((edge) => {
        const { has_error } = edge;
    
        const edgeStyle = EdgeStyleMap[has_error ? 'error' : 'default'];
    
        const { id, fromId, toId } = getEdgeId(nodes, edge);
    
        return {
          ...edge,
          source: fromId,
          target: toId,
          ...edgeStyle,
          id,
          from: fromId, // 前端直接替换掉get接口返回的随机数id
          to: toId, // 前端直接替换掉get接口返回的随机数id
        };
      });
    };
    
    

    4.2 删除边

    可以跟删除节点的方式一致,快捷键删除这样子。

    但是根据我们业务需求的话,边是否存在,表示节点的关系是否存在,如果节点关系不存在,该边也需要被删除,涉及到的两个节点数据也会有改变。并不是简单根据边的id、source、target来考虑。

    所以我们这里的删除边并不做单独处理,最终确定如下的交互逻辑:

    • 点击边,弹出边信息的弹窗
    • 修改/删除/添加 边的关系
    • 如果不存在边的关系,点击保存按钮,画布上该边需要被删除
    • 否则,修改边和节点的数据,画布上的边依旧存在

    在这里插入图片描述

    4.3 添加边

    使用官方提供的内置create-edge模式来实现,具体交互:

    • 点击shift+click node,开启添加边的模式(为什么要加上shift辅助模式,为了跟select node交互区分,当点击节点的时候,节点id会回显到搜索输入框上,同时可以还会弹出节点的信息弹窗等)
    • 排出自环边和已经存在的边之后,创建边成功,显示虚线,表示该边还没有选择关系
    • 弹窗显示边关系
    • 如果不存在边的关系,点击保存按钮,画布上该边需要被删除
    • 否则,修改边和节点的数据,画布上的边边成实线

    另外还有一些细节,比如创建边的过程中,鼠标样式应该变成+,这里就不再赘述了,可以监听其变化设置鼠标样式。虽然可以设置节点的鼠标样式,但是边的鼠标样式无法设置,也无法对canvas通过update的方式设置,因为我们希望整个创建过程(包括点击节点-连线),鼠标样式都可以是+,所以这里建议直接设置画布容器的鼠标样式。

      // 容器的classname,用于全局设置画布的鼠标样式
      const [containerClassName, setContainerClassName] = useState<MttkComponentGraphClassName>(
        MttkComponentGraphClassName.DEFAULT,
      );
      
    <div id={containerId} className={containerClassName} style={{ height: '100%' }} />
    
    .g6-cell-container {
      canvas {
        cursor: cell !important;
      }
    }
    .g6-default-container {
      canvas {
        cursor: default;
      }
    }
    

    实现:

    • shouldend来判断是否应该创建该边,排出 自环边和已经存在的边
    • 监听时机事件aftercreateedge

    配置项:

      modes: {
        default: [
          {
            type: 'create-edge',
            trigger: 'click', // 'click' by default. options: 'drag', 'click'
            key: 'shift', // undefined by default, options: 'shift', 'control', 'ctrl', 'meta', 'alt'
            edgeConfig: {
              // 有该交互创建出的边的配置项,可以配置边的类型、样式等
              style: {
                radius: 20, // 拐弯处的圆角弧度
                offset: 20, // 拐弯处距离节点最小距离
                endArrow: true,
                lineAppendWidth: 20, // 提升边的击中范围
                ...EdgeStyleMap.default.style,
                lineDash: [5], // 设置线的虚线样式, 如果[0]表示直线
              },
            },
            shouldEnd: (e: any, self: any) => {
              const { item: toItem } = e;
              const { source: fromId, graph } = self;
              const toId = toItem._cfg.id;
    
              // 不允许创建自环边
              if (toId === fromId) {
                return false;
              }
    
              // 不允许创建已经存在的边
              const edges = graph.getEdges();
              if (
                edges.some((ed: any) => {
                  const { source, target } = ed.getModel();
                  return fromId === source && toId === target;
                })
              ) {
                return false;
              }
    
              return true;
            },
          },
        ],
      },
    

    代码:

      // tooltip
      const [isShowEdgeTooltip, setIsShowEdgeTooltip] = useState<boolean>(false);
      const [isAddEdge, setIsAddEdge] = useState<boolean>(false);
      const [newEdge, setNewEdge] = useState<Record<string, any>>(); // 添加/编辑边的时候
      const newEdgeRef = useRef(); // 永远拿到最新的边的实例
      // 弹窗是否打开
      const tooltipOpenRef = useRef<boolean>(true);
      // 表单数据是否发生变化
      const formDataChangedRef = useRef(false);
      // 键盘shift事件
      const keydownShiftRef = useRef(false);
      
      // 添加/删除边的时候,需要计算一下tooltip的位置
      const edgePoint = useMemo(() => {
        if (newEdge) {
          // tooltip容器的宽高
          const { width: tooltipWidth, height: tooltipHeight } =
            TooltipHeightAndWidthMap[MttkArchitectureGraphTooltip.ADD_EDGE];
    
          // 获取画布宽高
          const width = graph.current.getWidth() - tooltipWidth;
          const height = graph.current.getHeight() - tooltipHeight;
          // 获取边的中点point坐标
          const shape = newEdge.getKeyShape();
          const midPoint = shape.getPoint(0.5);
          // 将point坐标转换成canvas坐标
          const canvas = graph.current.getCanvasByPoint(midPoint.x, midPoint.y);
          return { x: canvas.x > width ? width : canvas.x, y: canvas.y > height ? height : canvas.y };
        }
        return { x: 0, y: 0 };
      }, [newEdge]);
      
      // 创建边之后的回调
      graph.current.on('aftercreateedge', (e: any) => {
        setIsAddEdge(true);
        setNewEdge(e.edge);
        newEdgeRef.current = e.edge;
        setIsShowEdgeTooltip(true);
        tooltipOpenRef.current = true;
      });
    
      // 隐藏所有弹窗
      const handleCancelAllTooltip = () => {
        setIsShowEdgeTooltip(false);
        setIsShowNodeTooltip(false);
        handleDeleteEdge(newEdgeRef.current);
        newEdgeRef.current = undefined;
        formDataChangedRef.current = false;
        tooltipOpenRef.current = false;
      };
      
      const handleEdgeUpdate = (values: { node: Record<string, any>; edge: Record<string, any> }) => {
        const toNode = newEdge?.getTarget();
    
        console.log('=== EdgeTooltip values:', values);
    
        graph.current?.updateItem(toNode, values.node);
        graph.current?.updateItem(newEdge, values.edge);
        setIsShowEdgeTooltip(false);
        formDataChangedRef.current = false;
        tooltipOpenRef.current = false;
    
        if (values.edge.invocations?.length === 0) {
          // 无论是添加还是编辑,只要invocation为空,都需要将该边删掉 --- 业务逻辑,边关系不存在,则该边也不需要存在
          graph.current.removeItem(newEdge);
          newEdgeRef.current = undefined;
        }
      };
    
      const handleFromDataChanged = (value: boolean) => {
        formDataChangedRef.current = value;
      };
    
      {isShowEdgeTooltip && (
        <EdgeTooltip
          position={edgePoint} // 弹窗位置
          edge={newEdge} // 边实例
          updateModel={handleEdgeUpdate} // 确认按钮回调函数
          cancel={handleCancelAllTooltip}
          isAdd={isAddEdge} // 添加新的边还是修改已有边
          setFormDataChanged={handleFromDataChanged} // 添加弹窗的表单数据是否发生改变,如果发生改变,则不允许用户通过 点击空白画布 或者 esc键盘快捷键 的方式隐藏弹窗。同添加节点的时候一样
        />
      )}
    

    在这里插入图片描述

    4.4 边控制点

    使用官方提供的polyline折线,里面存在一个控制点数据controlPoints,如果不给边指定的话,该数值是在图渲染后根据算法自动生成的。

    为了保持用户自定义的图每次刷新位置都是一致的,我们需要保存节点和边的位置信息,对于节点是x和y,对于边则是controlPoints。

    在这里插入图片描述

    如果在新建边之后,不提供一个默认的controlPoints的话,因为我们使用的是dagre层次布局算法,所以他会默认生成一个controlPoints值,但这个值并不是我们预期的,所以我们在创建边的时候,会给边默认一个controlPoints,同时希望用户可以拖拽修改controlPoints值,使画布操作更加友好。

    在这里插入图片描述

    所以我们需要解决的问题如下:

    • 创建边后,提供默认的controlPoints
    • 该controlPoints可以通过用户拖拽的方式改变
    • 保存的时候,需要将controlPoints提交给后端存储
    • 首次渲染的时候,在使用dagre布局&controlPoints为true的情况下,可以正常渲染边的controlPoints

    第一点: 创建边后,提供默认的controlPoints

    const fromBBox = fromNode.getBBox();
    const toBBox = toNode.getBBox();
    // 创建边的时候,并不会自动生成controlPoints值,因为controlPoints是在渲染图的时候根据A*算法生成的
    // 所以在这里我们手动生成一个
    const controlPoints = isAdd
      ? [{ x: fromBBox.centerX, y: (fromBBox.y + toBBox.y) / 2 }]
      : edgeCurrentModel?.controlPoints;
    

    第二点: 该controlPoints可以通过用户拖拽的方式改变

    调研发现g6并不支持对该controlPoints的拖拽改变,所以我们考虑在每一个控制点位置,生成一个透明的节点,拖拽该节点的同时,修改边的controlPoints值。

    export const CONTROL_POINT_NODE_TYPE = 'control-point'; // 控制点id前缀,也是节点type
    
    // 创建一个透明的圆形节点,作为控制点
    G6.registerNode(CONTROL_POINT_NODE_TYPE, {
      draw(cfg, group) {
        const keyShape = group.addShape('circle', {
          attrs: {
            x: 0,
            y: 0,
            r: 12,
            fill: 'transparent',
            stroke: 'transparent',
            cursor: 'move',
          },
          draggable: true,
        });
        return keyShape;
      },
    });
    
      // 节点拖拽
      graph.current.on('node:drag', (e) => {
        const { item, x, y } = e;
        const nodeId = item.get('id');
        if (nodeId.startsWith(CONTROL_POINT_NODE_TYPE)) {
          const edgeId = nodeId.split('&')[1];
          const allEdges = graph.current.getEdges();
          const edge = allEdges?.filter((ed: any) => {
            // 新边在创建之后的id就无法改变,所以需要根据model.id来判断
            const eModel = ed?.getModel();
            return eModel.id === edgeId;
          })[0];
          const model = edge?.getModel();
          const controlPoints = (model?.controlPoints as { x: number; y: number }[])?.map((point, index) => {
            const curNodeId = getControlPointNodeId(index, edgeId);
            if (curNodeId === nodeId) {
              // 一条边可能有多个控制点,仅修改当前拖拽的控制点坐标
              return { ...point, x, y };
            }
            return point;
          });
          graph.current.updateItem(edge, { controlPoints });
        }
      });
    
      const handleAddControlPointNode = (index: number, edgeId: string, x: number, y: number) => {
        graph.current.addItem('node', {
          id: getControlPointNodeId(index, edgeId), // id,表示边的id
          x,
          y,
          type: CONTROL_POINT_NODE_TYPE,
        });
      };
      // 该监听要写在 graph.render() 之前
      graph.current.on('afterlayout', () => {
        if (autoLayoutRef.current) {
          // 自动布局情况下,需要添加控制点节点
          const allEdges = graph.current.getEdges();
          allEdges.forEach((edge: any) => {
            const { id, controlPoints }: { id: string; controlPoints: { x: number; y: number }[] } = edge.getModel();
            controlPoints?.forEach(({ x, y }, index) => handleAddControlPointNode(index, id, x, y));
          });
          return;
        }
        // 会先使用默认的布局算法
        // 更新布局之后,这里的allNodes并不是最新的
        const allNodes = graph.current.getNodes();
        allNodes.forEach((node: any) => {
          const { coordinates } = node.getModel();
          if (coordinates?.x && coordinates?.y) {
            // 如果有存有坐标信息,则布局完成后手动修改一下节点位置
            graph.current.updateItem(node, { x: coordinates.x, y: coordinates.y });
          }
        });
    
        const allEdges = graph.current.getEdges();
        allEdges.forEach((edge: any) => {
          const {
            control_points,
            id,
            controlPoints,
          }: { control_points: { x: number; y: number }[]; id: string; controlPoints: { x: number; y: number }[] } =
            edge.getModel();
    
          if (control_points) {
            // 后端存储的坐标信息
            // 如果有控制点信息需要手动更新一下,否则会使用A*算法(https://www.yuque.com/antv/blog/eyi70n)默认生成
            graph.current.updateItem(edge, { controlPoints: control_points });
    
            // 添加控制点节点
            control_points?.forEach(({ x, y }, index) => handleAddControlPointNode(index, id, x, y));
          } else {
            // 如果后端没有该坐标信息的话,直接使用算法算出来的默认坐标,并添加控制点节点
            controlPoints?.forEach(({ x, y }, index) => handleAddControlPointNode(index, id, x, y));
          }
        });
      });
    
      graph.current.data({ nodes, edges });
      graph.current.render(); // 渲染图
    

    第四点: 首次渲染的时候,在使用dagre布局&controlPoints为true的情况下,可以正常渲染边的controlPoints

    测试发现,虽然节点和边已经包含了位置信息,但是在渲染的时候并不会生效,所以我门需要在render之前手动updateItem节点和边的位置信息。

    // 该监听要写在 graph.render() 之前
    graph.current.on('afterlayout', () => {
      // 会先使用默认的布局算法
      const allNodes = graph.current.getNodes();
      allNodes.forEach((node: any) => {
        const { coordinates } = node.getModel();
        if (coordinates?.x && coordinates?.y) {
          // 如果有存有坐标信息,则布局完成后手动修改一下节点位置
          graph.current.updateItem(node, { x: coordinates.x, y: coordinates.y });
        }
      });
    
      const allEdges = graph.current.getEdges();
      allEdges.forEach((edge: any) => {
        const { control_points } = edge.getModel();
        if (control_points) {
          // 如果有控制点信息需要手动更新一下,否则会使用A*算法(https://www.yuque.com/antv/blog/eyi70n)默认生成
          graph.current.updateItem(edge, { controlPoints: control_points });
        }
      });
    });
    
    graph.current.data({ nodes, edges });
    graph.current.render(); // 渲染图
    

    在这里插入图片描述

    5 画布全局配置

    
    export const LayoutMap = {
      [LayoutType.LR]: {
        // 从左到右
        type: 'dagre',
        ranksep: 70,
        controlPoints: true, // 是否保留布局连线的控制点
        rankdir: 'LR', // 可选,默认为图的中心
        nodesep: 10, // 可选
      },
      [LayoutType.TB]: {
        // 从上到下
        // type: 'dagre',
        // ranksep: 70,
        // controlPoints: true,
        rankdir: 'TB',
      },
    };
    
    export const DefaultOptions = {
      layout: LayoutMap.LR,
      defaultNode: {
        type: 'drag-inner-image-node',
        size: [50, 50],
        style: { cursor: 'move' },
        label: 'node-label',
        labelCfg: {
          position: 'bottom',
          offset: 2,
          style: {
            fill: '#666',
            fontSize: 14,
            cursor: 'move',
          },
        },
      },
      defaultEdge: {
        type: 'polyline',
        style: {
          radius: 20, // 拐弯处的圆角弧度
          offset: 20, // 拐弯处距离节点最小距离
          endArrow: true,
          lineAppendWidth: 20, // 提升边的击中范围
        },
      },
      modes: {
        default: [
          'drag-canvas',
          'drag-node',
          {
            type: 'create-edge',
            trigger: 'click', // 'click' by default. options: 'drag', 'click'
            key: 'shift', // undefined by default, options: 'shift', 'control', 'ctrl', 'meta', 'alt'
            edgeConfig: {
              // 有该交互创建出的边的配置项,可以配置边的类型、样式等
              style: {
                radius: 20, // 拐弯处的圆角弧度
                offset: 20, // 拐弯处距离节点最小距离
                endArrow: true,
                lineAppendWidth: 20, // 提升边的击中范围
                ...EdgeStyleMap.default.style,
                lineDash: [5], // 设置线的虚线样式, 如果[0]表示直线
              },
            },
            shouldEnd: (e: any, self: any) => {
              const { item: toItem } = e;
              const { source: fromId, graph } = self;
              const toId = toItem._cfg.id;
    
              // 不允许创建自环边
              if (toId === fromId) {
                return false;
              }
    
              // 不允许创建已经存在的边
              const edges = graph.getEdges();
              if (
                edges.some((ed: any) => {
                  const { source, target } = ed.getModel();
                  return fromId === source && toId === target;
                })
              ) {
                return false;
              }
    
              return true;
            },
          },
          {
            type: 'click-select',
            // 不允许节点被该交互选中。如果为true的话,会存在重复点击当前节点闪烁的情况,
            // 因为 已选中 > 再次点击,会默认给当前节点 selected status设置为false,我们再手动改为true的时候,就会存在闪烁
            selectNode: false,
            multiple: false, // 不允许多选
          },
        ],
      },
      fitView: true, // 图是否自适应画布
    };
    

    6 图例

    g6自带的图例不是很好自定义ui,虽然可以进行与节点/边数据联动的功能,所以考虑直接react实现。

    // interface Props {
    //   extendLegend?: React.ReactNode; // 扩展图例,比如错误的信息
    // }
    export const GraphNodeTypeConfigs = [
      {
        icon: IconImageMap[MttkComponentType.SERVICE],
        description: 'Service',
        key: MttkComponentType.SERVICE,
      },
      {
        icon: IconImageMap[MttkComponentType.MYSQL],
        description: 'MySQL',
        key: MttkComponentType.MYSQL,
      },
      {
        icon: IconImageMap[MttkComponentType.KAFKA],
        description: 'Kafka',
        key: MttkComponentType.KAFKA,
      },
      {
        icon: IconImageMap[MttkComponentType.REDIS],
        description: 'Redis',
        key: MttkComponentType.REDIS,
      },
      {
        icon: IconImageMap[MttkComponentType.UNKNOWN],
        description: 'Unknown',
        key: MttkComponentType.UNKNOWN,
      },
    ];
    
    export function LegendRow() {
      return (
        <>
          {GraphNodeTypeConfigs.map(({ icon, description }) => (
            <Row justify="start" align="middle" wrap={false} style={{ marginRight: 8 }}>
              <img src={icon} style={{ width: 18, height: 18, marginRight: 4 }} />
              {description}
            </Row>
          ))}
        </>
      );
    }
    

    7 工具栏

    跟图例一样,考虑不太好自定义ui,所以直接react实现。

    import { ZoomInOutlined, ZoomOutOutlined, FullscreenExitOutlined } from '@ant-design/icons';
    import { Col, Row, Button } from 'antd';
    
    interface Props {
      onZoomIn: () => void; // 放大
      onZoomOut: () => void; // 缩小
      onFixCenter: () => void; // 回到中间
    }
    
    export function Toolbar(props: Props) {
      const { onZoomIn, onZoomOut, onFixCenter } = props;
      return (
        <Col style={{ width: 30 }}>
          <Row justify="center">
            <Button type="link" style={{ padding: 0 }} onClick={onZoomIn}>
              <ZoomInOutlined />
            </Button>
          </Row>
          <Row justify="center">
            <Button type="link" style={{ padding: 0 }} onClick={onZoomOut}>
              <ZoomOutOutlined />
            </Button>
          </Row>
          <Row justify="center">
            <Button type="link" style={{ padding: 0 }} onClick={onFixCenter}>
              <FullscreenExitOutlined />
            </Button>
          </Row>
        </Col>
      );
    }
    
    

    8 小地图

      const minimapContainerId = 'g6-architecture-edit-minimap';
    
      // 初始化
      const minimap = new G6.Minimap({
        size: [100, 50],
        type: 'delegate',
        container: minimapContainerId,
      });
      graph.current = new G6.Graph({
        container, // String | HTMLElement,必须
        width, // Number,必须,图的宽度
        height, // Number,必须,图的高度
        ...DefaultOptions,
        plugins: [minimap], // 将 minimap 实例配置到图上
      });
      
     <div
        id={minimapContainerId}
        style={{
          zIndex: 100,
          backgroundColor: 'white',
          position: 'absolute',
          right: 0,
          border: '1px solid #f0f0f0',
          marginTop: 8,
        }}
      />
    

    9 其他

    9.1 样式

    对于tooltip弹窗,还有小地图,可以使用absolute定位,让元素悬浮在画布上。

    .architecture-tooltip-view {
      z-index: 100;
      background-color: #f0f0f0;
      position: absolute;
      border: 1px solid #f0f0f0;
      border-radius: 8px;
      padding: 14px;
    }
    
    <div
      className="architecture-tooltip-view"
      style={{
        top: `${position?.y}px`,
        left: `${position?.x}px`,
      }}
    ></div>
    

    9.2 事件监听

    对于事件监听里面的方法,使用setState的方式无效,需要该用ref的方式。比如点击节点的时候

      // 点击节点
      graph.current.on('node:click', (e: any) => {
        const nodeItem = e.item; // 获取被点击的节点元素对象
        nodeItem.setState('selected', true); // 需要手动设置,始终为true,这样子可以保证始终有一个节点/边被选中
    
        // here,对节点进行筛选
        const selectedNode = currentNodesRef.current?.filter((node) => node.id === nodeItem._cfg?.id)[0];
        if (selectedNode) {
          callback({
            type: MttkArchitectureSelectedNodeType.NODE,
            data: selectedNode as unknown as MttkArchitectureNode,
          });
          setSearchValue(selectedNode.id);
        }
    
        if (keydownShiftRef.current) {
          // 添加边
          // shift模式下,需要修改鼠标样式
          // 画布的鼠标样式 - 这里可以统一设置该样式即可,不用再单独设置node和icon的样式
          setContainerClassName(MttkComponentGraphClassName.CELL);
        } else {
          // 不是添加边的情况下,需要关闭弹窗
          handleCancelAllTooltip();
        }
      });
    

    9.3 切换tab,图会消失

    在浏览器来回切换tab,我们原来的图的tab上面的图会消失,可能是由于浏览器自带的优化算法,tab切换,图的资源也会被隐藏。

    // 2. 浏览器选项卡是否可见
    const onChangePageVisbility = () => {
      if (document.visibilityState === 'visible') {
        // 当切换选项卡的时候,可能会导致当前图片消失
        // 所以需要重新刷新视图
        graph.current?.refresh();
      }
    };
    document.addEventListener('visibilitychange', onChangePageVisbility);
    

    9.4 画布大小随窗口大小自适应

    // 1. 浏览器窗口变化
    const onChangeResize = debounce(() => {
      // 窗口大小变化,画布大小也需要随之改变
      const graphContainer = document.getElementById(containerId);
      const width = graphContainer?.offsetWidth;
      const height = graphContainer?.offsetHeight || 500;
      graph.current?.changeSize(width, height - 10); // 改变画布大小,10 - margin bottom
    }, 500);
    window.addEventListener('resize', onChangeResize);
    
  • 相关阅读:
    澳洲最热门职业,护士排第一,医生竟然不如程序员?
    制作web3d动态产品展示的优点
    【最强最全车牌识别算法】支持13种中文车牌识别的云端API部署(可直接获取源码使用)
    推进智慧工地建设,智慧工地是什么?建筑工地人必看!
    rust学习——操作字符串、字符串转义、操作UTF8-字符串 (操作中文字符串)
    【监控系统】日志可视化监控体系ELK搭建
    某验四代滑块验证码逆向分析
    flink 处理IOT数据
    注解方式优雅的实现 Redisson 分布式锁
    代理 模式
  • 原文地址:https://blog.csdn.net/weixin_43973415/article/details/139843334