使用vue3和svg画布标签实现一个思维导图功能的开发

前言

SVG 的定义

  1. SVG 是一种用 XML 定义的语言,用来描述二维矢量及矢量或栅格图形。SVG 提供了3种类型的图形对象,矢量图形、图象、文本。SVG 是一种图像文件格式,它的英文全称为 ScalableVectorGraphics,意思为可缩放的矢量图形。
  2. 矢量图,也称为面向对象的图像或绘图图像,在数学上定义为一系列由线连接的点。矢量文件中的图形元素称为对象。每个对象都是一个自成一体的实体,它具有颜色、形状、轮廓、大小和屏幕位置等属性。
  3. XML 是一套定义语义标记的规则,这些标记将文档分成许多部件并对这些部件加以标识。它也是元标记语言,即定义了用于定义其他与特定领域有关的、语义的、结构化的标记语言的句法语言。

什么是思维导图

  1. 思维导图,英文是 The Mind Map,又名心智导图,是表达发散性思维的有效图形思维工具 ,它简单却又很有效同时又很高效,是一种实用性的思维工具。
  2. 思维导图运用图文并重的技巧,把各级主题的关系用相互隶属与相关的层级图表现出来,把主题关键词与图像、颜色等建立记忆链接
  3. 思维导图充分运用左右脑的机能,利用记忆、阅读、思维的规律,协助人们在科学与艺术、逻辑与想象之间平衡发展,从而开启人类大脑的无限潜能。思维导图因此具有人类思维的强大功能。

image.png

最初的想法

由于自己在开发的项目中使用 D3.js 完成了一个组织架构图的功能,觉得还蛮有意思的,然后刚刚好又在网上看到有人使用 Xmind 绘制了许多很好看的脑图,于是就想仿着 Xmind 的UI使用 D3.js 也做一个简易版的思维导图,正好可以加深自己对 svgD3.js的认识。

开发过程

想要开发一个最基础版本的思维导图首先要了解思维导图的数据结构该如何定义,并且需要会动态的计算各个节点所处画布的坐标位置信息。接下来我们就来讲讲如何计算思维导图的坐标信息和数据结构的定义。

数据结构

元数据定义

每个节点的基本数据包括 名称、宽、高、位置坐标 等元数据。

之后如果考虑做 style 编辑的话还可以存储一些样式信息等。

{
  id: '', // 节点唯一标识
  text: '节点名称', // 节点名称
  parentId: '', // 父节点名称
  width: 0, // 节点宽度
  height: 0, // 节点高度
  x: 0,
  y: 0,
  marks: [], // 节点上的标记列表
  link: '', // 超链接
  imageInfo: '', // 图片信息
  comment: '', // 备注信息
  // ... attrs 更多元数据
}
数据格式定义

因为开发思维导图需要设计到节点的 增、删、改、查 ,所以怎么合理的去定义数据格式对我们后续对数据的处理有很大的关系。

第一种:平铺数据

const list = [
  { id: '', text: '', width: '', height: '', // ···· },
  { id: '', text: '', width: '', height: '', // ···· },
  { id: '', text: '', width: '', height: '', // ···· },
  { id: '', text: '', width: '', height: '', // ···· },
  { id: '', text: '', width: '', height: '', // ···· }
]

平铺的数据格式会让我们对原数据操作会更加的方便,直接使用数组的一些方法(findfindIndexsplice)就可以对指定的数据进行更新,添加节点的话直接 push 就可以了,只不过每个数据里面需要存储一个 parentId,以便于计算节点坐标前进行树形格式化。

数据平铺转树形格式

/**
 * list transform tree
 * @param {array} data 原始数据
 * @param {string} 数据唯一标识 id
 * @param {string} 父节点关联字段名称 parentId
 */
exports.transformTree = (data, key, pkey) => {
  if (!Array.isArray(data)) {
    throw new Error('data is must be array.')
  }
  const clonedata = this.deepClone(data)
  const map = clonedata.reduce((prev, cur) => {
    prev[cur[key]] = cur
    return prev
  }, {})
  const transformdata = []
  for (let i = 0; i < clonedata.length; i++) {
    const parent = map[clonedata[i][pkey]]
    if (!parent) {
      transformdata.push(clonedata[i])
    } else {
      parent.children = [...(parent.children || []), clonedata[i]]
    }
  }
  return transformdata
}

优点:

  • 操作原始数据简单,不需要使用递归

缺点

  • 每次计算节点坐标的时候需要把平铺的数据结构转成树形结构
  • 数据结构关系不清晰,父子对应关系不明显

第二种:树形数据

const root = {
  id: '',
  text: '',
  // ... attrs,
  children: [
    {
      id: '',
      text: '',
      // ... attrs
    }
  ]
}

优点:

  • 数据结构关系清晰,父子对应关系明显

缺点:

  • 操作原始数据都需要通过递归实现,较为复杂

通过以上对比,笔者最后使用 树形结构 的数据格式进行原始数据存储。

数据处理

想要实现思维导图仅仅只有节点的信息是不够的,我们还需要有节点连线的信息,那么如何根据节点的关系生成对应的连线数据信息呢?我们可以借助 d3.js 来生成节点的连线信息。

节点实例化
import { hierarchy, tree } from 'd3-hierarchy'

/**
 * 根据根节点root平铺所有节点数据并且赋予节点x/y坐标
 * @param {*} root
 */
function dataTreeLayoutPackage (root) {
  const d3Tree = tree()
  const hierarchydata = d3Tree(hierarchy(root, d => d.children))
  nodes = hierarchydata.descendants()
  links = hierarchydata.links()
  return {
    nodes,
    links
  }
}

通过以上的方法,我们可以获取到一个 nodes 数组和 links 的数组, nodes 里面就是每个节点的数据信息, links 就是每两个节点之间的连线信息。

先来看看 nodeslinks 里面每一条数据格式是怎样的吧

// node
{
  children: [Node],
  data: { text: '', id: '', // ... },
  depth: 1,
  height: 1,
  parent: Node,
  x: 0,
  y: 0
}

// link
{
  source: Node,
  target: Node
}

数据解释

  • parent: node实例信息
  • children: children 实例集合
  • depth: 节点所处层级
  • data: 原始数据信息
  • source: 父节点实例信息
  • target: 子节点实例信息

从上面的数据可以看到,node实例数据里面有 heightxy 等信息,但是这些数据都是不能直接使用的,我们还需要通过自己的二次计算获取的真实节点的 widthheightxy 等数据信息。

节点数据计算

宽高获取

从上面可以发现,每个节点里面的text是可以不一样的,那么节点的宽度和高度都跟text的内容有关,那我们该如何通过text的数据来计算出节点的实际宽和高呢?

思路: 我们可以通过 js 的方式动态生成一个标签,然后把text的内容放放到这个标签内,然后把标签渲染到 html 上面,那这样这个标签的宽度和高度就是我们节点的宽度和高度了。

/**
 * 长文本换行后文本宽高获取
 * @param {*} options
 */
function getTextNodeRect (options) {
  const {
    text,
    fontSize,
    fontWeight = 'normal',
    fontFamily = "微软雅黑, 'Microsoft YaHei'",
    fontStyle = 'normal'
  } = options
  const textSpan = document.createElement('p')
  const spanStyle = {
    maxWidth: '300px',
    fontSize: fontSize + 'px',
    fontWeight,
    fontFamily,
    fontStyle,
    whiteSpace: 'pre-wrap',
    display: 'inline-block',
    position: 'fixed',
    left: '-2000px',
    wordBreak: 'break-all',
    lineBreak: 'anywhere'
  }
  for (const key in spanStyle) {
    textSpan.style[key] = spanStyle[key]
  }
  textSpan.innerText = text
  document.body.append(textSpan)
  const { width, height } = textSpan.getBoundingClientRect()
  textSpan.remove()
  return {
    width: fontStyle === 'italic' ? width + 2 : width,
    height
  }
}

节点的宽度和高度有了,接下来我们就需要动态的去计算每个的 x 和 y 坐标了。

定位
获取节点的 x 坐标

首先根节点我们把它定位到画布中间的位置,然后遍历子节点,那么子节点的 left 就是 根节点的left + 根节点的width + 它们之间的间距 marginX,如下图所示:

image.png

然后再遍历每个子节点的子节点(其实就是递归遍历)以同样的方式进行计算left,这样一次遍历完成后所有节点的left值就计算好了,可以初始化根节点的 x/y 坐标

function firstWalk (nodes) {
  nodes.forEach(node => {
    node.x = node.parent.x + node.parent.width + marginX
  })
}
获取节点的 y 坐标

接下来是 top,首先最开始也只有根节点的 top 是确定的,那么子节点怎么根据父节点的 top 进行定位呢?上面说过每个节点是相对于其所有子节点居中显示的,那么如果我们知道所有子节点的总高度,那么第一个子节点的top 也就确定了:

firstChildNode.top = (node.top + node.height / 2) - childrenAreaHeight / 2

如图所示:

image.png

第一个子节点的 top 确定了,其他节点只要在前一个节点的 top 上累加即可。

如何计算节点的 childrenAreaHeight

// 第一次遍历
function firstWalk (nodes) {
  nodes.forEach(node => {
      node.childrenAreaHeight = (node.children || []).reduce((prev, cur) => {
        return prev + cur.height
      }, 0) + (len - 1) * 16
    }
  })
}

这一步可以和上面计算节点的 X 坐标放在一起,只要遍历一遍就可以了。

接下来开启第二轮遍历,这轮遍历可以计算所有节点的 top

// 第二次遍历
function secondWalk (nodes) {
  nodes.forEach(node => {
    if (hasChild(node)) {
      const y = node.y + node.height / 2 - node.childrenAreaHeight / 2
      let startY = y
      node.children.forEach(n => {
        n.y = startY
        startY += n.height + marginY
      })
    }
  })
}

事情到这里并没有结束,请看下图:

image.png

可以看到对于每个节点来说,位置都是正确的,但是,整体来看就不对了,因为发生了重叠,原因很简单,因为【二级节点1】的子节点太多了,子节点占的总高度已经超出了该节点自身的高,因为【二级节点】的定位是依据【二级节点】的总高度来计算的,并没有考虑到其子节点,解决方法也很简单,再来一轮遍历,当发现某个节点的子节点所占总高度大于其自身的高度时,就让该节点前后的节点都往外挪一挪,比如上图,假设子节点所占的高度比节点自身的高度多出了100px,那我们就让【二级节点2】向下移动50px,如果它上面还有节点的话也让它向上移动50px,需要注意的是,这个调整的过程需要一直往父节点上冒泡。

【子节点1-2】的子元素总高度明显大于其自身,所以【子节点1-1】需要往上移动,这样显然还不够,假设上面还有【二级节点0】的子节点,那么它们可能也要发生重叠了,而且下方的【子节点2-1-1】和【子节点1-2-3】显然挨的太近了,所以【子节点1-1】自己的兄弟节点调整完后,父节点【二级节点1】的兄弟节点也需要同样进行调整,上面的往上移,下面的往下移,一直到根节点为止:

// 第三次遍历
function thirdWalk (nodes) {
  nodes.forEach(node => {
    const difference = node.childrenAreaHeight - node.height
    if (difference > 0) {
      updateBrothers(node, difference / 2)
    }
  })
}

updateBrothers用来向上递归移动兄弟节点:

function updateBrothers (node, addHeight) {
  if (node.parent) {
    const childrenList = node.parent.children
     // 找到自己处于第几个节点
    const index = childrenList.findIndex(item => item === node)
    childrenList.forEach((item, _index) => {
      if (item === node) return
      let _offset = 0
      if (_index < index) {
      // 上面的节点往上移
        _offset = -addHeight
      } else if (_index > index) {
         // 下面的节点往下移
        _offset = addHeight
      }
      // 移动节点
      item.y += _offset
      // 节点自身移动了,还需要同步移动其所有下级节点
      if (hasChild(item)) {
        updateChildren(item.children, 'y', _offset)
      }
    })
    updateBrothers(node.parent, addHeight)
  }
}

更新所有子节点的坐标:

// 更新节点的所有子节点的位置
function updateChildren (children, prop, offset) {
  children.forEach((item) => {
    item[prop] += offset
    if (hasChild(item)) {
      updateChildren(item.children, prop, offset)
    }
  })
}

到此【逻辑结构图】的整个布局计算就完成了,当然,有一个小小小的问题:

就是严格来说,某个节点可能不再相对于其所有子节点居中了,而是相对于所有子孙节点居中。

节点连线

节点定位好了,接下来就要进行连线,把节点和其所有子节点连接起来,连线风格有很多,可以使用直线,也可以使用曲线,直线的话很简单,因为所有节点的left、top、width、height都已经知道了,所以连接线的转折点坐标都可以轻松计算出来:

image.png

也可以通过曲线连接(二次贝塞尔曲线、三次贝塞尔曲线)

image.png

这种简单的曲线可以使用二次贝塞尔曲线,起点坐标为根节点的中间点:

let x1 = root.left + root.width / 2
let y1 = root.top + root.height / 2

终点坐标为各个子节点的左侧中间:

let x2 = node.left
let y2 = node.top + node.height / 2

那么只要确定一个控制点即可,具体这个点可以自己调节,找一个看的顺眼的位置即可,最终选择在两个点的中点选择作为控制点。

let cx = x1 + (x2 - x1) * 0.5
let cy = y1 + (y2 - y1) * 0.5

接下来给加个渲染连线的方法即可:

export function renderNewEdges (links) {
  const enter = edgeContainer
    .selectAll('g')
    .data(links)
    .enter()
    .append('g')
  enter
    .append('path')
    .attr('d', d => {
      const sx = d.source.width + d.source.x
      const sy = d.source.y + d.source.height
      const tx = d.target.x
      const ty = d.target.y + d.target.height / 2
      const cx = sx + (tx - sx) * 0.5
      const cy = sy + (ty - sy) * 0.5
      return `M${sx} ${sy} Q${cx} ${cy} ${tx} ${ty}`
    })
    .attr('stroke-linecap', 'round')
    .attr('stroke', d => d.source.style.lineStyle.fill)
    .attr('stroke-width', 2)
    .attr('fill', 'none')
}

基本功能

  • 支持图标、标签、插画、图片、超链接、备注等数据的插入
  • 支持单个节点概要信息插入
  • 支持两个节点之间关系连线插入
  • 画布导出(支持.png、.svg、.json格式)
  • .json文件导入
  • 支持上一步、下一步保存画布历史
  • 部分操作快捷键实现
  • 画布缩略图
  • 单个节点样式修改(字体、背景颜色、连线样式、图标大小,节点内外边距等)
  • 主题修改
  • 脑图结构切换(目前只支持逻辑结构图、思维导图、括号图和组织架构图)

演示地址

web思维导图

Git仓库地址

x-mind-map

  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
使用Vue 3和AntV来实现思维导图,可以按照以下步骤进行操作: 1. 安装Vue 3和AntV:在项目目录下运行命令`npm install vue@next @antv/g6`来安装Vue 3和AntV G6图表库。 2. 创建Vue组件:在Vue项目中创建一个新的组件,命名为"MindMap"。在组件中导入Vue和AntV G6。 3. 初始化G6图表:在组件的生命周期钩子函数`mounted`中,使用AntV G6的`Graph`类初始化一个图表实例,并将其绑定到Vue组件实例的数据属性中。 4. 定义思维导图数据:在Vue组件中定义一个数据属性,用于存储思维导图的节点和边的数据。 5. 绘制思维导图:在Vue组件的`mounted`生命周期钩子函数中,使用G6图表实例的`data`方法将思维导图的数据绑定到图表上,并使用G6图表实例的`render`方法绘制图表。 6. 添加交互能力:通过G6图表实例的事件监听和交互能力,为思维导图添加节点的拖拽、缩放、连线等交互操作。 7. 样式定制和主题设置:通过G6图表实例的配置项,可以定制思维导图的样式和主题,如节点的颜色、形状、连线的样式等。 8. 完善其他功能:根据实际需求,可以实现添加节点、删除节点、编辑节点内容、保存导图等功能,通过Vue的数据双向绑定,更新思维导图的数据并重新渲染图表。 以上是用Vue 3和AntV实现思维导图的基本步骤,具体的实现方式可以根据项目需求进行调整和扩展。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值