使用G6绘制几种关联图谱

1,923 阅读1分钟

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 效果

image.png

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 效果

guquan.gif

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 效果

image.png

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/…