前言

这种需求,日常开发中应该比较常见吧“在一个图画布中点击节点时异步请求数据,同时保证原有画布不变, 做增量布局”。
explore.gif

实现

看到这种图相关的需求,我一般会考虑 G6 或者 D3,当然,极端情况下也会自己造轮子。最终选择的是 G6,也并不是因为我是 G6 开发者!

快速接入

由于 G6 没有对业务来说,成本稍高,而且没有现存案例,我们会对其进行简单封装(Ant Design Charts),后面会给出实现代码。
step1:依赖安装

  1. yarn add @ant-design/graphs -S

step2:组件引用

  1. import React, { useRef } from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { RadialGraph } from '@ant-design/graphs';
  4. const DemoRadialGraph = () => {
  5. const chartRef = useRef();
  6. const data = {
  7. nodes: [
  8. {
  9. id: '0',
  10. label: '0',
  11. },
  12. {
  13. id: '1',
  14. label: '1',
  15. },
  16. {
  17. id: '2',
  18. label: '2',
  19. },
  20. {
  21. id: '3',
  22. label: '3',
  23. },
  24. {
  25. id: '4',
  26. label: '4',
  27. },
  28. {
  29. id: '5',
  30. label: '5',
  31. },
  32. {
  33. id: '6',
  34. label: '6',
  35. },
  36. {
  37. id: '7',
  38. label: '7',
  39. },
  40. {
  41. id: '8',
  42. label: '8',
  43. },
  44. {
  45. id: '9',
  46. label: '9',
  47. },
  48. ],
  49. edges: [
  50. {
  51. source: '0',
  52. target: '1',
  53. },
  54. {
  55. source: '0',
  56. target: '2',
  57. },
  58. {
  59. source: '0',
  60. target: '3',
  61. },
  62. {
  63. source: '0',
  64. target: '4',
  65. },
  66. {
  67. source: '0',
  68. target: '5',
  69. },
  70. {
  71. source: '0',
  72. target: '6',
  73. },
  74. {
  75. source: '0',
  76. target: '7',
  77. },
  78. {
  79. source: '0',
  80. target: '8',
  81. },
  82. {
  83. source: '0',
  84. target: '9',
  85. },
  86. ],
  87. };
  88. // 模拟请求
  89. const fetchData = (node) => {
  90. return new Promise((resolve, reject) => {
  91. const data = new Array(Math.ceil(Math.random() * 10) + 2).fill('').map((_, i) => i + 1);
  92. setTimeout(() => {
  93. resolve({
  94. nodes: [
  95. {
  96. ...node,
  97. },
  98. ].concat(
  99. data.map((i) => {
  100. return {
  101. id: `${node.id}-${i}`,
  102. label: `${node.label}-${i}`,
  103. };
  104. }),
  105. ),
  106. edges: data.map((i) => {
  107. return {
  108. source: node.id,
  109. target: `${node.id}-${i}`,
  110. };
  111. }),
  112. });
  113. }, 1000);
  114. });
  115. };
  116. const asyncData = async (node) => {
  117. return await fetchData(node);
  118. };
  119. const config = {
  120. data,
  121. autoFit: false,
  122. layout: {
  123. unitRadius: 80,
  124. /** 节点直径 */
  125. nodeSize: 20,
  126. /** 节点间距 */
  127. nodeSpacing: 10,
  128. },
  129. nodeCfg: {
  130. asyncData,
  131. size: 20,
  132. style: {
  133. fill: '#6CE8DC',
  134. stroke: '#6CE8DC',
  135. },
  136. labelCfg: {
  137. style: {
  138. fontSize: 5,
  139. fill: '#000',
  140. },
  141. },
  142. },
  143. menuCfg: {
  144. customContent: (e) => {
  145. return (
  146. <button
  147. onClick={() => {
  148. chartRef.current.emit('node:dblclick', e);
  149. }}
  150. >
  151. 手动拓展(双击节点也可以拓展)
  152. </button>
  153. );
  154. },
  155. },
  156. edgeCfg: {
  157. style: {
  158. lineWidth: 1,
  159. },
  160. endArrow: {
  161. d: 10,
  162. size: 2,
  163. },
  164. },
  165. behaviors: ['drag-canvas', 'zoom-canvas', 'drag-node'],
  166. onReady: (graph) => {
  167. chartRef.current = graph;
  168. },
  169. };
  170. return <RadialGraph {...config} />;
  171. };
  172. ReactDOM.render(<DemoRadialGraph />, document.getElementById('container'));

实现原理

事件绑定

双击节点时发起数据请求,也可手动 emit已经绑定的事件,布局结束后触发位置变更动画graph.positionsAnimate

  1. /** bind events */
  2. export const bindDblClickEvent = (
  3. graph: IGraph,
  4. asyncData: (nodeCfg: NodeConfig) => GraphData,
  5. layoutCfg?: RadialLayout,
  6. fetchLoading?: FetchLoading,
  7. ) => {
  8. const onDblClick = async (e: IG6GraphEvent) => {
  9. const item = e.item as INode;
  10. const itemModel = item.getModel();
  11. createLoading(itemModel as NodeConfig, fetchLoading);
  12. const newData = await asyncData(item.getModel() as NodeConfig);
  13. closeLoading();
  14. const nodes = graph.getNodes();
  15. const edges = graph.getEdges();
  16. const { x, y } = itemModel;
  17. const centerNodeId = graph.get('centerNode');
  18. const centerNode = centerNodeId ? graph.findById(centerNodeId) : nodes[0];
  19. const { x: centerX, y: centerY } = centerNode.getModel();
  20. // the max degree about foces(clicked) node in the original data
  21. const pureNodes = newData.nodes.filter(
  22. (item) => findIndex(nodes, (t: INode) => t.getModel().id === item.id) === -1,
  23. );
  24. const pureEdges = newData.edges.filter(
  25. (item) =>
  26. findIndex(edges, (t: IEdge) => {
  27. const { source, target } = t.getModel();
  28. return source === item.source && target === item.target;
  29. }) === -1,
  30. );
  31. // for graph.changeData()
  32. const allNodeModels: GraphData['nodes'] = [];
  33. const allEdgeModels: GraphData['edges'] = [];
  34. pureNodes.forEach((nodeModel) => {
  35. // set the initial positions of the new nodes to the focus(clicked) node
  36. nodeModel.x = itemModel.x;
  37. nodeModel.y = itemModel.y;
  38. graph.addItem('node', nodeModel);
  39. });
  40. // add new edges to graph
  41. pureEdges.forEach((em, i) => {
  42. graph.addItem('edge', em);
  43. });
  44. edges.forEach((e: IEdge) => {
  45. allEdgeModels.push(e.getModel());
  46. });
  47. nodes.forEach((n: INode) => {
  48. allNodeModels.push(n.getModel() as NodeConfig);
  49. });
  50. // 这里使用了引用类型
  51. radialSectorLayout({
  52. center: [centerX, centerY],
  53. eventNodePosition: [x, y],
  54. nodes: nodes.map((n) => n.getModel() as NodeConfig),
  55. layoutNodes: pureNodes,
  56. options: layoutCfg as any,
  57. });
  58. graph.positionsAnimate();
  59. graph.data({
  60. nodes: allNodeModels,
  61. edges: allEdgeModels,
  62. });
  63. };
  64. graph.on('node:dblclick', (e: IG6GraphEvent) => {
  65. onDblClick(e);
  66. });
  67. };

节点布局

对节点进行拓展时,根据拓展出的节点数量以及节点之间的距离(nodeSpacing) ,计算出下一层级存放当前拓展节点所需的弧,检测下一层级该区域内是否存在重叠节点,没有即符合要求,如果有重叠,拓展节点移动到下一层级,依次检测。当然,也可以选择在对应层级移动节点,或者固定层级节点数量,放不下的移动到下一层级,主要还是看业务需求。
image.png

代码:

  1. type INode = {
  2. id: string;
  3. x?: number;
  4. y?: number;
  5. layer?: number;
  6. [key: string]: unknown;
  7. };
  8. export type IRadialSectorLayout = {
  9. /** 布局中心 [x,y] */
  10. center: [number, number];
  11. /** 事件节点坐标 */
  12. eventNodePosition: [number, number];
  13. /** 画布当前节点信息,可通过 graph.getNodes().map(n => n.getModel()) 获取 */
  14. nodes: INode[];
  15. /** 布局节点,拓展时的新节点,会和当前画布节点做去重处理 */
  16. layoutNodes: INode[];
  17. options?: {
  18. /** 圈层半径 */
  19. unitRadius: number;
  20. /** 节点直径 */
  21. nodeSize: number;
  22. /** 节点间距 */
  23. nodeSpacing: number;
  24. };
  25. };
  26. export const radialSectorLayout = (params: IRadialSectorLayout): INode[] => {
  27. const { center, eventNodePosition, nodes: allNodes, layoutNodes, options = {} } = params;
  28. const { unitRadius = 80, nodeSize = 20, nodeSpacing = 10 } = options as IRadialSectorLayout['options'];
  29. if (!layoutNodes.length) layoutNodes;
  30. // 过滤已经在画布上的节点,避免上层传入重复节点
  31. const pureLayoutNodes = layoutNodes.filter((node) => {
  32. return (
  33. allNodes.findIndex((n) => {
  34. const { id } = n;
  35. return id === node.id;
  36. }) !== -1
  37. );
  38. });
  39. if (!pureLayoutNodes.length) return layoutNodes;
  40. const getDistance = (point1: Partial<INode>, point2: Partial<INode>) => {
  41. const dx = point1.x - point2.x;
  42. const dy = point1.y - point2.y;
  43. return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
  44. };
  45. // 节点裁剪
  46. const [centerX, centerY] = center;
  47. const [ex, ey] = eventNodePosition;
  48. const diffX = ex - centerX;
  49. const diffY = ey - centerY;
  50. const allNodePositions: INode[] = [];
  51. allNodes.forEach((n) => {
  52. const { id, x, y } = n;
  53. allNodePositions.push({
  54. id,
  55. x,
  56. y,
  57. layer: Math.round(getDistance({ x, y }, { x: centerX, y: centerY })) / unitRadius,
  58. });
  59. });
  60. const currentRadius = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2));
  61. const degree = Math.atan2(diffY, diffX);
  62. let minRadius = currentRadius + unitRadius;
  63. let pureNodePositions: Partial<INode>[] = [];
  64. const getNodesPosition = (nodes: INode[], r: number) => {
  65. const degreeStep = 2 * Math.asin((nodeSize + nodeSpacing) / 2 / r);
  66. pureNodePositions = [];
  67. const l = nodes.length - 1;
  68. nodes.forEach((n, i) => {
  69. n.x = centerX + r * Math.cos(degree + (-l / 2 + i) * degreeStep);
  70. n.y = centerY + r * Math.sin(degree + (-l / 2 + i) * degreeStep);
  71. pureNodePositions.push({ x: n.x as number, y: n.y as number });
  72. });
  73. };
  74. const checkOverlap = (nodesPosition: INode[], pureNodesPosition: Partial<INode>[]) => {
  75. let hasOverlap = false;
  76. const checkLayer = Math.round(minRadius / unitRadius);
  77. const loopNodes = nodesPosition.filter((n) => n.layer === checkLayer);
  78. for (let i = 0; i < loopNodes.length; i++) {
  79. const n = loopNodes[i];
  80. // 因为是同心圆布局,最先相交的应该是收尾节点
  81. if (
  82. getDistance(pureNodesPosition[0], n) < nodeSize ||
  83. getDistance(pureNodesPosition[pureNodesPosition.length - 1], n) < nodeSize
  84. ) {
  85. hasOverlap = true;
  86. break;
  87. }
  88. }
  89. return hasOverlap;
  90. };
  91. getNodesPosition(pureLayoutNodes, minRadius);
  92. while (checkOverlap(allNodePositions, pureNodePositions)) {
  93. minRadius += unitRadius;
  94. getNodesPosition(pureLayoutNodes, minRadius);
  95. }
  96. return layoutNodes;
  97. };

问题

  1. 拓展节点过多,一圈存放不下怎么办?