本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
记得初次接触到台风路径是2016年海马台风登录,广州全市停工、学校停课、地铁停运的时候,那时躲在公寓里哪也不敢去,微信群里有人发了个h5页面可以关注到全国台风的实时动向,预测到几点台风过境离境了,觉得技术大佬们还挺厉害的。如今刚好有项目也需要集成气象数据到数据可视化大屏上,我也试着用高德地图API重新实现了这个图层,接下来让我们从需求出发逐步了解实现的过程。
需求分析
- 能够稳定地从可靠的气象数据源获取台风的实时位置、移动速度、强度等数据,确保数据的准确性和及时性
- 可以通过不同的颜色区分台风的强度等级,例如,强台风用红色表示,中等台风用橙色表示,弱台风用黄色表示等
- 随着地图的缩放,台风路径图层能够自动调整显示效果,确保在不同比例尺下都能清晰展示
- 用户可以点击台风图标或路径上的某一点,获取该位置的台风详细信息,如风速、气压、预计到达时间
- 用户可以方便地开启或关闭台风路径图层,以及与其他地图图层进行叠加显示的控制
- 2D的台风气象图,能够适配地图的3D模式,实现两个模式的无缝切换
技术栈说明
| 工具名称 | 版本 | 用途 |
|---|---|---|
| 高德地图 JSAPI | 2.0 | 为本次案使用到的高德API有LabelsLayer、LabelMarker、Polyline、Marker、CanvasLayer等 |
| three.js | 0.157 | 主流webGL引擎之一,负责实现展示层面的功能 |
| 船讯网 | - | 船讯网沿海气象专题有世界台风数据可供我们参考学习 |
| 天地图 | 4.0 | 提供地图瓦片服务 |
实现步骤
-
获取台风数据,船讯网提供了两个接口用于获取台风数据,GetTyphoonList用于获取近三年历史台风列表List,GetSingleTyphoon?tid=${台风序号} 用于获取单个台风路径关键节点信息Detail。
以Detail为我们可以获取到如下格式的路径数据,这里有必要对关键的几个属性进行讲解,这些信息在接口文档没有写清楚,我是找到开发团队询问了才知道的。😂
/* * 1. time为关键时间点标识(格林威治时区的年月日时分,转为北京时间需要加上8小时时差), * 比如202408221500, time的值可以重复,若出现重复值需要看forast; * 2. forcast标识了当前节点是否为预测点,如果值为空则表示真实发生点,其余为预测点,如何区分 * 预测点的事件,此时需要看fhour; * 3. fhour为预测时间范围,比如值为8,则表示time+fhour为当前预测点的预测时间, * 4. grade为风级标识,6-7为热带低压、8-9为热带风暴、10-11为强热带风暴、12-13为台风、 * 14-15为强台风、>=16为超强台风。 */ const GetSingleTyphoonRespon =[ { time, forcast, fhour, lon, lat, grade, direction ...}, { time, forcast, fhour, lon, lat, grade, direction ...}, { time, forcast, fhour, lon, lat, grade, direction ...}, // ... ]我们可以提前将time值相同的数据合并为一个结构体数值NodeList,代表一个关键的路径节点,其中NodeList[0]为真实点,其余的成员NodeList[*]为预测点
-
是否做数据二次处理?由于台风数据使用的是WGS84的坐标系,而高德地图使用的是GCJ02坐标系,如果直接使用高德底图,需要将获取到的气象数据进行一次坐标系转换,以便在地图上准确展示;当然还有另一种做法,就是直接换地铁图,我们不做数据二次处理了,这里直接使用与之匹配的天地图卫星影像作为底图。
-
绘制历史的台风路径,根据关键节点的风力级别,绘制不同颜色的路径轨迹,我们历从上一步获取的路径关键节点即可绘制如下路径,通过对grade变换点和最终点的条件判断,就可以根据grade风力级别绘制不同颜色的台风路径。
-
绘制路径上的关键节点,每个路径节点都需要有一个标记点,这里一开始我使用的是Marker绘制展示,后来发现有些大型的台风数据居居然有成百上千个节点,需要考虑到性能问题,就换成了可以支持高性能的海量标注LabelMarker去实现,代价就是不能单独定义节点的颜色。将节点信息存入extData,并给每个labelMarker单独做鼠标事件监听即可实现节点信息查看。
-
绘制预测的台风路径,点击每个关键节点时,能够绘制当前节点的预测路径,通过节点的time属性我们就能找到同一簇的预测节点,按fhour排序即可得到预测路径数据,按照前两步直接绘制即可。值得注意的是当前仅存在1个节点的预测路径,所以要将上一次预测路径清除后再渲染。
-
绘制台风风圈和风眼动画,并使之能够在3D模式下贴地显示。初次看到这个代表风圈的数据我是比较懵逼的,这些个数字跟地图上奇怪的风圈是怎么对应上。
后来经过搜索了解到风圈的威力是由东北EN,东南ES,西南WS,西北WN这个象限的圆圈半径表示的,在7级、10级、12级的程度上都可以有一个风圈,比如下图最外圈为7级风圈,按照1-4的顺序找到radius7_s对应的半径数值绘制1/4的圆最后合并就是7级风圈了,以此类推。
在这里我们可以使用高德的CanvasLayer进行绘制,主要是因为它可以做动画且可以兼容二维和三维模式,绘制方法其实很简单,知道了每个象限的半径,就知道了每个关键顶点的坐标,如下图所示,我们只要按顺序连线绘制封闭图形并填充就可以了;绘制完风圈后再在中心置入台风图标图片,并使用逐帧函数调整旋转角度,形成旋转动画。关于风圈如何绘制我写了个小Demo可供调试
-
封装代码,实现台风图层的显示隐藏,台风数据的切换功能。 这里的图层显示、隐藏、事件监听、事件派发、清空图层都是属于常规功能了,继承图层基类后将对应的方法覆盖即可;而台风数据切换则是用新的数据更新图层,只要清空当前图层元素后重新绘制即可,需要注意的点是把旧元素销毁后置空,及时释放内存。
代码实现
-
整理数据,将相同时间点的数据整理为簇,方便后续使用
groupAndSortData(data) { return data.reduce((result, item) => { const time = item.time; if (!result[time]) { result[time] = []; } // 格式化时间 item.formatTime = convertGMTToBeijingTime(time, parseInt(item.fhour || 0)); result[time].push(item); result[time].sort((a, b) => { return parseInt(a.fhour || 0) - parseInt(b.fhour || 0); }); return result; }, {}); } //整理前 /* [ { time:202408211800, forecast:"", fhour: "72", ... }, { time:202408211800, forecast:"BABJ", fhour: "120" ... }, { time:202408211800, forecast:"BABJ", fhour: "48" ... }, { time:202408211800, forecast:"BABJ", fhour: "24" ... }, { time:202408211200, forecast:"", fhour: "" ... }, ] */ //整理后 /* { 202408211800: [ {fhour: "24", forecast, ...}, {fhour: "48", forecast, ...}, {fhour: "72", forecast, ...}, {fhour: "120", forecast, ...}, ], 2024081200: [ ... ] } */ -
绘制台风路径
render() { const {map, paths, markers, geometries, visible} = this; const dataArr = Object.values(paths || []); // 上一节点的状态 let currLevel = null; let path = []; const nodes = []; // 遍历所有节点 dataArr.forEach((arr, index) => { const {lon, lat, grade} = arr[0]; if (path.length <= 1) { currLevel = TyphoonConf.getLevel(grade); } path.push([parseFloat(lon), parseFloat(lat)]); if (index === 0) { // 第一个节点 } else if (index === dataArr.length - 1) { // 最后一个节点,绘制线 const polyline = this.generatePolyLine(path, currLevel, {forecast: false}); geometries.push(polyline); map.add(polyline); // 渲染最后节点的预测路径 this.renderForecast(arr); // 添加台风动画 this.updateMarker(arr[0]); } else { const nextNode = dataArr[index + 1][0]; const nextNodeLevel = TyphoonConf.getLevel(nextNode.grade); if (nextNodeLevel.name !== currLevel.name) { // 级别切换了,绘制线 const polyline = this.generatePolyLine(path, currLevel, {forecast: false}); geometries.push(polyline); map.add(polyline); path = [[lon, lat]]; } } // 增加dom节点 const node = this.generateNode(arr[0], {forecast: false}); nodes.push(node); }); this.markerLayer.add(nodes); } -
创建路径上的关键节点
/** * 创建节点 * @param item * @returns {AMap.Marker} */ generateNode(item, extData) { const {lat, lon, time, formatTime, radius7_s, radius10_s, radius12_s} = item; const {map, infoTip, visible} = this; const size = 14; const node = new AMap.LabelMarker({ position: [parseFloat(lon), parseFloat(lat)], opacity: 1, icon: { image: `${import.meta.env.BASE_URL}images/map/icon/dot0.svg`, size: [size, size], anchor: 'center' }, extData: {...extData, radius7_s, radius10_s, radius12_s, time}, text: { content: formatTime, direction: 'right', zooms: [8, 22], offset: [8, -5], style: { fontSize: 14, fillColor: '#fff', strokeColor: 'rgb(18,53,103)', strokeWidth: 2, } } }); node.on('mouseover', event => { const {target} = event; const {time} = target.getExtData(); const detail = this.getDataByTime(time); infoTip.setPosition(target.getPosition()); infoTip.setContent(this.getInfoTipContent(detail)); map.add(infoTip); }); node.on('mouseout', throttle(e => { map.remove(infoTip); }, 1000)); node.on('click', event => { const extData = event.target.getExtData(); const {lng, lat} = event.target.getPosition(); // 渲染预测路径 const arr = this.paths[extData.time]; this.clearForecast(); this.renderForecast(arr); this.updateMarker({...extData, lon: lng, lat}); }); return node; } // 用于储存节点的高性能图层 initMarkerLayer() { const {zooms, map, infoTip, visible} = this; const layer = new AMap.LabelsLayer({ collision: false, allowCollision: true, opacity: 1, zIndex: 999, visible, zooms }); map.add(layer); this.markerLayer = layer; } // 把生成的节点放入markerLayer this.markerLayer.add(nodes) -
绘制台风风圈和风眼动画
/** * 绘制风圈 * @param ctx * @param radius [radius_EN, radius_ES, radius_WS, radius_WN] * @param color */ drawWindCircles(ctx, {radius, color = '#ffffff'}) { const {centerX, centerY} = this; const fillColor = hexToRGBA(color, 0.2); const [radius_EN, radius_ES, radius_WS, radius_WN] = radius; // 初始角度重置到时钟12点 const initRotate = -Math.PI / 2; // ctx.clearRect(0, 0, width, height); ctx.beginPath(); // 东北 ctx.moveTo(centerX, centerY - radius_EN); ctx.arc(centerX, centerY, radius_EN, initRotate + 0, initRotate + Math.PI / 2); // 东南 ctx.lineTo(centerX + radius_ES, centerY); ctx.arc(centerX, centerY, radius_ES, initRotate + Math.PI / 2, initRotate + Math.PI); // 西南 ctx.lineTo(centerX, centerY + radius_WS); ctx.arc(centerX, centerY, radius_WS, initRotate + Math.PI, initRotate + Math.PI * 1.5); // 西北 ctx.lineTo(centerX - radius_WN, centerY); ctx.arc(centerX, centerY, radius_WN, initRotate + Math.PI * 1.5, initRotate + Math.PI * 2); // 回到原点 ctx.lineTo(centerX, centerY - radius_EN); ctx.fillStyle = fillColor; ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.stroke(); } // 绘制中心图片 drawImg(ctx, rotationAngle) { const {centerX, centerY, _img} = this; ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(rotationAngle); ctx.drawImage(_img, -_img.width / 2, -_img.height / 2); ctx.restore(); } async render() { const {_canvas, canvasLayer, data} = this; const ctx = _canvas.getContext('2d'); const {width, height} = _canvas; const {windCircle} = TyphoonConf; let rotationAngle = 0; this._img = await this.getImage(); // 绘制风圈和风眼图并旋转 const draw = () => { ctx.clearRect(0, 0, width, height); this.data.forEach(item => { const {radius, grade} = item; this.drawWindCircles(ctx, {radius, color: windCircle[grade].color}); this.drawWindCircles(ctx, {radius, color: windCircle[grade].color}); }); this.drawImg(ctx, rotationAngle); rotationAngle -= 0.02; // 刷新渲染图层 canvasLayer.reFresh(); window.requestAnimationFrame(draw); }; draw(); }
总结
至此就是在高德地图上实现台风路径的全部过程了,源代码稍后会放在github工程中供大家查看和调试。本文示例中使用的数据结构为船讯网专有,如果调用了其他气象方面的API需要根据实际情况做一下调整,另外需要注意的是南北半球的气旋方向可能是相反的,这个如有场景需求,需要在开发中区分情况。