基于 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);