原项目是纯C端程序,未来要移植成BS架构。其中一个功能是界面上展示树,基本要求是:
(1)按层次展示树各个结点
(2)根结点以及各个子树结点可以点击,点击后切换展开状态与折叠状态
(3)叶子结点不能相互遮挡
(4)折叠的子树结点也不能被遮挡
很多年前,大约是2009年,用C#实现过这样的逻辑。记得当时还有个优化要求,即叶子结点可以被遮挡,但是折叠的子树结点不能被遮挡。
当时不喜欢用递归,基本实现逻辑是:
1、遍历该树,找到所有的叶子结点
(1)含折叠的子树结点,不含其叶子结点
(2)按顺序排列这些结点,要保证一个子树结点在另一个子树结点前面,则前者所有叶子结点都在后者所有叶子结点的前面。
2、对这些叶子排序以确定Y值,X值是根据结点深度计算的
3、遍历这些叶子结点,找到其父结点,再根据该父结点的所有子结点确定其Y值
4、继续,最终要确定根结点的Y值
要在B端实现这个功能,需要用js。
非递归的算法简单,简单在算法容易理解,基本流程定下来就可以实现了,但是过程很复杂。而递归算法简单在实现过程,但是最难的是算法思路。
递归算法基本思路如下:
1、任意一个结点,X值由深度确定,Y值由上一个兄弟结点的位置和该结点的范围计算得到
2、叶子结点和折叠的子树结点的范围是它本身
3、子树结点的范围,宽度是本身的宽度加间距,再加最宽的子结点宽度;高度是其所有子结点高度加各子结点间的间距
有了十多年代码经验,用递归还是比较轻松简单的,反而不喜欢用非递归,实现过程太麻烦了。
算法基本过程如下:
1、初始化树
遍历所有结点,设置每一个结点本身的大小(位置还不能确定)、绑定计算位置的方法cacluPos和计算范围的方法cacluSize(每一个结点都可以单独计算其位置和大小)、删除无效子结点
2、计算根结点的宽度和高度
cacluSize方法实现:
(1)递归结束条件:当前结点是叶子结点或该结点被折叠,范围是其本身
(2)遍历子结点,找到最宽的子结点的宽度:
第一步,计算各个子结点的范围
第二步,找到最宽的子结点,记录该宽度
第三步,累加这些子结点的高度
(3)当前结点的宽度,等于该结点本身的宽度加横向间距,再加最宽的子结点的宽度值
(4)当前结点的高度,等于累加到的子结点的高度,再加这些子结点的纵向间距
3、设计根结点范围起点为(0,0),计算根结点的位置
cacluPos方法实现:
(1)当前结点本身的位置
X值是其范围的X值,Y值是范围的Y值+(范围的高度-本身的高度)/2
(2)当前结点是子树结点,且没被折叠,则遍历当前结点的子结点:
第一步,子结点的范围X值是当前结点的X值,加当前结点的宽度,再加横向间距
第二步,第一个子结点的范围Y值是当前结点的范围Y值;后续子结点的范围Y值,是前一子结点的范围Y值加范围高度,再加纵向间距
第三步,计算子结点本身的位置
直接贴上js源码,如下
let node_width = 140;
let node_height = 60;
let node_spaceH = 50;
let node_spaceV = 20;
function cacluPos() {
this.node_x = this.x;
this.node_y = this.y + (this.height - this.node_height) / 2;
// 递归求当前结点的子结点的位置
if (this.children && !this.folded) {
let y = this.y;
this.children.forEach(child => {
// 子结点的横坐标为当前结点横坐标 + 当前结点本身的宽度 + 横间距
child.x = this.x + this.node_width + node_spaceH;
// 子结点的纵坐标为前一个子结点的纵坐标 + 纵间距
child.y = y;
y += child.height + node_spaceV;
// 求该结点本身的位置
child.cacluPos();
});
}
}
function cacluSize() {
if (!this.children || this.folded) {
this.width = this.node_width;
this.height = this.node_height;
return;
}
// 当前结点的宽度,等于当前结点本身的宽度 + 横间距 + 最宽的子结点的宽度
// 当前结点的高度,等于所有子结点的高度 + 纵间距
let childWidth = 0;
this.height = 0;
this.children.forEach(child => {
// 先求子结点的尺寸(递归)
child.cacluSize();
// 找到最宽的子结点
if (childWidth < child.width) {
childWidth = child.width;
}
// 各子结点的高累加
this.height += child.height;
});
// 计算当前结点的宽度
this.width = node_width + node_spaceH + childWidth;
// 当前结点的高度,记得加上各子结点的纵轴方向上的间距
this.height += (this.children.length - 1) * node_spaceV;
}
function traversalTree(tree, findNode) {
let list = [tree];
while (list.length > 0) {
// 取出首结点
let node = list[0];
list = list.splice(1);
// 找到一个结点
findNode(node);
// 将该结点的子结点加入列表
if (node.children && !node.folded) {
list.push(...node.children);
}
}
}
function initTree(tree) {
// 遍历树的所有结点
traversalTree(tree, (node) => {
// 初始化该结点
node.folded = false;
node.node_width = node_width;
node.node_height = node_height;
node.cacluPos = cacluPos;
node.cacluSize = cacluSize;
// 删除无效的子结点
if (node.children) {
if (!(node.children instanceof Array) && node.children.length < 1) {
delete node.children;
}
}
});
}
function printTree(tree) {
// 遍历树的所有结点
traversalTree(tree, (node) => {
// 打印该结点位置
console.log(node.name + ": " + node.node_x + ", " + node.node_y + ", " + node.width + ", " + node.height);
});
}
// 布局树的所有结点
function layoutTree(tree) {
// 初始化树的所有结点
initTree(tree);
// 计算各结点宽度和高度
tree.cacluSize();
// 计算各结点的位置
tree.x = 0;
tree.y = 0;
tree.cacluPos();
// 打印所有结点的位置
printTree(tree);
}
export default {
layoutTree: layoutTree,
}
关于结点点击的代码比较简单,就不分享了,无非是遍历结点,看看点击位置在哪个结点本身的范围内,依然可以用递归。
广度优先遍历,先看点击位置在哪个结点范围内,再进一步精确到具体的结点,比直接比对所有结点肯定要好。
该算法其实不难,但是也需要相当的代码经验才能独立开发实现。如果上网搜代码,怕是很难搜到比较满意的,也不可能刚刚满足自己的项目需求。
所以,要多写代码。