How to Develop a Relationship Graph with G6

Recently, I was thinking about how to implement the relationship diagram of Feishu Docs, so I remembered the G6 I used before, so I simply implemented a version.

Final effect

The main functions are as follows:

  • The node is divided into two parts, the top half is the icon and the bottom half is the text
    • When the mouse is placed on the icon, it will produce a diffusion effect of water ripples, and a circle of borders will appear at the same time
    • Text needs to be underlined and have a white background, and the water ripple effect cannot be blocked by the white background
    • The underline of the text needs to be a square
  • The connection should have a gradual change of color, and the gradual change of color should be from the beginning of the arrow to the end of the arrow, and if the two-way relationship is supported, how to achieve two edges
  • There should be a Label in the middle of the connection
  • Nodes and connections should support translucency
  • The overall layout should be a force-oriented layout, and node dragging is supported. When dragging, nodes cannot overlap, but other nodes cannot be dragged due to connection (this point is raised separately because G6’s built-in force-oriented layout nodes will be dragged).

Concrete realization

Custom node

The first difficulty of this Functional Button is:

  • G6 Only keyShapre can respond to events, and the connection is connected to the keyShape injury, and the keyShape is the first shape of each group
  • So if we want the mouse to Hover the image to produce water ripples, we should make the image node the keyShape
  • But if we let image be keyShape, even if our picture is round, the box of keyShape is square, if the line is connected to the four corners of the image, there will be a little white space between the line and the picture, so we need to Let circle be keyShape, and then image is added later, but this will cause our keyShapre, that is, circle is covered by image, so that the hover event cannot be triggered

There are two solutions, depending on whether the renderer you use is svg or canvas

  • If it is canvas, let circle be keyShape, but add zIndex to both circle and image, and then call group.sort ()

  • If it is svg, group.sort is invalid, we need to change the hierarchy through the js method in the afterDraw method, and move the back circle to the front (the earlier you add it, the later it will be)

The second difficulty is how to add the water ripple effect and how to turn on the water ripple when the Hover circle

The solution is divided into two steps:

  • Use the water ripple code from the official example, but remember to set visible to false first, and pay attention to the hierarchy, so that the text is at the top
  • Then listen to the’node: mousemove ‘event, if the Hover target is circle, call setItemState, then when registering the node, set the setState callback, and then set visible to true through the shape.attr method, then listen to’node: mouseleave’, set the state to another, and set visible to false in the setState callback

The scheme of setting and listening to state changes can also be used to implement borders. Of course, this simple style can also be solved by stateStyle

The third difficulty is how to add text underline and how to break lines if the text is too long, because the text in G6 does not support the underline I need

The solution is:

  • Calculate the length of the text by yourself, break if it exceeds a certain length, delete and add ellipsis after more than two lines
  • Then add the path shape as an underscore below the text, and the length should be the text length.

Note that custom nodes should remember to inherit a built-in node, at least inherit single-node, otherwise many features are not available

Here only shows the canvas code:

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',
);

Connection

The first difficulty is that you can’t simply add a lable to draw or afterdraw. This only applies to the situation where the relative position of the text and the starting point of the line remains unchanged. When we need it, the node can be dragged and dropped, which means that the length of the line can be changed, and what we require is that the text is in the middle of the line.

The solution is:

  • Remove the previous added text shape and add new text shape every time in afterUpdate
  • There is actually no group in afterUpdate. You can forcibly mount group to cfg after draw.

The second difficulty is that how to keep the gradual change of color must be from the starting point to the end point of the connection, because G6 only supports the setting of a fixed angle, such as setting a gradual change of 0 degrees, it must be a gradual change from left to right, if A line is from right to left, then this gradual change of color is reversed

The solution is:

  • Or in afterUpdate, constantly calculate the position of the new startPoint and endPoint to calculate the angle
  • Set the gradual change color attribute of stroke by calculating the angle and then using shape.attr

The third difficulty is how to support bilinear curves, because the default is a straight line, and the two-way lines will overlap

The solution lies in:

  • Define another custom type, based on Bezier curve extension

The specific code is as follows:

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
//Get the coordinates of the midpoint of the path graph
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',
);

Event monitoring changes node and connection styles

Code for event monitoring:

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]);
}

Layout algorithms and drag nodes

G6 actually comes with a force-oriented layout, but it’s weird, the effect is different from d3, and it doesn’t support only collision detection when dragging

There are a few difficulties here:

  • How to use force-oriented layout of d3, here mainly d3 will change the data structure of points, causing G6 to execute abnormally
  • If you implement collision detection yourself
  • How to smooth a bit when dragging nodes

The solution is as follows:

  • Use Promise to encapsulate the layout algorithm of d3, process the data into the format supported by G6 after monitoring the end event, otherwise let G6 render without waiting for all ticks to be completed. Halfway through, d3 changes the data format again, an error will be reported
  • Self-implemented collision detection is actually a simple way to determine the distance between nodes. If it is less than a certain value, push other nodes away, then record the nodes that are pushed away, and calculate the distance between the nodes that are pushed away and all nodes., if there is still close, modify the position and continue the recursion of the nodes that modify the position in this round. In order to prevent stack overflow, you can set the maximum recursion layer of 30
  • If the refreshPosition method is called after each node is calculated, it will actually cause line shaking. The solution is to enable force layout, so layout can be used to achieve smooth re-rendering, but the positions of all nodes are set with fx and fy to prevent force-oriented layout from modifying position

The code is as follows:

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),
);

//Root formula for quadratic equation system of one yuan 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);