前言:大数据,人工智能,工业物联网,5G 已经或者正在潜移默化地改变着我们的生活。在信息技术快速发展的时代,谁能抓住数据的核心,利用有效的方法对数据做数据挖掘和数据分析,从数据中发现趋势,谁就能做到精准控制,实时分析,有的放矢,从而获取更快速、更平稳、更长远地发展。在航空领域,机场、航班和航线信息是至关重要的数据,本文将介绍以 HT 为平台,应用 JavaScript、HTML5、GIS 等技术开发的全球航线实例。
界面预览
- 主界面
- 飞机及飞机阴影动画
代码实现
- 场景搭建
本实例的场景包括 3D 和 2D 场景两部分,分别是通过 HT 的 3D 和 2D 编辑器构建,该编辑工具基于 HTML5 技术开发,易于上手,而且预定义了许多图元类型,用户可以无编码快速可视化搭建各种 3D/2D 场景。3D 场景效果如下:
2D 面板部分主要包括左侧航线表格,右侧风暴实时数据表格以及底部的信息面板。左侧航线表格展示了不同大洲的航线信息,大洲可以通过底部的左侧按钮进行切换;右测风暴信息是模拟生成,实时更新;底部信息栏包括大洲按钮及航线详细信息。面板截图:
- 航线来源及机场位置的计算
实例的机场和航线源数据来自于开源网站 openflights.org。拿到原始数据之后,我们首先对机场和航线数据进行了初步处理将其存为 JSON 文件。处理后的机场数据格式如下,每个域对应的信息依次是纬度、经度、海拔、机场简称、大洲、国家、地区和机场名字。
[[-9.443380356,147.2200012, 146,"POM","OC","PG","PG-NCD","Port Moresby"], [63.98500061,-22.60560036, 171,"KEF","EU","IS","IS-2","Reykjavík"], [36.001741,117.63201,0,"CN-0083","AS","CN","CN-U-A",""], … ]
处理后的航线数据片段格式如下,以第一条信息为例,航线的起始机场为 MIA,能够抵达的机场包括["3201:PUJ","24:MSY","24:MVD","24:NAS","24:ORF","24:PHL","24:PTP","24:PTY","24:RIC","24:SAL","24:SAN","24:SDQ","24:SFO","1299:AMS"]。
{"MIA":["3201:PUJ","24:MSY","24:MVD","24:NAS","24:ORF","24:PHL","24:PTP","24:PTY","24:RIC","24:SAL","24:SAN","24:SDQ","24:SFO","1299:AMS"], "HKG":["3021:SIN","1683:MNL","2994:ICN","15999:PVG","24:JFK","24:LAX","24:NRT","24:SFO","330:YVR","218:KIX","576:KUL","1680:SGN","328:POM"], "SJU":["3029:SXM","3029:TPA"], … }
通过对处理后的机场、航线数据分析,可以看出机场位置是生成航线的基础。在处理后的机场数据中,已经具备了机场的经纬度信息,因此问题的关键点在于如何将经纬度转换为球体坐标,转换代码如下:
// 将经纬度转换为球体位置 getSpherePos(radius, longitude, latitude) { let ang1 = Math.PI * (longitude - 90) / 180; let ang2 = Math.PI * latitude / 180; let x, y, z; let s_r = radius; x = s_r * Math.sin(ang1) * Math.cos(ang2); y = s_r * Math.cos(ang1) * Math.cos(ang2); z = s_r * Math.sin(ang2); return [x, y, z]; }
对所有机场数据循环处理,计算每个机场的球体坐标,并将坐标信息与其它既有的机场信息保存于全局数组中。
- 航线生成
在生成航线时,使用了 ht.Polyline 类型,该类型支持三维空间点描述,而且结合 segments 参数,实现了从二维平面曲线延伸到三维空间曲线的效果。在本实例中,根据航线的起点和终点的位置,利用向量运算构造出中间的控制点,生成贝塞尔曲线来渲染航线。航线创建并添加到 DataModel (通过 add 函数)之后, 调用 setHost(host) 函数将其吸附到地球,这样地球在移动或者旋转时,航线也会随之变化。以下为创建一条航线的代码实现:
/** * 根据航线起点,终点位置创建航线(贝塞尔曲线) * @param {Object} start 起点机场信息 * @param {Object} end 终点机场信息 */ createEdge(start, end) { let edge; let distance = ht.Default.getDistance(start.point, end.point); let ratio = distance / this.radius; let v1 = new ht.Math.Vector3(start.point); let v2 = new ht.Math.Vector3(end.point); let v3 = v1.clone().add(v2).setLength(distance / 2); let v4 = v3.clone().add(v2); v3.add(v1); edge = new ht.Polyline(); // 此处设置 edge 样式和属性的代码省略 edge.setPoints([ { x: start.point[0], y: start.point[2], e: start.point[1] }, { x: v3.x, y: v3.z, e: v3.y }, { x: v4.x, y: v4.z, e: v4.y }, { x: end.point[0], y: end.point[2], e: end.point[1] }, ]); edge.setSegments([1, 4]); this.dm3d.add(edge); edge.setHost(this.earth); }
这部分的难点在于如何根据航线的起点和终点位置构造中间控制点来生成贝塞尔曲线。下面的示意图演示了代码中向量的计算及各个向量变量的变化。
对所有航线数据循环处理,调用创建航线的 createEdge(start, end) 函数,就能完成所有航线的绘制生成。如图所示:
- 2D/3D 互动画线
在文章的第二幅图中,有一条黄色的线。这条线的起点对应着表格中选中的航线,终点对应着 3D 空间的航线。当点击表格中某条航线时,如何生成一条线,跨越 2D 和 3D 空间呢?本实例的思路是获取 3D 空间的位置坐标 p3 后,调用 g3d.toViewPosition 获取二维屏幕坐标 p,之后通过调用 g2d.getLogicalPoint 得到 2D 坐标,这个坐标就是终点的位置。以下是获取终点位置的代码实现:
// 获取定位线的终点 -- 3D 球体中选中航线对应的位置 getLineEnd() { let p3 = this.g3d.getLineOffset(this.selectedEdge, this.g3d.getLineLength(this.selectedEdge) * 0.5); let p = g3d.toViewPosition([p3.point.x, p3.point.y, p3.point.z]); p = this.g2d.getLogicalPoint(p); this.endPoint = p; }
线的起点位置代码如下,分别计算起点的横坐标和纵坐标。
// 获取定位线的起点 -- 航线表格对应的位置 getLineStart() { let offset = this.table.a('ht.translateY'); let lineStartPoint = {}; let height = this.table.getHeight(); let origY = this.table.p().y - height / 2 + this.table.a('ht.headHeight') + this.table.a("ht.rowHeight") / 2; lineStartPoint.x = this.table.p().x + this.table.getWidth() / 2; lineStartPoint.y = origY + this.rowIndex * this.table.a("ht.rowHeight") + offset; this.startPoint = lineStartPoint; }
- 飞机,飞机阴影动画及光源移动
在表格中选中某条航线或者双击地球上某条航线时,飞机将会沿着航线飞行,飞机上方有光源移动,下方有飞机阴影移动。这部分使用了 HT 内置的 startAni 函数启动动画。在 startAni 函数中,action 函数必须提供,实现动画过程中的属性变化;finishFunc 为动画结束后调用的函数。一个简单的动画例子如下:
ht.Default.startAnim({ frames: 60, interval: 16, finishFunc: function() { console.log('finish'); }, action: function(t) { console.log(t); } });
以下为本 Demo 中的 action 函数,该函数完成了动画过程中飞机、光源及飞机阴影的移动,飞机姿态调整和旋转。
action: function (v, t) { let offset = that.g3d.getLineOffset(that.selectedEdge, length * v); // 偏移量 let p1 = offset.point; // 3D 坐标 let tangent = offset.tangent; // 切线方向 let direction = new ht.Math.Vector3(tangent); let vp1 = new ht.Math.Vector3(p1); direction.multiplyScalar(0.1); direction.add(vp1); direction.setLength(direction.length() + 2); vp1.setLength(vp1.length() + 2); that.airPlane.p3(vp1.x, vp1.y, vp1.z); that.airPlane.setRotationMode('yxz'); that.airPlane.lookAtX([0, 0, 0], 'bottom'); that.airPlane.lookAtX([direction.x, direction.y, direction.z], 'front'); lightP = new ht.Math.Vector3(p1); lightP.setLength(that.radius * 2); that.spotLight.p3(lightP.x, lightP.y, lightP.z); direction.setLength(that.radius); lightP.setLength(that.radius); that.planeShadow.p3(lightP.x, lightP.y, lightP.z); that.planeShadow.setRotationMode('yxz'); that.planeShadow.lookAtX([0, 0, 0], 'back'); that.planeShadow.lookAtX([direction.x, direction.y, direction.z], 'right'); }
- 卫星动画
实例中,卫星按照椭圆轨道围绕地球旋转,Logo 和光晕又围绕卫星旋转。椭圆轨道的计算方式采用的是参数方程。假设椭圆的半长轴和半短轴的长度分别为 a 和 b,分别以半长轴和半短轴做椭圆的内切圆和外切圆。通过下图可以看出椭圆上任意一点 A 与内切圆上的 A1 点有相同的纵坐标,与外切圆上的 A2 点有相同的横坐标,所以 A 点的坐标就可以描述为 (a * cosθ,b * sinθ),其中 θ 是椭圆内切圆或者外切圆的圆心角。
Logo 和光晕的旋转使用了 3D 旋转函数,具体使用方法可以参照 HT 3D 手册 中的 3D 旋转函数部分。卫星动画的代码实现如下所示:
// 卫星及 Logo 的旋转 startSat() { let dm = this.dm3d; let a = 1226; // 椭圆半长轴 let b = 698; // 椭圆半短轴 let x, y, z; y = 0; let sat_ang = 0; // 卫星初始角度 let logo_ang = 0; // Logo 初始角度 setInterval(() => { sat_ang = sat_ang + this.satelliteSpeed; logo_ang = logo_ang + 0.01 x = a * Math.cos(-sat_ang); // 卫星当前 x 轴坐标 z = b * Math.sin(-sat_ang); // 卫星当前 z 轴坐标 y = x * Math.sin(Math.PI * 16 / 180); // 卫星当前 y 轴坐标 x = x * Math.cos(Math.PI * 16 / 180); // 卫星轨道面沿 z 轴旋转之后的新的 x 轴坐标 this.sat.p3(x, y, z); this.logo.setRotationY(logo_ang); this.logo.setRotationZ(28 / 180 * Math.PI); this.logo.setRotationMode('yzx'); this.sat_p.setRotationY(logo_ang); this.sat_p.setRotationZ(-35 / 180 * Math.PI); this.sat_p.setRotationMode('yzx'); }, 16.7); }
- 风暴动画
风暴动画使用 setInterval() 方法重复调用风暴动画部分,模拟风暴的移动,风暴变大及变小。风暴变大及变小的实现思路是设置两个 Flag 来判断风暴变大或者变小,风暴变大时,不断加大风暴在 x,y,z 轴方向的长度,并利用 setSize3d 函数赋值;风暴变小时,不断减小风暴在 x,y,z 轴方向的长度,并利用 setSize3d 函数赋值。风暴的移动代码实现如下:
// 风暴动画 startStorm() { let s_ang = 0; let s_ang2 = 0; let s_x, s_y, s_z; let s_r = 380.07; setInterval(() => { s_ang = s_ang + 0.002; s_ang2 = s_ang2 + 0.002; s_x = s_r * Math.sin(s_ang) * Math.cos(s_ang2); s_z = s_r * Math.cos(s_ang) * Math.cos(s_ang2); s_y = s_r * Math.sin(s_ang2); this.storm.p3(s_x, s_y, s_z); this.storm.lookAtX([0, 0, 0], 'bottom'); this.storm.setRotationMode('yzx'); this.storm.setRotationY(s_ang * 20); }, 60); }
性能优化
为带来更好的用户体验,本实例还进行了一系列的优化,使得实例的运行更加流畅,美观。
- 分批显示航线
在该实例中共有 2486 条航线,如果一次性显示在地球上,加上各种样式,那么不但加载速度非常缓慢,而且可能会因为内存过大而导致程序崩溃。因此,本实例采用了分批加载航线的方式,来提高系统性能。具体实现思路是在初次加载时,设置一个名称为 display_flag 的样式来控制航线的显示与否,然后每隔一定时间(本 Demo 中是每隔 30s)更新一次航线。相关代码如下:
this.maxDisplayCount = 300; // 30s 更新一次航线 this.MAX_DISPLAY_COUNT = 6; edge.s({ // 创建航线时 'display_flag': parseInt(Math.random() * 10) % this.MAX_DISPLAY_COUNT, }); start() { this.edgeTimer = setInterval(() => { this.edges.forEach((val) => { let showFlag = this.checkStormDistance(val); showFlag = showFlag && (val.s('display_flag') == this.displayFlag); val.s('3d.visible', showFlag) }); this.displayCount++; if (this.displayCount > this.maxDisplayCount) { this.displayFlag = (this.displayFlag + 1) % this.MAX_DISPLAY_COUNT; this.displayCount = 0; } }, 100); }
- Polyline resolution 动态改变
HT 通过微分段的方式实现曲线,参数 shape3d.resolution 用来控制曲线微分段数,这个参数决定 3D 图形精度,数值越大曲线越均匀,但同时会影响性能。在本 Demo 中,为防止飞机抖动 shape3d.resolution 设置为 60。但是这样设置之后,性能影响会很大,因此我们采用了动态调整 resolution 的方式,根据航线是否被选中动态调整,提高性能。代码如下。在 updateResolution 中也需要调用 g3d.invalidateCachedGeometry(data) 来重置 geometry,更新方法见 “Polyline cache 以及更新方法” 部分。
// 动态改变 resolution updateResolution(isRestore) { if (!this.selectedEdge) { // 没有航线被选中 return; } let res, thickness; let len = this.g3d.getLineLength(this.selectedEdge); if (isRestore) { // 需要恢复默认值 res = 30; thickness = 0.7; } else { res = len / 200 * 30; if (res < 60) { res = 60; } thickness = 5; } this.selectedEdge.s('shape3d.resolution', res); this.selectedEdge.setThickness(thickness); }
- Polyline cache 以及更新方法
如前所述,本 Demo 中创建了 2486 条航线,每条航线都是一个 ht.polyLine 类型的 3D 曲线。为提高性能,在创建航线时,将其属性 geometry.cache 设置为 true。在后续 polyLine 的属性(例如 points, segments, width)发生变化时,使用 g3d.invalidateCachedGeometry(data) 来重置 geometry。
// 创建航线时设置属性 edge.s({ 'geometry.cache': true }); // this.selectedEdge 属性发生变化时,重置 geometry。 let ui = g3d.getData3dUI(this.selectedEdge); ui.shapeModel = ui.info = null; this.g3d.invalidateData(this.selectedEdge);
- 有效大洲中心添加辅助定位用的立方体
在有效的大洲中心位置添加一个辅助定位用的立方体,当点击大洲按钮时,使用 flyTo() 函数调整球体视角。
- 2D/3D 互动画线调用 setTimeout
当 2D/3D 定位线显示在面板后,用户每次移动界面,定位线都需要重新计算和绘制。考虑到移动界面触发这个事件的频率非常高,如果每次都响应,那么程序将会变得非常繁忙,出现卡顿现象;甚至可能造成事件丢失的情况,比如出现用户已经停止了移动,线却没有画到位的现象。因此使用 setTimout 保证更新的最短间隔为 50ms,去掉不必要的更新。当然这个间隔可以根据实际情况调整,以降低视觉上的迟钝感。
this.updateTimer = setTimeout(() => { this.updateTimer = null; if (this.selectedEdge == null) { // 没有航线被选中 return; } this.getLineEnd(); // 计算 2D/3D 定位线的终点 this.updateLine(true); // 绘制定位线 }, 50);
有了 2D 和 3D 场景,按照文中介绍的思路和逻辑,就可以完成动画的生成,航线数据加载,航线可视化,飞机态势可视化和风暴数据的实时显示,整个过程其乐无穷。
基于航空大数据,在本实例中提到的数据分析和可视化的基础上,还可以挖掘更多的应用场景,比如航班运行数据可视化,飞机数据实时展示,航班历史数据分析,应急航线调度等。如果想了解更多工业互联网 2D, 3D 可视化应用案例,可以到这里参考更多 http://www.hightopo.com/blog/1103.html 《分享数百个 HT 工业互联网 2D 3D 可视化应用案例之 2019 篇》。