前言

很长一段时间,在我的认知里,对地图的理解都只是在百度和高德,直到我换了工作岗位后,才知道原来有个很有名的开源地图库叫作 Openlayer (与它同级别的还有Leaflet),因为项目需要,所以开始学习这个库,这篇文章带大家走进 Openlayer ,记录我踩的坑,并推荐给有同样需求的人。

什么是数据可视化

讲 Openlayer 之前,先给大家说一下什么时数据可视化,因为地图从某种程度上来讲也是一种可视化工具。那数据可视化是用某Chart来堆图吗?饼图、散点、柱状、曲线图?严格意义上讲也算是一种,但是可视化远不止这些图那么简单,看一下百度百科的定义数据可视化 : 是关于数据视觉表现形式的科学技术研究。其中,这种数据的视觉表现形式被定义为,一种以某种概要形式抽提出来的信息,包括相应信息单位的各种属性和变量。 它是一个处于不断演变之中的概念,其边界在不断地扩大。主要指的是技术上较为高级的技术方法,而这些技术方法允许利用图形、图像处理、计算机视觉以及用户界面,通过表达、建模以及对立体、表面、属性以及动画的显示,对数据加以可视化解释。与立体建模之类的特殊技术方法相比,数据可视化所涵盖的技术方法要广泛得多。 在大数据浪潮的今天,数据可视化是一种重要的数据分析和挖掘的手段。它的底层是一套可视化算法及技术实现手段。

什么是Openlayer

Ok,现在来说一下今天的主角 Openlayer,它是一款可视化地图开源库,与它齐名的还有Leaflet,但是本人更倾向于Openlayer,因为它的API更详细点,针对初学者还有官方的示例,社区也不小。所以,如果你刚好也在用,或者想用Openlayer开发,请继续往下看。

最前面

让我们开始,我们假定现在项目使用的是Vue,下面都是基于在Vue框架下的开发(React其实也差不多)。

Step 1 安装到项目

: npm i ol --save

我现在用的版本是 "ol": "^5.3.3",应该是最新版本了(更新:现在已经到6了,不过改动不大)。然后在项目的入口处引用它的样式:

import 'ol/ol.css';

ok,这样就可以在项目中使用了,但是开发一个项目应该会使用Openlayer的很多组件,很多功能,所以我那边还是开发一个工具类来支持整个项目,像这样:

import Map from 'ol/Map.js';
import View from 'ol/View.js';
import Feature from 'ol/Feature.js';
import Overlay from 'ol/Overlay';
export default class OpenLayerHelper {
	async fun(){
		// TODO
	}
}

哈哈,是的,其实就是一个类。这样的好处是统一封装,不会到处引用。

Step 2 初始化一个地图

好了,大概思路有了,下面就开始画一个地图(利用Map类),有三个基本信息要告诉它,1、Layers,就是初始的层;2、Target,也就是目标Dom,告诉Map地图需要在哪个页面的元素上画;3、设定一个View,它包括Zoom和中心点坐标,还有其他参数,详见View 的 API。具体代码如下:

constructor(id) {
        this.map = null;
        this.raster = new TileLayer({
            source: new OSM()
        });
        this.source = new VectorSource();
        this.vector = new VectorLayer({
            source: this.source,
            style: new Style({
                fill: new Fill({
                    color: 'rgba(255, 255, 255, 0.2)'
                }),
                stroke: new Stroke({
                    color: '#b40000',
                    width: 6
                }),
                image: new CircleStyle({
                    radius: 7,
                    fill: new Fill({
                        color: '#ffcc33'
                    })
                })
            })
        });
        this.id = id;
    }

    async init(){
        this.map = new Map({
            layers: [this.raster, this.vector],
            target: this.id,
            view: new View({
                center: [22.53558, 113.960649],
                zoom: 10
            })
        });
    }

在类的Constructor方法中,初始化传入了id(这个ID就是它的Target),然后利用 Tile 类 新建了一个层Layer,它其实是一个底图基类(底图基类可以替换的,后面会讲到),还需要一个基本的交互层,用来进行对地图的操作(比如,画图形,路径和样式等等),这个层就是 Vector ,在这里我给它定义了 Source 和样式。最后就是设置一个 View ,并且设置了基本的中心点和 Zoom。最后一个基本的地图就生成了,效果如下图:

嗯,地图已经生成了,不过,好像有什么不对,这个文字不认识,它是不同国家显示不同国家的语言。那要是我要生成统一语言的地图怎么办呢?

Step 3 切换中文底图

文章开始说到,基本认知都只在百度和高德,那有没有可能用它们的底图呢 ?答案是肯定的。网上已经有很多关于集成百度底图的教程,这里我就把我用的贡献给大家,逻辑都是一样的:

createBaiduTile() {

        let extent = [72.004, 0.8293, 137.8347, 55.8271];

        let baiduMercator = new Projection({
            code: 'baidu',
            extent: applyTransform(extent, projzh.ll2bmerc),
            units: 'm'
        });

        addProjection(baiduMercator);
        addCoordinateTransforms('EPSG:4326', baiduMercator, projzh.ll2bmerc, projzh.bmerc2ll);
        addCoordinateTransforms('EPSG:3857', baiduMercator, projzh.smerc2bmerc, projzh.bmerc2smerc);

        let bmercResolutions = new Array(19);
        for (let i = 0; i < 19; ++i) {
            bmercResolutions[i] = Math.pow(2, 18 - i);
        }

        let urls = [0, 1, 2, 3, 4].map(function () {
            return "http://online1.map.bdimg.com/onlinelabel/?qt=tile&x={x}&y={y}&z={z}&styles=pl&scaler=1&p=1";
        });

        let baidu = new TileLayer({
            source: new XYZ({
                projection: 'baidu',
                maxZoom: 18,
                tileUrlFunction: function (tileCoord) {
                    let x = tileCoord[1];
                    let y = tileCoord[2];
                    let z = tileCoord[0];
                    let hash = (x << z) + y;
                    let index = hash % urls.length;
                    index = index < 0 ? index + urls.length : index;
                    return urls[index].replace('{x}', x).replace('{y}', y).replace('{z}', z);
                },
                tileGrid: new TileGrid({
                    resolutions: bmercResolutions,
                    origin: [0, 0],
                    extent: applyTransform(extent, projzh.ll2bmerc),
                    tileSize: [256, 256]
                })
            })
        });
        return baidu;
    }

然后,在地图初始化那里就把原来的底图换成百度底图就行,代码如下:

async init(){
        let baiduLayer = this.createBaiduTile();
        this.map = new Map({
            // layers: [this.raster, this.vector],
            layers: [baiduLayer, this.vector],
            target: this.id,
            view: new View({
                center: [22.53558, 113.960649],
                zoom: 10
            })
        });
    }

最后,看一下效果 ,可以看到,现在除了中国外,其他国家也都是中文显示了。

数据可视化 开源 代码 数据可视化 开发_Openlayer

它的基本逻辑就是替换掉 TileLayer ,内部是一个 XYZ 的Source。好了,现在地图是中文的了,现在我想加个坐标上去。你会发现,又不对了,明明输入的是北京,去定位到其他地方???一脸黑…

Step 4 转换坐标

通常,国内开发软件的话,基本上都是用百度,或者高德来选点。第3步中,已经把底图换成中文的百度底图了,那坐标应该也可以转换。在这之前,咱们先了解一下这个坐标。 Openlayer 官网上有个例子,大家可以先看一下 EPSG:4326 ,这个EPSG是什么呢?EPSP的英文全称是European Petroleum Survey Group,中文名称为欧洲石油调查组织。这个组织成立于1986年,2005年并入IOGP(InternationalAssociation of Oil & Gas Producers),中文名称为国际油气生产者协会。它为每个地区都绘制了地图,但是由于坐标系不同,所以地图也各不相同。1 有个专门的网站可以查看EPSG,epsg.io ,有兴趣大家可以去搜一下,这里咱们要做的就是把坐标系转换成正常的百度地图用的格式就行。

这里咱们要用到一个非常好用的库:coordtransform

它在转换EPSG之前,把坐标先转换成百度支持的格式,然后用transform转换。比如百度地图的就是要 “ EPSG:3857 ”,所以,问题就简单了,在得到坐标时,把它转一下就行,具体如下:

import coordtransform from 'coordtransform';

    let bd09togcj02 = coordtransform.bd09togcj02(local[0], local[1]);
    let gcj02towgs84 = coordtransform.gcj02towgs84(bd09togcj02[0], bd09togcj02[1]);
    let coordinate = transform(gcj02towgs84, 'EPSG:4326', 'EPSG:3857'); // 转换坐标 经纬

现在,地图上的点就是准确的了,如下图:

数据可视化 开源 代码 数据可视化 开发_VUE_02

Step 5 画画

好了,现在有了精准定位的地图,你肯定还想做点什么 。是的, Openlayer 的功能可强大了,可以画图形,路径等等,但是要做这些这前,首先要拿到一支可以画画的笔,就跟小朋友画画一样,所以,我们要初始化画笔。不多说,上代码 :

addInteractions(type) {
        if (this.draw !== null) {
            this.draw.un('drawend');
        }
        this.draw = new Draw({
            source: this.source,
            type: type,
            style: new Style({
                fill: new Fill({
                    color: '#a4d9ff66'
                }),
                stroke: new Stroke({
                    color: '#1565c0',
                    width: 1
                }),
            })
        });
        this.draw.on('drawstart', (event) => { // set color after select 
            let s = new Style({
                fill: new Fill({
                    color: '#a4d9ff66'
                }),
                stroke: new Stroke({
                    color: '#1565c0',
                    width: 1
                }),
            });
            event.feature.setStyle(s);
            event.feature.setId(this.curId);
        });
        this.map.addInteraction(this.draw);
        this.snap = new Snap({ source: this.source });
        this.map.addInteraction(this.snap);
        return null;
    }

这里利用的就是 Draw类 定义一个Draw实例,然后监听它的开始事件,并把它添加到Map实例下,还有一点,所以的画的操作都是作为 Interaction 相互作用 来添加到地图实例的。Draw支持的Type有:‘Point’, ‘LineString’, ‘LinearRing’, ‘Polygon’, ‘MultiPoint’, ‘MultiLineString’, ‘MultiPolygon’, ‘GeometryCollection’, ‘Circle’ 9种,很丰富,本例中,我用的是’Polygon’ 多边形,具体如下:

数据可视化 开源 代码 数据可视化 开发_数据可视化 开源 代码_03

Step 6 特征元素

在第5步中,我们添加了一个多边形, Openlayer 把这个多边形叫做 Feature,所有Draw支持的Type,画出来都是一个 Feature 。所以,当我们想删除掉已经画的 Feature 时,可以像这样:

redoDraw(id) {
      this.polygonFeatures.forEach((item) => {
          let source = this.vector.getSource();
          if (id == item.id) {
              item.feature.setStyle( // 为了隐藏
                  new Style({
                      image: new CircleStyle({ opacity: 0 }),
                  })
              );
              let fid = item.feature.getId();
              let back = source.getFeatureById(fid);
              if (back != null) {
                  source.removeFeature(back);
              }
          }
      });
      return;
  }

我们假定你之前添加的Feature都已经添加到 polygonFeatures 中,这个时候就可以遍历得到那个 Feature 并根据ID删除(注意我写的方式,直接用你保存的特征去删除是删除不了的,必须用ID去查找,再删除)。同时这里会有一个BUG,已经删除的元素还显示在地图上,所以我加了一段代码 ,添加一个没用的Image并把它Opacity设置为0,这样就可以把已经删除的 Feature 隐藏。

Step 7 保存特征

回看第6步中,polygonFeatures 。特征是怎么被保存进去的呢?

其实,API中并没有告诉你怎么保存,只是提供了一些看似不相干的方法,需要你去实践。 很庆幸的是,本人已经实践过了。回到第5步,咱们设置了画笔,这个时候,官方留了个回调函数,用于咱们画笔画完时。这个方法就是 drawend ,在这个回调函数中,组件给了你很多信息,其中就包括当前它画完的Feature信息。利用 GeoJSON 可以把当前的特征转换成Json格式,然后你自然就可以存储了。下面是我写的部分代码:

首先在设置画笔的时候要监听:

this.addDrawEndEventListener(this.draw);

然后是具体代码:

addDrawEndEventListener(draw) {
        draw.on('drawend', async (evt) => {
            let GEOJSON_PARSER = new GeoJSON();
            let currentZone = GEOJSON_PARSER.writeFeatureObject(evt.feature);// 得到区域
            let res = this.polygonFeatures.findIndex(item => this.curId === item.id); 
            if (res !== -1) { // 当前id说明还在修改中,
                this.polygonFeatures[res].feature = evt.feature;
                this.polygonFeatures[res].geojson = currentZone;
            } else {// 是新的特征,重新push
                this.polygonFeatures.push({ id: this.curId, feature: evt.feature, geojson: currentZone });
            }
            this.map.removeInteraction(this.draw);// A
            this.map.removeInteraction(this.snap);// B
            return true;
        });
        return null;
    }

curId 是当前上下文id,可以根据实际情况替换,每次画完之后可以根据实际情况执行A行和B行。

Step 8 画路径

前面几节,咱们弄清楚了怎么画特征,但是,有一个特殊的特征必须要单独拿出来说一下,因为我相信有很多同学都会有这种需求,那就是画路径。其实,路径虽为特征,但是画法却跟普通特征不同。实际上它是把多个点连接成一条路径,通过 LineString 来画。但是这里还需要涉及到一个转码的过程(在第4步中文章有写),这里咱们还是以百度为例,假定咱们的路径数据是下面这样:

{
"path": "121.45728226951,31.057582190748;121.45780355619,31.056256219353;121.45819467841,31.05523208799;121.4582649259,31.055001382341;121.45853576502,31.054288457325;121.45867617017,31.054046921623;121.4588667906,31.053684810262;121.45935852303,31.052429165577;121.46004114539,31.050441059497;121.46019179122,31.050049311415;121.4603022828,31.049748054872;121.46047296085,31.049276019896;121.46054329817,31.049105242118;121.46056342021,31.049045067628;121.46072412707,31.048633126578;121.46110581708,31.047648281616;121.4612364307,31.047326675259;121.461296707,31.047176004082;121.46140719858,31.046904671603;121.46174882416,31.045990272299;121.46185931574,31.045718859057;121.46225124643,31.044783641471;121.46233164478,31.04458261218;121.46236182784,31.044512302103;121.46249253129,31.044190607696;121.46251265334,31.044130430068;121.46254301606,31.043770136986"
}

那路径就可以这样画出来:

addLines(data) {
        let locations = data.path.split(";");
        locations = locations.map((item) => {// 转换坐标
            let local = item.split(",");
            let bd09togcj02 = coordtransform.bd09togcj02(parseFloat(local[0]), parseFloat(local[1]));
            let gcj02towgs84 = coordtransform.gcj02towgs84(bd09togcj02[0], bd09togcj02[1]);
            return gcj02towgs84;
        });
        let polyline = new LineString(locations);
        polyline.transform('EPSG:4326', 'EPSG:3857');
        let feature = new Feature({
            geometry: polyline,
            name: "diy",
        });
        feature.setStyle(new Style({
            stroke: new Stroke({
                color: '#FC2828',
                width: 6
            }),
        }));
        this.source.addFeature(feature);
    }

记得给已经画的路径设置样式。并把它添加到当前的Source上下文。这样就会在这个Layer中显示出来:

数据可视化 开源 代码 数据可视化 开发_Openlayer_04

示例中只画了一小段路径,哈哈,实现就行(关于路径 ,其实还有Hover高亮和颜色控制的问题,这里就不再延伸了)。

Step 9 画热力图

热力图官方有一个专门的例子,可以参考Earthquakes Heatmap ,我也不班门弄斧,只讲相关的参数:

let HeatmapLayer = new Heatmap({
            source: new VectorSource(),
            radius: 18,
            shadow: 500,
            blur:45,
            zIndex: 1
        });

它的原理很简单,就是把很多特征量化,给它们加半径和阴影,这样在地图上看的话,特征的分布就呈现热力图的效果,但是最为关键的是这个Blur (模糊度),越模糊,特征的边界就越不明显,热力图就更像是一个整体。

Step 10 生成Tooltips

好了,告诉我怎么给地图加ToolTips,这应该是一个很普遍的需求了。很简单,就是生成一个Dom然后插入到指定位置,与地图通过事件回调来联动。可以参考下面代码:

createToolTips(coordinate, name) {
        let span = document.createElement('span');
        span.className = "tooltips-diy";
        document.body.append(span);
        let overlay = new Overlay({
            element: span,
            offset: [-15, -45],
            positioning: 'top'
        });
        this.map.addOverlay(overlay);
        overlay.setPosition(coordinate);
        span.innerHTML = name;
    }

这里要记住,下次添加的时候要把这个去掉,不然会重复,这个我本来想会有更官方的解决方案,但是我没有找到,哈哈,不过这样也行的通。

结语

纯粹的技术博客,分享给那些跟我一样被这类需求困扰的同学们,有问题欢迎私信或者留言。

ps: 需要源代码请留言


  1. 这一段引用的是卡哥的文章 EPSG是什么? ↩︎