基於 HTML5 的工控物聯網的隧道監控實戰

  • 2019 年 10 月 16 日
  • 筆記

前言

監控隧道內的車道堵塞情況、隧道內的車禍現場,在隧道中顯示當前車禍位置並在隧道口給與提示等等功能都是非常有必要的。這個隧道 Demo 的主要內容包括:照明、風機、車道指示燈、交通訊號燈、情報板、消防、火災報警、車行橫洞、風向儀、微波車檢、隧道緊急逃生出口的控制以及事故模擬等等。

效果圖

http://www.hightopo.com/demo/tunnel2/index.html

上圖中的各種設備都可以雙擊,此時 camera 的位置會從當前位置移動到雙擊的設備的正前方;隧道入口的展示牌會自動輪播,出現事故時會展示牌中的內容會由“限速80,請開車燈”變為“超車道兩車追尾,請減速慢行”;兩隧道中間的逃生通道上方的指示牌是可以點擊的,點擊切換為藍綠色激活狀態,兩旁的逃生通道門也會打開,再單擊指示牌變為灰色,門關閉;還有一個事故現場模擬,雙擊兩旁變壓器中其中一個,在隧道內會出現一個“事故現場圖標”,單擊此圖標,出現彈出框顯示事故等等等等。

程式碼實現

場景搭建

整個隧道都是基於 3D 場景上繪製的,先來看看怎麼搭建 3D 場景:

dm = new ht.DataModel();//數據容器  g3d = new ht.graph3d.Graph3dView(dm);// 3d 場景  g3d.addToDOM();//將場景添加到 body 中

上面程式碼中的 addToDOM 函數,是一個將組件添加到 body 體中的函數的封裝,定義如下:

複製程式碼
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);//窗口大小改變事件,調用刷新函數  }
複製程式碼

JSON 反序列化

整個場景是由名為 隧道1.json 的文件導出而成的,我只需要用程式碼將 json 文件中的內容轉換為我需要的部分即可:

ht.Default.xhrLoad('./scenes/隧道1.json', function(text) {//xhrLoad 函數是一個非同步載入文件的函數      var json = ht.Default.parse(text);//將 json 文件中的文本轉為我們需要的 json 格式的內容      dm.deserialize(json);//反序列化數據容器,解析用於生成對應的Data對象並添加到數據容器 這裡相當於把 json 文件中生成的 ht.Node 節點反序列化到數據容器中,這樣數據容器中就有這個節點了  });

由於 xhrLoad 函數是一個非同步載入函數,所以如果 dm 數據容器反序列化未完成就直接調用了其中的節點,那麼會造成數據獲取不到的結果,所以一般來說我是將一些邏輯程式碼寫在這個函數內部,或者給邏輯程式碼設置 timeout 錯開時間差。

首先,由於數據都是存儲在 dm 數據容器中的(通過 dm.add(node) 添加的),所以我們要獲取數據除了可以通過 id、tag 等獨立的方式,還可以通過遍曆數據容器來獲取多個元素。由於這個場景比較複雜,模型的面也比較多,鑒於設備配置,我將能 Batch 批量的元素都進行了批量:

複製程式碼
dm.each(function(data) {      if (data.s('front.image') === 'assets/sos電話.png'){//對“電話”進行批量          data.s('batch', 'sosBatch');      }      else if (data.s('all.color') === 'rgba(222,222,222,0.18)') {//逃生通道批量(透明度也會影響性能)          data.s('batch', 'emergencyBatch');      }      else if (data.s('shape3d') === 'models/隧道/攝影機.json' || data.s('shape3d') === 'models/隧道/橫洞.json' || data.s('shape3d') === 'models/隧道/捲簾門.json') {          if(!data.s('shape3d.blend'))//個別攝影機染色了 不做批量              data.s('batch', 'basicBatch');//基礎批量什麼也不做      }      else if (data.s('shape3d') === 'models/大型變壓器/變壓器.json') {          data.s('batch', 'tileBatch');          data.setToolTip('單擊漫遊,雙擊車禍地點出現圖標');      }      else if (data.getDisplayName() === '地面') {          data.s('3d.selectable', false);//設置隧道“地面”不可選中      }      else if (data.s('shape3d') === 'models/隧道/排風.json') {          data.s('batch', 'fanBatch');//排風扇的模型比較複雜,所以做批量      }      else if (data.getDisplayName() === 'arrow') {//隧道兩旁的箭頭路標          if (data.getTag() === 'arrowLeft') data.s('shape3d.image', 'displays/abc.png');          else data.s('shape3d.image', 'displays/abc2.png');          data.s({              'shape3d': 'billboard',              'shape3d.image.cache': true,//快取,設置了 cache 的代價是需要設置 invalidateShape3dCachedImage              'shape3d.transparent': true //設置這個值,圖片上的鋸齒就不會太明顯了(若圖片類型為 json,則設置 shape3d.dynamic.transparent)          });          g3d.invalidateShape3dCachedImage(data);      }      else if (data.getTag() === 'board' || data.getTag() === 'board1') {//隧道入口處的情報板          data.a('textRect', [0, 2, 244, 46]); //業務屬性,用來控制文本的位置[x,y,width,height]          data.a('limitText', '限速80,請開車燈');//業務屬性,設置文本內容          var min = -245;          var name = 'board' + data.getId();          window[name] = setInterval(function() {              circleFunc(data, window[name], min)//設置情報板中的文字向左滾動,並且當文字全部顯示時重複閃爍三次          }, 100);      }        //給逃生通道上方的指示板 動態設置顏色      var infos = ['人行橫洞1', '人行橫洞2', '人行橫洞3', '人行橫洞4', '車行橫洞1', '車行橫洞2', '車行橫洞3'];      infos.forEach(function(info) {          if(data.getDisplayName() === info) {              data.a('emergencyColor', 'rgb(138, 138, 138)');          }      });        infos = ['車道指示器', '車道指示器1', '車道指示器2', '車道指示器3'];      infos.forEach(function(info) {          if (data.getDisplayName() === info) {              createBillboard(data, 'assets/車道訊號-過.png', 'assets/車道訊號-過.png', info)//考慮到性能問題 將六面體變換為 billboard 類型元素          }      });  });
複製程式碼

上面有一處設置了 tooltip 文字提示資訊,在 3d 中,要顯示這個文字提示資訊,就需要設置 g3d.enableToolTip() 函數,默認 3d 組件是關閉這個功能的。

邏輯程式碼

情報板滾動條

我就直接按照上面程式碼中提到的方法進行解釋,首先是 circleFunc 情報板文字循環移動的函數,在這個函數中我們用到了業務屬性 limitText 設置情報板中的文字屬性以及 textRect 設置情報板中文字的移動位置屬性:

複製程式碼
function circleFunc(data, timer, min) {//設置情報板中的文字向左滾動,並且當文字全部顯示時重複閃爍三次      var text = data.a('limitText');//獲取當前業務屬性 limitText 的內容      data.a('textRect', [data.a('textRect')[0]-5, 2, 244, 46]); //設置業務屬性 textRect 文本框的坐標和大小      if (parseInt(data.a('textRect')) <= parseInt(min)) {          data.a('textRect', [255, 2, 244, 46]);      }      else if (data.a('textRect')[0] === 0) {          clearInterval(timer);          var index = 0;          var testName = 'testTimer' + data.getId();//設置多個 timer 是因為能夠進入這個函數中的不止一個 data,如果在同一時間多個 data 設置同一個 timer,那肯定只會對最後一個節點進行動畫。後面還有很多這種陷阱,要注意          window[testName] = setInterval(function() {              index++;              if(data.a('limitText') === '') {//如果情報板中文本內容為空                  setTimeout(function() {                      data.a('limitText', text);//設置為傳入的 text 值                  }, 100);              }              else {                  setTimeout(function() {                      data.a('limitText', ''); //若情報板中的文本內容不為空,則設置為空                  }, 100);              }                if(index === 11) { //重複三次                  clearInterval(window[testName]);                  data.a('limitText', text);              }          }, 100);            setTimeout(function() {              timer = setInterval(function() {                  circleFunc(data, timer, min) //回調函數              }, 100);          }, 1500);      }  }
複製程式碼

由於 WebGL 對瀏覽器的要求不低,為了能盡量多的適應各大瀏覽器,我們將所有的“道路指示器” ht.Node 類型的六面體全部換成 billboard 類型的節點,性能能提升不少。

http://www.hightopo.com

設置 billboard 的方法很簡單,獲取當前的六面體節點,然後給這些節點設置:

node.s({      'shape3d': 'billboard',      'shape3d.image': imageUrl,      'shape3d.image.cache': true  });  g3d.invalidateShape3dCachedImage(node); //還記得用 shape3d.image.cache 的代價么?

當然,因為 billboard 不能雙面顯示不同的圖片,只是一個“面”,所以我們還得在這個節點的位置創建另一個節點,在這個節點的“背面”顯示圖片,並且跟這個節點的配置一模一樣,不過位置要稍稍偏移一點。

Camera 緩慢偏移

其他動畫部分比較簡單,我就不在這裡多說了,這裡有一個雙擊節點能將視線從當前 camera 位置移動到雙擊節點正前方的位置的動畫我提一下。我封裝了兩個函數 setEye 和 setCenter,分別用來設置 camera 的位置和目標位置的:

複製程式碼
function setCenter(center, finish) {//設置“目標”位置      var c = g3d.getCenter().slice(0), //獲取當前“目標”位置,為一個數組,而 getCenter 數組會在視線移動的過程中不斷變化,所以我們先拷貝一份          dx = center[0] - c[0], //當前x軸位置和目標位置的差值          dy = center[1] - c[1],          dz = center[2] - c[2];      // 啟動 500 毫秒的動畫過度      ht.Default.startAnim({          duration: 500,          action: function(v, t) {              g3d.setCenter([ //將“目標”位置緩慢從當前位置移動到設置的位置處                  c[0] + dx * v,                  c[1] + dy * v,                  c[2] + dz * v              ]);          }      });  };    function setEye(eye, finish) {//設置“眼睛”位置      var e = g3d.getEye().slice(0),//獲取當前“眼睛”位置,為一個數組,而 getEye 數組會在視線移動的過程中不斷變化,所以我們先拷貝一份          dx = eye[0] - e[0],          dy = eye[1] - e[1],          dz = eye[2] - e[2];        // 啟動 500 毫秒的動畫過度      ht.Default.startAnim({          duration: 500,          action: function(v, t) {//將 Camera 位置緩慢地從當前位置移動到設置的位置              g3d.setEye([                  e[0] + dx * v,                  e[1] + dy * v,                  e[2] + dz * v              ]);          }      });  };
複製程式碼

後期我們要設置的時候就直接調用這兩個函數,並設置參數為我們目標的位置即可。比如我這個場景中的各個模型,由於不同視角對應的各個模型的旋轉角度也不同,我只能找幾個比較有代表性的 0°,90°,180°以及360° 這四種比較典型的角度了。所以繪製 3D 場景的時候,我也盡量設置節點的旋轉角度為這四個中的一種(而且對於我們這個場景來說,基本上只在 y 軸上旋轉了):

複製程式碼
var p3 = e.data.p3(), //獲取事件對象的三維坐標      s3 = e.data.s3(),//獲取事件對象的三維尺寸      r3 = e.data.r3();//獲取事件對象的三維旋轉值    setCenter(p3);//設置“目標”位置為當前事件對象的三維坐標值  if (r3[1] !== 0) {//如果節點的 y 軸旋轉值 不為 0      if (parseFloat(r3[1].toFixed(5)) === parseFloat(-3.14159)) { //浮點負數得做轉換才能進行比值          setEye([p3[0], p3[1]+s3[1], p3[2] * Math.abs(r3[1]*2.3/6)]);//設置camera 的目標位置      }      else if (parseFloat(r3[1].toFixed(4)) === parseFloat(-1.5708)) {          setEye([p3[0] * Math.abs(r3[1]/1.8), p3[1]+s3[1], p3[2]]);      }      else {          setEye([p3[0] *r3[1], p3[1]+s3[1], p3[2]]);      }  }  else {      setEye([p3[0], p3[1]+s3[1]*2, p3[2]+1000]);  }
複製程式碼

事故模擬現場

最後來說說模擬的事故現場吧,這段還是比較接近實際項目的。操作流程如下:雙擊“變壓器”–>隧道中間某個部分會出現一個“事故現場”圖標–>單擊圖標,彈出對話框,顯示當前事故資訊–>點擊確定,則事故現場之前的燈都顯示為紅色×,並且隧道入口的情報板上的文字顯示為“超車道兩車追尾,請減速慢行”–>再雙擊一次“變壓器”,場景恢復事故之前的狀態。

在 HT 中,可通過 Graph3dView#addInteractorListener(簡寫為 mi)來監聽交互過程:

複製程式碼
g3d.addInteractorListener(function(e) {      if(e.kind === 'doubleClickData') {          if (e.data.getTag() === 'jam') return;//有“事故”圖標節點存在          if (e.data.s('shape3d') === 'models/大型變壓器/變壓器.json') {//如果雙擊對象是變壓器              index++;              var jam = dm.getDataByTag('jam');//通過唯一標識tag標籤獲取“事故”圖標節點對象              if(index === 1){                  var jam = dm.getDataByTag('jam');                  jam.s({                      '3d.visible': true,//設置節點在 3d 上可見                      'shape3d': 'billboard',//設置節點為 billboard 類型                      'shape3d.image': 'assets/車禍.png', //設置 billboard 的顯示圖片                      'shape3d.image.cache': true,//設置 billboard 圖片是否快取                      'shape3d.autorotate': true,//是否始終面向鏡頭                      'shape3d.fixSizeOnScreen': [30, 30],//默認保持圖片原本大小,設置為數組模式則可以設置圖片顯示在介面上的大小                  });                  g3d.invalidateShape3dCachedImage(jam);//cache 的代價是節點需要設置這個函數               }               else {                   jam.s({                       '3d.visible': false//第二次雙擊變壓器就將所有一切恢復“事故”之前的狀態                  });                  dm.each(function(data) {                      var p3 = data.p3();                      if ((p3[2] < jam.p3()[2]) && data.getDisplayName() === '車道指示器1') {                          data.s('shape3d.image', 'assets/車道訊號-過.png');                      }                      if(data.getTag() === 'board1') {                          data.a('limitText', '限速80,請開車燈');                      }                  });                  index = 0;              }            }      }  });
複製程式碼

 既然“事故”節點圖標出現了,接著點擊圖標出現“事故資訊彈出框”,監聽事件同樣是在 mi(addInteractorListener)中,但是這次監聽的是單擊事件,我們知道,監聽雙擊事件時會觸發一次單擊事件,為了避免這種情況,我在單擊事件裡面做了延時:

複製程式碼
else if (e.kind === 'clickData'){//點擊圖元      timer = setTimeout(function() {          clearTimeout(timer);          if (e.data.getTag() === 'jam') {//如果是“事故”圖標節點              createDialog(e.data);//創建一個對話框          }      }, 200);  }
複製程式碼

在上面的雙擊事件中我沒有 clearTimeout,怕順序問題給大家造成困擾,要記得加一下。

彈出框如下:

這個彈出框是由兩個 ht.widget.FormPane 表單構成的,左邊的表單只有一行,行高為 140,右邊的表單是由 5 行構成的,點擊確定,則“事故”圖標節點之前的道路指示燈都換成紅色×的圖標:

複製程式碼
function createForm4(node, dialog) {//彈出框右邊的表單      var form = new ht.widget.FormPane();//表單組件      form.setWidth(200);//設置表單組件的寬      form.setHeight(200);//設置表單組件的高      var view = form.getView();//獲取表單組件的底層 div      document.body.appendChild(view);//將表單組件添加到 body 中        var infos = [          '編輯框內容為:2輛',          '編輯框內容為:客車-客車',          '編輯框內容為:無起火',          '編輯框內容為:超車道'      ];      infos.forEach(function(info) {          form.addRow([ //向表單中添加行              info          ], [0.1]);//第二個參數為行寬度,小於1的值為相對值      });        form.addRow([          {              button: {//添加一行的“確認”按鈕                  label: '確認',                  onClicked: function() {//按鈕點擊事件觸發                      dialog.hide();//隱藏對話框                      dm.each(function(data) {                          var p3 = data.p3();                          if ((p3[2] < node.p3()[2]) && data.getDisplayName() === '車道指示器1') {//改變“車道指示器”的顯示圖片為紅色×,這裡我是根據“事故”圖標節點的坐標來判斷“車道顯示器”是在前還是在後的                              data.s('shape3d.image', 'assets/車道訊號-禁止.png');                          }                          if(data.getTag() === 'board1') {//將隧道口的情報板上的文字替換                              data.a('limitText', '超車道兩車追尾,請減速慢行');                          }                      });                  }              }          }      ], [0.1]);      return form;  }
複製程式碼

結束語

這個工業隧道的 Demo 是我通過幾天不斷地完善完善而成的,可能還是有不足的地方,但是總體來說我是挺滿意的了,可能之後還會繼續完善,也得靠大家不斷地給我意見和建議,我只希望在自己努力的同時也可以幫助到別人。整個 Demo 中,我主要遇到了兩個問題,一個是我在程式碼中提到過的設置 timer 的問題,多個節點如果同時用一個 timer,那就只有最後一個節點能夠顯示出 timer 的效果;另一個是 getEye 和 getCenter 的問題,這兩個值都是在不斷變化的,所以得先拷貝一份數據,再進行數據的變換。