如何用G6开发一个关系图谱

最近在想着飞书文档的关系图是怎么实现的,于是想起了之前用的G6,于是简单实现了一版。

最终效果

主要功能如下:

  • 节点分为两部分,上半部分是图标,下半部分是文字
    • 鼠标放到图标上时,会产生水波纹的扩散特效,同时出现一圈边框
    • 文字需要有下划线以及白色背景,且水波纹特效不能被白色背景挡住
    • 文字的下划线需要是方格
  • 连线要有渐变色,且渐变色要从箭头起点到箭头终点,且如果支持双向关系,如何实现两条边
  • 连线上中间位置要有一个Label
  • 节点和连线要支持半透明
  • 整体布局要是力导向布局,且支持节点拖拽,拖拽时不能让节点之间产生重叠,但是也不能因为连线拖动其他节点(单独提出这一点是因为G6自带的力导向布局节点会被拖拽)

具体实现

自定义节点

这个功能点的第一个难点在于:

  • G6只有keyShapre能够响应事件,且连线是连到keyShape伤的,而keyShape就是每个group的第一个shape
  • 所以我们如果想要鼠标Hover到image产生水波纹,应该让image节点当keyShape
  • 但是如果让image当keyShape,即使我们的图片是圆的,keyShape的框也是方的,连线如果连接到image的四个角上,就会出现连线和图片之间有一点空白,所以我们又需要让circle当作keyShape,然后image在后面添加,但这样又会导致我们的keyShapre,也就是circle被image盖在上面,导致无法触发hover事件

解决方案有两种,取决于你用的renderer是svg还是canvas

  • 如果是canvas,那么还是让circle当keyShape,不过给circle和image都添加zIndex,然后调用group.sort()

  • 如果是svg,group.sort是无效的,我们需要自己在afterDraw方法里通过js的方法改变层级,把后面的circle移动到最前面(越早添加越在后面)

第二个难点在于,如何添加水波纹特效以及,如何在Hover circle的时候开启水波纹

解决方案分为两个步骤:

  • 使用官方示例的水波纹代码,不过记住先把visible设置为false,同时注意层级,要让文字在最上方
  • 然后监听node:mousemove事件,如果Hover的target是circle,调用setItemState,然后在注册节点时,设置setState的回调,再通过shape.attr方法设置visible为true就好,然后监听node:mouseleave,将状态设置为另一个,同时setState回调中设置visible为false

设置并监听state变化的方案也可以用来实现边框,当然这种简单的样式也可以通过stateStyle解决

第三个难点在于,如何添加文字下划线,以及文字过长如何折行,因为G6的text不支持我需要的下划线

解决方案是:

  • 自己算文字长度,超过一定长度就打断,超过两行后面的删除并添加省略号
  • 然后在文字下方添加path shape作为下划线,长度用文字长度即可

注意,自定义节点要记得继承某个内置节点,最起码继承single-node,不然很多特性是没有的

这里只展示canvas代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
export enum ItemStatus {
OPACITY = 'opacity',
NORMAL = 'normal',
ACTIVE = 'active',
}

function getLetterWidth(letter: string, fontSize: number) {
const pattern = new RegExp('[\u4E00-\u9FA5]+');
if (pattern.test(letter)) {
// Chinese charactors
return fontSize;
} else {
// get the width of single letter according to the fontSize
return G6.Util.getLetterWidth(letter, fontSize);
}
}

export function getStringWidth(str: string, fontSize: number) {
let currentWidth = 0;
str.split('').forEach(letter => {
currentWidth += getLetterWidth(letter, fontSize);
});
return currentWidth;
}

function replaceTooLongStringWithEllipsis(
strs: string[],
maxWidth: number,
fontSize: number,
) {
const ellipsis = '...';
const ellipsisLength = getStringWidth(ellipsis, fontSize);
if (strs.length > 2) {
const secondLine = strs[1];
let currentWidth = ellipsisLength;
for (let i = 0; i < secondLine.length; i++) {
currentWidth += getLetterWidth(secondLine[i], fontSize);
if (currentWidth >= maxWidth) {
strs[1] = `${secondLine.slice(0, i)}${ellipsis}`;
}
}
return [strs[0], strs[1]];
} else {
return strs;
}
}

const NODE_ICON_SIZE = 48;
const NODE_ICON_RADIUS = NODE_ICON_SIZE / 2;
const NODE_NAME_FONT_SIZE = 14;
const NODE_NAME_HEIGHT = 18

G6.registerNode(
'test-node',
{
draw(cfg, group) {
const keyShape = group?.addShape('circle', {
attrs: {
x: 0,
y: 0,
r: NODE_ICON_RADIUS,
fill: 'rgba(255,255,255,0)',
opacity: 0,
lineWidth: 1,
cursor: 'pointer',
},
name: 'test-node-dummy',
draggle: true,
zIndex: 10,
});

group?.addShape('image', {
attrs: {
x: -NODE_ICON_SIZE / 2,
y: -NODE_ICON_SIZE / 2,
width: NODE_ICON_SIZE,
height: NODE_ICON_SIZE,
img: '',
cursor: 'pointer',
},
name: 'test-node-icon',
draggle: true,
zIndex: 9,
});

const lableSplit = fittingString(
cfg?.supName as string,
200,
NODE_NAME_FONT_SIZE,
);
for (let i = 0; i < lableSplit.length; i++) {
const label = lableSplit[i];
const labelWidth = getStringWidth(
label,
NODE_NAME_FONT_SIZE,
);
group?.addShape('rect', {
attrs: {
x: -labelWidth / 2,
y:
NODE_ICON_SIZE -
NODE_NAME_HEIGHT +
i * NODE_NAME_HEIGHT +
4,
width: labelWidth,
height: NODE_NAME_HEIGHT,
fill: '#FFFFFF',
},
name: `test-node-name-background-${i}`,
zIndex: 3,
});

group?.addShape('text', {
attrs: {
text: label,
fill: '#646A73',
fontSize: 14,
textAlign: 'center',
x: 0,
y: NODE_ICON_SIZE + i * NODE_NAME_HEIGHT,
width: labelWidth,
height: NODE_NAME_HEIGHT,
cursor: 'pointer',
},
name: `test-node-name-${i}`,
zIndex: 3,
});

group?.addShape('path', {
attrs: {
path: [
[
'M',
-labelWidth / 2,
NODE_ICON_SIZE + i * NODE_NAME_HEIGHT + 2,
],
[
'L',
labelWidth / 2,
NODE_ICON_SIZE + i * NODE_NAME_HEIGHT + 2,
],
],
stroke: '#000000',
lineWidth: 1,
lineDash: [2, 2],
},
name: `test-node-name-underline-${i}`,
zIndex: 3,
});
}

const back1 = group?.addShape('circle', {
zIndex: 5,
attrs: {
x: 0,
y: 0,
r: 0,
fill: 'rgba(255,255,255,0)',
opacity: 0,
},
name: 'test-node-wave1',
draggle: true,
});
const back2 = group?.addShape('circle', {
zIndex: 6,
attrs: {
x: 0,
y: 0,
r: 0,
fill: 'rgba(255,255,255,0)',
opacity: 0,
},
name: 'test-node-wave2',
draggle: true,
});
const back3 = group?.addShape('circle', {
zIndex: 7,
attrs: {
x: 0,
y: 0,
r: 0,
fill: 'rgba(255,255,255,0)',
opacity: 0,
},
name: 'test-node-wave3',
draggle: true,
});

back1?.animate(
{
// Magnifying and disappearing
r: NODE_ICON_RADIUS + 16,
opacity: 0.1,
},
{
duration: 3000,
easing: 'easeCubic',
delay: 100,
repeat: true, // repeat
},
); // no delay
back2?.animate(
{
// Magnifying and disappearing
r: NODE_ICON_RADIUS + 16,
opacity: 0.1,
},
{
duration: 3000,
easing: 'easeCubic',
delay: 1000,
repeat: true, // repeat
},
); // 1s delay
back3?.animate(
{
// Magnifying and disappearing
r: NODE_ICON_RADIUS + 16,
opacity: 0.1,
},
{
duration: 3000,
easing: 'easeCubic',
delay: 2000,
repeat: true, // repeat
},
); // 3s delay

back1?.hide();
back2?.hide();
back3?.hide();

group?.sort();
return keyShape as IShape;
},
setState(name, value, item) {
if (name === 'status') {
if (value === ItemStatus.ACTIVE) {
const group = item?.getContainer();
const shapes = group?.getChildren() ?? [];
for (const shape of shapes) {
if (shape.cfg.name?.includes('test-node-wave')) {
shape.attr('opacity', 0.6);
shape.attr('fill', '#4E83FD');
shape.show();
} else if (shape.cfg.name?.includes('test-node-dummy')) {
shape.attr('opacity', 1);
shape.attr('stroke', '#4E83FD');
} else {
shape.attr('opacity', 1);
}
}
} else {
const group = item?.getContainer();
const shapes = group?.getChildren() ?? [];
for (const shape of shapes) {
if (shape.cfg.name?.includes('test-node-wave')) {
shape.attr('opacity', 0);
shape.attr('fill', '#FFFFFF');
shape.hide();
} else if (shape.cfg.name?.includes('test-node-dummy')) {
shape.attr('opacity', 0);
shape.attr('stroke', '#FFFFFF');
} else {
shape.attr('opacity', value === ItemStatus.OPACITY ? 0.2 : 1);
}
}
}
}
},
},
'single-node',
);

连线

第一个难点在于,不能简单的在draw或者afterdraw中添加一个lable就可以了,这种只适用于文字和连线的起点相对位置不变的情况,而我们的需求时,节点可以拖拽,那就意味着连线长度可以变,而我们要求的是文字在连线中间。

解决方案是:

  • 在afterUpdate中每次都删除上一个添加的text shape并添加新的text shape
  • afterUpdate中其实是没有group的,可以在draw之后,把group强行挂载到cfg上

第二个难点在于,如何保持渐变色一定是从连线起点到终点,因为G6只支持某个固定角度的设置,比如设置0度的渐变,就一定是从左到右渐变,如果某条线是从右到左,那这个渐变色就反了

解决方案是:

  • 还是在afterUpdate中,不断计算新的startPoint和endPoint的位置,算出角度来
  • 通过计算出来的角度,再通过shape.attr设置stroke的渐变色属性

第三个难点在于,如何支持双线曲线,因为默认的是直线,双向直线会重合

解决方案在于:

  • 定义另一个自定义类型,基于贝塞尔曲线扩展

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
const EDGE_LABEL_HEIGHT = 18;
const EDGE_LABEL_FONT_SIZE = 10;

function addLabelToEdge(cfg: ModelConfig | undefined) {
const config = cfg as ModelConfig;
const group = cfg?.group as IGroup;
const shape = group?.get('children')[0];
// get the coordinate of the mid point on the path
// 获取路径图形的中点坐标
const midPoint = shape.getPoint(0.5);
const startPoint = cfg?.startPoint;
const endPoint = cfg?.endPoint;
if (startPoint && endPoint) {
const angle =
Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x) * 2;
if (angle > -Math.PI && angle < Math.PI) {
shape.attr('stroke', `l(0) 0:#4E83FD 1:#B6CBFE`);
} else {
shape.attr('stroke', `l(0) 0:#B6CBFE 1:#4E83FD`);
}
}
if (midPoint) {
const labelWidth = getStringWidth(
config.text as string,
EDGE_LABEL_FONT_SIZE,
);
group?.removeChild(config.preEdgeText as any);
group?.removeChild(config.preEdgeTextBackground as any);
config.preEdgeTextBackground = group.addShape('rect', {
attrs: {
x: midPoint.x - (labelWidth + 12) / 2,
y: midPoint.y - EDGE_LABEL_HEIGHT + 4,
width: labelWidth + 12,
height: EDGE_LABEL_HEIGHT,
fill: '#E1EAFF',
radius: EDGE_LABEL_HEIGHT / 2,
},
name: 'test-edge-background',
});
config.preEdgeText = group.addShape('text', {
attrs: {
text: cfg?.text,
fill: '#3370FF',
fontWeight: 500,
fontSize: 10,
lineHeight: 16,
textAlign: 'center',
x: midPoint.x,
y: midPoint.y,
width: labelWidth + 12,
height: EDGE_LABEL_HEIGHT,
},
name: 'test-edge-text',
});
}
}

G6.registerEdge(
'test-edge-single',
{
afterDraw(cfg, group) {
const config = cfg as ModelConfig;
config.group = group;
addLabelToEdge(cfg);
},
afterUpdate(cfg) {
addLabelToEdge(cfg);
},
},
'line',
);

G6.registerEdge(
'test-edge-double',
{
afterDraw(cfg, group) {
const config = cfg as ModelConfig;
config.group = group;
addLabelToEdge(cfg);
},
afterUpdate(cfg) {
addLabelToEdge(cfg);
},
},
'quadratic',
);

事件监听改变节点和连线样式

事件监听的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
function highlightLocalNodesAndEdges(graphInstance: IGraph, e: IG6GraphEvent) {
const { localEdges, localNodesId, otherNodesId, otherEdges } =
getAllLocalNodesAndEdges(graphInstance, e.item?.getModel().id as string);

localEdges.forEach(edge => {
graphInstance.setItemState(
edge.getModel().id as string,
'status',
ItemStatus.NORMAL,
);
});

otherEdges.forEach(edge => {
graphInstance.setItemState(
edge.getModel().id as string,
'status',
ItemStatus.OPACITY,
);
});

localNodesId.forEach(nodeId => {
graphInstance.setItemState(nodeId as string, 'status', ItemStatus.NORMAL);
});

otherNodesId.forEach(nodeId => {
graphInstance.setItemState(nodeId as string, 'status', ItemStatus.OPACITY);
});
}

function highlightAllNodesAndGraph(graphInstance: IGraph) {
graphInstance.findAll('edge', edge => {
graphInstance.setItemState(edge, 'status', ItemStatus.NORMAL);
return true;
});

graphInstance.findAll('node', node => {
graphInstance.setItemState(node, 'status', ItemStatus.NORMAL);
return true;
});
}

export function useInternalEventListener(graphInstance: IGraph | null) {
useEffect(() => {
function onMouseMoveOnNode(e: IG6GraphEvent) {
if (
!e.target.cfg.name?.includes('test-node--name-') &&
graphInstance
) {
highlightLocalNodesAndEdges(graphInstance, e);
graphInstance.setItemState(e.item as Item, 'status', ItemStatus.ACTIVE);
}
}

function onMouseLeaveNode() {
if (graphInstance) {
highlightAllNodesAndGraph(graphInstance);
}
}

graphInstance?.on('node:mousemove', onMouseMoveOnNode);

graphInstance?.on('node:mouseleave', onMouseLeaveNode);

return () => {
graphInstance?.off('node:mousemove', onMouseMoveOnNode);

graphInstance?.off('node:mouseleave', onMouseLeaveNode);
};
}, [graphInstance]);
}

布局算法和拖拽节点

G6其实自带了一个力导向布局,不过很怪,其效果和d3的不一样,且拖拽时不支持只有碰撞检测

这里有几个难点:

  • 如何使用d3的力导向布局,这里主要是d3会改变点的数据结构,导致G6执行异常
  • 如果自己实现碰撞检测
  • 拖拽节点的时候,如何平滑一点

解决方案如下:

  • 用Promise封装d3的布局算法,在监听到end事件之后再把数据处理成G6支持的格式,不然没等所有tick都完成就让G6渲染,到一半的时候,d3又把数据格式改了,就会报错
  • 自己实现的碰撞检测其实简单判断下节点之间的距离,如果小于一定值,就把其他节点推开,然后记录被推开的节点,算一被推开的节点和所有节点之间的距离,如果还是有接近的,再修改位置并继续递归这一轮修改位置的节点,为了防止栈溢出,可以设置最大递归30层
  • 如果每次计算完成节点后调用refreshPosition方法,其实会导致线条抖动,解决方案是开启force布局,于是可以用layout来实现平滑的重新渲染,但是所有节点的位置使用fx和fy来设置,防止力导向布局修改位置

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
const REFRESH_NODE_POSITION_RECURSION_COUNT = 30;
const REFRESH_NODE_COLLIDE_RADIUS = 150;
const REFRESH_NODE_COLLIDE_RADIUS_SEQUARE = Math.pow(
REFRESH_NODE_COLLIDE_RADIUS,
2,
);

function getTwoNodeDistance(node1: NodeConfig, node2: NodeConfig) {
const distance =
Math.pow((node1.x as number) - (node2.x as number), 2) +
Math.pow((node1.y as number) - (node2.y as number), 2);
return Math.sqrt(distance);
}

function refreshNodesPositionHelper(
dragedNode: NodeConfig,
positionChangedNodes: NodeConfig[],
allNodes: NodeConfig[],
deep = 0,
) {
if (deep > REFRESH_NODE_POSITION_RECURSION_COUNT) {
return;
}
const nextPositionChangedNodesSet = new Set<NodeConfig>();
for (const positionChangedNode of positionChangedNodes) {
for (const everyNode of allNodes) {
if (positionChangedNode.id === everyNode.id) {
continue;
}
const distance = getTwoNodeDistance(positionChangedNode, everyNode);
if (distance < REFRESH_NODE_COLLIDE_RADIUS) {
nextPositionChangedNodesSet.add(everyNode);
const detaX = Math.abs(
(positionChangedNode.x as number) - (everyNode.x as number),
);
const detaY = Math.abs(
(positionChangedNode.y as number) - (everyNode.y as number),
);

// 一元二次方程组求根公式 2 * moveDistance^2 + 2(detaX + detaY)*moveDistance = REFRESH_NODE_COLLIDE_RADIUS_SEQUARE - (detaX^2 + detaY^2)
const a = 2;
const b = 2 * (detaX + detaY);
const c =
detaX * detaX + detaY * detaY - REFRESH_NODE_COLLIDE_RADIUS_SEQUARE;
const moveDistance = (Math.sqrt(b * b - 4 * a * c) - b) / (2 * a);

everyNode.x =
(everyNode.x as number) +
((everyNode.x as number) - (positionChangedNode.x as number) > 0
? 1
: -1) *
moveDistance;

everyNode.y =
(everyNode.y as number) +
((everyNode.y as number) - (positionChangedNode.y as number) > 0
? 1
: -1) *
moveDistance;
}
}
}

if (nextPositionChangedNodesSet.size > 0) {
const nextPositionChangedNodes: NodeConfig[] = [];
nextPositionChangedNodesSet.forEach(value => {
nextPositionChangedNodes.push(value);
});
refreshNodesPositionHelper(
dragedNode,
nextPositionChangedNodes,
allNodes,
deep + 1,
);
} else {
allNodes.forEach(node => {
node.fx = node.x;
node.fy = node.y;
});
}
}

function onNodeDrag(e: IG6GraphEvent) {
graphInstance?.layout();
const model = e.item?.get('model');
model.x = e.x;
model.y = e.y;
model.fx = e.x;
model.fy = e.y;
const allNodes = graphInstance
?.findAll('node', node => node.getID() !== e.item?.getID())
.map(node => node.getModel()) as NodeConfig[];

refreshNodesPositionHelper(model, [model], allNodes);
}

function onMouseMoveOnNode(e: IG6GraphEvent) {
if (
!e.target.cfg.name?.includes('test-node--name-') &&
graphInstance
) {
highlightLocalNodesAndEdges(graphInstance, e);
graphInstance.setItemState(e.item as Item, 'status', ItemStatus.ACTIVE);
}
}

function onMouseLeaveNode() {
if (graphInstance) {
highlightAllNodesAndGraph(graphInstance);
}
}

graphInstance?.on('node:drag', onNodeDrag);