目录
1 背景
为例描述各个服务、redis、mysql等之间的联系及其健康状态,构建系统拓扑图,考虑 g6 更适合处理大量数据之间的关系,所以我们采用g6来绘制前端的图形。
g6提供的支持:
- 节点/边类型多样,同样支持自定义
- 对于节点的样式可以直接配置化处理
- 丰富的事件体系,包括对节点/边/画布,以及时机事件的监听
- 多种布局算法
- 节点/边的数据,都是可以配置化的json对象
在线工具:g6示例
G6 更适合于需要处理大规模图数据和复杂交互的场景,而 X6 更适合于图形编辑和定制化节点样式的场景。
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);
9.5 鼠标样式
点击shift进行连线的时候,鼠标样式需要全局显示成+的样式,提示用户正在进行连线操作;
取消shift的时候,鼠标样式需要变会箭头的样式。
- 监听键盘键变化:
document.removeEventListener('keydown/keyup', onChangeKeydown);
- 按下shift,设置cursor为cell
- 取消shift,设置cursor为default
.g6-cell-container {
canvas {
cursor: cell !important;
}
}
.g6-default-container {
canvas {
cursor: default;
}
}
在画布上面,需要给container容器全局设置cursor,仅仅只针对节点设置是不行的,因为离开节点后,画布上面的鼠标样式就会失效。
// 容器的classname,用于全局设置画布的鼠标样式
const [containerClassName, setContainerClassName] = useState<MttkComponentGraphClassName>(
MttkComponentGraphClassName.DEFAULT,
);
// 4. 释放键盘键变化
const onChangeKeyup = (event: any) => {
// 检查释放的键是否是 Shift 键
if (event.key === 'Shift') {
keydownShiftRef.current = false;
// 全局更新canvas画布的鼠标样式
setContainerClassName(MttkComponentGraphClassName.DEFAULT);
}
};
// 监听键盘释放事件
document.addEventListener('keyup', onChangeKeyup);
<div id={containerId} className={containerClassName} style={{ height: '100%' }} />
9.6 字符省略样式
.overview-event-timeline-event-name {
word-break: break-all;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: wrap;
}