vue3中使用Antv G6渲染树形结构并支持节点增删改

写在前面

在一些管理系统中,会对组织架构、级联数据等做一些管理,你会怎么实现呢?在经过调研很多插件之后决定使用 Antv G6 实现,文档也比较清晰,看看怎么实现吧,先来看看效果图。点击在线体验

实现的功能有:

  1. 增加节点
  2. 删除节点
  3. 编辑节点
  4. 展开收起

思路:增加/删除/编辑都可以改变对应的数据,更新元素数据,展开收起 G6 自带有 collapsed 状态。新增/编辑时可通过 G6 的坐标转换动态更改输入框位置达到编辑指定节点的效果。

具体实现

  1. 先在项目中安装 antv g6
shell 复制代码
npm install --save @antv/g6
  1. vue 文件创建容器渲染
  • 渲染的容器
vue 复制代码
<div id="container" class="one-tree"></div>
  • 渲染方法和初始化树图
js 复制代码
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
}
  • 事件处理
js 复制代码
/**
 * @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
}
  • 节点的增加、删除、编辑时间
js 复制代码
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
    })
}
  • dom 节点渲染后渲染树图
js 复制代码
onMounted(() => {
    nextTick(() => {
        state.graph = initGraph('container')
        state.graph.clear()
        addEvent(state.graph)
        renderMap(state.treeData, state.graph)
    })
})

存在问题

配置了滚动画布scroll-canvas,文档上说可以配置 scalableRange 参数,设置拖动 canvas 可扩展的范围,默认为 0,值为 -1 ~ 1 代表可超出视口的范围的比例值。但好像设置了不生效,画布会无限滚动。欢迎各位大佬指点解决方式。

官网文档链接

相关链接

  1. 源码链接
  2. Antv G6 官网
  3. 参考文章
相关推荐
Wang's Blog几秒前
Webpack: 构建微前端应用
前端·webpack
软件编程工程师15 分钟前
基于vue脚手架创建的图书商城
前端·javascript·css·vue.js·html·网站·图书商城
I like Code?17 分钟前
Vue图片路径问题分析
javascript·vue.js·ecmascript
柯基的小屁墩18 分钟前
Mac|install vue
前端·vue.js·macos
林恒smileZAZ23 分钟前
为什么 [] == ![] 为 true?
前端·javascript
亦世凡华、28 分钟前
探索 Electron:将 Web 技术带入桌面应用
前端·javascript·经验分享·electron·桌面应用
Zww089130 分钟前
axios的底层ajax,XMLHttpRequest原理解释及使用方法
前端·javascript·ajax·okhttp
幸运小男神39 分钟前
npm如何清空缓存并重新打包?
前端·npm
北原_春希42 分钟前
Vue的服务器代理如何配置
服务器·前端·vue.js
co松柏1 小时前
在非 antd pro 项目中使用 umi OpenAPI
java·前端