在高德地图上实现台风路径图层

3,028 阅读4分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

记得初次接触到台风路径是2016年海马台风登录,广州全市停工、学校停课、地铁停运的时候,那时躲在公寓里哪也不敢去,微信群里有人发了个h5页面可以关注到全国台风的实时动向,预测到几点台风过境离境了,觉得技术大佬们还挺厉害的。如今刚好有项目也需要集成气象数据到数据可视化大屏上,我也试着用高德地图API重新实现了这个图层,接下来让我们从需求出发逐步了解实现的过程。

Honeycam_2024-08-25_20-43-20.gif

需求分析

  1. 能够稳定地从可靠的气象数据源获取台风的实时位置、移动速度、强度等数据,确保数据的准确性和及时性
  2. 可以通过不同的颜色区分台风的强度等级,例如,强台风用红色表示,中等台风用橙色表示,弱台风用黄色表示等
  3. 随着地图的缩放,台风路径图层能够自动调整显示效果,确保在不同比例尺下都能清晰展示
  4. 用户可以点击台风图标或路径上的某一点,获取该位置的台风详细信息,如风速、气压、预计到达时间
  5. 用户可以方便地开启或关闭台风路径图层,以及与其他地图图层进行叠加显示的控制
  6. 2D的台风气象图,能够适配地图的3D模式,实现两个模式的无缝切换

技术栈说明

工具名称版本用途
高德地图 JSAPI2.0为本次案使用到的高德API有LabelsLayer、LabelMarker、Polyline、Marker、CanvasLayer等
three.js0.157主流webGL引擎之一,负责实现展示层面的功能
船讯网-船讯网沿海气象专题有世界台风数据可供我们参考学习
天地图4.0提供地图瓦片服务

实现步骤

  1. 获取台风数据,船讯网提供了两个接口用于获取台风数据,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[*]为预测点

  2. 是否做数据二次处理?由于台风数据使用的是WGS84的坐标系,而高德地图使用的是GCJ02坐标系,如果直接使用高德底图,需要将获取到的气象数据进行一次坐标系转换,以便在地图上准确展示;当然还有另一种做法,就是直接换地铁图,我们不做数据二次处理了,这里直接使用与之匹配的天地图卫星影像作为底图。

  3. 绘制历史的台风路径,根据关键节点的风力级别,绘制不同颜色的路径轨迹,我们历从上一步获取的路径关键节点即可绘制如下路径,通过对grade变换点和最终点的条件判断,就可以根据grade风力级别绘制不同颜色的台风路径。

    image.png

  4. 绘制路径上的关键节点,每个路径节点都需要有一个标记点,这里一开始我使用的是Marker绘制展示,后来发现有些大型的台风数据居居然有成百上千个节点,需要考虑到性能问题,就换成了可以支持高性能的海量标注LabelMarker去实现,代价就是不能单独定义节点的颜色。将节点信息存入extData,并给每个labelMarker单独做鼠标事件监听即可实现节点信息查看。

    Honeycam_2024-08-25_19-59-22.gif

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

    Honeycam_2024-08-25_20-01-37.gif

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

    image 1.png

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

    image 2.png

    在这里我们可以使用高德的CanvasLayer进行绘制,主要是因为它可以做动画且可以兼容二维和三维模式,绘制方法其实很简单,知道了每个象限的半径,就知道了每个关键顶点的坐标,如下图所示,我们只要按顺序连线绘制封闭图形并填充就可以了;绘制完风圈后再在中心置入台风图标图片,并使用逐帧函数调整旋转角度,形成旋转动画。关于风圈如何绘制我写了个小Demo可供调试

    image 3.png

  7. 封装代码,实现台风图层的显示隐藏,台风数据的切换功能。 这里的图层显示、隐藏、事件监听、事件派发、清空图层都是属于常规功能了,继承图层基类后将对应的方法覆盖即可;而台风数据切换则是用新的数据更新图层,只要清空当前图层元素后重新绘制即可,需要注意的点是把旧元素销毁后置空,及时释放内存。

代码实现

  1. 整理数据,将相同时间点的数据整理为簇,方便后续使用

    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: [
      ...
      ]
    }
    */
    
  2. 绘制台风路径

    
    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);
    }
    
  3. 创建路径上的关键节点

    /**
     * 创建节点
     * @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)
    
  4. 绘制台风风圈和风眼动画

    /**
     * 绘制风圈
     * @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