很长一段时间,在我的认知里,对地图的理解都只是在百度和高德,直到我换了工作岗位后,才知道原来有个很有名的开源地图库叫作 Openlayer (与它同级别的还有Leaflet),因为项目需要,所以开始学习这个库,这篇文章带大家走进 Openlayer ,记录我踩的坑,并推荐给有同样需求的人。
讲 Openlayer 之前,先给大家说一下什么时数据可视化,因为地图从某种程度上来讲也是一种可视化工具。那数据可视化是用某Chart来堆图吗?饼图、散点、柱状、曲线图?严格意义上讲也算是一种,但是可视化远不止这些图那么简单,看一下百度百科的定义数据可视化 : 是关于数据视觉表现形式的科学技术研究。其中,这种数据的视觉表现形式被定义为,一种以某种概要形式抽提出来的信息,包括相应信息单位的各种属性和变量。
它是一个处于不断演变之中的概念,其边界在不断地扩大。主要指的是技术上较为高级的技术方法,而这些技术方法允许利用图形、图像处理、计算机视觉以及用户界面,通过表达、建模以及对立体、表面、属性以及动画的显示,对数据加以可视化解释。与立体建模之类的特殊技术方法相比,数据可视化所涵盖的技术方法要广泛得多。
在大数据浪潮的今天,数据可视化是一种重要的数据分析和挖掘的手段。它的底层是一套可视化算法及技术实现手段。
Ok,现在来说一下今天的主角 Openlayer,它是一款可视化地图开源库,与它齐名的还有Leaflet,但是本人更倾向于Openlayer,因为它的API更详细点,针对初学者还有官方的示例,社区也不小。所以,如果你刚好也在用,或者想用Openlayer开发,请继续往下看。
让我们开始,我们假定现在项目使用的是Vue,下面都是基于在Vue框架下的开发(React其实也差不多)。
: 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
}
}
哈哈,是的,其实就是一个类。这样的好处是统一封装,不会到处引用。
好了,大概思路有了,下面就开始画一个地图(利用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。最后一个基本的地图就生成了,效果如下图:
嗯,地图已经生成了,不过,好像有什么不对,这个文字不认识,它是不同国家显示不同国家的语言。那要是我要生成统一语言的地图怎么办呢?
文章开始说到,基本认知都只在百度和高德,那有没有可能用它们的底图呢 ?答案是肯定的。网上已经有很多关于集成百度底图的教程,这里我就把我用的贡献给大家,逻辑都是一样的:
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
})
});
}
最后,看一下效果 ,可以看到,现在除了中国外,其他国家也都是中文显示了。
它的基本逻辑就是替换掉 TileLayer ,内部是一个 XYZ 的Source。好了,现在地图是中文的了,现在我想加个坐标上去。你会发现,又不对了,明明输入的是北京,去定位到其他地方???一脸黑…
通常,国内开发软件的话,基本上都是用百度,或者高德来选点。第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'); // 转换坐标 经纬
现在,地图上的点就是准确的了,如下图:
好了,现在有了精准定位的地图,你肯定还想做点什么 。是的, 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’ 多边形,具体如下:
在第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 隐藏。
回看第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行。
前面几节,咱们弄清楚了怎么画特征,但是,有一个特殊的特征必须要单独拿出来说一下,因为我相信有很多同学都会有这种需求,那就是画路径。其实,路径虽为特征,但是画法却跟普通特征不同。实际上它是把多个点连接成一条路径,通过 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中显示出来:
示例中只画了一小段路径,哈哈,实现就行(关于路径 ,其实还有Hover高亮和颜色控制的问题,这里就不再延伸了)。
热力图官方有一个专门的例子,可以参考Earthquakes Heatmap ,我也不班门弄斧,只讲相关的参数:
let HeatmapLayer = new Heatmap({
source: new VectorSource(),
radius: 18,
shadow: 500,
blur:45,
zIndex: 1
});
它的原理很简单,就是把很多特征量化,给它们加半径和阴影,这样在地图上看的话,特征的分布就呈现热力图的效果,但是最为关键的是这个Blur (模糊度),越模糊,特征的边界就越不明显,热力图就更像是一个整体。
好了,告诉我怎么给地图加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: 需要源代码请留言
这一段引用的是卡哥的文章 EPSG是什么? ↩︎