台风轨迹动画是一种最常见的轨迹动画的应用方式,在许多气象、水利、应急相关的系统中都有类似的功能。最近我在学习了一些案例后自己也尝试实现了一个简单的台风轨迹动画。效果如下(数据源自温州台风网):

一、功能概述
我所实现的台风轨迹动画大致可以拆解为以下的几个功能点:
1.路径点和路径线
用点和线来表示台风的运动路径。

路径点有一个特别的要求:要按照当时的台风强度设置路径点的颜色
台风强度与颜色的关系如下:
JavaScript
const mapObj = {
TD: "#02ff02", // 热带低压
TS: "#0264ff", // 热带风暴
STS: "#fffb05", // 强热带风暴
TY: "#ffac05", // 台风
STY: "#f00f00", // 强台风
SuperTY: "#b10021", // 超强台风
};
2.台风标识
使用一个图标来表示台风本身,通过台风图标在路径上的移动模拟台风运动的过程。

3.台风风圈
通过一个不规则的图形来表示台风风圈的影响范围。

关于台风风圈有两个点必需要实现说明一下。
第一,台风风圈在各个方向上的半径是不一样的,所以它不是一个圆。
以我所使用的这套数据为例,其中提供了台风风圈在东北、西北、东南、西南四个方向上的半径。
JavaScript
{
"radius7_quad": {
"ne": 150, //东北
"se": 260,//东南
"sw": 260,//西南
"nw": 100//西北
},
}
第二,台风风圈分为radius7
、radius10
、radius12
三种。它们是台风的"三围"分别指其12级、10级和7级风圈的半径大小,即在最大风速半径外,近地面风速衰减至32.7、24.5以及17.2m/s时离台风中心的距离。

除了这几个功能之外还可以尝试去绘制台风预报路径、添加一些信息提示框、封装一个台风的图层类等,感兴趣的可以自己去尝试一下。
二、功能实现
1.路径绘制
路径绘制非常简单,我手上数据就是以一个个的路径点为单位组织的,其中有每个路径点的坐标,而将路径点连起来就是一条路径线。

大致的代码如下:
JavaScript
let timer, source, typhoonLayer;
let index = 0;
timer && clearInterval(timer);
source && source.clear();
// 创建图层
if (!typhoonLayer) {
source = new VectorSource();
typhoonLayer = new VectorLayer({
id: "typhoonLayer",
name: "台风路径_蝴蝶",
source: source,
});
window.map.addLayer(typhoonLayer);
}
timer = setInterval(() => {
if (index >= mockData.points.length) {
clearInterval(timer);
return;
}
const pointItem = mockData.points[index];
const position = [pointItem.lng, pointItem.lat];
const lastPointItem = index > 0 ? mockData.points[index - 1] : null;
const lastPointPosition = lastPointItem
? [lastPointItem.lng, lastPointItem.lat]
: null;
// 绘制台风路径点
const feature = new Feature({
geometry: new Point(position),
});
const pointStyle = new Style({
image: new Circle({
fill: new Fill({
color: judgeColorByWindLevel(pointItem.strong),
}),
stroke: new Stroke({
color: "#000",
width: 1,
}),
radius: 6,
}),
});
feature.setStyle(pointStyle);
feature.set("attribute", pointItem);
source.addFeature(feature);
// 绘制台风路径线
if (index > 0) {
const lineFeature = new Feature({
geometry: new LineString([lastPointPosition, position]),
});
lineFeature.setStyle(
new Style({
stroke: new Stroke({
color: "#595959",
}),
})
);
source.addFeature(lineFeature);
}
},100)
2.台风标识
台风标识本质上是一张图片,想要在地图上显示一张图片,一般有两种方法:给一个点要素设置Icon
样式,或者使用Overlay
。如果台风标识是静态的那两种方法都可以,但是这里我希望我的台风标识可以转动起来,因此只能使用Overlay
,这样就可以使用CSS动画来实现图片元素的旋转。
CSS
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.typhoon-flag {
width: 62px;
height: 62px;
background: url("./typhoon.png") no-repeat;
animation: rotate 0.5s linear infinite;
}
之后只要在计时器中修改Overlay
的位置即可。
JavaScript
timer = setInterval(() => {
//省略......
// 绘制台风标识
let typhoonFlag = window.map.getOverlayById("typhoonFlag");
if (!typhoonFlag) {
const typhoonFlagElement = document.createElement("div");
typhoonFlagElement.className = "typhoon-flag";
typhoonFlag = new Overlay({
id: "typhoonFlag",
name: "台风标识",
element: typhoonFlagElement,
positioning: "center-center",
autoPan: true,
});
window.map.addOverlay(typhoonFlag);
}
typhoonFlag.setPosition(position);
//省略......
},100)
3.绘制风圈
由于台风在不同方位的风力影响范围(即风圈半径)通常是不同的,因此在绘制风圈范围图形不能简单的画成一个正圆,而是一个四个象限中半径不同的特殊圆。
象限 | 风圈半径 |
---|---|
第一象限(0° ~ 90°) | 使用东北方向的半径(ne) |
第一象限(90° ~ 180°) | 使用东南方向的半径(se) |
第一象限(180° ~ 270°) | 使用西南方向的半径(sw) |
第一象限(270° ~ 360°) | 使用西北方向的半径(nw) |
这个风圈图形非常特殊其中涉及到绘制弧线,OpenLayers中是没办法直接去绘制弧线的,这里我采用描点法实现弧线的绘制,将弧线拆解成一个个的散点而散点连起来就是弧线,因此只要计算出所有散点的坐标即可绘制出一条弧线。具体的代码如下:
JavaScript
// 绘制台风风圈
function drawWindCircle(source, center, radiusQuad, maxRadius, level) {
let color = "#2196f329";
let strokeColor = "#2196f3";
let featureId = "windCircle7";
switch (level) {
case 10:
color = "#ff980042";
strokeColor = "#ff9800";
featureId = "windCircle10";
break;
case 12:
color = "#ff000042";
strokeColor = "#ff0000";
featureId = "windCircle12";
break;
default:
break;
}
// 如果风圈已经存在,则删除
const feature = source.getFeatureById(featureId);
if (feature) {
source.removeFeature(feature);
}
if (!maxRadius) return;
const Configs = {
CIRCLE_CENTER_X: center[0],
CIRCLE_CENTER_Y: center[1],
CIRCLE_R:
{
SE: radiusQuad.se / 100, //东南
NE: radiusQuad.ne / 100, //东北
NW: radiusQuad.nw / 100, //西北
SW: radiusQuad.sw / 100 //西南
}
};
const points = [];
const _interval = 6;
for (let i = 0; i < 360 / _interval; i++) {
let _r = 0;
let _ang = i * _interval;
if (_ang > 0 && _ang <= 90) {
_r = Configs.CIRCLE_R.NE;
} else if (_ang > 90 && _ang <= 180) {
_r = Configs.CIRCLE_R.SE;
} else if (_ang > 180 && _ang <= 270) {
_r = Configs.CIRCLE_R.SW;
} else {
_r = Configs.CIRCLE_R.SW;
}
const x = Configs.CIRCLE_CENTER_X + _r * Math.cos((_ang * Math.PI) / 180);
const y = Configs.CIRCLE_CENTER_Y + _r * Math.sin((_ang * Math.PI) / 180);
points.push([x, y]);
}
const polyFeature = new Feature({
geometry: new Polygon([points]),
});
const style = new Style({
fill: new Fill({
color: color,
}),
//边框
stroke: new Stroke({
color: strokeColor,
width: 1,
}),
image: new Circle({
radius: 2,
fill: new Fill({
color: "#ff0000",
}),
}),
});
polyFeature.setId(featureId);
polyFeature.setStyle(style);
source.addFeature(polyFeature);
return polyFeature;
}
完整代码
HTML
<template>
<div
ref="mapContainer"
id="mapContainer"
style="width: 100vw; height: 100vh"></div>
<activity-panel>
<el-button @click="drawTyphoon">开始</el-button>
</activity-panel>
</template>
<script setup>
import { onMounted, nextTick, ref } from "vue";
// ol custom API
import useInitMap from "@/utils/ol/useInitMap";
// ol API
import { Vector as VectorLayer } from "ol/layer";
import { Vector as VectorSource } from "ol/source";
import Feature from "ol/Feature";
import { Point, LineString, Polygon } from "ol/geom";
import { Style, Circle, Fill, Stroke, Text } from "ol/style";
import Overlay from "ol/Overlay";
// 台风路径数据
import mockData from "./mockData.json";
useInitMap({
target: "mapContainer",
zoom: 6,
onLoadEnd: onLoadEnd,
});
function onLoadEnd(map) {
}
let timer, source, typhoonLayer;
async function drawTyphoon() {
let index = 0;
timer && clearInterval(timer);
source && source.clear();
if (!typhoonLayer) {
source = new VectorSource();
typhoonLayer = new VectorLayer({
id: "typhoonLayer",
name: "台风路径_蝴蝶",
source: source,
});
window.map.addLayer(typhoonLayer);
}
timer = setInterval(() => {
if (index >= mockData.points.length) {
clearInterval(timer);
return;
}
const pointItem = mockData.points[index];
const position = [pointItem.lng, pointItem.lat];
const lastPointItem = index > 0 ? mockData.points[index - 1] : null;
const lastPointPosition = lastPointItem
? [lastPointItem.lng, lastPointItem.lat]
: null;
// 绘制台风路径点
const feature = new Feature({
geometry: new Point(position),
});
const pointStyle = new Style({
image: new Circle({
fill: new Fill({
color: judgeColorByWindLevel(pointItem.strong),
}),
stroke: new Stroke({
color: "#000",
width: 1,
}),
radius: 6,
}),
});
if (index === 0) {
pointStyle.setText(
new Text({
text: mockData.name,
scale: 1.3,
offsetY: 0,
offsetX: 30,
fill: new Fill({
color: "#000",
}),
})
);
}
feature.setStyle(pointStyle);
feature.set("attribute", pointItem);
source.addFeature(feature);
// 绘制台风路径线
if (index > 0) {
const lineFeature = new Feature({
geometry: new LineString([lastPointPosition, position]),
});
lineFeature.setStyle(
new Style({
stroke: new Stroke({
color: "#595959",
}),
})
);
source.addFeature(lineFeature);
}
// 绘制台风风圈
drawWindCircle(
source,
position,
pointItem.radius7_quad,
pointItem.radius7,
7
);
drawWindCircle(
source,
position,
pointItem.radius10_quad,
pointItem.radius10,
10
);
drawWindCircle(
source,
position,
pointItem.radius12_quad,
pointItem.radius12,
12
);
// 绘制台风标识
let typhoonFlag = window.map.getOverlayById("typhoonFlag");
if (!typhoonFlag) {
const typhoonFlagElement = document.createElement("div");
typhoonFlagElement.className = "typhoon-flag";
typhoonFlag = new Overlay({
id: "typhoonFlag",
name: "台风标识",
element: typhoonFlagElement,
positioning: "center-center",
autoPan: true,
});
window.map.addOverlay(typhoonFlag);
}
typhoonFlag.setPosition(position);
index++;
}, 100);
}
function judgeColorByWindLevel(strong) {
const flag = strong.split(/[()]/)[1];
const mapObj = {
TD: "#02ff02", // 热带低压
TS: "#0264ff", // 热带风暴
STS: "#fffb05", // 强热带风暴
TY: "#ffac05", // 台风
STY: "#f00f00", // 强台风
SuperTY: "#b10021", // 超强台风
};
return mapObj[flag];
}
/**
* 绘制台风风圈
* @param {VectorSource} source 图层数据源
* @param {Array} center 中心点
* @param {Object} radiusQuad 风圈半径对象
* @param {Number} maxRadius 最大半径
* @param { 7 | 10 | 12} level 风圈等级
*/
function drawWindCircle(source, center, radiusQuad, maxRadius, level) {
let color = "#2196f329";
let strokeColor = "#2196f3";
let featureId = "windCircle7";
switch (level) {
case 10:
color = "#ff980042";
strokeColor = "#ff9800";
featureId = "windCircle10";
break;
case 12:
color = "#ff000042";
strokeColor = "#ff0000";
featureId = "windCircle12";
break;
default:
break;
}
// 如果风圈已经存在,则删除
const feature = source.getFeatureById(featureId);
if (feature) {
source.removeFeature(feature);
}
if (!maxRadius) return;
const Configs = {
CIRCLE_CENTER_X: center[0],
CIRCLE_CENTER_Y: center[1],
CIRCLE_R: {
SE: radiusQuad.se / 100, //东南
NE: radiusQuad.ne / 100, //东北
NW: radiusQuad.nw / 100, //西北
SW: radiusQuad.sw / 100, //西南
},
};
const points = [];
const _interval = 6;
for (let i = 0; i < 360 / _interval; i++) {
let _r = 0;
let _ang = i * _interval;
if (_ang > 0 && _ang <= 90) {
_r = Configs.CIRCLE_R.NE;
} else if (_ang > 90 && _ang <= 180) {
_r = Configs.CIRCLE_R.SE;
} else if (_ang > 180 && _ang <= 270) {
_r = Configs.CIRCLE_R.SW;
} else {
_r = Configs.CIRCLE_R.SW;
}
const x = Configs.CIRCLE_CENTER_X + _r * Math.cos((_ang * Math.PI) / 180);
const y = Configs.CIRCLE_CENTER_Y + _r * Math.sin((_ang * Math.PI) / 180);
points.push([x, y]);
}
const polyFeature = new Feature({
geometry: new Polygon([points]),
});
const style = new Style({
fill: new Fill({
color: color,
}),
//边框
stroke: new Stroke({
color: strokeColor,
width: 1,
}),
image: new Circle({
radius: 2,
fill: new Fill({
color: "#ff0000",
}),
}),
});
polyFeature.setId(featureId);
polyFeature.setStyle(style);
source.addFeature(polyFeature);
return polyFeature;
}
</script>
<style>
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.typhoon-flag {
width: 62px;
height: 62px;
background: url("./typhoon.png") no-repeat;
animation: rotate 0.5s linear infinite;
}
</style>