在HT for Web中2D和3D應用都支援樹狀結構資料的展示,展現效果各異,2D上的樹狀結構在展現層級關係明顯,但是如果資料量大的話,看起來就沒那麼直觀,找到指定的節點比較困難,而3D上的樹狀結構在展現上配合HT for Web的彈力佈局組件會顯得比較直觀,一眼望去可以把整個樹狀結構數據看個大概,但是在彈性佈局的作用下,其層次結構看得就不是那麼清晰了。所以這時候結構清晰的3D樹的需求就來了,那麼這個3D樹具體長成啥樣呢,我們來一起目睹下~
要實現這樣的效果,該從何下手呢?接下來我們就將這個問題拆解成若干個小問題來解決。
有了解HT for Web的朋友,對樹狀結構資料的建立應該都不陌生,在這裡我就不做深入的探討了。樹狀結構數據的創建很簡單,在這裡為了讓程式碼更簡潔,我封裝了三個方法來創建樹狀結構數據,具體程式碼如下:
/** * 创建连线 * @param {ht.DataModel} dataModel - 数据容器 * @param {ht.Node} source - 起点 * @param {ht.Node} target - 终点 */ function createEdge(dataModel, source, target) { // 创建连线,链接父亲节点及孩子节点 var edge = new ht.Edge(); edge.setSource(source); edge.setTarget(target); dataModel.add(edge); } /** * 创建节点对象 * @param {ht.DataModel} dataModel - 数据容器 * @param {ht.Node} [parent] - 父亲节点 * @returns {ht.Node} 节点对象 */ function createNode(dataModel, parent) { var node = new ht.Node(); if (parent) { // 设置父亲节点 node.setParent(parent); createEdge(dataModel, parent, node); } // 添加到数据容器中 dataModel.add(node); return node; } /** * 创建结构树 * @param {ht.DataModel} dataModel - 数据容器 * @param {ht.Node} parent - 父亲节点 * @param {Number} level - 深度 * @param {Array} count - 每层节点个数 * @param {function(ht.Node, Number, Number)} callback - 回调函数(节点对象,节点对应的层级,节点在层级中的编号) */ function createTreeNodes(dataModel, parent, level, count, callback) { level--; var num = (typeof count === 'number' ? count : count[level]); while (num--) { var node = createNode(dataModel, parent); // 调用回调函数,用户可以在回调里面设置节点相关属性 callback(node, level, num); if (level === 0) continue; // 递归调用创建孩子节点 createTreeNodes(dataModel, node, level, count, callback); } }
嘿嘿,程式碼寫得可能有些複雜了,簡單的做法就是嵌套幾個for迴圈來創建樹狀結構數據,在這裡我就不多說了,接下來我們來探究第二個問題。
在3D下的樹狀結構體最大的問題就在於,每個節點的層次及每層節點圍繞其父親節點的半徑計算。現在樹狀結構資料已經有了,那麼接下來就該開始計算半徑了,我們從兩層樹狀結構開始推算:
我現在先創建了兩層的樹狀結構,所有的子節點是一字排開,並沒有環繞其父親節點,那麼我們該如何確定這些孩子節點的位置呢?
首先我們得知道,每個末端節點都有一圈屬於自己的領域,不然節點與節點之間將會存在重疊的情況,所以在這裡,我們假定末端節點的領域半徑為25,那麼兩個相鄰節點之間的最短距離將是兩倍的節點領域半徑,也就是50,而這些末端節點將均勻地圍繞在其父親節點四周,那麼相鄰兩個節點的張角就可以確認出來,有了張角,有了兩點間的距離,那麼節點繞其父親節點的最短半徑也就能計算出來了,假設張角為a,兩點間最小距離為b,那麼最小半徑r的計算公式為:
r = b / 2 / sin(a / 2);
那麼接下來我麼就來佈局下這個樹,程式碼是這樣寫的:
/** * 布局树 * @param {ht.Node} root - 根节点 * @param {Number} [minR] - 末端节点的最小半径 */ function layout(root, minR) { // 设置默认半径 minR = (minR == null ? 25 : minR); // 获取到所有的孩子节点对象数组 var children = root.getChildren().toArray(); // 获取孩子节点个数 var len = children.length; // 计算张角 var degree = Math.PI * 2 / len; // 根据三角函数计算绕父亲节点的半径 var sin = Math.sin(degree / 2), r = minR / sin; // 获取父亲节点的位置坐标 var rootPosition = root.p(); children.forEach(function(child, index) { // 根据三角函数计算每个节点相对于父亲节点的偏移量 var s = Math.sin(degree * index), c = Math.cos(degree * index), x = s * r, y = c * r; // 设置孩子节点的位置坐标 child.p(x + rootPosition.x, y + rootPosition.y); }); }
在程式碼中,你會發現我將末端半徑預設為25了,如此,我們透過呼叫layout()方法就可以對結構樹進行佈局了,其佈局效果如下:
從效果圖可以看得出,末端節點的預設半徑並不是很理想,佈局出來的效果連線都快看不到了,因此我們可以增加末端節點的預設半徑來解決佈局太密的問題,如將預設半徑設定成40的效果圖如下:
#現在兩層的樹狀分佈解決了,那麼我們來看看三層的樹狀分佈該如何處理。
將第二層和第三層看成一個整體,那麼其實三層的樹狀結構跟兩層是一樣的,不同的是在處理第二層節點時,應該將其看做一個兩層的樹狀結構來處理,那麼像這種規律的處理用遞歸最好不過了,因此我們將程式碼稍微該著下,在看看效果如何:
不行,節點都重疊在一起了,看來簡單的遞歸是不行的,那麼具體的問題出在哪裡呢?
仔細分析了下,發現父親節點的領域半徑是由其孩子節點的領域半徑決定的,因此在佈局時需要知道自身節點的領域半徑,而且節點的位置取決於父親節點的領域半徑及位置訊息,這樣一來就無法邊計算半徑邊佈局節點位置了。
那麼現在只能將半徑的計算和佈局分開來,做兩步操作了,我們先來分析下節點半徑的計算:
首先需要明確最關鍵的條件,父親節點的半徑取決於其孩子節點的半徑,這個條件告訴我們,只能從下往上計算節點半徑,因此我們設計的遞歸函數必須是先遞歸後計算,廢話不多說,我們來看下具體的程式碼實作:
/** * 就按节点领域半径 * @param {ht.Node} root - 根节点对象 * @param {Number} minR - 最小半径 */ function countRadius(root, minR) { minR = (minR == null ? 25 : minR); // 若果是末端节点,则设置其半径为最小半径 if (!root.hasChildren()) { root.a('radius', minR); return; } // 遍历孩子节点递归计算半径 var children = root.getChildren(); children.each(function(child) { countRadius(child, minR); }); var child0 = root.getChildAt(0); // 获取孩子节点半径 var radius = child0.a('radius'); // 计算子节点的1/2张角 var degree = Math.PI / children.size(); // 计算父亲节点的半径 var pRadius = radius / Math.sin(degree); // 设置父亲节点的半径及其孩子节点的布局张角 root.a('radius', pRadius); root.a('degree', degree * 2); }
OK,半径的计算解决了,那么接下来就该解决布局问题了,布局树状结构数据需要明确:孩子节点的坐标位置取决于其父亲节点的坐标位置,因此布局的递归方式和计算半径的递归方式不同,我们需要先布局父亲节点再递归布局孩子节点,具体看看代码吧:
/** * 布局树 * @param {ht.Node} root - 根节点 */ function layout(root) { // 获取到所有的孩子节点对象数组 var children = root.getChildren().toArray(); // 获取孩子节点个数 var len = children.length; // 计算张角 var degree = root.a('degree'); // 根据三角函数计算绕父亲节点的半径 var r = root.a('radius'); // 获取父亲节点的位置坐标 var rootPosition = root.p(); children.forEach(function(child, index) { // 根据三角函数计算每个节点相对于父亲节点的偏移量 var s = Math.sin(degree * index), c = Math.cos(degree * index), x = s * r, y = c * r; // 设置孩子节点的位置坐标 child.p(x + rootPosition.x, y + rootPosition.y); // 递归调用布局孩子节点 layout(child); }); }
代码写完了,接下来就是见证奇迹的时刻了,我们来看看效果图吧:
不对呀,代码应该是没问题的呀,为什么显示出来的效果还是会重叠呢?不过仔细观察我们可以发现相比上个版本的布局会好很多,至少这次只是末端节点重叠了,那么问题出在哪里呢?
不知道大家有没有发现,排除节点自身的大小,倒数第二层节点与节点之间的领域是相切的,那么也就是说节点的半径不仅和其孩子节点的半径有关,还与其孙子节点的半径有关,那我们把计算节点半径的方法改造下,将孙子节点的半径也考虑进去再看看效果如何,改造后的代码如下:
/** * 就按节点领域半径 * @param {ht.Node} root - 根节点对象 * @param {Number} minR - 最小半径 */ function countRadius(root, minR) { …… var child0 = root.getChildAt(0); // 获取孩子节点半径 var radius = child0.a('radius'); var child00 = child0.getChildAt(0); // 半径加上孙子节点半径,避免节点重叠 if (child00) radius += child00.a('radius'); …… }
下面就来看看效果吧~
哈哈,看来我们分析对了,果然就不再重叠了,那我们来看看再多一层节点会是怎么样的壮观场景呢?
哦,NO!这不是我想看到的效果,又重叠了,好讨厌。
不要着急,我们再来仔细分析分析下,在前面,我们提到过一个名词——领域半径,什么是领域半径呢?很简单,就是可以容纳下自身及其所有孩子节点的最小半径,那么问题就来了,末端节点的领域半径为我们指定的最小半径,那么倒数第二层的领域半径是多少呢?并不是我们前面计算出来的半径,而应该加上末端节点自身的领域半径,因为它们之间存在着包含关系,子节点的领域必须包含于其父亲节点的领域中,那我们在看看上图,是不是感觉末端节点的领域被侵占了。那么我们前面计算出来的半径代表着什么呢?前面计算出来的半径其实代表着孩子节点的布局半径,在布局的时候是通过该半径来布局的。
OK,那我们来总结下,节点的领域半径是其下每层节点的布局半径之和,而布局半径需要根据其孩子节点个数及其领域半径共同决定。
好了,我们现在知道问题的所在了,那么我们的代码该如何去实现呢?接着往下看:
/** * 就按节点领域半径及布局半径 * @param {ht.Node} root - 根节点对象 * @param {Number} minR - 最小半径 */ function countRadius(root, minR) { minR = (minR == null ? 25 : minR); // 若果是末端节点,则设置其布局半径及领域半径为最小半径 if (!root.hasChildren()) { root.a('radius', minR); root.a('totalRadius', minR); return; } // 遍历孩子节点递归计算半径 var children = root.getChildren(); children.each(function(child) { countRadius(child, minR); }); var child0 = root.getChildAt(0); // 获取孩子节点半径 var radius = child0.a('radius'), totalRadius = child0.a('totalRadius'); // 计算子节点的1/2张角 var degree = Math.PI / children.size(); // 计算父亲节点的布局半径 var pRadius = totalRadius / Math.sin(degree); // 缓存父亲节点的布局半径 root.a('radius', pRadius); // 缓存父亲节点的领域半径 root.a('totalRadius', pRadius + totalRadius); // 缓存其孩子节点的布局张角 root.a('degree', degree * 2); }
在代码中我们将节点的领域半径缓存起来,从下往上一层一层地叠加上去。接下来我们一起验证其正确性:
搞定,就是这样子了,2D拓扑上面的布局搞定了,那么接下来该出动3D拓扑啦~
3D拓扑上面布局无非就是多加了一个坐标系,而且这个坐标系只是控制节点的高度而已,并不会影响到节点之间的重叠,所以接下来我们来改造下我们的程序,让其能够在3D上正常布局。
也不需要太大的改造,我们只需要修改下布局器并且将2D拓扑组件改成3D拓扑组件就可以了。
/** * 布局树 * @param {ht.Node} root - 根节点 */ function layout(root) { // 获取到所有的孩子节点对象数组 var children = root.getChildren().toArray(); // 获取孩子节点个数 var len = children.length; // 计算张角 var degree = root.a('degree'); // 根据三角函数计算绕父亲节点的半径 var r = root.a('radius'); // 获取父亲节点的位置坐标 var rootPosition = root.p3(); children.forEach(function(child, index) { // 根据三角函数计算每个节点相对于父亲节点的偏移量 var s = Math.sin(degree * index), c = Math.cos(degree * index), x = s * r, z = c * r; // 设置孩子节点的位置坐标 child.p3(x + rootPosition[0], rootPosition[1] - 100, z + rootPosition[2]); // 递归调用布局孩子节点 layout(child); }); }
上面是改造成3D布局后的布局器代码,你会发现和2D的布局器代码就差一个坐标系的的计算,其他的都一样,看下在3D上布局的效果:
恩,有模有样的了,在文章的开头,我们可以看到每一层的节点都有不同的颜色及大小,这些都是比较简单,在这里我就不做深入的讲解,具体的代码实现如下:
var level = 4, size = (level + 1) * 20; var root = createNode(dataModel); root.setName('root'); root.p(100, 100); root.s('shape3d', 'sphere'); root.s('shape3d.color', randomColor()); root.s3(size, size, size); var colors = {}, sizes = {}; createTreeNodes(dataModel, root, level - 1, 5, function(data, level, num) { if (!colors[level]) { colors[level] = randomColor(); sizes[level] = (level + 1) * 20; } size = sizes[level]; data.setName('item-' + level + '-' + num); // 设置节点形状为球形 data.s('shape3d', 'sphere'); data.s('shape3d.color', colors[level]); data.s3(size, size, size); });
在这里引入了一个随机生成颜色值的方法,对每一层随机生成一种颜色,并将节点的形状改成了球形,让页面看起来美观些(其实很丑)。
提个外话,节点上可以贴上图片,还可以设置文字的朝向,可以根据用户的视角动态调整位置,等等一系列的拓展,这些大家都可以去尝试,相信都可以做出一个很漂亮的3D树出来。
到此,整个Demo的制作就结束了,今天的篇幅有些长,感谢大家的耐心阅读,在设计上或则是表达上有什么建议或意见欢迎大家提出,点击这里可以访问HT for Web官网上的手册。
以上就是基於 HTML5 的 3D 網路拓樸樹呈現圖文程式碼詳解的內容,更多相關內容請關注PHP中文網(www.php.cn)!