原项目是纯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,
- }
关于结点点击的代码比较简单,就不分享了,无非是遍历结点,看看点击位置在哪个结点本身的范围内,依然可以用递归。
广度优先遍历,先看点击位置在哪个结点范围内,再进一步精确到具体的结点,比直接比对所有结点肯定要好。
该算法其实不难,但是也需要相当的代码经验才能独立开发实现。如果上网搜代码,怕是很难搜到比较满意的,也不可能刚刚满足自己的项目需求。
所以,要多写代码。