基於 HTML5 Canvas 的電信機櫃 U 位動態管理

圖撲軟體發表於2019-02-16

前言

U 是一種表示伺服器外部尺寸的單位,是 unit 的縮略語,詳細的尺寸由作為業界團體的美國電子工業協會(EIA)所決定。之所以要規定伺服器的尺寸,是為了使伺服器保持適當的尺寸以便放在鐵質或鋁質的機架上。機架上有固定伺服器的螺孔,以便它能與伺服器的螺孔對上號,再用螺絲加以固定好,以方便安裝每一部伺服器所需要的空間。規定的尺寸是伺服器的寬(48.26cm=19 英寸)與高(4.445cm 的倍數)。由於寬為19英寸,所以有時也將滿足這一規定的機架稱為“19 英寸機架”。厚度以 4.445cm 為基本單位。1U 就是 4.445cm,2U 則是 1U 的 2 倍為 8.89cm。所謂“1U 的 PC 伺服器”,就是外形滿足 EIA 規格、厚度為 4.445cm 的產品。設計為能放置到 19 英寸機櫃的產品一般被稱為機架伺服器。

工控上運用到機櫃 U 位的非常普遍,但是經常在建立 2D/3D 模型的時候,我們向內新增裝置,每個裝置佔的 U 位不同,如果只是單純地向機櫃內部新增節點,在節點還未新增的時候我們沒法直觀地看到具體的效果,所以我就想能不能在新增的過程中就讓大家直接看到機房裝置的 U 位佔位以及效果,這個 Demo 因此而生。

https://hightopo.com/demo/rack-builder/index.html

程式碼生成

場景搭建

整個 Demo 由最左側的樹,中間部分的列表以及右邊的電信機櫃拓撲圖整體構成,為了讓整個佈局乾淨一點,這裡結合 splitView 和 borderPane 兩種佈局方式來進行。首先將場景分為左右兩個部分,左邊為樹,右邊是列表和電信機櫃拓撲圖的組合:

treeView = this.treeView = new ht.widget.TreeView(),// 樹元件 (http://www.hightopo.com/guide/guide/core/treeview/ht-treeview-guide.html)
splitView = this.splitView = new ht.widget.SplitView(treeView, null, 'h', 280);// 分割元件,將場景分為左右兩個部分,左邊為樹元件,右邊為空,左邊的寬度為280,右邊的元件先設定為空到時候根據具體情況分配 (http://www.hightopo.com/guide/guide/core/splitview/ht-splitview-guide.html)
this.splitView.addToDOM();

佈局結束記得將最外層元件的最底層 div 新增到 body 中,HT 的元件一般都會嵌入 BorderPane、SplitView 和 TabView 等容器中使用,而最外層的HT元件則需要使用者手工將 getView() 返回的底層 div 元素新增到頁面的 DOM 元素中,這裡需要注意的是,當父容器大小變化時,如果父容器是 BorderPane 和 SplitView 等這些HT預定義的容器元件,則HT的容器會自動遞迴呼叫孩子元件 invalidate 函式通知更新。但如果父容器是原生的 html 元素, 則 HT 元件無法獲知需要更新,因此最外層的 HT 元件一般需要監聽 window 的視窗大小變化事件,呼叫最外層元件 invalidate 函式進行更新。

為了最外層元件載入填充滿視窗的方便性,HT 的所有元件都有 addToDOM 函式,其實現邏輯如下,其中 iv 是 invalidate 的簡寫:

addToDOM = function(){
    var self = this,
          view = self.getView(),//獲取元件的底層 div
          style = view.style;
    document.body.appendChild(view);//將元件底層div新增進body中
    style.left = '0';//ht 預設將所有的元件的position都設定為absolute絕對定位
    style.right = '0';
    style.top = '0';
    style.bottom = '0';
    window.addEventListener('resize', function () { self.iv(); }, false);//視窗大小改變事件,呼叫重新整理函式
}

右邊的拓撲圖部分是在監聽選中變化事件的時候更新的,當然,初始化設定的選中樹上的第一個節點就觸發了選中變化事件:

cms.treeView.sm().ss(cms.treeView.dm().getDatas().get(0));// 設定選中樹上的第一個節點
treeView.sm().ms(function(){// 監聽選中變化事件
    var ld = treeView.sm().ld();// 獲取最後選中的節點
    if (ld) self.updateForm(ld.a('type'));
});
CMS.prototype.updateForm = function(type){
    var self = this,
        ld = this.treeView.sm().ld();// 獲取樹上選中的最後一個節點
    if (type === self.TYPE_RACK_SPACE) {// 如果是在樹上選中了節點,那麼點選“新增機櫃”就直接在樹上選中的節點下生成
        if (!this.rackBuild) {
            this.rackBuild = new RackBuild(this);// 此類中定義了場景的中間列表部分,右邊拓撲圖部分以及對應的邏輯
        }
        this.rackBuild.setData(ld);// 在樹上新增一個新的節點
        this.splitView.setRightView(this.rackBuild.getHTView());// 設定分割元件右邊的內容為整個場景的中間“列表”內容+右邊的拓撲內容
    }
}

上面程式碼中 splitView.setRightView 函式意為設定右側元件,有了這個函式,我就可以動態地改變 spliteView 元件中的右側元件了。

初始化樹

既然佈局布好了,就該向具體的位置新增內容了。先來看看如何向樹上新增電信機櫃節點。首先我定義了一個初始化的樹上的值 treeData,通過遍歷這個陣列建立樹上的節點以及節點上的父子關係:

var treeData = [{
    name: 'Racks',
    type: 8,
    children: [
        {
            name: 'rack1',
            type: 18,
            usize: 32
        }, {
            name: 'rack2',
            type: 18
        }
    ]
}];
CMS.prototype.loadTreeData = function(){// 載入樹上的節點
    var self = this;
    setTimeout(function(){
        var data = treeData;

        data.forEach(function(d) {// 遍歷 treeData 陣列的值
            self.createData(d, null);// 第一個節點父親為空
        });
        self.treeView.expandAll();// 展開樹
    }, 10);
}

通過 createData 函式建立節點,並給節點設定父子關係:

CMS.prototype.createData = function(data, parent){// 在樹上建立一個節點
    var self = this,
        htData = new ht.Data(),// 新建 Data 型別節點
        dm = this.treeView.dm();// 獲取樹的資料容器
    htData.a(data);// 設定節點業務屬性 data
    htData.setName(data.name)// 設定節點的 name 屬性
    if (parent) {
        htData.setParent(parent);// 設定父親節點
    }
    dm.add(htData);// 將節點新增到資料容器中
    if (data.children) {// 如果節點中有 children 物件
        data.children.forEach(function(d){// 遍歷 children 物件
            self.createData(d, htData);// 再建立 children 物件中的節點作為孩子節點
        });
    }
    return htData;
}

建立場景右邊部分

眼尖的同學在前面的程式碼中可能注意到了一個未宣告的 RackBuild 類,在此類的宣告中我們將場景的右半部分主要分為左右兩個部分,左邊又分為上下兩個部分,右邊也分為上下兩個部分。

這裡先將整個右邊的部分進行佈局,下面程式碼中的變數 listBorder 為上圖的左半部分,變數 borderPane 為上圖的右半部分,至於鷹眼元件部分,是新增到在 borderPane 的上層:

listView = this.listView = new ht.widget.ListView(),// 列表元件(http://www.hightopo.com/guide/guide/core/listview/ht-listview-guide.html)
listForm = this.listForm = new ht.widget.FormPane(),// 表單元件(http://www.hightopo.com/guide/guide/plugin/form/ht-form-guide.html)
listBorder = this.listBorder = new ht.widget.BorderPane(),// 場景中間邊框皮膚元件(http://www.hightopo.com/guide/guide/core/borderpane/ht-borderpane-guide.html)
gv = this.gv = new ht.graph.GraphView(),// 拓撲元件(http://www.hightopo.com/guide/guide/core/beginners/ht-beginners-guide.html#ref_graphview)
borderPane = this.borderPane = new ht.widget.BorderPane(),
toolbar = this.toolbar = new ht.widget.Toolbar(),// 工具條元件(http://www.hightopo.com/guide/guide/core/toolbar/ht-toolbar-guide.html)
splitView = this.splitView = new ht.widget.SplitView(listBorder, borderPane, 'h', 220),// 分割元件
overview = this.overview = new ht.graph.Overview(gv),// 鷹眼元件(http://www.hightopo.com/guide/guide/plugin/overview/ht-overview-guide.html)
overviewDiv = overview.getView();// 獲取鷹眼元件底層 div

overviewDiv.style.height = '120px';// HT 的元件預設都是絕對定位的
overviewDiv.style.width = '120px';
overviewDiv.style.left = '0';
overviewDiv.style.bottom = '0';
overviewDiv.style.zIndex = 10;
borderPane.getView().appendChild(overview.getView());// 將鷹眼元件底層 div 新增到皮膚元件的底層 div 中

listBorder.setTopView(listForm);// 設定頂部元件
listBorder.setCenterView(listView);// 設定中間元件
listBorder.setTopHeight(32);// 設定頂部元件高度
listForm.setVPadding(2);// 設定表單頂部和頂部與元件內容的間距
listForm.setHPadding(4);// 設定表單左邊和右邊與元件內容的間距
listForm.addRow([// 新增一行元件
    {
        comboBox: {// 組合框類
            labels: ['All', 'Pathch Panel', 'Switch', 'Server', 'Backbone Switch/Router'],// 設定下拉可選值對應文字
            values: [-1, 5, 9, 10, 11],// 設定下拉可選值
            value: -1,// 設定當前值,可為任意型別
            onValueChanged: function(e) {// 值變化觸發函式
                var val = this.getValue();// 獲取當前的值
                self.listTypeFilter = val;
                self.listView.ivm();// 最徹底的重新整理方式
            }
        }
    }
], [0.1], 28);// 引數二為行內元素的寬度,引數三為該行高度

borderPane.setCenterView(gv);// 設定中間元件
borderPane.setTopView(toolbar);// 設定頂部元件
borderPane.setTopHeight(32);// 設定中間元件高度

從上面的程式碼可以看出,splitView 為最外層元件,通過 getHTView 函式返回這個元件,在前面動態設定整個場景的右半部分的元件的時候我們就是通過設定 this.splitView.setRightView(this.rackBuild.getHTView()) 設定場景的右半部分為 rackBuild 的底層 div:

getHTView: function(){// 獲取最外層元件
    return this.splitView;
}

新增工具條內容

toolbar 工具條中總共的元素就三個:新增機櫃,編輯機櫃和刪除機櫃。這三個元素只需要通過 setItems 的方式新增到 toolbar 工具條元件上即可,元素的具體定義如下:

var toolbarItems = [// 工具條上三個的元素
    {
        icon: self.getToolbarIcon('toolbar.add.rack'),// 用的是我們前面宣告過的圖片
        toolTip: 'Add a rack',// 文字提示顯示內容
        action: function(){// 點選按鈕後觸發的函式
            self._editingRack = null;
            self.addRackForm.reset();
            self.addRackDialog.show();// 彈出對話方塊,新增一個新的機架,並填寫該機架的資訊
        }
    },{
        icon: self.getToolbarIcon('toolbar.edit.rack', function(){// 判斷右側拓撲圖上最後選中的節點 來決定這個圖示的顯示顏色(如果沒有選中機櫃,那麼此圖示顯示顏色為灰色)
            return self.gv.sm().ld() instanceof Rack;
        }),
        toolTip: 'Edit rack info',
        action: function(){
            var ld = self.gv.sm().ld();// 獲取 gv 中最後選中的節點
            if (!ld) return;
            self._editingRack = ld;
            self.addRackForm.v('name', ld.a('name'));// 彈出框中的 name 賦值為 ld 的業務屬性 name 的值
            self.addRackForm.v('usize', ld.a('usize'));// 彈出框中的 usize 賦值為 ld 的業務屬性 usize 的值
            self.addRackDialog.show();// 點選此按鈕會出現彈出框
        }
    },{
        icon: self.getToolbarIcon('toolbar.delete', function(){
            return self.gv.sm().ld() instanceof Rack;// 判斷右側拓撲圖上最後選中的節點的型別
        }),
        toolTip: 'Delete a rack',
        action: function(){
            self.handleRemoveRack();// 在拓撲圖上刪除機櫃,並刪除樹上此機櫃對應的節點
        }
    },
]

接下來只要把這個 item 新增到 toolbar 中並設定一下排布的方式即可:

toolbar.setItems(toolbarItems);// 設定工具條元素陣列
toolbar.setStickToRight(true);// 設定工具條是否向右對齊排布
toolbar.enableToolTip(true);// 工具條允許文字提示

上面出現的點選 toolbar 工具條按鈕觸發的事件中有一個“彈出對話方塊”的操作,通過 this.addRackDialog.show() 來實現,addRackDialog 物件定義在 initDialog 函式中,作用為建立一個 dialog 對話方塊(http://www.hightopo.com/guide/guide/plugin/dialog/ht-dialog-guide.html),我們設定此對話方塊中的內容為一個 form 表單進行顯示,同時還設計了兩個按鈕,“OK”按鈕作為執行建立/更改機櫃的屬性,“Cancel”按鈕不執行其他操作,只是將對話方塊隱藏:

initDialog: function(){// 初始化點選“增改”出現的對話方塊
    var self = this,
        addRackDialog = this.addRackDialog = new ht.widget.Dialog(),
        addRackForm = this.addRackForm = new FormPane(),// 此類繼承於 ht.widget.FormPane
        labelWidth = 72;

    addRackForm.addRow([// 新增行
        'Name',{
            id: 'name',
            textField: {}
        }
    ], [labelWidth, 0.1]);

    addRackForm.addRow([
        'Height(U)',{
            id: 'usize',
            textField: {
                type: 'number'
            }
        }
    ], [labelWidth, 0.1]);

    addRackDialog.setConfig({// 配置對話方塊的標題,尺寸,內容等
        title: "New Rack",// 對話方塊的標題
        content: addRackForm,// 指定對話方塊的內容
        width: 320,// 指定對話方塊的寬度
        height: 220,// 指定對話方塊的高度
        draggable: true,// 指定對話方塊是否可拖拽調整位置
        closable: true,// 可選值為true/false,表示是否顯示關閉按鈕
        resizeMode: "none",// 滑鼠移動到對話方塊右下角可改變對話方塊的大小 none 表示不可調整寬高
        buttons: [// 指定對話方塊按鈕組內容
            {
                label: "Ok",// 按鈕顯示文字
                action: function(button, e) {// action為回撥函式,當此按鈕被當點選時,回撥函式會執行
                    var formData = addRackForm.getValueObject(), rack;
                    if (!formData.usize) {// 如果沒有填寫 Height 的值,則預設高度為18
                        formData.usize = 18;
                    }
                    if (self._editingRack) {// 如果是“編輯rack資訊”的彈框
                        rack = self._editingRack;
                        rack.a(formData);
                        rack.a('treeNode').a(rack.getAttrObject());// 
                    }
                    else {// “增加”新的機櫃
                        rack = self.createRack(formData);// 建立一個新的 rack 模型
                        self.gv.dm().add(rack);// 在拓撲圖上新增這個rack
                        // update tree
                        formData.type = self.cms.TYPE_RACK;
                        var treeNode = self.cms.createData(formData, cms.treeView.sm().ld());
                        rack.a('treeNode', treeNode);
                    }
                    self.gv.fitContent(1);// 新增元素之後,讓所有的圖元顯示在介面上
                    addRackDialog.hide();// 隱藏對話方塊
                }
            }, {
                label: 'Cancel',
                action: function(){
                    addRackDialog.hide();// 隱藏對話方塊
                }
            }
        ],
        buttonsAlign: "right"
    });
}

上面程式碼出現的 FormPane 類,繼承於 ht.widget.FormPane 類,在 htwidget.FormPane 的基礎上修改也增加了一些函式,主要的內容還是 ht.widget.FormPane 的實現,文章篇幅有限,這裡就不貼程式碼了,有興趣的可以參考 FormPane.js 檔案。

實現了新增和編輯機房機櫃的兩個功能,刪除機房機櫃的功能實現上非常容易,只要將節點從拓撲圖和樹上移除即可:

handleRemoveRack: function(){// 在拓撲圖上刪除機櫃,並刪除樹上此機櫃對應的節點
    var ld = this.gv.sm().ld();// 獲取 gv 上選中的最後一個節點
    if (ld && ld instanceof Rack) {// 機櫃是 Rack 型別
        this.cms.treeView.dm().remove(ld.a('treeNode'));// 移出樹上的有 treeNode 屬性的節點
        this.gv.dm().remove(ld);// 刪除 gv 中的節點
    }
}

列表中元素拖拽

所有的內容都建立完畢,接下來要考慮的就是互動的內容了。列表元件中有 handleDragAndDrop 函式實現拖拽的功能:

listView.handleDragAndDrop = this.handleListDND.bind(this);// 列表上拖拽事件監聽(http://www.hightopo.com/guide/guide/core/listview/ht-listview-guide.html)
handleListDND: function(e, state){// 拖拽listView列表元件中的事件監聽
    var self = this,
        listView = self.listView,
        gv = self.gv,
        dm = gv.dm(),
        dnd = self.dnd;

    // handleDragAndDrop 函式有 prepare-begin-between-end 四種狀態
    if (state ==='prepare') {
        var data = listView.getDataAt(e);// 傳入邏輯座標點或者互動event事件引數,返回當前點下的資料元素
        listView.sm().ss(data);// 在拖拽的過程中設定列表元件中的被拖拽的元素被選中
        if (dnd && dnd.parentNode) {
            document.body.removeChild(dnd);
        }
        dnd = self.dnd = ht.Default.createDiv();// 建立一個 div
        dnd.style.zIndex = 10;
        dnd.innerText = data.getName();
    }
    else if (state === 'begin') {
        if (dnd) {
            var pagePoint = ht.Default.getPagePoint(e);// 返回頁面座標
            dnd.style.left = pagePoint.x - dnd.offsetWidth * 0.5 + 'px'; 
            dnd.style.top = pagePoint.y - dnd.offsetHeight * 0.5 + 'px';
            document.body.appendChild(dnd)
        }
    }
    else if (state === 'between') {
        if (dnd) {
            var pagePoint = ht.Default.getPagePoint(e);
            dnd.style.left = pagePoint.x - dnd.offsetWidth * 0.5 + 'px';
            dnd.style.top = pagePoint.y - dnd.offsetHeight * 0.5 + 'px';
            self.showDragHelper(e);
        }
    }
    else {// 拖拽“放開”滑鼠後的操作
        if (ht.Default.containedInView(e, self.gv)) {// 判斷互動事件所處位置是否在View元件之上
            if (dm.contains(self.dragHelper)) {// 判斷容器是否包含該data物件
                var rect = self.dragHelper.getRect(),// 獲取圖元的矩形區域(包括旋轉)
                    target = self.showDragHelper(e),// 
                    node,
                    ld = self.listView.sm().ld(),
                    uindex = target.getCellIndex(rect.y);
                node = self.createPane(rect, ld.getAttrObject(), target, uindex);// 建立裝置
                dm.add(node);
                // update tree data
                var treeNode = self.cms.createData(ld.getAttrObject(), target.a('treeNode'));// 在樹上建立節點,並設定父親節點
                treeNode.a('uindex', uindex);
                node.a('treeNode', treeNode);

                dm.remove(self.dragHelper);
            }
        }
        document.body.removeChild(dnd);
        self.dnd = null;
    }
}

裝置拖動

既然有了從列表元件上拖拽下來的互動動作,接下來應該是做裝置在機櫃上的拖拽改變位置的功能了,我們通過監聽拓撲元件 gv 的互動事件來對節點移動進行事件處理:

gv.mi(this.handleInteractor.bind(this));// 監聽互動
handleInteractor: function(e){// 移動機櫃中的裝置 的事件監聽
    if (e.kind.indexOf('Move') < 0) return;// 如果非move事件則直接返回不做處理

    var self = this,
        listView = self.listView,
        gv = self.gv,
        dm = gv.dm(),// 獲取資料容器
        target = gv.sm().ld(),// 獲取最後選中的節點
        uHeight = target.a('uHeight') || 1;// target.a('uHeight')獲取最後選中的節點的高度

    if (e.kind === 'prepareMove') {// 準備移動
        self._oldPosition = target.p();// 獲取節點當前的位置
    }
    else if (e.kind === 'betweenMove') {// 正在移動
        self.showDragHelper(e.event, uHeight);
        dm.sendToTop(target);// 將data在拓撲上置頂,顯示在最頂層 不會被別的節點遮蓋
    }
    else if (e.kind === 'endMove') {// 結束移動
        var rack = self.showDragHelper(e.event, uHeight);
        if (dm.contains(self.dragHelper)) {// 判斷容器是否包含該data物件
            target.p(self.dragHelper.p());// 設定節點的座標
            target.a('uindex', rack.getCellIndex(target.p().y));// 設定節點的業務屬性 uindex
            dm.remove(self.dragHelper);// 移除
            self._savable = true;
            self.toolbar.iv();
            target.setHost(rack);// 設定宿主節點
            target.setParent(rack);// 設定父親節點
            // update tree
            var treeNode = target.a('treeNode');// 獲取拓撲圖上對應的樹上的節點
            treeNode.setParent(rack.a('treeNode'));
        }
        else {
            target.p(self._oldPosition);
        }
    }
}

程式碼中的 showDragHelper 就是在裝置拖動的過程中,顯示在機櫃上,裝置下的作為佔位的綠色的矩形,為了方便看到當前移動的位置在機櫃上顯示的位置。有興趣的可以自己瞭解一下,篇幅有限,這裡就不提了。

列表元件過濾

會不會有同學對列表欄頂部的 form 表單做過濾有些好奇?這塊程式碼非常簡單,只需要對選中的型別進行過濾即可:

listView.setVisibleFunc(function(data){// 設定可見過濾器
    if (!self.listTypeFilter || self.listTypeFilter === -1)
        return true;
    return data.a('type') === self.listTypeFilter;// 根據節點的自定義屬性 type 來判斷節點屬於哪個型別 返回與當前 form 表單中選中的名稱相同的所有節點進行顯示
});

主要的程式碼就解釋到這裡,其他部分的內容有興趣的同學可以自己去摳程式碼瞭解 https://hightopo.com/demo/rack-builder/index.html。還有不懂的可以上官網瞭解 https://hightopo.com/

相關文章