第四节:气象实时数据加载
前几节一直在做铺垫,到此终于开始写数据加载和渲染了。一定有迫不及待的小伙伴直接从这一章节开始看,但是我们强烈建议您每一节都看一下,尤其是没有太多开发经验的同学。
实现Metar观测数据的显示
本小节将实现航空观测报文数据的加载,该数据是站点观测数据,因此我们会使用矢量图层进行加载。
构建数据解析器
构成图层的两要素是数据和样式,我们首先来获取数据。
Metar报文数据源
Metar报文数据可以从NOAA的https://www.aviationweather.gov网站获取,如以下接口用于获取指定范围的3小时内的观测信息,同时要求每个站点只取最新的时次:
CORS跨域问题
由于该站点没有指明支持跨域(关于浏览器跨域的更多知识,请自行搜索),因此我们不能直接在前端代码里面获取该网址的数据,一般在业务环境中,我们会通过配置nginx实现跨域,但是在咱们的演示系统中,我们使用一个公用的跨域proxy进行访问,访问格式如下:
https://api.codetabs.com/v1/proxy/?quest=your url
其中your url就是我们要访问的原始站点,注意,该proxy只支持get请求。
解析返回的数据结果为IFeaturesProvider类型
解析矢量数据的最直接的就是将数据解析为GeoJSON格式,然后使用GeoJSONFeatureProvider来进行包装,这个provider也是实现了IFeaturesProvider类型。
在QE中,我们还提供了很多其他类型的站点数据解析,如CIMISS/天擎数据,他们本质上都是在实现IFeaturesProvider这个接口类型。
如果您的数据是类似一个二维数组,还可以直接使用PointArrayFeatureProvider来构建,具体的您可以参考核心概念中的数据源部分。
private async _createMetarLayer() {
const res = await fetch("https://api.codetabs.com/v1/proxy/?quest=https://www.aviationweather.gov/adds/dataserver_current/httpparam?dataSource=metars&requestType=retrieve&minLat=15&minLon=70&maxLat=55&maxLon=140&hoursBeforeNow=3&format=xml&mostRecentForEachStation=constraint");
const text = await res.text();
const data = xml2js(text).elements[0].elements[6].elements;
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: "FeatureCollection",
features: []
}
for (const ele of data) {
const subElements = ele.elements;
const props: any = {};
for (const subEle of subElements) {
if (!subEle.elements?.length || subEle.elements[0].type !== "text") {
//暂时不处理云量和质控码
continue;
}
props[subEle.name] = subEle.elements[0].text;
if (subEle.name === "wind_speed_kt") {
props["wind_speed_ms"] = props[subEle.name] * 0.5144444; //to m/s
}
}
const feature: GeoJSON.Feature<GeoJSON.Point> = {
type: "Feature",
properties: props,
geometry: {
type: "Point",
coordinates: [props.longitude, props.latitude]
}
}
fc.features.push(feature);
}
const provider = new GeoJSONFeatureProvider(fc);
}
在以上代码中,我们首先使用fetch方法获取了原始的xml数据,
然后使用了xml2js库将返回的xml格式转换为json对象,方便我们处理,接下来构建了一个空的GeoJSON.FeatureCollection对象并要求内部类型为点,
将此部分加入你的html代码,或者使用npm安装后直接引入:
<script src="https://unpkg.com/xml-js@1.6.11/dist/xml-js.min.js"></script>
再接着遍历转换后的json对象,获取我们需要的字段,创建为Feature更新到集合中,
最后使用GeoJSONFeatureProvider进行封装,供后续使用。
QE框架为了支持数据格式扩展,定义了一系列的接口,只要您的数据满足框架的接口要求,即可使用框架进行渲染,所以您完全可以创建你自己的provider来实现IFeaturesProvider这个接口。
最简单的全局引入方式就是在index.html添加script标签,然后在main.tsx中定义全局变量declare var xml2js
这两个数据的格式与其他要素不同,由于是演示系统,我们不做复杂的兼容性处理。
设置图层样式
有了数据源,我们还需要样式,才能够将图层表达出来,由于我们是矢量数据,因此需要设置矢量样式,
在咱们这个系统,我们的数据是点类型的,因此我们设置点的样式,具体的包括了:
-
用一个点显示观测点的位置
- 点的颜色用气温的大小来分级填色
- 点的尺寸为3px
- 给点加一个黑色的描边,以便在不同的地图颜色上能够看清
-
添加一个温度值标签
- 保留1位小数
- 标签偏移一定的位置,避免和点以及其他标签覆盖
- 标签支持自动抽稀
- 给标签价一个白色的描边,以便在不同的地图颜色上能够看清
- 标签数值使用温度色标进行分级填色
-
添加一个风速值
- 保留一位小数
- 标签偏移一定的位置,避免和点以及其他标签覆盖
- 标签支持自动抽稀
- 给标签价一个白色的描边,以便在不同的地图颜色上能够看清
-
添加一个风向杆
- 能够表达风速大小
- 能够按照风向进行旋转
- 自动抽稀
- 图片大小为 20X20像素
- 风向杆的杆子尾部跟观测点贴合
-
添加一个站点编码
- 往下偏移一定像素
- 支持自动抽稀
- 颜色使用默认黑色
const style: IFeatureStyleOptions = {
point: {
size: 3,
color: "color-temp#res?field=temp_c",
strokeColor: "black",
label: [
{
text: {
data: "$temp_c#decimal?len=1",
offset: [-16, -12],
avoidCollison: true,
strokeColor: "white"
}
},
{
text: {
data: "$wind_speed_ms#decimal?len=1",
offset: [16, -12],
avoidCollison: true,
strokeColor: "white",
}
},
{
image: {
data: "image-wind#res?field=wind_speed_ms",
angle: "$wind_dir_degrees#degree2arc",
avoidCollison: true,
color: "black",
size: [20, 20],
//原始尺寸[32,32],对应偏移[-12,-28]。缩小为[20,20]后偏移量+6
offset: [-6, -22]
}
},
{
text: {
data: "$station_id",
offset: [0, 10],
avoidCollison: true,
}
}
]
}
}
以上大部分配置都很容易理解,只有一些带#和?这些的,我们做一些解释:
color: "color-temp#res?field=temp_c"
分级规则
这句话是用来设置点的颜色使用color-temp这个分级规则,那么这个分级规则是怎么确定呢?在第一节中,我们提到了可以预加载系统资源,那么color-temp这就是一个分级规则的资源,他对应的文件具体内容类似如下(中间省略了更多类似配置):
{
"stops": [
{
"stop": "rgb(37, 0, 45)",
"value": -60
},
...
{
"stop": "rgb(53, 0, 0)",
"value": 60
}
],
"fieldName": "0"
}
stops代表分级规则,fieldName代表分级字段,由于分级规则可以被共享,在这里只要使用一个占位的分级字段即可,实际会在使用的地方进行设置,这个一会儿下面讲到。
stops数组中的每一个对象表示一个分级规则,规则是从小到大的,value表示分级的值,stop表示分级目标,在这里分级的是颜色,即按照不同的数值来返回不同的颜色值,<=-60的使用rgb(37,0,45)。
分级规则除了支持区间规则之外,也支持关键字分级,如当字段值等于什么的时候,使用什么值这样。关于分级规则的更多内容,可以查看核心概念中相关描述。
函数加载器
在上面的color的分级表达式中,我们还看到了#
,这是一个函数加载器,该符号后面紧接的就是一个加载器的名称,在这里res
表示资源加载器,也就是#之前的实际上是资源加载器要加载的资源名称,而field是这个加载器支持的扩展参数,当加载的资源实例是一个分级规则的时候,将会动态替换分级规则中的fieldName值。
有了以上的知识,我们再看标签的data字段:
"$temp_c#decimal?len=1"
这就比较容易理解了,这里的#同样表示一个函数加载器,这个加载器是一个取固定小数位的函数,len参数表示要保留几位小数。
但是有一个区别是多了$符号,这个符号表示字段应用,即data内容动态的使用temp_c这个字段,也就是我们摄氏度的字段。
风速的显示
在加载风速的时候,我们使用了wind_speed_ms
这个字段,该字段并不是接口直接返回的,而是我们在构建provider的过程中二次创建的。
风杆的显示
在本示例中,我们通过图片进行风杆的显示,这是我们目前比较推荐的一种方式,主要原因是我们可以自定义各种好看的,个性化的风杆。除此之外,也可以通过风的字体进行加载,具体可以参考二维图层清单部分。
风杆是需要旋转的,旋转的角度是一个动态值,因此我们使用
angle: "$wind_dir_degrees#degree2arc"
这种字段引用的方式动态进行设置,由于我们角度默认是度,而系统使用要求是弧度,所以我们再次使用了loader进行数据转换,对于矢量数据,#之前的结果会被作为第一个参数传入loader,如果需要其他参数,还可以通过后面附加?的方式进行传递。
创建Metar图层
最后,就是创建图层并且将这个图层返回:
const layer = new LGeoJSONLayer({ pickType: "point", pane: consts.customPanes.station.name })
.setDataSource(provider)
.setDrawOptions(style)
return layer;
WOW,我们的Metar数据已经被展示出来了!
QE中还额外自定义一些新的pane,主要包括了顶部地图、站点、栅格等,具体在consts.customPanes中可以查看。
点的拾取和Popup
咱们获取的数据里面不仅仅有温度和风的信息,还有很多其他要素,这些要素虽然没有直接显示在地图上,但是我们希望需要的时候也能够看到,这就可以使用popup来实现,即通过点击地图上的点,然后弹出一个popup里面显示更多的信息。
在QE里面,已经内置了对点的拾取,只要在创建图层的时候将pickType设置为"point",然后监听点击消息,并根据消息加载popup即可:
layer.on(LGeoJSONLayer.EventTypes.picked, args => {
const features: GeoJSON.Feature<GeoJSON.Point>[] = args["data"];
if (!features.length) {
return;
}
const props = features[0].properties;
let tip = "";
for (const key in props) {
tip += key + ":" + props[key] + "<br/>";
}
map.openPopup(tip, L.latLng(props.latitude, props.longitude));
});
💡TIPS
如果觉得默认的拾取有些困难,还可以手动设定拾取的缓冲区大小,通过创建图层时的pickBufferSize
即可设置。
实现卫星云图的显示
在QE里面,卫星云图最好的展现方式是使用栅格数据进行直接渲染,但是这就需要我们对卫星云图数据做一些预处理,主要是数据格式的转换,使其适合网络传输。在本项目中,我们为了演示切片图层的使用,直接使用在线图层服务,在实际应用中,也可以自己后端生成图层切片后进行服务,但是通常我们建议使用数据渲染,后续的GFS数据则是直接渲染栅格数据。
获取数据源
我们使用OpenWeatherMap的在线图层服务,卫星云图的服务格式为:
https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=72c1a5d0a2601022673b426974163a7a
创建切片图层
默认情况下,切片图层是已经渲染完成的图片,是不支持二次样式设计的,但是OWM服务实际是支持调色板等部分样式个性化的,具体的可以参考其官方API,这里不做更多描述。
创建切片图层直接使用Leaflet自带的方法:
private _createCloudLayer() {
return L.tileLayer("https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=72c1a5d0a2601022673b426974163a7a");
}
这时候我们点击卫星云图,仔细观察,可以发现地图上多了一层白色的云图。
看不太清?我们来解决这个问题!
根据数据使用不同的背景地图
在前面的示例中,我们的Metar报文在默认地图上显示很清晰,但是卫星云图却看不太清,如果我们把地图切换为深色的背景,那就可以清楚的看清云图了。
简单的换初始背景
因此我们尝试把最开始创建默认背景图层的代码改成如下:
const defaultTileName = predefinedImageTiles.geoQPurplishBlue;
这时候,我们的卫星云图看的就很清晰可,但是发现风杆看不太清,那我们可以把风杆改成蓝色,这样在两个背景下都能看清:
动态设置
虽然看起来深色地图能够解决我们当前的问题,但是后面我们还要加载GFS数据,这是一个栅格图层,而且面对不同的要素可能会有不同的配色,因此我们需要一种动态设置方案,即加载不同的数据使用不同的背景地图。
既然跟数据有关,那么我们的设置也可以在数据的配置中添加,在配置图层菜单时,我们有个userData参数,这里面可以放一些用户自定义的信息,之前我们在设计图层是否允许叠加的时候已经使用过这个字段,当时添加了overlay字段表示是否允许叠加,现在我们添加一个新的baseMap
字段,用来表示这个数据加载的时候我们希望展示的地图,如果没有提供这个字段,则表示使用当前的地图(不一定是默认,可能被其他图层修改过),否则使用这个字段的信息来创建新的背景地图并移除旧的背景地图。
将我们的菜单配置修改如下:
public menuData: IMenuItem[] = [
{
name: "气象实况",
childs: [
{
name: "Metar报",
id: "realtime_metar",
userData: {
overlay: true
}
},
{
name: "卫星云图",
id: "realtime_sate",
userData: {
baseMap: predefinedImageTiles.geoQPurplishBlue
}
}
],
id: "realtime"
},
{
name: "GFS预报",
childs: [
{
name: "2米温度-024",
id: "gfs_temp2m_024",
userData: {
baseMap: predefinedImageTiles.windy,
baseMapPane: consts.customPanes.topmap.name
}
},
{
name: "累计降水-024",
id: "gfs_accprecp_024",
userData: {
baseMap: predefinedImageTiles.windy,
baseMapPane: consts.customPanes.topmap.name
}
},
{
name: "850hPa风场-024",
id: "gfs_850uv_024",
userData: {
baseMap: predefinedImageTiles.windy,
baseMapPane: consts.customPanes.topmap.name
}
},
{
name: "2米相对湿度-024",
id: "gfs_rhu2m-024",
userData: {
baseMap: predefinedImageTiles.windy,
baseMapPane: consts.customPanes.topmap.name
}
},
{
name: "零度层相对湿度-024",
id: "gfs_0rhu_024",
userData: {
baseMap: predefinedImageTiles.windy,
baseMapPane: consts.customPanes.topmap.name
}
},
{
name: "地面气压-024",
id: "gfs_pslp_024",
userData: {
baseMap: predefinedImageTiles.windy,
baseMapPane: consts.customPanes.topmap.name
}
}
],
id: "gfs"
}
];
** 创建图层之后,检查是否需要切换背景图**
首先要创建一个私有变量存储当前背景图信息,这样在遇到不同的背景图的时候再切换:
private _currentTileName = defaultTileName;
我们修改创建图层的代码如下:
public async createLayer(item: IMenuItem): Promise<boolean> {
...
if (layer) {
if (item.userData.baseMap && item.userData.baseMap !== this._currentTileName) {
map.removeLayer(tileLayer);
tileLayer = createTileLayer(item.userData.baseMap, { pane: item.userData.baseMapPane ?? "tilePane" });
this._currentTileName = item.userData.baseMap;
map.addLayer(tileLayer);
}
...
}
...
}
我们将地图默认背景还还原为浅色地图,当切换卫星云图时,背景地图就会自动切换为深色地图了!由于我们Metar数据没有配置默认的地图,因此即便Metar图层再加载,也不会切换为原来的浅色了,您可以试着设置Metar图层也有一个默认地图,这样取消再加载就会切换回去了。
添加loading状态
我们的数据加载时异步的,而且加载外网数据有时候还比较慢,在当前状态下,没有任何提示表示当前正在加载数据,这是不友好的,而且也可能会造成用户由于不知情导致的重复点击,增加了出错几率,为了避免这样的情况,我们可以在图层菜单上添加loading状态,当请求数据或者在处理数据的时候我们显示loading,并禁止点击。
先在AviationApp中创建loading状态变量,默认false。
const [loading, setLoading] = useState(false);
当需要调用创建图层的方法时,将loading设置为true,在创建完成后设置loading为false,得益于async异步操作,我们的代码看起来就跟同步操作一样:
setLoading(true);
//add layer
await props.layerStore.createLayer(item);
setLoading(false);
Spin是antd提供的loading组件,我们将其加入到render函数中,包裹在MenuList外面,并且与loading状态绑定:
...
<Spin
spinning={loading}
delay={200}
tip={"数据请求中..."}
>
<MenuList
dataSource={props.layerStore.menuData}
selectedItemIds={selected.map(i => i.id)}
onItemSelected={selectItem}
mode="inline"
theme="dark"
onItemDeSelect={deSelectItem}
defaultOpenKeys={props.layerStore.menuData.map(g => g.id)}
multiple={true}
/>
</Spin>
...
这里我们设置了delay参数为200毫秒,也就是如果能在200毫秒内处理完,就不显示loading。
现在,我们的应用已经具备loading状态了:
总结
本节中,我们为系统加入了真正的图层创建功能,了解了矢量数据源和样式的设置,并且实现了图层点的点击拾取和popup。
另外还添加了切片图层,实现了自动设置地图背景图层。
最后还给我们的应用添加了loading状态。
至此,我们的系统已经开始逐步具备实时数据的显示能力了,有了现在的基础,后面几节看起来就更容易了!
完整代码和效果
彩蛋
在我们创建卫星云图的时候,我们提到可以通过传入调色板设置图片的颜色,实际上我们还有另一种方法,就是使用LTileLayerGL,这个图层对切片图层进行实时的图片处理,也就是利用WebGL对图像进行处理:
const glLayer = new LTileLayerGL({
tileUrls: ["https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=72c1a5d0a2601022673b426974163a7a"],
colorShader: `
color = vec4(color.r,color.g,0.,color.a);
`
});
return glLayer;
这样我们就把蓝色通道颜色去掉,出来的颜色是红色和绿色通道混合的结果,利用这个强大的功能,我们可以实现各种实用或者有趣的图像处理效果,您可以自由发挥!
No Comments