電信資源管理系統:基於 H5 疊加 OpenLayers3 GIS
- 2019 年 10 月 13 日
- 筆記
前言
通過結合 HTML5 和 OpenLayers 可以組合成非常棒的一個電信地圖網路拓撲圖的應用,形成的效果可以用來作為電信資源管理系統,美食定位分享軟體,片區找房,繪製鐵軌線路等等,各個領域都能夠涉及的一款應用。雖然這個 Demo 是結合 OpenLayers3 的,其實還可推廣到與 ArcGIS、百度地圖以及 GoogleMap 等眾多 GIS 地圖引擎融合。
http://www.hightopo.com/demo/openlayers/
程式碼生成
創建地圖
OpenLayers 是一個用於開發 WebGIS 客戶端的 JavaScript 包。OpenLayers 支援的地圖來源包括 Google Maps、Yahoo、 Map、微軟 Virtual Earth 等多種離線在線地圖,這裡用到的是比較大眾化的Google地圖 Google Map 的在線地圖,使用 OpenLayers 前只需要引入相關的類庫以及 css 文件:
<link rel="stylesheet" href="css/ol.css" type="text/css"> <script src="lib/ol.js"></script>
初始化地圖的操作則是將 Map 放進一個 div 元素中,初始化一個 ol.Map 地圖類,這在整個電信資源管理系統中必不可少,然後設置這個類中的各個參數:
var mapDiv = document.getElementById('mapDiv'); map = new ol.Map({ target: 'mapDiv',// 地圖容器 controls: ol.control.defaults().extend([ graphViewControl,// 自定義拓撲控制項 new ol.control.OverviewMap(),// 地圖全局視圖控制項 new ol.control.ScaleLine(),// 比例尺控制項 new ol.control.ZoomSlider(),// 縮放刻度控制項 new ol.control.ZoomToExtent()// 縮放到全局控制項 ]), layers: [// 圖層 new ol.layer.Tile({ source: new ol.source.XYZ({// Google地圖 url:'http://www.google.cn/maps/vt/pb=!1m4!1m3!1i{z}!2i{x}!3i{y}!2m3!1e0!2sm!3i345013117!3m8!2szh-CN!3scn!5e1105!12m4!1e68!2m2!1sset!2sRoadmap!4e0' }) }) ], view: new ol.View({// 地圖視圖 projection: 'EPSG:3857',// 投影 center: ol.proj.fromLonLat([106, 35]),// 視圖的初始中心 中心的坐標系由projection選項指定 zoom: 4// 縮放級別 用於計算視圖的初始解析度 }) });
上面的程式碼根據每行的程式碼注釋加上官方 API 解釋應該沒有什麼難度。細心的朋友可能注意到了一個非官方的控制項:graphViewControl 控制項,這個控制項是我自定義出來,用來在這個控制項上繪製拓撲圖形的,聲明和定義部分在 GraphViewControl.js 文件中。
自定義控制項
自定義 OpenLayers 的控制項,無非就是將某個類繼承於 ol.control.Control 類,然後針對不同的需求重寫父類方法或者增加方法。
我在聲明類的時候傳了一個 options 參數,通過在定義類的時候設置控制項的容器元素並且將控制項渲染到 GIS 地圖的 viewport 之外:
var view = graphView.getView();// 獲取拓撲組件的 div ol.control.Control.call(this, { element: view,// 控制項的容器元素 target: options.target// 將控制項渲染到地圖的視口之外 });
上面的 graphView 是通過 GraphViewControl 在父類方法上新添加的一個方法並且初始化值為 ht.graph.GraphView,HT 的拓撲圖形組件:
// 獲取GraphView對象 GraphViewControl.prototype.getGraphView = function() { return this._graphView; }; var graphView = this._graphView = new ht.graph.GraphView();// 拓撲圖組件
我在控制項中還給 graphView 拓撲組件添加了一些事件的監聽,由於 OpenLayers 和 HT 是兩款不同的 js 庫,有著各自的交互系統和坐標系,首先我們將某些我們需要獲取在 HT 上做的交互事件並停止事件傳播到 OpenLayers 上:
// 拖拽 node 時不移動地圖 var stopGraphPropagation = function(e) { var data = graphView.getDataAt(e);// 獲取 graphView 事件下的節點 var interaction = graphView.getEditInteractor();// 獲取編輯交互器 if (data || e.metaKey || e.ctrlKey || interaction && interaction.gvEditing) { e.stopPropagation();// 不再派發事件 該方法將停止事件的傳播,阻止它被分派到其他 Document 節點 } } /** pointerdown 當指針變為活動事件 * 對於滑鼠,當設備從按下的按鈕轉換到至少一個按鈕被按下時,它會被觸發。 * 對於觸摸,當與數字化儀進行物理接觸時會被觸發。 * 對於筆,當觸筆與數字化儀進行物理接觸時會被觸發。 **/ view.addEventListener('pointerdown', stopGraphPropagation, false); view.addEventListener('touchstart', stopGraphPropagation, false);// 當觸摸點被放置在觸控面板上事件 view.addEventListener('mousedown', stopGraphPropagation, false);// 滑鼠點下事件
GraphViewControl 類定義部分還添加了一些關於移動和編輯節點的交互事件,主要是將節點的像素坐標轉為 OpenLayers 的 ol.Cordinate 地圖視圖投影中的坐標並存儲到節點的業務屬性(HT 的一個可以存儲任意值的對象)中,這樣我們只需要通過獲取或設置節點的業務屬性 coord 就可以自由獲取和設置節點在 map 上的像素坐標。
var position = data.getPosition(),// 獲取選中節點的坐標 x = position.x + graphView.tx(),// 節點橫坐標+graphView水平平移值 y = position.y + graphView.ty();// 節點縱坐標+graphView垂直平移值 var coord = map.getCoordinateFromPixel([x, y]);// 根據坐標的像素獲取地圖視圖投影中的坐標 data.a('coord', coord);
這裡我就提一些基礎的功能,其他的就不作解釋了,只是一些擴展。
值得注意的一點是,我們在上面對節點在電信 GIS 地圖視圖投影中的坐標進行了數據存儲,但是這個方法對於 Shape 類型的節點來說不太合適,因為地圖上一般都是用點圍成區域面,勾勒出某個國家或者某個城市的輪廓,縮放的時候並不實時保持大小,而是根據地圖的縮放來縮放,實時保持在電信 GIS 地圖的某個位置,所以我對 Shape 類型的節點中所有的點遍歷了一遍,都設置了業務屬性 pointCoord,獲取地圖視圖投影中的坐標:
// 給 shape 類型的節點的每個點位置都設置為經緯度 if (e.kind === 'endEditPoint' || e.kind === 'endEditPoints' || e.kind === 'endEditResize' || e.kind === 'endMove') { if (data instanceof ht.Shape) {// Shape 類型的節點 data.getPoints().forEach(function(point, index) { var pointCoord = map.getCoordinateFromPixel([point.x, point.y]);// 獲取給定像素的坐標 data.a('pointCoord['+index+']', pointCoord); }); } }
圖層疊加
OpenLayers 的結構比較複雜,而 HT 相對來說簡單很多,所以我將 HT 疊加到 OpenLayers Map 的 viewport 中。這裡我在子類 GraphViewControl 中重載了父類 ol.control.Control 的 setMap 方法,在此方法中將 HT 的拓撲組件 graphView 添加到 OpenLayers 的視圖 viewport 中,我們知道,HT 的組件一般都是絕對定位的,所以我們要設置 css 中的位置和寬高屬性:
var graphView = self._graphView;// = GraphViewControl.getGraphView() var view = graphView.getView();// 獲取 graphView 組件的 div var dataModel = graphView.getDataModel();// 獲取 graphView 的數據容器 view.style.top = '0'; view.style.left = '0'; view.style.width = '100%'; view.style.height = '100%'; map.getViewport().insertBefore(view, map.getViewport().firstChild);// getViewPort 獲取用作地圖視口的元素 insertBefore 在指定的已有子節點(參數二)之前插入新的子節點(參數一)
並對數據容器增刪變化事件進行監聽,通過監聽當前加入數據容器的節點類型,將當前節點的像素坐標轉為地圖視圖投影中的坐標存儲在節點的業務屬性 coord 上:
dataModel.addDataModelChangeListener(function(e) {// 數據容器增刪改查變化監聽 if (e.kind === 'add' && !(e.data instanceof ht.Edge)) {// 添加事件&&事件對象不是 ht.Edge 類型 if (e.data instanceof ht.Node) { var position = e.data.getPosition(); var coordPosition = map.getCoordinateFromPixel([position.x, position.y]);// 獲取給定像素的坐標 e.data.a('coord', coordPosition); } if (e.data instanceof ht.Shape) {// 給 shape 類型的節點上的每個點都設置經緯度 e.data.getPoints().forEach(function(point, index) {// 對 shape 類型的節點則將所有點的坐標都轉為經緯度 var pointCoord = map.getCoordinateFromPixel([point.x, point.y]);// 獲取給定像素的坐標 e.data.a('pointCoord['+index+']', pointCoord); }); } } });
最後監聽地圖更新事件,重設拓撲:
map.on('postrender', function() { self.resetGraphView(); });
坐標轉換
重設拓撲在這邊的意思就是將拓撲圖中節點坐標從我們一開始設置在 HT 中的像素坐標重新通過地圖的縮放或者移動將地圖視圖投影中的坐標轉為像素坐標設置到節點上,這時候前面存儲的業務屬性 coord 就派上用場了,記住,Shape 類型的節點是例外的,還是要對其中的每個點都重新設置坐標:
GraphViewControl.prototype.resetGraphView = function() {// 重置 graphView 組件的狀態 var graphView = this._graphView; graphView.tx(0);// grpahView 水平平移值 graphView.ty(0);// graphView 垂直平移值 graphView.dm().each(function(data) {// 遍歷 graphView 中的數據容器 var coord = data.a('coord');// 獲取節點的業務屬性 coord if (coord) { var position = map.getPixelFromCoordinate(coord);// 獲取給定坐標的像素 data.setPosition(position[0], position[1]);// 重新給節點設置像素坐標 } if (data instanceof ht.Shape) { var points = data.toPoints();// 構建一個新的Shape點集合併返回 data.getPoints().clear();// 清空點集合 data._points = new ht.List(); points.forEach(function(point, index) {// 給 shape 重新設置每一個點的像素坐標 point.x = map.getPixelFromCoordinate(data.a('pointCoord['+ index +']'))[0]; point.y = map.getPixelFromCoordinate(data.a('pointCoord['+ index +']'))[1]; data._points.add(point); }); data.setPoints(data._points); } }); graphView.validate();//刷新拓撲組件 }
場景搭建
OpenLayers 的 Map 部分做好了,接下來就是將它放進場景中了~但是從上面的截圖中能看到,除了地圖,頂部有工具條(但是我是用 formPane 表單組件做的),左側有一個可供拖拽的 Palette 面板組件,通過 HT 的 borderPane 邊框面板組件將整個場景布局好:
raphViewControl = new GraphViewControl();// 自定義控制項,作為 openlayers 地圖上自定義控制項 graphView = graphViewControl.getGraphView();// 獲取拓撲圖組件 dm = graphView.getDataModel();// 獲取拓撲圖中的數據容器 palette = new ht.widget.Palette();// 創建一個組件面板 formPane = createFormPane();// 工具條的 form 表單 borderPane = new ht.widget.BorderPane();// 邊框面板組件 borderPane.setTopView(formPane);// 設置頂部組件為 formPane borderPane.setLeftView(palette, 260);// 設置左邊組件為 palette 參數二為設置 該view的寬度 borderPane.setCenterView(mapDiv);// 設置中間組件為 mapDiv borderPane.addToDOM();// 將面板組件添加到 body 中
這樣整個場景的布局和顯示就完成了,非常輕鬆~
工具條
本身 HT 有自帶的工具條,但是因為 form 表單在排布以及樣式上面可以更靈活,所以採用這個。
var fp = new ht.widget.FormPane(); fp.setVGap(0);// 設置表單組件水平間距 默認值為6 fp.setHGap(0);// 設置表單的行垂直間距 默認值為6 fp.setHPadding(4);// 設置表單左邊和右邊與組件內容的間距,默認值為8 fp.setVPadding(4);// 設置表單頂部和頂部與組件內容的間距,默認值為8 fp.setHeight(40);// 設置表單高度 var btBgColor = '#fff', btnIconColor = 'rgb(159, 159, 159)', btnSelectColor = 'rgb(231, 231, 231)'; fp.addRow([// 添加行 首尾各加了一個'',並且占的寬度均為相對值0.1,就會將中間部分居中 '', { id: 'select',// id 唯一標示屬性,可通過 formPane.getItemById(id) 獲取添加到對應的 item 對象 button: {// ht.widget.Button 為按鈕類 background: btBgColor,// 設置背景顏色 icon: './symbols/icon/select.json',// 設置圖標 iconColor: btnIconColor,// 設置圖標顏色 selectBackground: btnSelectColor,// 設置選中背景顏色 togglable: true,// 設置按鈕是否處於開關狀態 groupId: 't',// 設置組編號,屬於同組的togglable按鈕具有互斥功能 toolTip: '編輯',// 設置文字提示,可通過 enableToolTip() 和 disableToolTip() 啟動和關閉文字提示 onClicked: function() {// 按鈕點擊觸發函數 editableFunc(); } } }, { id: 'pointLine', button: { background: btBgColor, icon: './symbols/icon/line.json', iconColor: btnIconColor, selectBackground: btnSelectColor, togglable: true, groupId: 't', toolTip: '連線', onClicked: function () { /** 通過 setInteractors 組合交互器 * DefaultInteractor實現Group、Edge和SubGraph圖元的默認雙擊響應,手抓圖平移,滾輪縮放,鍵盤響應等功能 * TouchInteractor實現移動設備上的Touch交互功能 * CreateEdgeInteractor 為 CreateEdgeInteractor.js 文件中自定義的連線交互器 * CreateShapeInteractor 為 CreateShapeInteractor.js 文件中自定義的多邊形交互器 **/ graphView.setInteractors([new ht.graph.DefaultInteractor(graphView), new ht.graph.TouchInteractor(graphView, { selectable: false }), new CreateEdgeInteractor(graphView)]); } } },'' ], [0.1, 36, 36, 0.1]);
上面的 form 表單中添加行我只列出了兩個功能,一個編輯的功能,另一個繪製連線的功能。formPane.addRow 為添加一行元素,參數一為元素數組,元素可為字元串、json 格式描述的組件參數資訊、html 元素或者為 null 的空,參數二為為每個元素寬度資訊數組,寬度值大於1代表固定絕對值,小於等於1代表相對值,也可為 80+0.3 的組合。
為了讓我想顯示的部分顯示在工具欄的正中央,所以我在第一項和最後一項都設置了一個空,占 0.1 的相對寬度,並且比例相同,所以中間的部分才會顯示在正中央。
上面程式碼通過 setInteractors 組合我們所需要的交互器。DefaultInteractor 實現 Group、Edge 和 SubGraph 圖元的默認雙擊響應,手抓圖平移,滾輪縮放,鍵盤響應等功能;TouchInteractor 實現移動設備上的 Touch 交互功能。至於最後面的 CreateEdgeInteractor 則是繼承於 ht.graph.Interactor 交互器的創建連線的交互器。這裡細細地分析一下這個部分,以後就可以修改或者自定義新的交互器。
自定義交互器
我們通過 ht.Default.def(className, superClass, methods) 定義類,並在 methods 對象中對方法和變數進行聲明。
setUp 方法在對象被創建的時候被調用,根據需求在這裡設置一些功能,我設置的是清除所有的選中的節點:
setUp: function () {// CreateEdgeInteractor 對象被創建的時候調用的函數 CreateEdgeInteractor.superClass.setUp.call(this);this._graphView.sm().cs();// 清除所有選中 }
tearDown 方法在對象結束調用的時候被調用,繪製連線的時候,如果未結束繪製怎麼辦?下一次繪製不可能連著上一次繼續繪製,所以我們得在結束調用這個類的時候將之前的繪製的點都清除:
tearDown: function () {// CreateEdgeInteractor 對象結束調用的時候調用的函數 CreateEdgeInteractor.superClass.tearDown.call(this); // 清除連線起點、終點以及連線中間的各個點 this._source = null; this._target = null; this._logicalPoint = null; }
關於滑鼠事件以及 touch 事件,我希望這兩者在操作上相同,所以直接在滑鼠事件中調用的 touch 事件的方法。
繪製連線需要滑鼠左鍵先選中一個節點,然後拖動滑鼠左鍵不放,移動滑鼠到連線的終點節點上,此時一條連線創建完畢。
首先是 touchstart 選中一個節點:
handle_mousedown: function (e) {// 滑鼠點下事件 this.handle_touchstart(e); }, handle_touchstart: function (e) {// 開始 touch this._sourceNode = this.getNodeAt(e);// 獲取事件下的節點 if (this._sourceNode) { this._targetNode = null;// 初始化 targetNode this.startDragging(e); this._graphView.addTopPainter(this);// 增加頂層Painter 使用Canvas的畫筆對象自由繪製任意形狀,頂層Painter繪製在拓撲最上面 this._graphView.sm().ss(this._sourceNode);// 設置選中 } }, getNodeAt: function(e){// 獲取事件下的節點 if (ht.Default.isLeftButton(e) && ht.Default.getTouchCount(e) === 1) {// 滑鼠左鍵被按下 && 當前Touch手指個數為1 var data = this._graphView.getDataAt(e);// 獲取事件下的節點 if(data instanceof ht.Node) return data;// 為 ht.Node 類型的節點 } return null; }
然後手指滑動 touchmove :
handleWindowMouseMove: function (e) { this.handleWindowTouchMove(e); }, handleWindowTouchMove: function (e) {// 手指滑動 var graphView = this._graphView;// 拓撲組件 this.redraw();// 如果不重新繪製矩形區域,那麼容易造成臟矩形 this._logicalPoint = graphView.getLogicalPoint(e);// 獲取事件下的邏輯坐標 this._targetNode = this.getNodeAt(e);// 獲取事件下的 edge 的終點 if (this._targetNode) graphView.sm().ss([this._sourceNode, this._targetNode]);// 設置起始和終止節點都被選中 else graphView.sm().ss([this._sourceNode]);// 只選中起始節點 }, redraw: function () { var p1 = this._sourceNode.getPosition(),// 獲取連線起始端的節點的坐標 p2 = this._logicalPoint; if (p1 && p2) { var rect = ht.Default.unionPoint(p1, p2);// 將點組合成矩形 ht.Default.grow(rect, 1);// 改變rect大小,上下左右分別擴展 extend 的大小 this._graphView.redraw(rect);// 重繪拓撲,rect參數為空時重繪拓撲中的所有圖元,否則重繪矩形範圍內的圖元 } }
最後 touchend 創建連線:
handleWindowMouseUp: function (e) { this.handleWindowTouchEnd(e); }, handleWindowTouchEnd: function (e) { if (this._targetNode) { var edge = new ht.Edge(this._sourceNode, this._targetNode);// 創建新的連線節點 if (this._edgeType) edge.s('edge.type', this._edgeType);// 設置連線的類型 this._graphView.dm().add(edge);// 將節點添加進數據容器 this._graphView.sm().ss(edge);// 設置選中您當前連線 } editableFunc();// 繪製結束後 工具條選中“編輯”項 this._graphView.removeTopPainter(this);// 移除頂層畫筆 }
至於還未創建連線之前(也就是說為選中終止節點),滑鼠在拖動的過程中會創建一條連線,這裡是直接用 canvas 繪製的:
draw: function (g) {// 繪製起點與滑鼠移動位置的連線 var p1 = this._sourceNode.getPosition(), p2 = this._logicalPoint; if(p1 && p2){ g.lineWidth = 1; g.strokeStyle = '#1ABC9C'; g.beginPath(); g.moveTo(p1.x, p1.y); g.lineTo(p2.x, p2.y); g.stroke(); } }
這樣,自定義連線類結束!
面板組件
左側面板組件 ht.widget.Palette 支援自定義樣式及單選、拖拽操作,由 ht.DataModel 驅動,用 ht.Group 展示分組,ht.Node 展示按鈕元素。
展示分組,首先得創建分組和組中的按鈕元素:
function initPalette(palette) {// 載入palette面板組件中的圖元 var nodeArray = ['city', 'equipment']; var nameArray = ['城市', '大型'];// arrNode中的index與nameArr中的一一對應 for (var i = 0; i < nodeArray.length; i++) { var name = nameArray[i]; nodeArray[i] = new ht.Group();// palette面板是將圖元都分在“組”裡面,然後向“組”中添加圖元即可 palette.dm().add(nodeArray[i]);// 向palette面板組件中添加group圖元 nodeArray[i].setExpanded(true);// 設置分組為打開的狀態 nodeArray[i].setName(name);// 設置組的名字 var imageArray = []; switch(i){ case 0: imageArray = ['symbols/5.json', 'symbols/6.json', 'symbols/叉車.json', 'symbols/公交車.json', 'symbols/人1.json', 'symbols/人2.json', 'symbols/人3.json', 'symbols/樹.json', 'symbols/樹2.json']; break; case 1: imageArray = ['symbols/飛機.json', 'symbols/吊機.json', 'symbols/卡車.json', 'symbols/貨輪.json', 'symbols/龍門吊.json', 'symbols/公園.json']; break; default: break; } setPaletteNode(imageArray, nodeArray[i], palette); } } function setPaletteNode(imageArray, array, palette) {// 創建 palette 上 節點及設置名稱、顯示圖片、父子關係 for (var i = 0; i < imageArray.length; i++) { var imageName = imageArray[i], name = imageName.slice(imageName.lastIndexOf('/')+1, imageName.lastIndexOf('.'));// 獲取最後一個 / 和最後一個.中間的文本,作為節點的 name createNode(imageName, name, array, palette);// 創建節點,顯示在 palette 面板上 } } function createNode(image, name, parent, palette) {// 創建palette面板組件上的節點 var node = new ht.Node(); palette.dm().add(node);// 將節點添加進 palette 的數據容器中 node.setImage(image);// 設置節點的圖片 node.setName(name);// 設置節點名稱 node.setParent(parent);// 設置節點的父親 node.s({// 設置節點的屬性 'draggable': true,// 如果Node的draggable設為true,Palette可以自動處理dragstart,但是dragover和drop事件需要我們處理 'image.stretch': 'centerUniform',// 圖片的繪製方式為非失真方式 }); return node; }
創建完後,我們就要啟用模擬的拖拽事件 handleDragAndDrop(e, state):
palette = new ht.widget.Palette();// 創建一個組件面板 var data; palette.handleDragAndDrop = function(e, state) {// 左側面板組件拖拽功能 if ( state === 'prepare' ) data = palette.getDataAt(e); else if( state === 'begin' || state === 'between' ) {} else { if (!ht.Default.containedInView(e, graphView)) return; // 判斷交互事件所處位置是否在graphView組件之上 var node = new ht.Node();// 拖拽到graphView中就創建一個新的節點顯示在graphView上 node.setImage(data.getImage());// 設置節點上貼圖 node.setName(data.getName());// 設置名稱(為了顯示在屬性欄中) node.s('label', '');// 在graphView中節點下方不會出現setName中的值,label優先順序高於name node.p(graphView.lp(e));// 將節點的位置設置為graphView事件下的拓撲圖中的邏輯坐標,即設置滑鼠點下的位置為節點坐標 graphView.dm().add(node);// 將節點添加進graphView中 graphView.sm().ss(node);// 默認選中節點 graphView.setFocus(node);// 設置將焦點聚集在該節點上 editableFunc();// 設置節點為可編輯狀態並且選中導航欄中的“編輯” } }
好了,先在你就可以直接從左側的 palette 面板組件上直接拖拽節點到右側的地圖上的 graphView 拓撲圖。
我們可以在 graphView 上進行繪製節點的編輯、繪製連線、繪製直角連線以及繪製多邊形。
最後
在上面基於 GIS 的電信資源管理系統的基礎上我嘗試了增加切換地圖的功能,同時還在導航欄上添加了“地鐵線路圖”,這個地鐵線路圖實現起來也是非常厲害的,下次我會再針對這個地鐵線路圖進行一次詳解,這裡就不多做解釋,來看看我添加後的最終結果:
http://www.hightopo.com/demo/openlayers/
如果有什麼建議或者意見,歡迎留言或者私信我,也可以直接去 HT for Web(https://hightopo.com/) 官網查閱相關資料。