• 作者:老汪软件技巧
  • 发表时间:2024-08-26 11:02
  • 浏览量:

本文为稀土掘金技术社区首发签约文章,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绘制展示,后来发现有些大型的台风数据居居然有成百上千个节点,需要考虑到性能问题,就换成了可以支持高性能的去实现,代价就是不能单独定义节点的颜色。将节点信息存入extData,并给每个labelMarker单独做鼠标事件监听即可实现节点信息查看。

台风路径图怎么读懂_台风路径图准确吗_

绘制预测的台风路径,点击每个关键节点时,能够绘制当前节点的预测路径,通过节点的time属性我们就能找到同一簇的预测节点,按fhour排序即可得到预测路径数据,按照前两步直接绘制即可。值得注意的是当前仅存在1个节点的预测路径,所以要将上一次预测路径清除后再渲染。

绘制台风风圈和风眼动画,并使之能够在3D模式下贴地显示。初次看到这个代表风圈的数据我是比较懵逼的,这些个数字跟地图上奇怪的风圈是怎么对应上。

后来经过搜索了解到风圈的威力是由东北EN,东南ES,西南WS,西北WN这个象限的圆圈半径表示的,在7级、10级、12级的程度上都可以有一个风圈,比如下图最外圈为7级风圈,按照1-4的顺序找到radius7_s对应的半径数值绘制1/4的圆最后合并就是7级风圈了,以此类推。

在这里我们可以使用进行绘制,主要是因为它可以做动画且可以兼容二维和三维模式,绘制方法其实很简单,知道了每个象限的半径,就知道了每个关键顶点的坐标,如下图所示,我们只要按顺序连线绘制封闭图形并填充就可以了;绘制完风圈后再在中心置入台风图标图片,并使用逐帧函数调整旋转角度,形成旋转动画。关于风圈如何绘制我写了个小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需要根据实际情况做一下调整,另外需要注意的是南北半球的气旋方向可能是相反的,这个如有场景需求,需要在开发中区分情况。

相关链接

本文源代码地址

船讯网-全球船舶动态位置跟踪

天地图底图API