Skip to main content

第四节:气象实时数据加载

前几节一直在做铺垫,到此终于开始写数据加载和渲染了。一定有迫不及待的小伙伴直接从这一章节开始看,但是我们强烈建议您每一节都看一下,尤其是没有太多开发经验的同学。

实现Metar观测数据的显示

本小节将实现航空观测报文数据的加载,该数据是站点观测数据,因此我们会使用矢量图层进行加载。

构建数据解析器

构成图层的两要素是数据和样式,我们首先来获取数据。

Metar报文数据源

Metar报文数据可以从NOAA的https://www.aviationweather.gov网站获取,如以下接口用于获取指定范围的3小时内的观测信息,同时要求每个站点只取最新的时次:

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

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来构建,具体的您可以参考核心概念中的数据源部分。

这些辅助的provider内部都是将数据转换为了GeoJSON的格式。
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像素
      • 风向杆的杆子尾部跟观测点贴合
    • 添加一个站点编码
      • 往下偏移一定像素
      • 支持自动抽稀
      • 颜色使用默认黑色
    在QE中,矢量数据的样式是描述性的,因此直接把以上的需求对应到各自的描述字段即可
    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数据已经被展示出来了!

    监测预报-第四节-Metar-.png

    这里面创建图层的时候,我们使用了pickType和pane这两个参数,第一个pickType我们下一小节介绍,pane是用来指定渲染的容器。Leaflet中默认有一些容器,如切片图层的容器,overlay的容器等等,不同的容器被赋予了不同的z-index(容器本质是个dom元素),这样可以让不同类型图层具有不同的上下顺序,如点应该在面的上面。

    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即可设置。

    监测预报-第四节-popup.png

    实现卫星云图的显示

    在QE里面,卫星云图最好的展现方式是使用栅格数据进行直接渲染,但是这就需要我们对卫星云图数据做一些预处理,主要是数据格式的转换,使其适合网络传输。在本项目中,我们为了演示切片图层的使用,直接使用在线图层服务,在实际应用中,也可以自己后端生成图层切片后进行服务,但是通常我们建议使用数据渲染,后续的GFS数据则是直接渲染栅格数据。

    获取数据源

    我们使用OpenWeatherMap的在线图层服务,卫星云图的服务格式为:

    https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=72c1a5d0a2601022673b426974163a7a

    其中appid为我们的示例id,使用的时免费配额,因此可能会超量导致数据无法显示。

    创建切片图层

    默认情况下,切片图层是已经渲染完成的图片,是不支持二次样式设计的,但是OWM服务实际是支持调色板等部分样式个性化的,具体的可以参考其官方API,这里不做更多描述。

    创建切片图层直接使用Leaflet自带的方法:

    private _createCloudLayer() {
        return L.tileLayer("https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=72c1a5d0a2601022673b426974163a7a");
    }
    

    这时候我们点击卫星云图,仔细观察,可以发现地图上多了一层白色的云图。

    监测预报-第四节-云图.png

    看不太清?我们来解决这个问题!

    根据数据使用不同的背景地图

    在前面的示例中,我们的Metar报文在默认地图上显示很清晰,但是卫星云图却看不太清,如果我们把地图切换为深色的背景,那就可以清楚的看清云图了。

    简单的换初始背景

    因此我们尝试把最开始创建默认背景图层的代码改成如下:

    const defaultTileName = predefinedImageTiles.geoQPurplishBlue;
    

    这时候,我们的卫星云图看的就很清晰可,但是发现风杆看不太清,那我们可以把风杆改成蓝色,这样在两个背景下都能看清:

    监测预报-第四节-云图.png

    动态设置

    虽然看起来深色地图能够解决我们当前的问题,但是后面我们还要加载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"
        }
    ];
    
    我们除了增加了baseMap字段,还增加了一个baseMapPane字段,这是因为我们不同的背景图层可能需要加载的容器是不同的,比如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状态了:

    监测预报-第四节-loading.png

    总结

    本节中,我们为系统加入了真正的图层创建功能,了解了矢量数据源和样式的设置,并且实现了图层点的点击拾取和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; 
    

    这样我们就把蓝色通道颜色去掉,出来的颜色是红色和绿色通道混合的结果,利用这个强大的功能,我们可以实现各种实用或者有趣的图像处理效果,您可以自由发挥!

    监测预报-第四节-切片处理.png