前言
这种需求,日常开发中应该比较常见吧“在一个图画布中点击节点时异步请求数据,同时保证原有画布不变, 做增量布局”。
实现
看到这种图相关的需求,我一般会考虑 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 (
<button
onClick={() => {
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 data
const 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) node
nodeModel.x = itemModel.x;
nodeModel.y = itemModel.y;
graph.addItem('node', nodeModel);
});
// add new edges to graph
pureEdges.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;
};
问题
- 拓展节点过多,一圈存放不下怎么办?