基於 HTML5 的 3D 網路拓撲樹呈現

xhload3d發表於2015-10-06

HT for Web中2D和3D應用都支援樹狀結構資料的展示,展現效果各異,2D上的樹狀結構在展現層級關係明顯,但是如果資料量大的話,看起來就沒那麼直觀,找到指定的節點比較困難,而3D上的樹狀結構在展現上配合HT for Web的彈力佈局元件會顯得比較直觀,一眼望去可以把整個樹狀結構資料看個大概,但是在彈力佈局的作用下,其層次結構看得就不是那麼清晰了。所以這時候結構清晰的3D樹的需求就來了,那麼這個3D樹具體長成啥樣呢,我們來一起目睹下~

要實現這樣的效果,該從何下手呢?接下來我們就將這個問題拆解成若干個小問題來解決。

1. 建立一個樹狀結構

有了解過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迴圈來建立樹狀結構資料,在這裡我就不多說了,接下來我們來探究第二個問題。

2. 在2D拓撲下模擬3D樹狀結構每層的半徑計算

在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拓撲啦~

3. 加入z軸座標,呈現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官網上的手冊

相關文章