基於 HTML5 WebGL 的高爐煉鐵廠可視化系統

// 三維拓撲視圖
let g2d = new ht.graph.GraphView();
let g3dDm = g2d.dm();
// 三維拓撲視圖
let g3d = new ht.graph3d.Graph3dView();
let g3dDm = g3d.dm();
// 2D 視圖組件和 3D 視圖組件進行反序列化
g2d.deserialize('displays/index.json');
g3d.deserialize('scenes/index.json');
在內容呈現上還需要將組件加入到 body 下,一般 2/3D 結合的項目上,都會使用 2D 組件加入到 3D 組件的根 div 下,然後 3D 組件再加入到 body下的方式實現面板與場景的載入。
// 將 3D 組件加入到 body 下
g3d.addToDOM();
// 將 2D 組件加入到 3D 組件的根 div 下,父子 DOM 事件會冒泡,這樣不會影響 3D 場景的交互
g2d.addToDOM(g3d.getView());
同時,在交互與呈現上改變了一些實現方式。例如,修改了左右鍵的交互方式,設置左鍵點擊旋轉 3D 場景,右鍵點擊為 pan 抓圖的場景移動方式。其次,在點擊 2D 有點到圖元像素時,我們希望不觸發 3D 的交互,例如在對 2D 面板表格中用滾輪滑動的時候,會觸發 3D 場景的縮放,這裡通過監聽 moudedown、touchstart 和 wheel 三種交互來進行控制,對於 wheel 的監聽方式,為了保證兼容性就通過封裝一個 getWheelEventName() 的方法來得到事件名。
// 修改左右鍵交互方式
let mapInteractor = new ht.graph3d.MapInteractor(this.g3d);
g3d.setInteractors([mapInteractor]);
// 設置修改最大仰角為 PI / 2
mapInteractor.maxPhi = Math.PI / 2;
// 避免 2D 與 3D 交互重疊
let div2d = g2d.getView();
const handler = e => {
if (g2d.getDataAt(e)) {
e.stopPropagation();
}
};
div2d.addEventListener('mousedown', handler);
div2d.addEventListener('touchstart', handler);
div2d.addEventListener(getWheelEventName(div2d), handler);
// 在一個 HTMLElement 上,可能支援下面三個事件的一種或者兩種,但實際回調只會回調一種事件,優先回調標準事件,觸發標準事件後,不會觸發兼容性事件
function getWheelEventName(element) {
if ('onwheel' in element) {
// 標準事件
return 'wheel';
} else if (document.onmousewheel !== undefined) {
// 通用舊版事件
return 'mousewheel';
} else {
// 舊版 Firefox 事件
return 'DOMMouseScroll';
}
}
// 昨日利用係數數據對接
axios.get('/yesterdayUse').then(res => {
setBindingDatasWithAnim(dm, res, undefined, v => v.toFixed(2));
});
// 昨日燃料比數據對接
axios.get('/yesterdayFuel').then(res => {
setBindingDatasWithAnim(dm, res, undefined, v => v.toFixed(2));
});
// 昨日入爐品位數據對接
axios.get('/yesterdayIn').then(res => {
setBindingDatasWithAnim(dm, res, undefined, v => v.toFixed(2));
});
// 昨日燃氣利用率數據對接
axios.get('/yesterdayCoal').then(res => {
setBindingDatasWithAnim(dm, res, undefined, v => v.toFixed(2));
});
// 實時警報資訊面板表格輪詢載入數據進行滾動播放
this.addTableRow();
setInterval(() => {
this.addTableRow();
}, 5000);
requestData() {
let dm = this.view.dm();
// 安全指數數據對接並載入圓環動畫
axios.get('/levelData').then(res => {
setBindingDatasWithAnim(dm, res, 800, v => Math.round(v));
});
// 實時數據(風量、風溫和富氧量)數據對接並載入進度條動畫
axios.post('/nature', [
'windNumber', 'windTemp', 'oxygenNumber'
]).then(res => {
setBindingDatasWithAnim(dm, res, 800, v => parseFloat(v.toFixed(1)));
});
}

對接數據後,實現一些圓環或者進度條值的增減動畫,其本質上是運用 HT 自帶的動畫函數 ht.Default.startAnim(),通過判斷數據綁定的屬性後,設定新值與舊值差額的範圍動畫,然後用戶定義函數 easing 參數通過數學公式來控制動畫的運動的快慢,例如勻速變化、先慢後快等效果。
這裡通過動畫函數封裝了一個差值的動畫效果,參數如下:
- node:動畫處理的節點;
- name:數據綁定的名稱;
- value:數據綁定的值;
- format:綁定數據值的格式規範;
- accesstype:數據綁定的屬性從屬 ;
- duration:動畫時間;
setValueWithAnimation (node, name, value, format, accesstype = 's', duration = 300) {
let oldValue;
// 判斷數據綁定為自定義屬性 attr 後根據綁定名字取出舊值
if (accesstype === 'a') {
oldValue = node.a(name);
}
// 判斷數據綁定為樣式屬性 style 後根據綁定名字取出舊值
else if (accesstype === 's') {
oldValue = node.s(name);
}
// 默認通過取值器 getter 得到數據綁定的值
else {
oldValue = node[ht.Default.getter(name)]();
}
// 設置新舊值的差額
let range = value - oldValue;
// 執行動畫函數
ht.Default.startAnim({
duration: duration,
easing: function (t) { return 1 - (--t) * t * t * t; },
action: (v, t) => {
// 新值增長的動畫範圍
let newValue = oldValue + range * v;
// 判斷有格式則制定數據格式
if (format) {
newValue = format(newValue);
}
// 判斷數據綁定為自定義屬性 attr 後設定新值
if (accesstype === 'a') {
node.a(name, newValue);
}
// 判斷數據綁定為樣式屬性 style 後設定新值
else if (accesstype === 's') {
node.s(name, newValue);
}
// 默認通過存值器 setter 設置數據綁定的新值
else {
node[ht.Default.setter(name)]()(node, newValue);
}
}
});
}
addTableRow() {
// 獲取表格節點
let table = this.right3;
// 通過 axios 的 promise 請求介面數據
axios.get('getEvent').then(res => {
// 獲取表格節點滾動資訊的數據綁定
let tableData = table.a('dataSource');
// 通過向 unshift() 方法可向滾動資訊數組的開頭添加一個或更多元素
tableData.unshift(res);
// 初始化表格的縱向偏移
table.a('ty', -54);
// 開啟表格滾動動畫
ht.Default.startAnim({
duration: 600,
// 動畫執行函數 action
action: (v, t) => {
table.a({
// 通過添加數據後,橫向滾動 100
'firstRowTx': 100 * (1 - v),
// 第一行行高出現的透明度漸變效果
'firstRowOpacity': v,
// 縱向偏移 54 的高度
'ty': (v - 1) * 54
});
}
});
});
}

hidePanel() {
// 將左側數據綁定裁剪的子元素存放進一個數組裡
let leftStartClipIndexs = (() => {
let arr = [];
for (let i = 1; i <= 4; i++) arr.push(this['left' + i].s('clip.percentage'));
return arr;
})();
// 將右側數據綁定裁剪的子元素存放進一個數組裡
let rightStartClipIndexs = (() => {
let arr = [];
for (let i = 1; i <= 3; i++) arr.push(this['right' + i].s('clip.percentage'));
return arr;
})();
// 設置面板裁剪的延遲時間,使得視覺上更有層次感
let delayArrays = [400, 800, 1200, 1600];
// 動畫執行函數
let action = (index) => {
ht.Default.startAnim({
duration: 700,
easing: Easing.swing,
action: (v, t) => {
this['left' + index].s('clip.percentage', leftStartClipIndexs[index - 1] + (0 - leftStartClipIndexs[index - 1]) * v);
this['right' + index].s('clip.percentage', rightStartClipIndexs[index - 1] + (0 - rightStartClipIndexs[index - 1]) * v);
}
});
};
// 通過判定延遲時間數組的長度,回調 action 動畫的執行
for (let i = 0, l = delayArrays.length; i < l; i++) {
ht.Default.callLater(action, this, [i + 1], delayArrays.shift());
}
}
data.s('clip.percentage') 是 HT 節點自帶的樣式屬性,其本質意義就是可以通過指定的方向進行對於整個矢量圖標的裁剪:

一部電影可以通過各種鏡頭的切換下呈現不盡相同的敘事效果,日劇夕陽下熱血跑的急速切換或者幽暗角落下驚恐的淡入淡出,都是一種敘事的處理手段。在 HT 設定的 3D 場景中同樣地也存在著許許多多敘述的手法,最為基礎的設定就是通過場景中的主觀眼睛 eye 和場景中心 center 來搭配各種動畫的實現,可以自己設定值的方法函數來修改,也可以通過 HT 自身封裝的方法函數來處理,例如 flyTo() 和 moveCamera() 就是最為基礎的相機動畫,有興趣的話可以了解一下,自己動手嘗試搭配,肯定能最大地發揮 3D 場景的優勢所在。

// 默認設置的眼睛視角數組
const ROAM_EYES = [
[1683.6555274005063, 939.9999999999993, 742.6554147474625],
[1717.1004359371925, 512.9256996098727, -1223.5575465999652],
[-181.41773461002046, 245.58303266170844, -2043.6755074222654],
[-1695.7113902533574, 790.0214102589537, -877.645744191523],
[-1848.1700283399357, 1105.522705042774, 1054.1519814237804],
[-108, 940, 1837]
];
// 開啟相機移動漫遊動畫
playRoam() {
// 設置場景眼睛視角
let eye = ROAM_EYES[this.roamIndex];
// 開啟相機視角移動動畫 moveCamera
this._roamAnim = this.view.moveCamera(eye, [0, 0, 0], {
duration: this.roamIndex ? 3000 : 4000,
easing: Easing.easeOut,
finishFunc: () => {
this.roamIndex ++;
let nextEye = ROAM_EYES[this.roamIndex];
// 判斷是否有下一組眼睛視角,有的話繼續執行相機視角移動動畫,反之則重置漫遊動畫
if (nextEye) {
this.playRoam();
}
else {
// 事件派發執行顯示面板動畫
event.fire(EVENT_SHOW_PANEL);
this.resetRoam();
}
}
});
}
如果說場景視角漫遊是一種大局整體觀的體現,那麼鐵水罐車裝載與運輸以及傳送帶的運送則是一個高爐煉鐵流程的拼圖。通過一系列動畫流程的表達,你會很清晰地發現,特定的 3D 場景下的講解說明具有完整的故事串聯性。
以下是鐵水罐車裝載與運輸的動畫流程:

在 3D 場景中是用 x, y, z 來分別表示三個軸,通過不斷修改節點的 3D 坐標就可以實現位移效果 car.setPosition3d(x, y, z),而對於鐵水罐車上的裝載標籤則使用吸附的功能,使其吸附在鐵水罐車上就能跟著一起行駛移動,然後在指定的空間坐標位置上通過 car.s('3d.visible', true | false) 來控制鐵水罐車的出現與隱藏的效果。

而關於傳送帶上煤塊、鐵礦的傳輸和管道氣體流通的指示,通過使用 UV 紋理貼圖的偏移來實現會方便很多,先來看看效果上的呈現:

對於三維模型,有兩個重要的坐標系統,就是頂點的位置坐標(X、Y、Z)以及 UV 坐標。形象地說,UV 就是貼圖影射到模型表面的依據,U 和 V 分別是圖片在顯示器水平、垂直方向上的坐標,取值一般都是0~1。而傳送帶以及管道的指示就是用這種方法實現的,HT 的模型節點自帶 uv 值的樣式屬性,我們只需要不斷地控制其偏移變化,就能實現傳輸的效果:
// 設置初始偏移值
let offset1 = 0, trackOffset = 0;
// 一直調用設置偏移值
setInterval(() => {
flows.each(node => {
node.s({
'top.uv.offset': [-offset1, 0],
'front.uv.offset': [-offset1, 0],
});
});
track.s('shape3d.uv.offset', [0, -trackOffset]);
// 偏移值增加
offset1 += 0.1;
trackOffset += 0.03;
}, 100);
