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

前言

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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章