vue3中使用Antv G6渲染树形结构并支持节点增删改
AprilTong 6/28/2024 Vue
# 写在前面
在一些管理系统中,会对组织架构、级联数据等做一些管理,你会怎么实现呢?在经过调研很多插件之后决定使用 Antv G6 实现,文档也比较清晰,看看怎么实现吧,先来看看效果图。点击在线体验 (opens new window)
实现的功能有:
- 增加节点
- 删除节点
- 编辑节点
- 展开收起
思路:增加/删除/编辑都可以改变对应的数据,更新元素数据,展开收起 G6 自带有 collapsed 状态。新增/编辑时可通过 G6 的坐标转换动态更改输入框位置达到编辑指定节点的效果。
# 具体实现
- 先在项目中安装 antv g6
npm install --save @antv/g6
1
- vue 文件创建容器渲染
- 渲染的容器
<div id="container" class="one-tree"></div>
1
- 渲染方法和初始化树图
import G6 from '@antv/g6'
const state = reactive({
treeData: {
id: 'root',
sname: 'root',
name: uniqueId(),
children: [],
},
graph: null,
})
function renderMap(data: any[], graph: Graph): void {
G6.registerNode(
'icon-node',
{
options: {
size: [60, 20],
stroke: '#73D13D',
fill: '#fff'
},
draw(cfg: any, group: any) {
const styles = (this as any).getShapeStyle(cfg)
const { labelCfg = {} } = cfg
const w = cfg.size[0]
const h = cfg.size[1]
const keyShape = group.addShape('rect', {
attrs: {
...styles,
cursor: 'pointer',
x: 0,
y: 0,
width: w, // 200,
height: h, // 60
fill: cfg.style.fill || '#fff'
},
name: 'node-rect',
draggable: true
})
// 动态增加和删除元素
group.addShape('text', {
attrs: {
x: 131,
y: 20,
r: 6,
stroke: '#707070',
cursor: 'pointer',
opacity: 1,
fontFamily: 'iconfont',
textAlign: 'center',
textBaseline: 'middle',
text: '\ue658',
fontSize: 16
},
name: 'add-item'
})
// 删除icon,根元素不能删除
if (cfg.id !== 'root') {
group.addShape('text', {
attrs: {
x: 110,
y: 20,
r: 6,
fontFamily: 'iconfont',
textAlign: 'center',
textBaseline: 'middle',
text: '\ue74b',
fontSize: 14,
stroke: '#909399',
cursor: 'pointer',
opacity: 0
},
name: 'remove-item'
})
}
if (cfg.sname) {
group.addShape('text', {
attrs: {
...labelCfg.style,
text: fittingString(cfg.sname, 110, 12),
textAlign: 'left',
x: 10,
y: 25
}
})
}
// 展开收起
if (cfg.children && cfg.children.length > 0) {
group.addShape('circle', {
attrs: {
width: 24,
height: 24,
x: 154,
y: 20,
r: 12,
cursor: 'pointer',
lineWidth: 1,
fill: !cfg.collapsed ? '#9e9e9e' : '#2196f3',
opacity: 1,
text: 1
},
name: 'collapse-icon'
})
group.addShape('text', {
attrs: {
...labelCfg.style,
text: cfg.children.length,
textAlign: 'left',
x: 150,
y: 25,
fill: '#ffffff',
fontWeight: 500,
cursor: 'pointer'
},
name: 'collapse-icon'
})
}
return keyShape
},
setState(name, value, item) {
const group = item?.getContainer()
if (name === 'collapsed') {
const marker = item?.get('group').find((ele: any) => ele.get('name') === 'collapse-icon')
const icon = value ? G6.Marker.expand : G6.Marker.collapse
marker.attr('symbol', icon)
}
if (name === 'selected') {
const nodeRect = group?.find(function (e) {
return e.get('name') === 'node-rect'
})
if (value) {
nodeRect?.attr({
stroke: '#2196f3',
lineWidth: 2
})
}
}
if (name === 'hover') {
const addMarker = group?.find(function (e) {
return e.get('name') === 'add-item'
})
const reduceMarker = group?.find(function (e) {
return e.get('name') === 'remove-item'
})
if (value) {
addMarker?.attr({
opacity: 1
})
reduceMarker?.attr({
opacity: 1
})
}
}
},
update: undefined
},
'rect'
)
graph.data(data)
graph.render()
mouseenterNode(graph)
mouseLeaveNode(graph)
collapseNode(graph)
}
function initGraph(graphWrapId: string): Graph {
const width = (document.getElementById(graphWrapId) as HTMLElement).clientWidth || 1000
const height = (document.getElementById(graphWrapId) as HTMLElement).clientHeight || 1000
const graph = new G6.TreeGraph({
container: graphWrapId,
width,
height,
linkCenter: true,
animate: false,
fitView: false, // 自动调整节点位置和缩放,使得节点适应画布大小
modes: {
default: ['scroll-canvas'],
edit: ['click-select']
},
defaultNode: {
type: 'icon-node',
size: [120, 40],
style: defaultNodeStyle,
labelCfg: defaultLabelCfg
},
defaultEdge: {
type: 'cubic-vertical'
},
comboStateStyles,
layout: defaultLayout
})
return graph
}
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
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
- 事件处理
/**
* @description:树型图的事件绑定
*/
// 展开收起子节点
function collapseNode(graph: Graph): void {
// 展开和收起子节点
graph.on('node:click', (e: any) => {
if (e.target.get('name') === 'collapse-icon') {
e.item.getModel().collapsed = !e.item.getModel().collapsed
graph.setItemState(e.item, 'collapsed', e.item.getModel().collapsed)
graph.layout()
}
})
}
// 鼠标滑入
function mouseenterNode(graph: Graph): void {
graph.on('node:mouseover', (evt: any) => {
const { item, target } = evt
if (item._cfg.id === 'root') return
const canHoverName = ['node-rect', 'remove-item']
if (!canHoverName.includes(target.get('name'))) return
// 显示icon
const deleteItem = item.get('group').find(function (el: any) {
return el.cfg.name === 'remove-item'
})
deleteItem.attr('opacity', 1)
if (item._cfg && item._cfg.keyShape) {
item._cfg.keyShape.attr('stroke', '#2196f3')
}
graph.setItemState(item, 'active', true)
})
}
// 鼠标离开
function mouseLeaveNode(graph: Graph): void {
graph.on('node:mouseout', (evt: any) => {
const { item, target } = evt
const canHoverName = ['node-rect', 'remove-item']
if (item._cfg.id === 'root') return
if (!canHoverName.includes(target.get('name'))) return
// 隐藏icon
const deleteItem = item.get('group').find(function (el: any) {
return el.cfg.name === 'remove-item'
})
deleteItem.attr('opacity', 0)
if (item._cfg && item._cfg.keyShape) {
item._cfg.keyShape.attr('stroke', '#fff')
}
graph.setItemState(item, 'active', false)
})
}
/**
* @description 文本超长显示
*/
const fittingString = (str: string, maxWidth: number, fontSize: number): string => {
const ellipsis = '...'
const ellipsisLength = Util.getTextSize(ellipsis, fontSize)[0]
let currentWidth = 0
let res = str
const pattern = new RegExp('[\u4E00-\u9FA5]+')
str.split('').forEach((letter, i) => {
if (currentWidth > maxWidth - ellipsisLength) return
if (pattern.test(letter)) {
currentWidth += fontSize
} else {
currentWidth += Util.getLetterWidth(letter, fontSize)
}
if (currentWidth > maxWidth - ellipsisLength) {
res = `${str.substr(0, i)}${ellipsis}`
}
})
return res
}
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
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
- 节点的增加、删除、编辑时间
const addEvent = (graph: any) => {
graph.on('node:click', (evt: any) => {
const { item, target } = evt
const name = target.get('name')
// 增加元素
const model = item.getModel()
if (name === 'add-item') {
state.editType = 'add'
// 如果收起需要展开
if (model.collapsed) model.collapsed = false
// 没有子级的时候设置空数组
if (!model.children) model.children = []
const id = uniqueId()
model.children.push({
id,
name: 1,
sname: '',
parentId: model.id,
})
graph.updateChild(model, model.id)
const curTarget = graph.findDataById(id)
const canvasXY = graph.getCanvasByPoint(curTarget.x, curTarget.y)
state.editOne = curTarget
state.input = curTarget.sname
setTimeout(() => {
state.showInput = true
nextTick(() => {
inputRref.value.focus()
})
}, 200)
// 更改输入框的位置
state.inputStyle = {
left: `${canvasXY.x}px`,
top: `${canvasXY.y}px`,
}
}
// 删除节点
if (name === 'remove-item') {
graph.removeChild(model.id)
// 查找当前的父id,更新其子元素的长度
graph.updateItem(model.parentId, {})
}
// 编辑
if (name === 'node-rect') {
const curTarget = graph.findDataById(item._cfg.id)
const canvasXY = graph.getCanvasByPoint(curTarget.x, curTarget.y)
state.editOne = evt.item
state.input = curTarget.sname
state.showInput = true
state.editType = 'edit'
nextTick(() => {
inputRref.value.focus()
})
state.inputStyle = {
left: `${canvasXY.x}px`,
top: `${canvasXY.y}px`,
}
}
})
// 画布滚动、拖动时,不能编辑节点名称
graph.on('dragstart', () => {
state.showInput = false
})
graph.on('wheel', () => {
state.showInput = false
})
}
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
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
- dom 节点渲染后渲染树图
onMounted(() => {
nextTick(() => {
state.graph = initGraph('container')
state.graph.clear()
addEvent(state.graph)
renderMap(state.treeData, state.graph)
})
})
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
# 存在问题
配置了滚动画布scroll-canvas,文档上说可以配置 scalableRange 参数,设置拖动 canvas 可扩展的范围,默认为 0,值为 -1 ~ 1 代表可超出视口的范围的比例值。但好像设置了不生效,画布会无限滚动。欢迎各位大佬指点解决方式。