前言
这种需求,日常开发中应该比较常见吧“在一个图画布中点击节点时异步请求数据,同时保证原有画布不变, 做增量布局”。
实现
看到这种图相关的需求,我一般会考虑 G6 或者 D3,当然,极端情况下也会自己造轮子。最终选择的是 G6,也并不是因为我是 G6 开发者!
快速接入
由于 G6 没有对业务来说,成本稍高,而且没有现存案例,我们会对其进行简单封装(Ant Design Charts),后面会给出实现代码。step1:依赖安装
yarn add @ant-design/graphs -S
step2:组件引用
import React, { useRef } from 'react';import ReactDOM from 'react-dom';import { RadialGraph } from '@ant-design/graphs';const DemoRadialGraph = () => {const chartRef = useRef();const data = {nodes: [{id: '0',label: '0',},{id: '1',label: '1',},{id: '2',label: '2',},{id: '3',label: '3',},{id: '4',label: '4',},{id: '5',label: '5',},{id: '6',label: '6',},{id: '7',label: '7',},{id: '8',label: '8',},{id: '9',label: '9',},],edges: [{source: '0',target: '1',},{source: '0',target: '2',},{source: '0',target: '3',},{source: '0',target: '4',},{source: '0',target: '5',},{source: '0',target: '6',},{source: '0',target: '7',},{source: '0',target: '8',},{source: '0',target: '9',},],};// 模拟请求const fetchData = (node) => {return new Promise((resolve, reject) => {const data = new Array(Math.ceil(Math.random() * 10) + 2).fill('').map((_, i) => i + 1);setTimeout(() => {resolve({nodes: [{...node,},].concat(data.map((i) => {return {id: `${node.id}-${i}`,label: `${node.label}-${i}`,};}),),edges: data.map((i) => {return {source: node.id,target: `${node.id}-${i}`,};}),});}, 1000);});};const asyncData = async (node) => {return await fetchData(node);};const config = {data,autoFit: false,layout: {unitRadius: 80,/** 节点直径 */nodeSize: 20,/** 节点间距 */nodeSpacing: 10,},nodeCfg: {asyncData,size: 20,style: {fill: '#6CE8DC',stroke: '#6CE8DC',},labelCfg: {style: {fontSize: 5,fill: '#000',},},},menuCfg: {customContent: (e) => {return (<buttononClick={() => {chartRef.current.emit('node:dblclick', e);}}>手动拓展(双击节点也可以拓展)</button>);},},edgeCfg: {style: {lineWidth: 1,},endArrow: {d: 10,size: 2,},},behaviors: ['drag-canvas', 'zoom-canvas', 'drag-node'],onReady: (graph) => {chartRef.current = graph;},};return <RadialGraph {...config} />;};ReactDOM.render(<DemoRadialGraph />, document.getElementById('container'));
实现原理
事件绑定
双击节点时发起数据请求,也可手动 emit已经绑定的事件,布局结束后触发位置变更动画graph.positionsAnimate。
/** bind events */export const bindDblClickEvent = (graph: IGraph,asyncData: (nodeCfg: NodeConfig) => GraphData,layoutCfg?: RadialLayout,fetchLoading?: FetchLoading,) => {const onDblClick = async (e: IG6GraphEvent) => {const item = e.item as INode;const itemModel = item.getModel();createLoading(itemModel as NodeConfig, fetchLoading);const newData = await asyncData(item.getModel() as NodeConfig);closeLoading();const nodes = graph.getNodes();const edges = graph.getEdges();const { x, y } = itemModel;const centerNodeId = graph.get('centerNode');const centerNode = centerNodeId ? graph.findById(centerNodeId) : nodes[0];const { x: centerX, y: centerY } = centerNode.getModel();// the max degree about foces(clicked) node in the original dataconst pureNodes = newData.nodes.filter((item) => findIndex(nodes, (t: INode) => t.getModel().id === item.id) === -1,);const pureEdges = newData.edges.filter((item) =>findIndex(edges, (t: IEdge) => {const { source, target } = t.getModel();return source === item.source && target === item.target;}) === -1,);// for graph.changeData()const allNodeModels: GraphData['nodes'] = [];const allEdgeModels: GraphData['edges'] = [];pureNodes.forEach((nodeModel) => {// set the initial positions of the new nodes to the focus(clicked) nodenodeModel.x = itemModel.x;nodeModel.y = itemModel.y;graph.addItem('node', nodeModel);});// add new edges to graphpureEdges.forEach((em, i) => {graph.addItem('edge', em);});edges.forEach((e: IEdge) => {allEdgeModels.push(e.getModel());});nodes.forEach((n: INode) => {allNodeModels.push(n.getModel() as NodeConfig);});// 这里使用了引用类型radialSectorLayout({center: [centerX, centerY],eventNodePosition: [x, y],nodes: nodes.map((n) => n.getModel() as NodeConfig),layoutNodes: pureNodes,options: layoutCfg as any,});graph.positionsAnimate();graph.data({nodes: allNodeModels,edges: allEdgeModels,});};graph.on('node:dblclick', (e: IG6GraphEvent) => {onDblClick(e);});};
节点布局
对节点进行拓展时,根据拓展出的节点数量以及节点之间的距离(nodeSpacing) ,计算出下一层级存放当前拓展节点所需的弧,检测下一层级该区域内是否存在重叠节点,没有即符合要求,如果有重叠,拓展节点移动到下一层级,依次检测。当然,也可以选择在对应层级移动节点,或者固定层级节点数量,放不下的移动到下一层级,主要还是看业务需求。
代码:
type INode = {id: string;x?: number;y?: number;layer?: number;[key: string]: unknown;};export type IRadialSectorLayout = {/** 布局中心 [x,y] */center: [number, number];/** 事件节点坐标 */eventNodePosition: [number, number];/** 画布当前节点信息,可通过 graph.getNodes().map(n => n.getModel()) 获取 */nodes: INode[];/** 布局节点,拓展时的新节点,会和当前画布节点做去重处理 */layoutNodes: INode[];options?: {/** 圈层半径 */unitRadius: number;/** 节点直径 */nodeSize: number;/** 节点间距 */nodeSpacing: number;};};export const radialSectorLayout = (params: IRadialSectorLayout): INode[] => {const { center, eventNodePosition, nodes: allNodes, layoutNodes, options = {} } = params;const { unitRadius = 80, nodeSize = 20, nodeSpacing = 10 } = options as IRadialSectorLayout['options'];if (!layoutNodes.length) layoutNodes;// 过滤已经在画布上的节点,避免上层传入重复节点const pureLayoutNodes = layoutNodes.filter((node) => {return (allNodes.findIndex((n) => {const { id } = n;return id === node.id;}) !== -1);});if (!pureLayoutNodes.length) return layoutNodes;const getDistance = (point1: Partial<INode>, point2: Partial<INode>) => {const dx = point1.x - point2.x;const dy = point1.y - point2.y;return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));};// 节点裁剪const [centerX, centerY] = center;const [ex, ey] = eventNodePosition;const diffX = ex - centerX;const diffY = ey - centerY;const allNodePositions: INode[] = [];allNodes.forEach((n) => {const { id, x, y } = n;allNodePositions.push({id,x,y,layer: Math.round(getDistance({ x, y }, { x: centerX, y: centerY })) / unitRadius,});});const currentRadius = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2));const degree = Math.atan2(diffY, diffX);let minRadius = currentRadius + unitRadius;let pureNodePositions: Partial<INode>[] = [];const getNodesPosition = (nodes: INode[], r: number) => {const degreeStep = 2 * Math.asin((nodeSize + nodeSpacing) / 2 / r);pureNodePositions = [];const l = nodes.length - 1;nodes.forEach((n, i) => {n.x = centerX + r * Math.cos(degree + (-l / 2 + i) * degreeStep);n.y = centerY + r * Math.sin(degree + (-l / 2 + i) * degreeStep);pureNodePositions.push({ x: n.x as number, y: n.y as number });});};const checkOverlap = (nodesPosition: INode[], pureNodesPosition: Partial<INode>[]) => {let hasOverlap = false;const checkLayer = Math.round(minRadius / unitRadius);const loopNodes = nodesPosition.filter((n) => n.layer === checkLayer);for (let i = 0; i < loopNodes.length; i++) {const n = loopNodes[i];// 因为是同心圆布局,最先相交的应该是收尾节点if (getDistance(pureNodesPosition[0], n) < nodeSize ||getDistance(pureNodesPosition[pureNodesPosition.length - 1], n) < nodeSize) {hasOverlap = true;break;}}return hasOverlap;};getNodesPosition(pureLayoutNodes, minRadius);while (checkOverlap(allNodePositions, pureNodePositions)) {minRadius += unitRadius;getNodesPosition(pureLayoutNodes, minRadius);}return layoutNodes;};
问题
- 拓展节点过多,一圈存放不下怎么办?
