前言
SVG並非僅僅是一種圖像格式, 由於它是一種基於XML的語言,也就意味着它繼承了XML的跨平台性和可擴展性,從而在圖形可重用性上邁出了一大步。如SVG可以內嵌於其他的XML文檔中,而SVG文檔中也可以嵌入其他的XML內容,各個不同的SVG圖形可以方便地組合, 構成新的SVG圖形。這個 Demo 運用的技術基於 HTML5 的技術適應了只能電網調度、配電網運行監控與配電網運維管控,通過移動終端實現 Web SCADA 賬上運維的時代需求。由於傳統電力行業 CS 桌面監控系統一直到新一代 Web 和移動終端進化中,HT 是實施成本最低,開發和運行效率最高的前端圖形技術解決方案。SVG 矢量圖形大家都不會陌生了,尤其是在工控電信等領域,但是這篇文章並不是要制作一個新的繪制 SVG 圖的編輯器,而是一個可繪制矢量圖形並且對這個圖形進行數據綁定的更高階。
效果圖

代碼實現
整體框架
根據上圖看得出來,整個界面被分為五個部分,分別為 palette 組件面板,toolbar 工具條,graphView 拓撲組件,propertyPane 屬性面板以及 treeView 樹組件,這五個部分中的組件需要先創建出來,然后才放到對應的位置上去:
palette = new ht.widget.Palette();//組件面板 toolbar = new ht.widget.Toolbar(toolbar_config);//工具條 g2d = new ht.graph.GraphView(dataModel);//拓撲組件 treeView = new ht.widget.TreeView(dataModel);//樹組件 propertyPane = new ht.widget.PropertyPane(dataModel);//屬性面板 propertyView = propertyPane.getPropertyView();//屬性組件 rulerFrame = new ht.widget.RulerFrame(g2d);//刻度尺

這些布局,只需要結合 splitView 和 borderPane 進行布局即可輕松完成~其中 splitView 為 HT 中的 分割組件,參數1為放置在前面的 view 組件(可為左邊的,或者上面的);參數2為放置在后面的 view 組件(可為右邊的,或者下面的);參數3為可選值,默認為 h,表示左右分割,若設置為 v 則為上下分割;參數4即為分割的比例。borderPane 跟 splitView 的作用有些相似,但是在這個 Demo 中布局,結合這兩種組件,代碼看起來會更加清爽。
borderPane = new ht.widget.BorderPane();//邊框面板 leftSplit = new ht.widget.SplitView(palette, borderPane, 'h', 260);//分割組件,h表示左右分割,v表示上下分割 rightSplit = new ht.widget.SplitView(propertyPane, treeView, 'v', 0.4); mainSplit = new ht.widget.SplitView(leftSplit, rightSplit, 'h', -260); borderPane.setTopView(toolbar);//設置邊框面板的頂部組件為 toolbar borderPane.setTopHeight(30); borderPane.setCenterView(rulerFrame);//設置邊框面板的中間組件為 rulerframe mainSplit.addToDOM();//將 mainSplit 的底層 div 添加進 body 體中 dataModel.deserialize(datamodel_config);//反序列化 datamodel_config 的內容,將json內容轉為拓撲圖場景內容 g2d.fitContent(true);
布局結束后,就要考慮每一個容器中應該放置哪些內容,我將這些內容分別封裝到不同的函數中,通過調用這些函數來進行數據的顯示。
Palette 組件面板
左側的 Palette 組件面板需要向其內部添加 group 作為分組,然后再向組內添加節點。但是我們使用這個組件的最重要的一個原因是它能夠拖拽節點,但是因為我們拖拽后需要在 graphView 拓撲組件中生成一個新的節點顯示在拓撲圖上,所以我將拖拽部分的邏輯寫在了 graphView 拓撲組件的初始化函數中,這一小節就不做解釋。
雖然說最重要的因素是拖拽,但是不可否認,這個組件在分類上也是非常直觀:

如上圖,我在 Palette 中做了三個分組:電力、食品加工廠以及污水處理。並在這些分組下面填充了很多屬於該組類型的節點。我將這些分組的信息存儲在 palette_config.js 文件中,由於三組中的信息量太大,這里只將一小部分的信息展示出來,看看是如何通過 json 對象來對分組進行數據顯示的:
palette_config = { scene: { name: '電力', items: [ { name: '文字', image: '__text__', type: ht.Text }, { name: '箭頭', image: 'symbols/arrow.json' }, { name: '地線', image: 'symbols/earthwire.json' } ] }, food: { name: '食品加工廠', items: [ { name: '間歇式流化床處理器', image: 'symbols/food/Batch fluid bed processor.json'}, { name: '啤酒瓶', image: 'symbols/food/Beer bottle.json'}, { name: '台式均質機', image: 'symbols/food/Batch fluid bed processor.json'} ] }, pumps: { name: '污水處理', items: [ { name: '3維泵', image: 'symbols/pumps/3-D Pump.json'}, { name: '18-惠勒卡車', image: 'symbols/pumps/18-wheeler truck 1.json'} ] } };
通過遍歷這個對象獲取內部數據,顯示不同的數據信息。當然,在獲取對象的信息的時候,我們需要創建 ht.Group 類的對象,以及分組內部的 ht.Node 類的元素(這些元素都為組的孩子),然后將這些獲取來的數據賦值到這兩種類型的節點上,並且將這些節點添加到 Palette 的數據容器中:
function initPalette(){//初始化組件面板中的內容 for(var name in palette_config){//從 palette_config.js 文件中獲取數據 var info = palette_config[name]; var group = new ht.Group();//組件面板用ht.Group展示分組,ht.Node展示按鈕元素 group.setName(info.name); group.setExpanded(false);//設置group默認關閉 palette.dm().add(group);//將節點添加到 palette 的數據容器中 info.items.forEach(function(item){ var node = new ht.Node();//新建 ht.Node 類型節點 node.setName(item.name);//設置名稱 用於顯示在 palette 面板中節點下方說明文字 node.setImage(item.image);//設置節點在 palette 面板中的顯示圖片 //文本類型 if (item.type === ht.Text) {//通過 json 對象中設置的 type 信息來獲取當前信息為何種類型的節點,不同類型的節點有些屬性設置不同 node.s({ 'text': 'Text',//文本類型的節點需要設置這個屬性顯示文本的內容 'text.align': 'center',//文本對齊方式 'text.vAlign': 'middle',//文本垂直對齊方式 'text.font': '32px Arial'//文本字體 }); } node.item = item; node.s({ 'image.stretch': item.stretch || 'centerUniform',//設置節點顯示圖片為填充的方式,這樣不同比例的圖片也不會因為拉伸而導致變形 'draggable': item.draggable === undefined ? true : item.draggable,//設置節點是否可被拖拽 }); group.addChild(node);//將節點設置為 group 組的孩子 palette.dm().add(node);//節點同樣也得添加到 palette 的數據容器中進行存儲 }); } }
graphView 拓撲組件

前面說到了 Palette 組件中節點拖拽到 graphView 拓撲圖形中,來看看這個部分是如何實現的。graphView 拓撲組件是 HT 非常重要的一個組件,了解它非常有必要。如果 Palette 中的 Node 的 draggable 屬性設置為 true ,那么 Palette 可以自動處理 dragstart ,但是 dragover 和 dragdrop 事件需要我們處理,我們知道 IOS 和 Android 設備上並不支持 dragover 和 dragdrop 這類事件,所以 Palette 插件還提供了模擬的拖拽事件 handleDragAndDrop,可以完美兼容 PC 和手持終端。
function initGraphView(){ if(ht.Default.isTouchable){//判斷是否為觸屏可Touch方式交互 palette.handleDragAndDrop = function(e, state) {//重寫此方法可以禁用HTML5原生的Drag和Drop事件並啟用模擬的拖拽事件 if(ht.Default.containedInView(e, g2d)){//判斷交互事件所處位置是否在View組件之上 if(state === 'between'){ e.preventDefault();//取消事件的默認動作。 } else if(state === 'end'){//當state為end時,判斷e是否在graphView的范圍內,如果是,則創建Node handleDrop(e); } } }; } else{ g2d.getView().addEventListener("dragover", function(e) { e.dataTransfer.dropEffect = "copy"; e.preventDefault(); }); g2d.getView().addEventListener("drop", function(e) { handleDrop(e); }); } } function handleDrop(e){//被拖拽的元素在目標元素上同時鼠標放開觸發的事件 e.preventDefault(); var paletteNode = palette.dm().sm().ld();//獲取 palette 面板上最后選中的節點 if (paletteNode) { var item = paletteNode.item, image = item.image; data = g2d.getDataAt(e, null, 5);//獲取事件下的節點 var node = new (item.type || ht.Node)(); node.setImage(image); //設置節點圖片 node.setName(item.name); //設置節點名稱 node.p(g2d.lp(e));//設置節點的坐標為拓撲中的邏輯坐標 lp函數為將事件坐標轉換為拓撲中的邏輯坐標 node.s('label', '');//設置節點在 graphView 中底部不顯示 setName 中的說明。因為 label 的優先級大於 name if(data instanceof ht.Group){//如果拖拽到“組類型”的節點上,那么直接設置父親孩子關系 node.setParent(data);//設置節點的父親 data.setExpanded(true);//展開分組 }else{ node.setParent(g2d.getCurrentSubGraph()); } g2d.dm().add(node); g2d.sm().ss(node); } }
我在 graphView 拓撲圖的場景中央添加了一個 json 場景,通過 dm.deserialize(datamodel_config) 反序列化 json 場景內容導出的一個電信行業的圖紙。HT 獨特的矢量引擎功能滿足電力行業設備種類繁多、設備圖元和線路網絡需無極縮放、綁定量測數據實時刷新等需求;三維呈現技術使得電力廠站和變壓器等設備 3D 可視化監控成為可能。
treeView 樹組件

至於樹組件,樹組件和 graphView 拓撲組件共用同一個 dataModl 數據容器,本來只需要創建出一個樹組件對象,然后將其添加進布局容器中即可顯示當前拓撲圖形中的所有的數據節點,一般 HT 會將樹組件上的節點分為幾種類型進行顯示,ht.Edge、ht.Group、ht.Node、ht.SubGraph、ht.Shape 等類型進行顯示,但是這樣做有一個問題,如果創建的節點非常多的話,那么無法分辨出那個節點是哪一個,也就無法快速地定位和修改該節點,會給繪圖人員帶來很大的困擾,所以我在 treeView 的 label 和 icon 的顯示上做了一些處理:
// 初始化樹組件 function initTreeView() { // 重載樹組件上的文本顯示 treeView.getLabel = function (data) { if (data instanceof ht.Text) { return data.s('text'); } else if (data instanceof ht.Shape) { return data.getName() || '不規則圖形' } return data.getName() || '節點' }; // 重載樹組件上的圖標顯示 var oldGetIconFunc = treeView.getIcon; treeView.getIcon = function (data) { if (data instanceof ht.Text) { return 'symbols/text.json'; } var img = data.getImage(); return img ? img : oldGetIconFunc.apply(this, arguments); } }
propertyPane 屬性面板

屬性面板,即為顯示屬性的一個容器,不同的類型的節點可能在屬性的顯示上有所不同,所以我在 properties_config.js 文件中將幾個比較常見的類型的屬性存儲到數組中,主要有幾種屬性: text_properties 用於顯示文本類型的節點的屬性、data_properties 所有的 data 節點均顯示的屬性、node_properties 用於顯示 ht.Node 類型的節點的屬性、group_properties 用於顯示 ht.Group 類型的節點的屬性以及 edge_properties 用於顯示 ht.Edge 類型的節點的屬性。通過將這些屬性分類,我們可以對在 graphView 中選中的不同的節點類型來對屬性進行過濾:
function initPropertyView(){//初始化屬性組件 dataModel.sm().ms(function(e){//監聽選中變化事件 propertyView.setProperties(null); var data = dataModel.sm().ld(); //針對不同類型的節點設置不同的屬性內容 if (data instanceof ht.Text) {//文本類型 propertyView.addProperties(text_properties); return; } if(data instanceof ht.Data){// data 類型,所有的節點都基於這個類型 propertyView.addProperties(data_properties); } if(data instanceof ht.Node){// node 類型 propertyView.addProperties(node_properties); } if(data instanceof ht.Group){//組類型 propertyView.addProperties(group_properties); } if(data instanceof ht.Edge){//連線類型 propertyView.addProperties(edge_properties); } }); }
數據綁定在屬性欄中也有體現,拿 data_properties 中的“標簽”和“可編輯”作為演示:
{ name: 'name',//設置了 name 屬性,如果沒有設置 accessType 則默認通過 get/setName 來獲取和設置 name 值 displayName: '名稱',//用於存取屬性名的顯示文本值,若為空則顯示name屬性值 editable: true//設置該屬性是否可編輯 }, { name: '2d.editable',//結合 accessType,則通過 node.s('2d.editable') 獲取和設置該屬性 accessType: 'style',//操作存取屬性類型 displayName: '可編輯',//用於存取屬性名的顯示文本值,若為空則顯示name屬性值 valueType: 'boolean',//布爾類型,顯示為勾選框 editable: true//設置該屬性是否可編輯 }
這兩個屬性比較有代表性,一個是直接通過 get/set 來設置 name 屬性值,一個是通過結合屬性的類型來控制 name 的屬性值。只要在屬性欄中操作“名稱”和“可編輯”兩個屬性,就可以直接在拓撲圖中看到對應的節點的顯示情況,這就是數據綁定。當然,還可以對矢量圖形進行局部的數據綁定,但是不是本文的重點,有興趣的可以參考我的這篇文章 WebGL 3D 電信機架實戰之數據綁定。
toolbar 工具欄
![]()
差點忘記說這個部分了,toolbar 上總共有 8 種功能,分別是選中編輯、連線、直角連線、不規則圖形、刻度尺顯示、場景放大、場景縮小以及場景內容導出 json。這 8 種功能都是存儲在 toolbar_config.js 文件中的,通過繪制 toolbar 中的元素給每一個元素都添加上了對應的點擊觸發的內容,主要講講 CreateEdgeInteractor.js 創建連線的內容。
我們通過 ht.Default.def 自定義了 CreateEdgeInteractor 類,然后通過 graphView.setInteractors([ new CreateEdgeInteractor(graphView, 'points')]) 這種方式來添加 graphView 拓撲圖中的交互器,可以實現創建連線的交互功能。
在 CreateEdgeInteractor 類中通過監聽 touchend 放手后事件向 graphView 拓撲圖中添加一個 edge 連線,可以通過在 CreateEdgeInteractor 函數中傳參來繪制不同的連線類型,比如 “ortho” 則為折線類型:
var CreateEdgeInteractor = function (graphView, type) { CreateEdgeInteractor.superClass.constructor.call(this, graphView); this._type = type; }; ht.Default.def(CreateEdgeInteractor, DNDInteractor, {//自定義類,繼承 DNDInteractor,此交互器有一些基本的交互功能 handleWindowTouchEnd: function (e) { this.redraw(); var isPoints = false; if(this._target){ var edge = new ht.Edge(this._source, this._target);//創建一條連線,傳入起始點和終點 edge.s({ 'edge.type': this._type//設置連線類型 為傳入的參數 type 類型 參考 HT for Web 樣式手冊 }); isPoints = this._type === 'points';//如果沒有設置則默認為 points 連線方式 if(isPoints){ edge.s({ 'edge.points': [{//設置連線的點 x: (this._source.p().x + this._target.p().x)/2, y: (this._source.p().y + this._target.p().y)/2 }] }); } edge.setParent(this._graphView.getCurrentSubGraph());//設置連線的父親節點為當前子網 this._graphView.getDataModel().add(edge); //將連線添加到拓撲圖的數據容器中 this._graphView.getSelectionModel().setSelection(edge);//設置選中該節點 } this._graphView.removeTopPainter(this);//刪除頂層Painter if(isPoints){ resetDefault();//重置toolbar導航欄的狀態 } } });
總結
一開始想說要做這個編輯器還有點怕怕的,就是感覺任務重,但是不上不行,所以總是在拖,但是后來整體分析下來,發現其實一步一步來就好,不要把步驟想得太復雜,什么事情都是從小堆到大的,以前我們用 svg 繪制的圖形都可以在這上面繪制,當然,如果有需要拓展也完全 ok,畢竟別人寫的編輯器不一定能夠完全滿足你的要求。這個編輯器雖說在畫圖上面跟別家無異,但是最重要的是它能夠繪制出矢量圖形,結合 HT 的數據綁定和動畫,我們就可以對這些矢量圖形中的每一個部分進行操作,比如燈的閃爍啊,比如人眨眼睛等等操作,至於這些都是后話了。有了這個編輯器我也能夠更加快速地進行開發了~
