前言
通過結合 HTML5 和 OpenLayers 可以組合成非常棒的一個電信地圖網路拓撲圖的應用,形成的效果可以用來作為電信資源管理系統,美食定位分享軟體,片區找房,繪製鐵軌線路等等,各個領域都能夠涉及的一款應用。雖然這個 Demo 是結合 OpenLayers3 的,其實還可推廣到與 ArcGIS、百度地圖以及 GoogleMap 等眾多 GIS 地圖引擎融合。
http://www.hightopo.com/demo/openlayers/
程式碼生成
建立地圖
OpenLayers 是一個用於開發 WebGIS 客戶端的 JavaScript 包。OpenLayers 支援的地圖來源包括 Google Maps、Yahoo、 Map、微軟 Virtual Earth 等多種離線線上地圖,這裡用到的是比較大眾化的谷歌地圖 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({// 谷歌地圖 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(https://hightopo.com/guide/guide/core/beginners/ht-beginners-guide.html#ref_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 皮膚元件(https://hightopo.com/guide/guide/plugin/palette/ht-palette-guide.html),通過 HT 的 borderPane 邊框皮膚元件(https://hightopo.com/guide/guide/core/borderpane/ht-borderpane-guide.html)將整個場景佈局好:
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 表單(https://hightopo.com/guide/guide/plugin/form/ht-form-guide.html)在排布以及樣式上面可以更靈活,所以採用這個。
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 (https://hightopo.com/guide/guide/plugin/palette/ht-palette-guide.html)支援自定義樣式及單選、拖拽操作,由 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/)查閱相關資料。