解析思维导图的实现
web思维导图的开发
为什么选择开发思维导图?
其实是最开始的时候看了一篇大佬写的关于思维导图架构思考的文章,然后就比较好奇思维导图内部的具体实现,于是便顺着大佬的思路,自己也开发了一个思维导图。功能可能有bug,还请见谅
功能
我开发的思维导图目前只支持一种形态,就是单向导图,后续有时间会进行多形态的增加,除了有基本的功能之外,我还做了一些样式的动态配置,并增加了保存功能,可以将数据保存在浏览器里,只要不手动清除,下次进来该页面,数据以及样式的配置都会直接显示。
难点技术实现
1. 存储数据结构
从思维导图的图片上看,就可以看出是从一个根节点向右延申,所以采用树结构来对节点的信息进行存储。先给大家看看我设计的节点的数据结构。
{
id,
father, // 父节点(为了方便节点插入,删除,拖拽等操作)
text, // 节点的文案
attr, // 绘制节点的信息(坐标/宽高)
sort, // 当前节点所在的索引
expand, // 是否展开节点
imageData, // 节点内容的图片信息
children // 子节点
}
以上节点的属性,在绘制思维导图的时候都是有用的。
2. 节点的位置计算
以上图为例,首先每个子节点,都会有一个固定的上下边距,是为了跟同级节点隔开;我们先计算子节点的整个区域的总高度,总高度 等于所有的子节点及其对应的padding
totalHeight = parentNode.children.length * ( node.height + 2 * padding)
然后开始计算每个节点的Y坐标,先计算出 第一个节点startY
startY = 父节点的中心点的Y坐标 - totalHeight / 2
然后第二个节点的startY就等于 startY + node.height + 2 * padding
同理可得所有节点的startY,进而得到节点的Y坐标,计算原理如下
const childLen = parentNode.children.length
const nodeAreaHeight = node.height + padding * 2 // 节点的区域高度
const totalHeight = childLen * nodeAreaHeight // 子区域的总高度
let startY = centerY - totalHeight / 2 // centerY指父节点的中心y坐标
parentNode.children.forEach(node => {
node.attr.y = startY + padding // 设置子节点的y坐标
startY += nodeAreaHeight
})
上面是只有一层节点的计算,当数据多层的时候需要怎么去计算呢?使用 递归
我们来重新梳理一下,当前节点的位置肯定是等于子节点区域高度的垂直居中的位置,所以我们只需要按照这个原理来进行编写,先通过递归计算出子节点的区域高度,然后计算出垂直居中的位置即可,代码如下
if (parentNode.children.length) {
let startY = parentNode.centerY - getNodeAreaHeight(parentNode) / 2
parentNode.children.forEach(node => {
const nodeAreaHeight = getNodeAreaHeight(node) // 子节点的区域高度
node.attr.y = startY + 0.5 * (nodeAreaHeight - node.height) // 子节点的y坐标(减去节点高度是因为绘制节点的时候取的是节点左上角的坐标)
startY += nodeAreaHeight
})
}
// 获取节点的区域高度
function getNodeAreaHeight (node) {
const nodeAreaHeight = node.height + padding * 2 // 节点的区域高度
if (node.children.length) {
const childrenAreaHeight = node.children.reduce((total, child) => {
return total + getNodeAreaHeight(child);
}, 0);
return childrenAreaHeight
} else {
return nodeAreaHeight
}
}
上面的代码可以做一些优化,比如将已经计算过的高度通过节点id保存在map,下次若map里有对应的节点的高度则直接取。
3. 拖拽节点
思维导图中拖拽节点是比较难实现的一个点,下面会讲一讲我的思路
首先我们需要根据拖拽的节点然后生成可插入的区域,如图是拖拽My Child - 1
节点所生成的可插入区域。
先观察一下规律,并梳理逻辑如下
-
- 每个框框以节点的中心位置切割
- 1-1. 如果是拖拽节点则合并其上下两个框框
- 1-2. 同级节点的框框宽度取最大值
- 每个框框以节点的中心位置切割
-
- 如果当前节点是拖拽节点则不递归,不生成子节点的框框
-
- 若该节点没有子节点,则默认生成一个子节点插入框框
我们以上图为例子讲解一下计算过程(请结合图片理解)
- 先遍历子节点,当前节点索引
i
为 0,判断当前节点My Child-1
(children[i])是否为拖拽节点 - 是的话,则当前可插入框的下边界(c->d)是当前节点的下一个节点(
My Child 第二个节点
)的垂直居中位置即children[i + 1].centerY
- 然后生成当前可插入区域的区间即上图的ABCD框,区间如下
- 拖拽节点是递归,则移动
startY
为下一个节点的垂直居中位置,索引 + 1
然后接着下个节点,如下图
- 当前节点,不是拖拽节点,是最后一个节点,则可插入框的下边界(c->d)则直接取
endY
- 然后生成当前可插入区域的区间即上图的ABCD框
- 当前节点有子节点则递归,若没有子节点并且不是拖拽节点则生成子节点插入框框
直接上代码
const areaList = [] // 可插入区域集合
function getNodeInsertArea(parentNode, dragNode) {
if (!parentNode.children.length) {
return;
}
const maxWidth = Math.max(...parentNode.children.map(node => node.width)); // 计算出兄弟节点宽度最长的
const areaHeight = getNodeAreaHeight(parentNode); // 父节点的区域高度
const endX = parentNode.centerX + nodeInterval + maxWidth; // nodeInterval代表节点之间的水平间隔
const endY = parentNode.centerY + 0.5 * areaHeight;
let startY = parentNode.centerY - 0.5 * areaHeight;
let skipCurArea = false; // 是否需要跳过当前节点
for (let i = 0; i < parentNode.children.length; i++) {
const node = parentNode.children[i];
const isDragNode = node.id === dragNode.id;
const isAfterChild = i + 1 < parentNode.children.length;
const y2 = isDragNode ? (isAfterChild ? parentNode.children[i + 1].centerY : endY) : node.centerY;
if (!skipCurArea) {
areaList.push({ x: parentNode.centerX, y: startY, x2: endX, y2 });
}
startY = y2;
skipCurArea = isDragNode;
// 如果不是拖拽节点并且有子节点则递归,否则不是拖拽节点但没有子节点则push子节点插入框
if (node.id !== dragNode.id) {
if (node.children.length) {
getNodeInsertArea(node, dragNode);
} else {
areaList.unshift(getArea());
}
}
}
// 拖拽节点在子节点里并且是最后一位这种情况下不需要额外填加区域(因为默认不是拖拽节点的区域里,会生成children.length + 1个插入框,这里就是为了处理 + 1)
if (!(parentNode.id === dragNode.father.id && dragNode.sort === parentNode.children.length - 1)) {
areaList.push(getArea());
}
}
// 匹配可插入区域(x,y鼠标坐标)
function matchInsertArea(areaList, x, y) {
for (let i = 0; i < areaList.length; i++) {
const areaBox = areaList[i];
if (x >= areaBox.x && x <= areaBox.x2 && y >= areaBox.y && y <= areaBox.y2) {
return areaBox;
}
}
return null;
}
4. 命令模式实现撤回恢复
当我们执行一些操作,比如不小心删除节点,想撤回操作的时候就可以通过命令模式实现撤回功能。 首选需要一个栈去记录你每次的操作
class CommandManager {
public executedCommands
constructor() {
this.executedCommands = []
}
pushCommand (command) {
this.executedCommands.push(command)
}
popCommand () {
return this.executedCommands.pop()
}
}
然后我们现在模拟实现添加节点的命令
class AddCommand {
constructor(public node, public checkNode) {}
// 添加操作
execute () {
this.checkNode.pushChild(this.node);
}
// 撤回操作
undo () {
this.node.father?.removeChild(this.node)
}
}
const commandManager = new CommandManager()
// 执行添加节点
const addCommand = new AddCommand(node, checkNode)
addCommand.execute()
commandManager.pushCommand(addCommand)
// 撤回操作
const command = commandManager.popCommad()
command.undo()
其实其他操作,例如删除节点,拖拽节点等操作也同理创建一个命令,也可实现撤回的功能
想体验的可以去思维导图,目前可能有挺多bug,想一起交流也可以加我wx,在思维导图里有微信号,可以互相交流
转载自:https://juejin.cn/post/7240333779222609976