1. 前言
记录一下使用G6绘制几种常见的关联图谱,包含自定义节点和自定义边的使用,还有一些交互行为。
2. 关系图谱
使用的是一般图中的力导向布局
2.1 绘制
2.1.1 生成实例
const charts = this.$refs.charts // 图的DOM容器
const { clientWidth: width, clientHeight: height } = charts
this.graph = new G6.Graph({
container: charts,
width,
height,
layout: {
type: 'gForce',
preventOverlap: true, // 防止节点重叠
nodeSpacing: 200, // 节点最小间距
nodeStrength: 2000, // 越大斥力越大
linkDistance: 150, // 边长度
gpuEnabled: true // 启用 GPU 并行计算
},
modes: {
// enableOptimize开启交互的优化
default: [
{
type: 'drag-node',
enableOptimize: true
},
{
type: 'drag-canvas',
enableOptimize: true
},
{
type: 'zoom-canvas',
sensitivity: 1, // 缩放灵敏度
minZoom: 0.5,
maxZoom: 1.5,
enableOptimize: true
}
]
},
defaultNode: {
size: 80,
labelCfg: {
style: {
fill: '#fff',
fontSize: 14
}
}
},
defaultEdge: {
type: 'line',
size: 1,
style: {
stroke: '#e2e2e2',
lineAppendWidth: 4, // 边的响应范围
endArrow: {
path: G6.Arrow.triangle(10, 10, 0)
}
},
labelCfg: {
autoRotate: true,
style: {
fill: '#e2e2e2',
fontSize: 12,
stroke: '#fff',
lineWidth: 0
}
}
}
})
2.1.2 数据转换
后端给的数据不一定是前端直接可以使用的,所以需要前端进行转换
const { nodes, relationships } = data
// 节点转换
nodes?.forEach((node, index) => {
const isRoot = index === 0 // 根节点
const isPerson = node.type === 'person' // 节点类型
node.label = fittingString(node.name, NODESIZE, 14) // 处理文本自动换行
const color = isPerson ? isRoot ? '#ff9e00' : '#ff6060' : '#4ea2f0'
const selectColor = isPerson ? isRoot ? '#ffc66b' : '#ffa0a0' : '#b6d4f0' // 选中节点后的颜色
node.style = {
fill: color,
lineWidth: 0
}
// 交互下的样式
node.stateStyles = {
select: {
stroke: selectColor,
lineWidth: 8
},
opacity: {
opacity: 0.1
}
}
})
// 边转换
const edges = relationships?.map(edge => {
const { role: label, startNode: source, endNode: target, type } = edge
const color = type === 'invest' ? '#ff6060' : '#4ea2f0'
return {
label,
source,
target,
style: {
endArrow: {
fill: color,
stroke: color
}
},
stateStyles: {
hover: {
stroke: color,
opacity: 1,
'text-shape': {
fontSize: 14,
fill: color,
}
},
opacity: {
opacity: 0.1
}
},
}
})
2.1.3 渲染
// 处理节点间多条边重叠的情况
G6.Util.processParallelEdges(edges)
// 点击节点的交互
graph.on('node:click', this.clickNode)
// 等于graph.data + graph.render方法
graph.read({ nodes, edges })
// 平移图到中心将对齐到画布中心
graph.fitCenter()
2.2 交互
2.2.1 节点的点击
// 记录选中
this.isClick = true
// 让所有的节点都透明并且置于底层
graph.getNodes().forEach(node => {
graph.clearItemStates(node)
graph.setItemState(node, 'opacity', true)
node.toBack()
})
// 取消点击的节点透明的状态
graph.setItemState(item, 'opacity', false)
// 设置点击的节点的样式
graph.setItemState(item, 'select', true)
// 设置点击节点的节点到最上层
item.toFront()
// 让所有的边都透明,和点击的节点有关联的边和节点都应用相关样式
graph.getEdges().forEach(edge => {
graph.setItemState(edge, 'hover', false)
graph.setItemState(edge, 'opacity', true)
if (edge.getSource() === item) {
graph.setItemState(edge.getTarget(), 'opacity', false)
graph.setItemState(edge, 'hover', true)
}
if (edge.getTarget() === item) {
graph.setItemState(edge.getSource(), 'opacity', false)
graph.setItemState(edge, 'hover', true)
}
})
2.2.2 边的移入移出
节点在选中状态的时候,边的状态就不能再改变了,使用isClick记录节点是否是选中状态
// 鼠标悬浮至边
graph.on('edge:mouseenter', ({ item }) => {
if (this.isClick) return
graph.setItemState(item, 'hover', true)
})
// 鼠标离开至边
graph.on('edge:mouseleave', ({ item }) => {
if (this.isClick) return
graph.setItemState(item, 'hover', false)
})
2.2.3 节点的移入移出
// 鼠标悬浮节点
graph.on('node:mouseenter', ({item}) => {
if (this.isClick) return
graph.getEdges().forEach(edge => {
graph.setItemState(edge, 'hover', false)
if (edge.getSource() === item) {
graph.setItemState(edge, 'hover', true)
}
if (edge.getTarget() === item) {
graph.setItemState(edge, 'hover', true)
}
})
})
// 鼠标离开节点
graph.on('node:mouseleave', ({item}) => {
graph.setItemState(item, 'hover', false)
graph.getEdges().forEach(edge => {
graph.setItemState(edge, 'hover', false)
})
})
2.2.4 点击画布
点击画布要清除所有节点和边的状态
graph.on('canvas:click', () => {
this.isClick = false
graph.getNodes().forEach(function (node) {
graph.clearItemStates(node)
node.toBack()
})
graph.getEdges().forEach(function (edge) {
graph.clearItemStates(edge)
edge.toBack()
})
})
2.3 效果
3. 股权穿透图
使用的是树图紧凑树布局,根节点在中间,垂直对称布局。
3.1 绘制
3.1.1 生成实例
// 生成实例
const graph = new G6.TreeGraph({
container,
width,
height,
layout: {
type: 'compactBox',
direction: 'V',
// 每个节点高度
getHeight: () => 54,
// 每个节点宽度
getWidth: () => 144,
// 每个节点的垂直间隙
getVGap: () => 70,
// 每个节点的水平间隙
getHGap: () => 16,
// 节点排布在根节点的哪一侧
getSide: (item) => item.data.location
},
modes: {
default: [
'drag-canvas',
{
type: 'zoom-canvas',
sensitivity: 1, // 缩放灵敏度
minZoom: 0.5,
maxZoom: 1.5
}
]
},
defaultNode: {
// 使用自定义节点
type: 'icon-node',
// 节点的连接点
// https://g6.antv.antgroup.com/manual/middle/elements/nodes/anchorpoint
anchorPoints: [
[0.5, 0],
[0.5, 1]
],
},
defaultEdge: {
// 使用自定义边
type: 'flow-line'
}
})
3.1.2 数据转换和渲染
data?.children?.forEach(item => {
// 根据条件把图渲染在上边或者下边
item.location = 'right'
})
// 绘制自定义节点
this.customNode()
// 绘制自定义边
this.customEdge()
// 解决渲染残影问题
graph.get('canvas').set('localRefresh', false)
graph.read(data)
graph.fitCenter()
3.1.3 自定义节点
G6.registerNode('icon-node',{
draw(cfg, group) {
const isRoot = '根节点'
// 取宽高的一半 后边的文本方便居中
const x = -144 / 2
const y = -54 / 2
// 画外边的盒子
const rectShape = group.addShape('rect', {
attrs: {
x,
y,
width: 144,
height: 54,
fill: isRoot ? '#4ea2f0' : '#fff',
stroke: '#4ea2f0',
radius: 2,
cursor: 'pointer'
},
name: 'container-node'
})
// 处理文本换行
const label = fittingString(cfg.name, 124, 12, 2)
// 画文本
group.addShape('text', {
attrs: {
text: label,
x: 0,
y: 0,
textAlign: 'center',
textBaseline: 'middle',
fill: isRoot ? '#fff' : '#303242',
cursor: 'pointer'
},
name: 'text-node'
})
// 画展开图标
if (cfg.hasChild) {
group.addShape('marker', {
attrs: {
x: 0,
y: -y + 6,
r: 6,
fill: '#fff',
stroke: '#4ea2f0',
cursor: 'pointer',
symbol: EXPAND_ICON // 图标的路径函数
},
name: 'collapse-node'
})
}
return rectShape
},
})
3.1.4 自定义边
G6.registerEdge('flow-line',{
draw(cfg, group) {
// 分别是边两端与起始节点和结束节点的交点
const startPoint = cfg.startPoint;
const endPoint = cfg.endPoint;
const { stockProportion } = cfg.targetNode.getModel()
// 根据两个点画出想要的边
const pathShape = group.addShape('path', {
attrs: {
stroke: '#eee',
// svg path
// https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Paths
path: [
['M', startPoint.x, startPoint.y],
['L', startPoint.x, (startPoint.y + endPoint.y) / 2],
['L', endPoint.x, (startPoint.y + endPoint.y) / 2],
['L', endPoint.x, endPoint.y]
],
// 箭头
endArrow: {
fill: '#4ea2f0',
stroke: '#4ea2f0',
path: G6.Arrow.triangle(10, 15, 0)
}
},
name: 'flow-edge'
})
// 画边的文字
if (stockProportion) {
const label = stockProportion + '%'
group.addShape('text', {
attrs: {
text: label,
x: endPoint.x + 4,
y: (startPoint.y + endPoint.y) / 2,
fill: '#4ea2f0',
stroke: '#fff',
},
name: 'text-node'
})
}
return pathShape
}
})
3.2 交互
3.2.1 节点点击
使用graph.updateChildren()更新图后会触发注册的自定义节点中的update方法
graph.on('node:click', (e) => {
const { target: { cfg: { type } }, item } = e
const isMarker = type === 'marker'
// 点击图标展开下一级
if (isMarker) {
// 如果children有值说明之前获取过了直接展开就可
if (children?.length > 0) return
// 获取下一级的数据然后使用graph.updateChildren()去更新图
this.getChildrenData(item)
} else {
// 点击形状可以执行跳转详情之类的逻辑
}
}
})
// update方法
G6.registerNode('icon-node', {
update(cfg, node) {
const group = node.getContainer()
// 找到marker那个节点name 替换图标的路径函数
const icon = group.find((e) => e.get('name') === 'collapse-node')
icon.attr('symbol', COLLAPSE_ICON<p align=left>)</p>
}
})
// 展开收起交互
new G6.TreeGraph({
modes: {
default: [
{
type: 'collapse-expand',
// 未点击到图标或者不能展开的时候阻止行为
shouldBegin(e) {
const targetType = e.target.get('type')
const { hasChild } = e.item.getModel()
return targetType === 'marker' && hasChild
},
// 展开收起时切换图标
onChange(item, collapsed) {
const group = item.getContainer()
const icon = group.find((e) => e.get('name') === 'collapse-icon')
if (collapsed) {
icon.attr('symbol', EXPAND_ICON)
} else {
icon.attr('symbol', COLLAPSE_ICON)
}
}
}
]
}
})
3.2.2 节点的移入移出
// 鼠标悬浮节点
graph.on('node:mouseenter', ({item}) => {
// 根节点
const isRoot = '根节点'
if (!isRoot) {
// 节点相关联的边执行动画
item.getEdges().forEach(edge => {
graph.setItemState(edge, 'hover', true)
edge.toFront()
})
}
})
// 鼠标离开节点
graph.on('node:mouseleave', ({item}) => {
// 根节点
const isRoot = '根节点'
if (!isRoot) {
item.getEdges().forEach(edge => {
graph.setItemState(edge, 'hover', false)
})
}
})
3.2.3 边的动画
在注册自定义边的时候,添加setState方法
G6.registerEdge('flow-line',{
// 状态名称 状态值 edge
setState(name, value, item) {
const shape = item.getKeyShape()
// 设置线的虚线样式
// https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/setLineDash
const lineDash = [10, 5]
if(name === 'hover') {
if(value) {
let index = 0
shape.attr('stroke', '#4ea2f0')
shape.animate(
() => {
index++
return {
lineDash,
lineDashOffset: -index
}
},
{
repeat: true, // 动画重复
duration: 10000, // 一次动画的时长为 10000
}
)
} else {
shape.attr('stroke', '#eee')
// 结束动画
shape.stopAnimate()
// 清空 lineDash
shape.attr('lineDash', null)
}
}
}
})
3.3 效果
4. 企业图谱
使用的是树图紧凑树布局,根节点在中间,水平对称布局。
4.1 绘制
4.1.1 生成实例
const graph = new G6.TreeGraph({
container: container,
width,
height,
layout: {
type: 'compactBox',
direction: 'H',
// 每个节点的垂直间隙
getVGap: () => 14,
// 每个节点的水平间隙
getHGap: () => 40,
// 每个节点宽度
getWidth: (item) => {
// 文字不换行 计算出节点宽度
const label = item.name + (item.stockProportion ? ` [${item.stockProportion}%]` : '')
return G6.Util.getTextSize(label, 12)[0] + 34
},
// 节点排布在根节点的哪一侧
getSide: (item) => item.depth === 1 && item.data.nodeType <= 7 ? 'right' : 'left'
},
modes: {
default: [
'drag-canvas',
{
type: 'zoom-canvas',
sensitivity: 1, // 缩放灵敏度
minZoom: 0.5,
maxZoom: 1.5
},
{
type: 'collapse-expand',
onChange(item, collapsed) {
const group = item.getContainer()
const icon = group.find((e) => e.get('name') === 'collapse-icon')
if (collapsed) {
icon?.attr('symbol', G6.Marker.expand)
} else {
icon?.attr('symbol', G6.Marker.collapse)
}
}
}
]
},
defaultNode: {
type: 'icon-node',
// https://g6.antv.antgroup.com/manual/middle/elements/nodes/anchorpoint
anchorPoints: [
[0, 0.5],
[1, 0.5]
],
},
defaultEdge: {
type: 'flow-line'
}
})
4.1.2 数据转换和渲染
data.detailId = data.id
function next(data) {
data?.children.forEach(item => {
item.location = (data.nodeType || item.nodeType) <= 7 ? 'right' : 'left'
if (!item.nodeType) {
// 子节点和父节点保持一个颜色
item.color = colorEnum[data.nodeType] ?? '#13c2c2'
}
// 避免返回相同的id 渲染失败
if (item.id) {
item.detailId = item.id
}
item.id = uid + ''
uid++
item.children && next(item)
})
}
next(data)
// 自定义绘制节点
this.customNode()
// 自定义绘制边
this.customEdge()
graph.read(data)
graph.fitCenter()
4.1.3 自定义节点
G6.registerNode('icon-node', {
draw(cfg, group) {
const { detailId, name, nodeType, location, color, isPersonal, collapsed, stockProportion } = cfg
const isRoot = '根节点'
// node布局是否靠右
const isRight = location === 'right'
// 是否有展开按钮
const hasCollapse = !isRoot && (detailId || nodeType)
// 画形状
const rectShape = group.addShape('rect', {
attrs: {
x: 0,
y: 0,
fill: isRoot ? '#4ea2f0' : !nodeType && '#fff',
stroke: isRoot ? '#4ea2f0' : color,
width: 1,
height: 1,
radius: 2,
cursor: 'pointer'
},
name: 'container-node',
})
// 画文本
const textShape = group.addShape('text', {
attrs: {
text: name,
x: 0,
y: 0,
textBaseline: 'middle',
fill: isRoot ? '#fff' : '#303242',
cursor: 'pointer'
},
name: 'text-node',
})
// 文本信息
const textBBox = textShape.getBBox()
let textWidth = textBBox.width
// 根据文本宽高设置容器位置和高度
rectShape.attr({
y: -textBBox.height / 2 - 9,
height: textBBox.height + 18
})
// 展开关闭图标
const symbol = collapsed || isPersonal ? G6.Marker.expand : G6.Marker.collapse
// 根据逻辑算出百分比数字还有圆点以及展开图标的宽度,最后算出外边的rectShape的宽度
if (isRight) {
// 根节点右边的渲染逻辑
const textPos = nodeType ? 16 : 10
textShape.attr({ x: textPos })
if (stockProportion) {
const leftRim = group.addShape('text', {
attrs: {
text: '[',
x: textWidth + 12,
y: 0,
textBaseline: 'middle',
fill: '#999',
cursor: 'pointer'
},
name: 'text-shape',
})
const leftRimBBox = leftRim.getBBox()
textWidth += leftRimBBox.width
const rateText = group.addShape('text', {
attrs: {
text: stockProportion + '%',
x: textWidth + 12,
y: 0,
textBaseline: 'middle',
fill: '#F9AD14',
cursor: 'pointer'
},
name: 'text-shape',
})
const rateTextBBox = rateText.getBBox()
textWidth += rateTextBBox.width
group.addShape('text', {
attrs: {
text: ']',
x: textWidth + 12,
y: 0,
textBaseline: 'middle',
fill: '#999',
cursor: 'pointer'
},
name: 'text-shape',
})
textWidth += leftRimBBox.width
}
// 有类型的文本边上画个圈
if (nodeType) {
group.addShape('circle', {
attrs: {
x: 5,
y: 0,
r: 5,
fill: colorEnum[nodeType] || '#13c2c2',
cursor: 'pointer'
},
name: 'circle-shape'
})
}
// 展开图标
if (hasCollapse) {
group.addShape('marker', {
attrs: {
x: textWidth + (nodeType ? 28 : 20),
y: 0,
r: 6,
symbol,
stroke: isRoot ? '#fff' : '#303242',
lineWidth: 1,
cursor: 'pointer'
},
name: 'collapse-icon',
})
}
rectShape.attr({ width: textWidth + (hasCollapse ? 34 : 20) })
} else {
// 根节点左边的渲染逻辑
const textPos = nodeType ? 16 : 10
textShape.attr({ x: textPos })
// 有类型的文本边上画个圈
if (nodeType) {
group.addShape('circle', {
attrs: {
x: textBBox.width + 28,
y: 0,
r: 5,
fill: colorEnum[nodeType] || '#13c2c2',
cursor: 'pointer'
},
name: 'circle-shape'
})
}
// 展开图标
if (hasCollapse) {
group.addShape('marker', {
attrs: {
x: nodeType ? 5 : -1,
y: 0,
r: 6,
symbol,
stroke: isRoot ? '#fff' : '#303242',
lineWidth: 1,
cursor: 'pointer'
},
name: 'collapse-icon',
})
}
if (stockProportion) {
const leftRim = group.addShape('text', {
attrs: {
text: '[',
x: textWidth + textPos,
y: 0,
textBaseline: 'middle',
fill: '#999',
cursor: 'pointer'
},
name: 'text-shape',
})
const leftRimBBox = leftRim.getBBox()
textWidth += leftRimBBox.width
const rateText = group.addShape('text', {
attrs: {
text: stockProportion + '%',
x: textWidth + textPos,
y: 0,
textBaseline: 'middle',
fill: '#F9AD14',
cursor: 'pointer'
},
name: 'text-shape',
})
const rateTextBBox = rateText.getBBox()
textWidth += rateTextBBox.width
group.addShape('text', {
attrs: {
text: ']',
x: textWidth + textPos,
y: 0,
textBaseline: 'middle',
fill: '#999',
cursor: 'pointer'
},
name: 'text-shape',
})
textWidth += leftRimBBox.width
}
rectShape.attr({
width: textWidth + (hasCollapse ? 34 : 20),
x: hasCollapse ? -14 : 0
})
}
return rectShape
},
update: undefined
}, 'rect')
4.1.4 自定义边
G6.registerEdge('flow-line', {
draw(cfg, group) {
const { nodeType } = cfg.targetNode.getModel()
const hasArrow = !!nodeType
const isLeft = ['1', '3', '4', '5'].includes(nodeType)
const startPoint = cfg.startPoint
const endPoint = cfg.endPoint
const shape = group.addShape('path', {
attrs: {
stroke: '#eee',
path: [
['M', startPoint.x, startPoint.y],
['L', endPoint.x / 3 + (2 / 3) * startPoint.x, startPoint.y], // 三分之一处
['L', endPoint.x / 3 + (2 / 3) * startPoint.x, endPoint.y], // 三分之二处
['L', endPoint.x, endPoint.y],
]
},
name: 'path-shape',
})
if (hasArrow) {
shape.attr({
endArrow: {
path: !isLeft ? 'M 10 0 L 25 -3 L 25 3 Z' : 'M 25 0 L 10 -3 L 10 3 Z',
fill: '#128bed',
stroke: '#128bed',
strokeOpacity: 0
}
})
}
return shape
},
update: undefined
}, 'polyline')
4.2 交互
交互和股权穿透图基本一致,展开收起和边动画。
4.3 效果
5. 其他功能
5.1 放大缩小视窗窗口
放大
// 获取视口中心绘制坐标
const point = graph.getViewPortCenterPoint()
// 获取当前视口的缩放比例
const zoom = graph.getZoom()
const size = Number(zoom.toFixed(2)) + 0.2
// 最大放大到1.5倍
graph.zoomTo(size > 1.5 ? 1.5 : size, { x: point.x, y: point.y })
缩小
// 获取视口中心绘制坐标
const point = graph.getViewPortCenterPoint()
// 获取当前视口的缩放比例
const zoom = graph.getZoom()
const size = Number(zoom.toFixed(2)) - 0.2
// 最小缩放到0.5倍
graph.zoomTo(size < 0.5 ? 0.5 : size, { x: point.x, y: point.y })
5.2 下载
graph.downloadFullImage('tree-graph', 'image/png', {
backgroundColor: '#ddd',
padding: [30, 15, 15, 15],
})
也可以使用graph.toFullDataURL(callback, type, imageConfig)方法,通过回调函数添加水印,然后在把base64转换成图片。
6. 优化
最后是官方给出的一些优化方案,比如关系图谱拖动的时候,非关联节点隐藏文本节点。 g6.antv.antgroup.com/manual/faq/…