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);
  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: ANTV G6是一款智能电视机顶盒,它不仅可以通过网络观看在线视频,还提供了离线文档的功能。 ANTV G6的离线文档功能允许用户在没有网络连接的情况下访问和浏览本地存储的文档文件。用户可以通过USB接口或者其他存储设备将文档文件导入到G6中,然后在离线状态下查看和编辑这些文档。 离线文档功能使得用户可以在没有网络的情况下继续处理文档工作,提高了工作和学习的效率。无论是在出差、旅行或者没有网络覆盖的地方,用户都可以依靠ANTV G6来查看和编辑文档,保证工作的顺利进行。 ANTV G6不仅支持常见的文档格式,如Word、Excel和PowerPoint,还支持PDF、TXT等多种格式。用户可以通过文件管理系统G6中浏览并打开各种格式的文件,进行阅读、编辑和保存。 此外,ANTV G6的离线文档功能还提供了一些便捷的操作选项,如快速搜索、书签、阅读模式等。用户可以根据自己的需求和喜好进行设置,提高使用的便捷性和个性化。 总之,ANTV G6的离线文档功能为用户提供了更加便利和灵活的文档处理方式,无论是在有网络的情况下,还是在没有网络的环境中,用户都可以通过G6进行文档工作,提升工作和学习的效率。 ### 回答2: ANTV G6是一款智能电视盒子,它可以通过连接到网络收看各种在线视频内容。但是,ANTV G6也支持离线播放,即使在没有网络连接的情况下,用户也可以通过存储在盒子内部的离线文档来观看内容。 离线文档功能使得用户可以在没有网络时仍然享受丰富的内容。用户可以下载电影、电视剧、纪录片等视频文件,并将其保存在ANTV G6的存储空间内。用户还可以下载各种格式的音乐文件,创建自己的音乐库。此外,用户还可以下载电子书、漫画等文档文件,并通过ANTV G6的离线文档功能进行阅读。 ANTV G6的离线文档功能具有很高的灵活性和可定制性。用户可以根据自己的口味和需求,选择并下载自己喜欢的内容。而且,用户可以通过文件管理器轻松管理和排序这些离线文档,让自己的媒体库更加整洁有序。 总的来说,ANTV G6的离线文档功能为用户提供了便捷的娱乐方式。无论是在没有网络的地方、网络出现故障或者想要隐私地享受内容,用户都可以通过离线文档功能满足自己的需求。ANTV G6的离线文档功能成为用户对于这款智能电视盒子的一个重要选择因素。 ### 回答3: ANTV G6是一款智能电视盒子,它可以连接到电视并提供多种功能。ANTV G6支持离线文档功能,这意味着用户可以在没有网络连接的情况下访问和查阅文档。 离线文档功能非常实用,特别是在没有网络连接或网络不稳定的情况下。用户可以事先下载需要的文档,然后在没有网络时随时打开并查看。这样,即使用户没有互联网,也可以方便地查阅重要资料或文件。 ANTV G6的离线文档功能支持多种文档格式,如PDF、DOC、PPT等常见格式。用户可以通过安装相应的文档阅读器应用程序来打开和阅读这些文档。除了查看文档外,用户还可以进行一些基本的操作,如搜索、添加书签、标注等。 另外,ANTV G6的离线文档功能还提供了快速跳转和目录导航等便捷功能,使得用户能够更方便地浏览和定位到所需的内容。此外,ANTV G6还支持对文档进行分享,用户可以将文档分享给其他设备或其他用户。 总的来说,ANTV G6的离线文档功能为用户提供了便捷的文档访问和查阅体验。无论用户身处何地,只要有ANTV G6,就能够随时打开和浏览各种文件。这对于那些需要频繁查阅文档并且经常处于没有网络连接的环境中的用户来说,无疑是一项非常有用且实用的功能。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值