引言

本小节原文链接及内容

欢迎来到Openlayers研讨会,Openlayers作为一个Web地图解决方案,本研讨会将带你全面了解Openlayers。

开发环境配置

首先,除了需要下载最新的workshop release(压缩包名为openLayer-works-en.zip)压缩包(在Github上已归档),还需要安装Node(v16或更高版本),使得项目代码可以正常运行。
解压压缩包后,进入openlayers-workshop-en目录,输入如下命令安装package.json文件中配置的开发依赖项:

1
npm install

现在,运行下面的命令即可启动项目代码,而解压的压缩包除了项目代码,还提供了研讨会的文档资料。

1
npm start

运行上述命令后,将启动一个开发服务器,你可以在其中阅读研讨会文档并完成练习。在浏览器输入启动地址http://localhost:5173/即可在浏览器打开项目(或者按住键盘的Ctrl键然后点击启动地址也可打开浏览器),如下图,会弹出一个确认窗口,表示已正常运行项目。

你还可以在http://localhost:5173/doc/上阅读研讨会文档。

概述

本研讨会以一系列的章节形式呈现,在每个章节中,你将实现该章节特定的目标任务,每个章节都建立在之前章节的基础之上,旨在迭代地建立你的知识库。
本研讨会将介绍以下章节的内容:

  • 基础概念:了解如何在网页中添加地图。
  • 矢量数据:处理矢量数据。
  • 移动地图和传感器:带有GPS和指南针的(移动)手机地图。
  • GeoTIFF渲染:生成并可视化来自从GeoTIFF数据源的数据切片。
  • 矢量切片和Mapbox样式:使用矢量切片创建一幅漂亮的地图。
  • WebGL点的渲染:使用WebGL来渲染点数据。
  • 部署:构建生产环境下的应用程序。

基础概念

本小节原文链接及内容

确保你已经完成了上一小节开发环境的配置,并使开发服务器运行起来。
接下来,让我们开始使用OpenLayers创建一个简单的Web地图页面,并理解代码。
在OpenLayers中,地图是渲染到网页上的图层的集合,要创建地图,你需要一些用于创建地图视图的标记元素(如<div>元素)、一些使地图视图在页面上具有适当的尺寸的样式,以及初始化地图Javascript代码。
OpenLayers支持不同类型的图层:

  • 切片图层,平铺的栅格数据切片集合(原文:Tile layers for tiled raster tile sets)
  • 影像图层,影像文件或根据地图范围需要提供的影像(原文:Image layers for static images or images that are provided on demand for the map’s extent)
  • 矢量图层,矢量数据文件或地图当前范围的矢量数据(原文:Vector layers for vector data from static files or for the map’s current extent)
  • 矢量切片图层,平铺的矢量切片集合(原文:Vector tile layers for tiled vector tile sets)

除了图层和视图之外,地图还可以配置一些控件(contorl,即地图上方的UI元素)和交互(interaction,即对地图上的触摸或(鼠标)指针手势做出反应的组件)。

界面的编写

编辑项目的根目录下的index.html文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>OpenLayers</title>
<style>
@import "node_modules/ol/ol.css";
</style>
<style>
html, body, #map-container {
margin: 0;
height: 100%;
width: 100%;
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="map-container"></div>
<script src="./main.js" type="module"></script>
</body>
</html>

上述HTML页面包含一个<div>,其idmap-container,用作地图的目标容器。<style>样式会使地图容器充满整个页面。

程序的入口文件

为了使用OpenLayers,我们安装了来自npm的ol包,这在上一小节的npm install步骤中已经完成。如果你是从头开始开发一个新的应用程序,你应该在终端中运行npm install ol

作为应用程序的入口位置,我们创建了一个main.js文件,并将其保存在项目代码的根目录下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import OSM from 'ol/source/OSM';
import TileLayer from 'ol/layer/Tile';
import {Map, View} from 'ol';
import {fromLonLat} from 'ol/proj';

new Map({
target: 'map-container',
layers: [
new TileLayer({
source: new OSM(),
}),
],
view: new View({
center: fromLonLat([0, 0]),
zoom: 2,
}),
});

上述代码块顶部的导入行,是从ol包中引入所需的模块,将需要的包导入后,我们首先要创建一个Map对象,配置其target为上述<div>容器的id属性;然后需要为地图配置一个切片图层对象(TileLayer)和一个XYZSource数据源对象;最后,需要为View对象配置初始的zoom值,以及视图投影下地图视图的center属性;为了提供地理坐标,我们使用了ol/proj模块中的fromLonLat函数进行了坐标转换。

查看地图

现在运行项目代码,让我们在web浏览器中打开地图:http://localhost:5173/,它应该是这样的:
一张世界地图

扩展阅读

研讨会的最后一章中,我们将学习如何创建用于部署的应用程序的生产版本。
以此为起点,在学习的过程中,我们建议查阅示例。另外,官方的API文档也提供了OpenLayers的所有类和函数的参考。

矢量数据

本小节原文链接及内容

在本章节中,我们将创建一个用于处理矢量数据的基本编辑器。目标是让用户可以导入数据、绘制新要素、修改现有要素并导出结果。除此之外,还会处理GeoJSON数据,但如果你有兴趣处理其他的数据源,OpenLayers也是支持的,并且它支持多种格式矢量数据。

渲染GeoJSON

本小节原文链接及内容

在开始编辑之前,我们先来看一下使用矢量数据源和图层对基本要素的渲染。在项目的data目录中包括了一个名为countries.json的GeoJSON文件。我们会先加载数据,然后在地图上进行渲染。
首先,编辑index.html,为地图添加一个深色背景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>OpenLayers</title>
<style>
@import "node_modules/ol/ol.css";
</style>
<style>
html, body, #map-container {
margin: 0;
height: 100%;
width: 100%;
font-family: sans-serif;
background-color: #04041b;
}
</style>
</head>
<body>
<div id="map-container"></div>
<script src="./main.js" type="module"></script>
</body>
</html>

我们将引入处理矢量数据三个关键要素:

  • 用于读写序列化数据的format对象(本例中为GeoJSON),:这里的format容易翻译为格式,其实format是矢量数据源对象的一个配置选项,详见api文档
  • 用于获取数据和管理要素空间索引的矢量数据源对象
  • 用于在地图上渲染要素的矢量图层对象。

更新main.js代码,以便加载并渲染包含GeoJSON要素的本地文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import GeoJSON from 'ol/format/GeoJSON';
import Map from 'ol/Map';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import View from 'ol/View';

new Map({
target: 'map-container',
layers: [
new VectorLayer({
source: new VectorSource({
format: new GeoJSON(),
url: './data/countries.json',
}),
}),
],
view: new View({
center: [0, 0],
zoom: 2,
}),
});

现在,你可以打开这个http://localhost:5173/链接中看到带有国家边界的地图。
GeoJSON 要素

假设我们将多次重新加载页面,如果地图留在我们重新加载时的位置就更好了,我们可以引入Link交互来实现这一点。

1
import Link from 'ol/interaction/Link';

接下来,我们需要给地图对象分配给一个变量(名为map),方便向其添加交互:

1
const map = new Map({

现在,我们可以向我们的地图添加一个Link交互:

1
map.addInteraction(new Link());

现在你应该看到,页面重新加载时地图视图得以保持,并且返回按钮的工作原理和你期望的一样。

文档在这里没有讲解清楚,为此,我特意测试了一下这个功能并录制了一个效果图:多次拖动地图后,刷新页面,地图仍然保持在了刷新前的位置,认真的小伙伴可能注意到地址栏其实记录了每次移动后坐标、缩放级别、旋转角度等参数,使得我们在多次拖动地图后,单击浏览器的返回键及前进键便可以切换至上一视图和下一视图。

拖放交互

本小节原文链接及内容

对于要创建的要素编辑器,我们希望用户能够导入自己的数据进行编辑。为此,我们将使用DragAndDrop交互。与前面一样,我们将继续使用GeoJSON format对象来解析要素,但是可以将交互配置为使用任意数量的要素格式(即formatConstructors属性值是一个数组,我们可以为其添加解析其他格式数据的format对象)。

首先创建一个Map对象,并将其分配给一个名为map的变量:

1
const map = new Map({

然后在main.js中引入DragAndDrop包:

1
import DragAndDrop from 'ol/interaction/DragAndDrop';

接下来,我们将创建一个没有初始化数据的矢量数据源,这与上一个示例中从远程位置加载数据不同,此数据源将存储用户拖放到地图上的要素。

1
const source = new VectorSource();

现在,我们用的空的矢量数据源创建一个图层,并将图层添加到地图中。

1
2
3
4
const layer = new VectorLayer({
source: source,
});
map.addLayer(layer);

最后,我们将创建一个拖放交互,配置它与我们的矢量数据源一起工作,并将它添加到地图:

1
2
3
4
5
6
map.addInteraction(
new DragAndDrop({
source: source,
formatConstructors: [GeoJSON],
})
);

现在,你应该能够将GeoJSON文件拖放到地图上,并看到其中的数据被渲染。
拖放文件到地图上

修改要素

本小节原文链接及内容

现在,用户能够将数据加载到编辑器中,但为了能够编辑要素,我们需要使用Modify交互,接下来将对其进行配置,以修改矢量数据源上的要素。
首先,在main.js中引入Modify交互:

1
import Modify from 'ol/interaction/Modify';

然后,需要创建一个连接到矢量数据源的新交互,并将其添加到map(在main.js文件的底部):

1
2
3
4
5
map.addInteraction(
new Modify({
source: source,
})
);

将数据添加到地图后,请确认你可以通过拖动要素的顶点来修改要素,也可以通过按住键盘上的Alt键并单击来删除顶点。
修改要素

绘制新要素

本小节原文链接及内容

我们的要素编辑器现在可以用于加载数据和修改要素,接下来,我们将添加一个Draw交互,允许用户绘制新的要素,并将其添加到数据源中。
首先,在main.js中引入Draw交互:

1
import Draw from 'ol/interaction/Draw';

现在,创建一个绘制交互,并配置为绘制多边形,并将它们添加到矢量数据源中:

1
2
3
4
5
6
map.addInteraction(
new Draw({
type: 'Polygon',
source: source,
})
);

绘制交互的type属性控制绘制几何图形的类型,该值可以是任何GeoJSON的几何类型。

有了绘制交互组件,我们现在就可以向矢量数据源添加新的要素了。
加勒比海的一个新岛国

开启捕捉交互

本小节原文链接及内容

你可能已经注意到,很容易绘制出与现有要素不能很好对齐的要素,此外,当修改要素时,我们可能会破坏拓扑关系-在之前相邻的多边形之间添加一个空隙,Snap交互可用于在绘制和编辑要素时保留拓扑关系。
首先,在main.js中引入Snap交互:

1
import Snap from 'ol/interaction/Snap';

与其他编辑交互组件一样,我们将配置捕捉交互组件,并使用矢量数据源,并将交互对象添加至地图:

1
2
3
4
5
map.addInteraction(
new Snap({
source: source,
})
);

在绘制、修改和捕捉交互都处于活动状态的情况下,我们可以在保持拓扑关系的同时编辑数据。
使用捕捉交互合并各个国家

下载要素

本小节原文链接及内容

上传数据并进行编辑后,我们希望能够将编辑后的数据下载下来。为此,我们需要将要素数据序列化为GeoJSON,并创建一个<a>元素,该元素具有触发浏览器的文件保存对话框的download属性。与此同时,我们将在地图中添加一个按钮,让用户清除现有要素并重新开始。
首先,在index.html中的map容器之后添加以下HTML元素:

1
2
3
4
<div id="tools">
<a id="clear">Clear</a>
<a id="download" download="features.json">Download</a>
</div>

将以下内容添加到index.html中的<style>标签中:

1
2
3
4
5
6
7
8
9
10
11
#tools {
position: absolute;
top: 1rem;
right: 1rem;
}
#tools a {
display: inline-block;
padding: 0.5rem;
background: white;
cursor: pointer;
}

清除要素则比较简单,矢量数据源对象有一个soure.clear()方法,我们希望单击“清除”按钮来调用该方法,因此我们将在main.js中为清除按钮添加单击的侦听器:

1
2
3
4
const clear = document.getElementById('clear');
clear.addEventListener('click', function () {
source.clear();
});

我们将使用GeoJSON格式对象来序列化要下载的数据,因为我们希望“下载”按钮在编辑过程中的任何时候都能正常运行,所以我们将在数据源对象的change事件中序列化要素,并为锚元素的href属性构造一个数据URI:

1
2
3
4
5
6
7
8
const format = new GeoJSON({featureProjection: 'EPSG:3857'});
const download = document.getElementById('download');
source.on('change', function () {
const features = source.getFeatures();
const json = format.writeFeatures(features);
download.href =
'data:application/json;charset=utf-8,' + encodeURIComponent(json);
});

清除和下载数据的按钮

让加载的数据看起来更漂亮

本小节原文链接及内容

此时,我们有了一个具有基本导入、编辑和导出功能的要素编辑器。但是我们没有花费任何时间试图使要素看起来更好看。当你在OpenLayers中创建矢量图层时,你将得到一组默认样式,编辑交互(包括绘制和修改交互)也有自己的默认样式。在编辑过程中,几何图形的笔划比较粗,解决办法是给矢量图层设置style属性并编辑交互。

首先,我们导入要用到的类:

1
import {Style, Fill, Stroke} from 'ol/style';

静态样式

如果我们想给所有要素赋予相同的样式,可以这样配置我们的矢量图层:

1
2
3
4
5
6
7
8
9
10
11
const layer = new VectorLayer({
source: source,
style: new Style({
fill: new Fill({
color: 'red',
}),
stroke: new Stroke({
color: 'white',
}),
}),
});

也可以将style属性设置为一组样式。例如,这允许渲染套接线(下面是宽笔划,上面是更窄的笔划)【感觉这里缺少一块代码示例,但基本能看懂】。

动态样式

当你需要根据有关要素或当前视图分辨率的内容来决定如何渲染每个要素时,可以使用样式函数配置矢量图层。每个渲染帧上的每个要素都会调用该函数,因此,如果你有许多要素并希望保持良好的渲染性能,编写一个高效的函数是很重要的。
下面是一个使用两种样式之一呈现要素的示例,具体取决于“name”属性是以“A-M”开头还是以“N-Z”开头(完全是人为设计的示例)。

1
2
3
4
5
6
7
const layer = new VectorLayer({
source: source,
style: function (feature, resolution) {
const name = feature.get('name').toUpperCase();
return name < 'N' ? style1 : style2; // assuming these are created elsewhere
},
});

根据几何图形的面积来设置样式

为了了解动态样式的工作原理,我们将创建一个样式函数,该函数将基于几何图形的面积来渲染要素。为此,我们将使用npm上的colormap包。我们可以将其添加到依赖项中,如下所示:

1
npm install colormap

现在,我们需要导入colormap包和ol/sphere来进行球面面积的计算。

1
2
import colormap from 'colormap';
import {getArea} from 'ol/sphere';

接下来,我们将编写两个函数来根据几何图形的面积确定颜色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const min = 1e8; // 最小面积
const max = 2e13; // 最大面积
const steps = 50;
const ramp = colormap({
colormap: 'blackbody',
nshades: steps,
});

function clamp(value, low, high) {
return Math.max(low, Math.min(value, high));
}

function getColor(feature) {
const area = getArea(feature.getGeometry());
const f = Math.pow(clamp((area - min) / (max - min), 0, 1), 1 / 2);
const index = Math.round(f * (steps - 1));
return ramp[index];
}

现在我们可以添加一个函数,该函数将基于几何图形的面积来创建具有填充颜色的样式,并将此函数设置为矢量图层的style属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
const layer = new VectorLayer({
source: source,
style: function (feature) {
return new Style({
fill: new Fill({
color: getColor(feature),
}),
stroke: new Stroke({
color: 'rgba(255,255,255,0.8)',
}),
});
},
});

按面积着色的要素

移动地图和传感器

本小节原文链接及内容

在本节内容中,我们将创建一个移动地图,显示用户的GPS位置和航向。此示例的目的是展示如何将OpenLayers与浏览器API和第三方实用程序集成。
只需几行代码,我们就可以利用浏览器的地理定位API获取GPS位置,并使用kompas程序从设备的陀螺仪获取航向。使用一个矢量图层,我们可以很容易地在地图上显示结果。

移动地图

本小节原文链接及内容

OpenLayers支持开箱即用的移动设备,提供多点触摸手势,如多点触摸缩放和旋转。因此,这里没有针对OpenLayers特别需要做的事情,只需应用移动端网页的一般规则即可。
移动设备的好处是,我们可以使用GPS或陀螺仪等传感器,我们会将它们当作指南针来使用。

为移动端网页添加一个meta标签

我们需要在之前已有的index.html中增加了一个额外的meta标签,用于添加视区的设备宽度(device-width)和初始比例(initial-scale)设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenLayers</title>
<style>
@import "node_modules/ol/ol.css";
</style>
<style>
html, body, #map-container {
margin: 0;
height: 100%;
width: 100%;
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="map-container"></div>
<script src="./main.js" type="module"></script>
</body>
</html>

添加导航用的街道地图

与之前的练习基本地图一样,在main.js添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import OSM from 'ol/source/OSM';
import TileLayer from 'ol/layer/Tile';
import {Map, View} from 'ol';
import {fromLonLat} from 'ol/proj';

new Map({
target: 'map-container',
layers: [
new TileLayer({
source: new OSM(),
}),
],
view: new View({
center: fromLonLat([0, 0]),
zoom: 2,
}),
});

在移动设备上测试

由于陀螺仪通常在台式计算机上不可用,因此我们需要在移动设备上测试我们的应用程序。出于安全原因,对地理位置的访问权限,仅授予通过安全连接提供的页面。
解决方法是:使用 https://ngrok.com 设置完成后,可以在新终端中使用以下命令为应用程序提供服务:

1
./ngrok http 5173 --host-header="localhost:5173"

一切正常后,在移动设备上打开ngrok输出所示的https://页面:
智能手机上的地图

地理位置

本小节原文链接及内容

如果我们想看看在地图上我们所处的位置,那么需要借助浏览器的地理定位API来访问设备的GPS位置(或者没有GPS的设备上的估计位置)。在OpenLayers中,我们可以在地图上用图标可视化该位置。此外,我们还可以在所报告的位置周围显示精度半径。我们也可以添加一个按钮,允许在当前位置使地图居中。
首先在main.js中引入需要使用的矢量数据源和图层的类:

1
2
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';

然后创建一个Map实例对象:

1
const map = new Map({

接下来,为了要显示的GPS位置,需要创建一个矢量数据源,并将该数据源对象添加到矢量图层对象中,然后将该图层添加到地图:

1
2
3
4
5
const source = new VectorSource();
const layer = new VectorLayer({
source: source,
});
map.addLayer(layer);

现在是时候导入可视化GPS位置所需的要素和几何图形的类了:

1
2
3
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import {circular} from 'ol/geom/Polygon';

有了这些,我们可以开始编写代码,从而实现通过浏览器的Geolocation API来获取位置及其精度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
navigator.geolocation.watchPosition(
function (pos) {
const coords = [pos.coords.longitude, pos.coords.latitude];
const accuracy = circular(coords, pos.coords.accuracy);
source.clear(true);
source.addFeatures([
new Feature(
accuracy.transform('EPSG:4326', map.getView().getProjection())
),
new Feature(new Point(fromLonLat(coords))),
]);
},
function (error) {
alert(`ERROR: ${error.message}`);
},
{
enableHighAccuracy: true,
}
);

上面的代码使用了watchPosition()函数,该函数在用户的位置发生变化时会被触发,它将获取纬度、经度和精度,并创建两个要素:具有精确半径的圆和表示该位置的点,这两个要素都从地理坐标转换至地图视图的投影坐标。
除此之外,我们还增加了一个错误处理程序,当位置不可用时通知用户,并配置Geolocation API以实现高精度。以使让浏览器获取到确切的GPS位置,而不仅仅是估计的位置。
此时,地图虽然已经显示了用户的位置,但是还需要添加一个按钮,使地图在该位置居中。要实现此目的,最简单的方法是使用OpenLayers控件,我们现在将引入该控件:

1
import Control from 'ol/control/Control';

接下来,我们将为该控件创建html标签,并注册一个单击侦听器。当单击按钮时,将会在0.5秒的动画中,将地图缩放至表示所处位置的点和精度多边形要素的范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const locate = document.createElement('div');
locate.className = 'ol-control ol-unselectable locate';
locate.innerHTML = '<button title="Locate me">◎</button>';
locate.addEventListener('click', function () {
if (!source.isEmpty()) {
map.getView().fit(source.getExtent(), {
maxZoom: 18,
duration: 500,
});
}
});
map.addControl(
new Control({
element: locate,
})
);

为了将控制按钮放置在缩放按钮下面,我们在index.html<style>部分添加了几行css样式:

1
2
3
4
.locate {
top: 6em;
left: .5em;
}

点击按钮后的结果应该是这样的:
使用精度多边形进行定位

指南针

本小节原文链接及内容

大多数移动设备都配备了陀螺仪,我们将使用它作为指南针,在地图上显示我们的航向。
在底层,浏览器可以通过deviceorientation事件访问陀螺仪,侦听器会接收设备三个轴的读数。幸运的是,我们可以利用kompa软件包,直接获取航向。
我们想给位置点一个箭头图标,显示航向。
首先,我们需要导入的OpenLayers的style模块,以使位置和方向指示器看起来更漂亮:

1
import {Fill, Icon, Style} from 'ol/style';

现在,我们可以创建样式并为图层设置。在此期间,我们不仅为位置和方向创建了一个带有箭头的漂亮图标,而且还可以使表示精度的多边形看起来更好看:

1
2
3
4
5
6
7
8
9
10
11
const style = new Style({
fill: new Fill({
color: 'rgba(0, 0, 255, 0.2)',
}),
image: new Icon({
src: './data/location-heading.svg',
imgSize: [27, 55],
rotateWithView: true,
}),
});
layer.setStyle(style);

样式包含一个用于精度多边形的填充。对于位置点,我们使用一个svg文件,它位于项目的data/目录中。rotateWithView选项使得图标随着航向的变化和地图视图一块旋转。此时,图标没有旋转,所以箭头将指向上方。
接下来,我们将使用kompas从设备定位API获取航向。此包已在项目中安装。如果还没有包含它,你可以通过npm install kopas从命令行或终端安装它。
main.js中导入kopas包:

1
import kompas from 'kompas';

最后要做的是借助kompa工具来获取航向,并用航向为图标设置旋转角度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function startCompass() {
kompas()
.watch()
.on('heading', function (heading) {
style.getImage().setRotation((Math.PI / 180) * heading);
});
}

if (
window.DeviceOrientationEvent &&
typeof DeviceOrientationEvent.requestPermission === 'function'
) {
locate.addEventListener('click', function () {
DeviceOrientationEvent.requestPermission()
.then(startCompass)
.catch(function (error) {
alert(`ERROR: ${error.message}`);
});
});
} else if ('ondeviceorientationabsolute' in window) {
startCompass();
} else {
alert('No device orientation provided by device');
}

用户查找方向的最终导航工具现在应该如下所示:
用户用导航工具环顾四周

GeoTIFF渲染

本小节原文链接及内容

在本小节内容中,我们将演示如何通过可视化Cloud-Optimized GeoTIFF(COG)的数据去渲染一张地图。除了基本的全分辨率图像之外,GeoTIFF(或任何TIFF)图像还允许具有附加的概览图像。此外,图像的内部像素布局可以是平铺的(我理解的是一块一块的),而不是以条带(一条一条的)组织的。Cloud-Optimized GeoTIFF格式更提倡使用常规的平铺布局和内置的概览视图来托管数据,使客户端更高效地渲染图像的一小部分(只读取所需的切片,而不是整个图像)或较低分辨率的概览。
OpenLayers的ol/source/GeoTIFF的数据源对象,可以从一个或多个远程托管的GeoTIFF数据源读取多波段数据,接下来将演练渲染单个多波段图像、渲染多个单波段图像以及对输入数据执行简单的波段计算

真彩色GeoTIFF

本小节原文链接及内容

Sentinel-2(哨兵2号)卫星的任务是收集和传播覆盖地球陆地表面的图像,重访周期为2至5天,其传感器收集了多个波段的图像,其中每个波段是电磁波谱的一部分。2A(L2A)级产品提供以下波段的表面反射率测量:

Band Description Central Wavelength (μm) Resolution (m)
B01 Coastal aerosol 0.433 60
B02 Blue 0.460 10
B03 Green 0.560 10
B04 Red 0.665 10
B05 Vegetation red edge 0.705 20
B06 Vegetation red edge 0.740 20
B07 Vegetation red edge 0.783 20
B08 Near-infrared 0.842 10
B09 Water vapor 0.945 60
B10 Short-wave infrared - Cirrus 1.375 60
B11 Short-wave infrared 1.610 20
B12 Short-wave infrared 2.190 20

当查看包含来自可见光波谱之外的数据的多波段图像时,我们必须选择如何将每个波段映射到可在数字显示器上渲染的三个可见通道(红色、绿色或蓝色)之一。真彩色合成是一种渲染,在蓝色通道中显示可见蓝色(来自Sentinel-2的B02),在绿色通道中显示可见绿色(B03),在红色通道中显示可见红色(B04),任何其他卫星图像波段到显示频道的映射都是假彩色合成。

Amazon S3上有一系列被作为Cloud-Optimized GeoTIFF数据来托管的Sentinel-2 L2A产品。在本练习中,我们将在地图上渲染其中一个。

首先,修改index.html的代码,以便开始渲染一整幅地图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>OpenLayers</title>
<style>
@import "node_modules/ol/ol.css";
</style>
<style>
html, body, #map-container {
margin: 0;
height: 100%;
width: 100%;
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="map-container"></div>
<script src="./main.js" type="module"></script>
</body>
</html>

现在我们将导入两个以前没有使用过的新组件:

  • ol/source/GeoTIFF 数据源对象:用于处理多波段栅格数据。
  • ol/layer/WebGLTile 图层对象:使用GPU上的着色器操作数据切片。

更新main.js以在地图上加载和渲染远程托管的GeoTIFF文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import GeoTIFF from 'ol/source/GeoTIFF.js';
import Map from 'ol/Map.js';
import Projection from 'ol/proj/Projection.js';
import TileLayer from 'ol/layer/WebGLTile.js';
import View from 'ol/View.js';
import {getCenter} from 'ol/extent.js';

const projection = new Projection({
code: 'EPSG:32721',
units: 'm',
});

// metadata from https://s3.us-west-2.amazonaws.com/sentinel-cogs/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/S2B_21HUB_20210915_0_L2A.json
const sourceExtent = [300000, 6090260, 409760, 6200020];

const source = new GeoTIFF({
sources: [
{
url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/TCI.tif',
},
],
});

const layer = new TileLayer({
source: source,
});

new Map({
target: 'map-container',
layers: [layer],
view: new View({
projection: projection,
center: getCenter(sourceExtent),
extent: sourceExtent,
zoom: 1,
}),
});

运行项目代码,结果如下图所示,在WebGL切片图层中渲染了GeoTIFF的地图。

Sentinel-2 GeoTIFF的真彩色渲染

这里最棘手的部分是找到你可能感兴趣的图像的URL,要做到这一点,你可以尝试在 EO (Earth Observation) Browser中搜索。如果你安装了aws命令行界面,你还可以列出s3://seninel-cogs/存储桶内容,以通过Sentinel-2网格单元标识符和日期获取图像所在的路径。例如,要搜索2021年9月布宜诺斯艾利斯周围的影像:

1
aws s3 ls s3://sentinel-cogs/sentinel-s2-l2a-cogs/21/H/UB/2021/9/ --no-sign-request

接下来最困难的部分是:找出适合地图视图的投影和范围。在下一小节中,我们将使这一点变得更容易。

简化地图视图的配置

本小节原文链接及内容

在前面的示例中,我们不得不配置与地图视图有关的图像的空间参考系统和坐标位置的信息。

首先我们需要知道的是空间参考系统的标识符,这个标识符用于创建OpenLayers投影(还需要为它配置单位):

1
2
3
4
const projection = new Projection({
code: 'EPSG:32721',
units: 'm',
});

然后,关于图像我们还需要知道的是它的坐标位置—用于创建边界框或范围数组:

1
2
// metadata from https://s3.us-west-2.amazonaws.com/sentinel-cogs/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/S2B_21HUB_20210915_0_L2A.json
const extent = [300000, 6090260, 409760, 6200020];

有了这些信息,我们终于能够配置地图的视图:

1
2
3
4
5
6
7
8
9
10
new Map({
target: 'map-container',
layers: [layer],
view: new View({
projection: projection,
center: getCenter(extent),
extent: extent,
zoom: 1,
}),
});

GeoTIFF Imagery使用特殊的“geo”标签扩展了常规的TIFF图像,这些标签提供了有关图像的空间参考系统和坐标位置等信息。OpenLayers中的ol/source/GeoTIFF数据源能够解析此信息,我们理所当然地将它用于配置地图的视图。

GeoTIFF数据源对象的source.getView()方法,会返回解析的GeoTIFF元数据的视图属性(如投影、中心、范围和缩放)。地图的构造函数现在接受一个视图选项,该选项可以是上述这些属性。因此,我们可以为地图提供来自数据源的视图属性,而不是自己在元数据中挖掘以查找投影和范围之类的内容。

更新main.js,以便在map构造函数使用从数据源获取视图属性:

1
2
3
4
5
new Map({
target: 'map-container',
layers: [layer],
view: source.getView(),
});

现在你可以从上一小节的main.js文件中删除投影、范围和相关的导入(View、projection和getCenter)。

此时你会发现,我们用了更少的代码实现了相同的结果:

Sentinel-2 GeoTIFF的真彩色渲染

假彩色合成

本小节原文链接及内容

在上一小节中,我们使用ol/source/GeoTIFF数据源对象从单个多波段数据源(具有红、绿、蓝和α波段)渲染真彩色图像。在这个例子中,我们将从可见波谱之外拉入数据,并使用它来渲染假彩色合成。

我们将要渲染一个假彩色合成,突出裸露土壤区域的植被。富含叶绿素的植被和它在可见光波长上的反射率相比,它在波谱的近红外(Sentinel-2 B08)部分是明亮的。相比之下,裸露的土壤则在近红外区的亮度不如其在可见光波段的反射率。用Sentinel-2波段显示的绿色植被和裸露土壤的反射波谱见下图。

植被(绿色)、土壤(红色)和水(蓝色)的波谱特征。

为了在多波谱图像中突出植被,通常在红色通道中显示近红外(B08)反射率,在绿色通道中显示红色反射率(B04),在蓝色通道中显示绿色反射率(B03)。我们可以使用ol/source/GeoTIFF数据源以RGB顺序加载三个单独的单波段GeoTIFF图像来完成此操作。

更新main.js代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const source = new GeoTIFF({
sources: [
{
// near-infrared(近红外) reflectance
url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/B08.tif',
max: 5000,
},
{
// red reflectance
url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/B04.tif',
max: 5000,
},
{
// green reflectance
url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/B03.tif',
max: 5000,
},
],
});

运行项目,查看假彩色合成的结果:

Sentinel-2 GeoTIFF的假彩色渲染

波段计算

本小节原文链接及内容

在之前的内容中,我们已经看到了ol/source/GeoTIFF数据源如何用于渲染真彩色和假彩色合成:通过将缩放的反射率值直接渲染到红色、绿色或蓝色显示通道之一来实现这一点。除此之外,还可以对来自GeoTIFF(或其他数据的切片数据源)的反射率值执行计算,并将输出值映射到RGBA颜色值。

ol/layer/WebGLTile图层对象接受可用于控制数据源渲染的style属性。在本例中,我们将使用前面的示例中使用过的Sentinel-2数据,并借助数学表达式来计算归一化植被指数(或植被覆盖指数,NDVI)。

NDVI是近红外(NIR)和红色之间的差值与近红外和红色反射率值之和的比率。

1
NDVI = (NIR - RED) / (NIR + RED)

这种归一化差异提供了绿色植被密度或健康的指数。在将近红外和红色反射率的差除以其总和时,根据亮度或照度的变化对指数进行归一化。我们的想法是,植被覆盖的阳坡应该与植被覆盖的阴坡具有类似的指数。

要渲染NDVI的值,我们需要使用近红外(B08)和红色(B04)波段来配置数据源对象,更新main.js脚本,使源代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const source = new GeoTIFF({
sources: [
{
// red reflectance
url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/B04.tif',
max: 10000,
},
{
// near-infrared(近红外) reflectance
url: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/B08.tif',
max: 10000,
},
],
});

接下来,我们将根据数据源的输入波段来创建表达式,然后用表达式来计算NDVI。将以下变量添加到main.js中:

1
2
3
4
5
6
7
8
9
10
// near-infrared(近红外) is the second band from above
const nir = ['band', 2];

// red is the first band from above
const red = ['band', 1];

const difference = ['-', nir, red];
const sum = ['+', nir, red];

const ndvi = ['/', difference, sum];

上面的表达式使用了基于数组的语法:[运算符,...参数]。波段运算符从ol/source/GeoTIFF的数据源列表中访问波段-其中第一个配置的波段为1,第二个为2,依此类推。-+/运算符计算其输入参数的差、和和比率。NDVI表达式将产生一个介于-1(植物不是很多)和1(植物很多)之间的值。下一步是将这些值映射到颜色值上。

ol/layer/WebGLTile图层的style属性中有一个color属性,它决定了每个像素的最终输出颜色。我们想在非植被(棕色)和植被(绿色)NDVI值之间插入颜色,为此,我们使用interpolate(插值)运算符,在一些停止值之间应用linear(线性)插值。

编辑你的main.js代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const layer = new TileLayer({
source: source,
style: {
color: [
'interpolate',
['linear'],
ndvi,
-0.2, // ndvi values <= -0.2 will get the color below
[191, 191, 191],
0, // ndvi values between -0.2 and 0 will get an interpolated color between the one above and the one below
[255, 255, 224],
0.2,
[145, 191, 82],
0.4,
[79, 138, 46],
0.6,
[15, 84, 10],
],
},
});

如果一切顺利,NDVI可视化效果如下:

由Sentinel-2 GeoTIFF计算生成的NDVI

选择这些停靠值和颜色是一项艰巨的工作,接下来我们将借助第三方库来帮助我们做这项工作。

colormap程序包

本小节原文链接及内容

colormap程序包是一个很好的创建色彩映射表的实用程序库,此库已作为项目的依赖项被添加。要导入它,请编辑main.js如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getColorStops(name, min, max, steps, reverse) {
const delta = (max - min) / (steps - 1);
const stops = new Array(steps * 2);
const colors = colormap({colormap: name, nshades: steps, format: 'rgba'});
if (reverse) {
colors.reverse();
}
for (let i = 0; i < steps; i++) {
stops[i * 2] = min + i * delta;
stops[i * 2 + 1] = colors[i];
}
return stops;
}

现在我们可以修改图层样式的color表达式,以使用一组停靠值和颜色值,编辑main.js中的图层定义以使用我们的新函数:

1
2
3
4
5
6
7
8
9
10
11
12
const layer = new TileLayer({
source: source,
style: {
color: [
'interpolate',
['linear'],
ndvi,
// color ramp(渐变) for NDVI values
...getColorStops('earth', -0.5, 1, 10, true),
],
},
});

使用新的彩色映射应用到NDVI输出,效果如下:

由Sentinel-2 GeoTIFF计算生成的NDVI

添加下拉框

本小节原文链接及内容

在前面的示例中,我们已经看到了同一个Sentinel-2图像的真彩色合成、假彩色合成和NDVI渲染。如果用户可以在不需要每次都更改代码的情况下从这些可视化效果中选择一种或更多,那就太好了。为此,我们将创建一个可用的可视化的列表,并向我们的页面添加一个<select>元素(即下拉框),让用户选择要显示的内容。

除了真彩色、假彩色和NDVI可视化之外,我们还将添加一个新的归一化水指数(NDWI),它NDVI类似,不同之处在于它可以用于监测水体的变化。

1
NDWI = (GREEN - NIR) / (GREEN + NIR)

正如我们已经看到的,每个可视化都需要有一组数据源(这些是用于单波段或多波段GeoTIFF的URL)、用于缩放GeoTIFF值的可选max值以及用于渲染图层的可选style。此外,我们将为每个可视化显示一个name

编辑main.js代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const visualizations = [
{
name: 'True Color',
sources: ['TCI'],
},
{
name: 'False Color',
sources: ['B08', 'B04', 'B03'],
max: 5000,
},
{
name: 'NDVI',
sources: ['B04', 'B08'],
max: 10000,
style: {
color: [
'interpolate',
['linear'],
['/', ['-', ['band', 2], ['band', 1]], ['+', ['band', 2], ['band', 1]]],
...getColorStops('earth', -0.5, 1, 10, true),
],
},
},
{
name: 'NDWI',
sources: ['B03', 'B08'],
max: 10000,
style: {
color: [
'interpolate',
['linear'],
['/', ['-', ['band', 1], ['band', 2]], ['+', ['band', 1], ['band', 2]]],
...getColorStops('viridis', -1, 1, 10, true),
],
},
},
];

现在,我们不是一次创建我们的GeoTIFF数据源和图层,而是在用户选择下拉框的可视化选项时借助函数创建它们。此函数的参数为base(即url的基础部分)和visualization,它会返回一个图层。编辑main.js以删除数据源和图层定义,并添加此函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function createLayer(base, visualization) {
const source = new GeoTIFF({
sources: visualization.sources.map((id) => ({
url: `${base}/${id}.tif`,
max: visualization.max,
})),
});

return new TileLayer({
source: source,
style: visualization.style,
});
}

接下来,我们可以更改main.js中的地图对象的定义,使其不包含任何图层(这些图层将在用户选择下拉框的可视化选项时添加):

1
2
3
const map = new Map({
target: 'map-container',
});

现在我们需要一种让用户选择显示哪种可视化方法的控件,为此,我们将在index.html<script>标签之前添加一个下拉框:

1
2
3
<div id="controls">
<select id="visualization"></select>
</div>

要使下拉框显示在地图的右上角,请将以下样式添加到index.html中的<style>标签中:

1
2
3
4
5
#controls {
position: absolute;
top: 20px;
right: 20px;
}

准备好<select>元素后,我们需要将每个可视化名称添加到下拉框的<option>标签中。为此,需要将以下代码添加到main.js中:

1
2
3
4
5
6
const visualizationSelector = document.getElementById('visualization');
visualizations.forEach((visualization) => {
const option = document.createElement('option');
option.textContent = visualization.name;
visualizationSelector.appendChild(option);
});

最后,我们将创建一个函数,根据选定的可视化效果使用新的图层更新地图。我们将为下拉框添加此函数作为change侦听器,并调用它来初始化应用程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
function updateVisualization() {
const visualization = visualizations[visualizationSelector.selectedIndex];
const base =
'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A';

const layer = createLayer(base, visualization);
map.setLayers([layer]);

map.setView(layer.getSource().getView());
}

visualizationSelector.addEventListener('change', updateVisualization);
updateVisualization();

运行代码后效果如下:

为多种类型的可视化添加一个下拉框

本小节原文链接及内容

接下来要实现让用户可以选择图像数据源。首先需要列出一个图像列表,对于每张图像,我们希望在下拉框中显示图像名称,修改main.js如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const images = [
{
name: 'Buenos Aires',
base: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A',
},
{
name: 'Minneapolis',
base: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/15/T/WK/2021/9/S2B_15TWK_20210918_0_L2A',
},
{
name: 'Cape Town',
base: 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/34/H/BH/2021/9/S2B_34HBH_20210922_0_L2A',
},
];

接下来,我们将添加另一个下拉框,让用户选择图像,并在index.html中,将控件调整为如下所示:

1
2
3
4
<div id="controls">
<select id="image"></select>
<select id="visualization"></select>
</div>

我们需要为每个图像源使用一个<option>标签并添加到下拉框中,在main.js中添加以下内容:

1
2
3
4
5
6
const imageSelector = document.getElementById('image');
images.forEach((image) => {
const option = document.createElement('option');
option.textContent = image.name;
imageSelector.appendChild(option);
});

接下来只需对更新可视化的函数做一个微小的调整,以便它从所选图像源获取base(即url的基础部分)。在main.js中编辑updateVisualization函数及其周围的行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let previousBase;
function updateVisualization() {
const visualization = visualizations[visualizationSelector.selectedIndex];
const base = images[imageSelector.selectedIndex].base;
const newBase = base !== previousBase;
previousBase = base;

const layer = createLayer(base, visualization);
map.setLayers([layer]);

if (newBase) {
map.setView(layer.getSource().getView());
}
}

visualizationSelector.addEventListener('change', updateVisualization);
imageSelector.addEventListener('change', updateVisualization);
updateVisualization();

运行结果如下:如果用户选择了新的图像源或可视化类型,都会更新视图。

矢量切片和Mapbox样式

本小节原文链接及内容

在本节内容中,我们将学习有关在OpenLayers中使用矢量切片的所有内容,并引入ol-mapbox-style实用程序来处理Mapbox Style文件。

矢量切片图层

本小节原文链接及内容

我们现在知道了如何加载切片影像,并且我们学习了加载和渲染矢量数据的不同方法。但是,若切片数据不仅可以快速地传输到浏览器上,并且可以动态地设置样式,那会怎么样?这就是矢量切片的用途。OpenLayers通过VectorTile图层对象来支持矢量切片。

使用矢量数据渲染世界地图

index.html界面布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>OpenLayers</title>
<style>
@import "node_modules/ol/ol.css";
</style>
<style>
html, body, #map-container {
margin: 0;
height: 100%;
width: 100%;
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="map-container"></div>
<script src="./main.js" type="module"></script>
</body>
</html>

main.js中引入所需的包:

1
2
3
4
5
import MVT from 'ol/format/MVT';
import VectorTileLayer from 'ol/layer/VectorTile';
import VectorTileSource from 'ol/source/VectorTile';
import {Map, View} from 'ol';
import {fromLonLat} from 'ol/proj';

我们要使用的数据源是来自Natural Earth数据的世界各国的简单地图,并由Geoserver提供矢量切片。

创建一个map对象:

1
2
3
4
5
6
7
const map = new Map({
target: 'map-container',
view: new View({
center: fromLonLat([0, 0]),
zoom: 2,
}),
});

我们这次使用的图层类型是VectorTileLayer,并使用VectorTileSource

1
2
3
4
5
6
7
8
9
10
const layer = new VectorTileLayer({
source: new VectorTileSource({
format: new MVT(),
url:
'https://ahocevar.com/geoserver/gwc/service/tms/1.0.0/' +
'ne:ne_10m_admin_0_countries@EPSG%3A900913@pbf/{z}/{x}/{-y}.pbf',
maxZoom: 14,
}),
});
map.addLayer(layer);

上述提供的数据源仅支持从0到14的缩放级别,因此我们需要将此信息传递给数据源。矢量切片图层通常针对512像素的切片大小进行优化,这也是VectorTile数据源的切片网格的默认大小。数据提供程序要求我们显示一些属性,我们也将这些属性添加到数据源对象的配置中。

正如你所看到的,和VectorSource一样,VectorTileSource也配置了formaturl属性。MVT格式对象可以解析Mapbox矢量切片。与使用栅格数据切片一样,切片数据通过切片的缩放级别以及x和y坐标进行访问,因此,URL包含缩放级别的{z}占位符,以及瓦片坐标的{x}{y}占位符。

运行项目代码后,一个未设置样式的矢量切片地图如下图所示:

一个未设置样式的世界地图

本小节原文链接及内容

显然,上面的地图需要设置样式,而VectorTile图层的样式与Vector图层的样式工作方式完全相同,因此在使用Vector图层时创建的样式也可以在这里使用。

对于像上面这样的地图,想为矢量图层创建数据驱动的样式其实很简单,但矢量切片也被用于街道地图,根据地图的缩放级别,样式通常会有很大差异,在这样的情况下,手工完成所有这些工作可能太耗时了。

在Web制图的历史上,曾有过许多尝试,创建了许多用于设置地图样式的工具和格式,这其中最流行的格式可能是SLD和CartoCSS,能想到的一个图形工具便是Atlas Styler,但这些格式或工具使用起来并不是非常方便。

Mapbox最终推出了Mapbox Studio,一个对用户非常友好的样式编辑器,以及Mapbox Style格式。Mapbox Style的格式易于手动读取和写入,并得到越来越多的应用程序的支持。图形化开源编辑器像Maputnik可作为Mapbox Studio的独立替代品,用于创建和修改Mapbox Style文件。

使用MapBox Style定义

在OpenLayers中使用带有Mapbox Style的矢量切片图层有两种方法,其中,最简单的是MapboxVector图层,它配置了一个指向Mapbox Style文档的url。首先导入需要用到的包:

1
import MapboxVectorLayer from 'ol/layer/MapboxVector';

我们要使用的切片数据集位于 https://cloud.maptiler.com/maps/bright/ ,要将其添加到我们的示例中,您将需要一个MapTiler帐户(请将下面代码中的key替换为你自己的),或者,如果你有一个Mapbox帐户,你可以使用Mapbox中的地图原件(参见下面代码中的注释)。

1
2
3
4
5
6
7
8
const layer = new MapboxVectorLayer({
styleUrl:
'https://api.maptiler.com/maps/bright/style.json?key=lirfd6Fegsjkvs0lshxe',
// or, instead of the above, try
// styleUrl: 'mapbox://styles/mapbox/bright-v9',
// accessToken: 'Your token from https://mapbox.com/'
});
map.addLayer(layer);

上面的代码替换了上一步中的VectorTileLayer。运行项目代码,打开地图后,放大到布宜诺斯艾利斯,效果如下:

一张明亮的布宜诺斯艾利斯地图

根据MapBox Style定义构建一幅完整的地图

Mapbox Style格式不仅仅是为样式化矢量数据而设计的,它是用来描述一个完整的地图,包括它所有的数据源和图层,以及它的初始视图配置(例如中心和缩放级别)。

ol-mapbox-style包使得OpenLayers能够支持Mapbox Style格式。因此,使用OpenLayers矢量切片图层的第二种方法是:使用ol-mapbox-style来创建整个地图。修改main.js中的代码:

1
2
3
4
5
import olms from 'ol-mapbox-style';
olms(
'map-container',
'https://api.maptiler.com/maps/bright/style.json?key=lirfd6Fegsjkvs0lshxe'
);

与矢量切片要素交互

本小节原文链接及内容

我们可以与加载到客户端的矢量切片数据的要素进行交互。有一点需要注意的是,矢量切片在渲染时被优化过,这意味着要素只包含过滤和渲染所需的属性,并且几何图形针对渲染的分辨率进行了优化,并在切片(瓦片)边界附近进行了裁剪。

在本次练习中,当鼠标悬停在要素上时,我们将在指针所在的位置周围画一个方框。

如下所示:导入需要用到的包:

1
2
3
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import {Stroke, Style} from 'ol/style';

接下来,我们创建一个数据源并将其配置到矢量图层上:

注:为矢量图层设置map属性的用处:将该层设置为地图上的overlay,地图不会在其Layers集合中管理该层,并且该层将在顶部渲染,这对于临时图层非常有用。而将图层添加到地图并由地图管理它的标准方法是使用map.addLayer(),详见api文档解释:OpenLayers v7.3.0 API - Class: VectorLayer

1
2
3
4
5
6
7
8
9
10
11
const source = new VectorSource();
new VectorLayer({
map: map,
source: source,
style: new Style({
stroke: new Stroke({
color: 'red',
width: 4,
}),
}),
});

现在是时候给地图添加一个pointermove监听器了,它可以获取指针位置上的所有要素,并将它们的边界框添加到图层中。我们需要两个额外的导入:

1
2
import Feature from 'ol/Feature';
import {fromExtent} from 'ol/geom/Polygon';

最后,我们在获取新要素之前先清空数据源对象中的所有要素,并将指针位置的要素的边界框作为新要素添加到数据源对象中:

1
2
3
4
5
6
7
8
9
10
11
12
13
map.on('pointermove', function (event) {
source.clear();
map.forEachFeatureAtPixel(
event.pixel,
function (feature) {
const geometry = feature.getGeometry();
source.addFeature(new Feature(fromExtent(geometry.getExtent())));
},
{
hitTolerance: 2,
}
);
});

现在,当鼠标指针悬停在地图上时,结果应该如下所示:

在地图上悬停鼠标指针

WebGL点的渲染

本小节原文链接及内容

在本节内容中,我们将使用WebGL从CSV文件中自定义渲染陨石撞击数据。

本文将借助Canvas 2D上下文并使用标准的矢量图层来渲染点要素,然后我们将使用新的点渲染工具来使用WebGL渲染相同的数据。最后,我们将开发一个自定义片段着色器来展示如何动态地设置数据样式。

使用Canvas 2D渲染点

本小节原文链接及内容

在OpenLayers 6版本中,地图中的每个图层都有一个独立的渲染器。这在以前,所有图层渲染都由单个地图渲染器来管理,而且所有图层的渲染使用的是单个渲染策略。因此,在OpenLayers 6版本之前,你需要去选择到底使用Canvas 2D去渲染所有图层还是使用WebGL去渲染所有图层。在OpenLayers 6中,您可以拥有由具有不同渲染策略的图层组成的地图。例如,可以使用Canvas 2D渲染器渲染一些图层,而使用WebGL渲染器渲染其他图层。

ol/layer/Vector类会使用Canvas 2D来渲染点、线或多边形。该图层有一个功能齐全的渲染器,在要素的样式化(设计)方面具有很大的灵活性。对于大量的要素,WebGL是一种更合适的技术。在本文中,我们将首先使用Canvas 2D渲染45000个陨石位置,然后将示例迁移至使用WebGL来渲染。

首先,编辑index.html文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>OpenLayers</title>
<style>
@import "node_modules/ol/ol.css";
</style>
<style>
html, body, #map-container {
margin: 0;
height: 100%;
width: 100%;
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="map-container"></div>
<script src="./main.js" type="module"></script>
</body>
</html>

接下来,我们将从本地CSV文件获取并解析数据,将生成的要素添加到矢量数据源,并使用矢量图层将其渲染到地图上。

meteorites.csv文件中包含的数据如下所示:

1
2
3
name,mass,year,reclat,reclong
Aachen,21,1880,50.775000,6.083330
...

文件的第一行是标题行,我们将在解析时跳过它,之后的每一行根据首行的地点名称、陨石质量、撞击年份、纬度和经度使用逗号分隔。我们将使用XMLHttpRequest客户端来获取数据,并编写一个函数将文件中的每一行数据解析为具有点几何形状的要素。

更新main.js文件,以加载和渲染一个包含陨石撞击的数据的本地CSV文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import {Map, View} from 'ol';
import {Stamen, Vector as VectorSource} from 'ol/source';
import {fromLonLat} from 'ol/proj';

const source = new VectorSource();

const client = new XMLHttpRequest();
client.open('GET', './data/meteorites.csv');
client.onload = function () {
const csv = client.responseText;
const features = [];

let prevIndex = csv.indexOf('\n') + 1; // 忽略首行

let curIndex;
while ((curIndex = csv.indexOf('\n', prevIndex)) != -1) {
const line = csv.substr(prevIndex, curIndex - prevIndex).split(',');
prevIndex = curIndex + 1;

const coords = fromLonLat([parseFloat(line[4]), parseFloat(line[3])]);
if (isNaN(coords[0]) || isNaN(coords[1])) {
// guard against bad data,避免空(无效)数据的读取
continue;
}

features.push(
new Feature({
mass: parseFloat(line[1]) || 0,
year: parseInt(line[2]) || 0,
geometry: new Point(coords),
})
);
}
source.addFeatures(features);
};
client.send();

const meteorites = new VectorLayer({
source: source,
});

new Map({
target: 'map-container',
layers: [
new TileLayer({
source: new Stamen({
layer: 'toner',
}),
}),
meteorites,
],
view: new View({
center: [0, 0],
zoom: 2,
}),
});

在获取和解析数据之后,特征被添加到矢量源。该源在平铺层上的矢量层中渲染。我们在这里没有对要素进行任何定制样式,重点只是想看看使用带有45,000个要素的地图是什么感觉,这些要素是用Canvas 2D渲染的。

在获取和解析数据之后,要素被添加到矢量数据源,该数据源会在已加载的切片图层的上方被渲染。在这里我们没有对要素配置任何的样式,重点只是想看看使用带有45,000个要素的地图是什么感觉,并且这些要素是用Canvas 2D渲染的。

陨石撞击地点

使用WebGL渲染点

本小节原文链接及内容

在上一小节中,我们使用标准的矢量图层来渲染点要素,并且该图层使用Canvas 2D上下文进行了渲染。有了这个图层,只需认真编写高效的样式代码,就可以渲染数万个点。但对于渲染更多的点,或者进行更高效的动态样式,WebGL会是一个更好的解决方案。OpenLayers拥有越来越多的使用WebGL进行渲染的实用程序集。在本练习中,我们将使用它来渲染点的几何图形。

首先,我们将导入启用webgl的点图层的构造函数,这种图层是利用WebGL技术优势的一个易于使用的入口点。

1
import WebGLPointsLayer from 'ol/layer/WebGLPoints';

现在可以将之前导入的VectorLayer包删除。

替换康斯特陨石。使用与上一小节相同的矢量数据源,对WebGLPointsLayer的实例进行赋值。

1
2
3
4
5
6
7
8
9
10
11
const meteorites = new WebGLPointsLayer({
source: source,
style: {
symbol: {
symbolType: 'circle',
size: 14,
color: 'rgb(255, 0, 0)',
opacity: 0.5,
},
},
});

运行项目代码,用WebGL渲染的陨石撞击地点的效果如下:

以圆形渲染的影响地点

从上面的代码可以看到,我们在创建图层时指定了style参数,并且该样式允许我们指定点的外观(红色、半透明圆)。

更改WebGL图层的样式与Openlayers库的其余部分非常不同。我们不像其他矢量图层那样使用FillStrokeImage类,而只需为对象提供Style参数。该对象支持的属性则直截了当:opacity(透明度)、color(颜色)、size(大小)、offset(偏移量)、src(用于图像)和symbolType(可以是circle(圆形)、square(正方形)、triangle(三角形)或image(图像))。

WebGL图层使用完全不同的渲染系统,style对象实际上被动态转换为片段和顶点着色器。

通过在地图中导航,你会发现与使用标准Canvas 2D渲染的图层相比,性能有所提高。

现在,事实上这张地图看起来并不是很好看:因为每个点都有相同的样式。

让我们先根据陨石的质量来确定圆的大小,为了实现这一点,我们将样式的大小替换为以下表达式:

1
2
3
4
5
size: [
'+',
['*', ['clamp', ['*', ['get', 'mass'], 1 / 20000], 0, 1], 18],
8,
],

这个表达式会产生最小8个像素的大小,它可以根据陨石的质量增加18个像素,而且WebGLPointsLayer类支持采用类似上述的这种表达式,配置其样式的数值属性(如size(大小)、opacity(透明度)、color components(颜色组件)等)。

表达式由包含运算符的数组来表示,如下所示:

1
2
3
4
['get', 'mass']
['clamp', value, 0, 1]
['*', value, 18]
['+', value, 8]

第一个操作符get将根据要素的名称读取要素的属性。这里展示的其他操作符clamp*+允许操作另一个运算符的输出。在前面的例子中,我们用这些数据将陨石的质量数值转换为8到26之间,并作为数值最终的大小。

实现的效果如下:看起来好看多了。

以陨石质量为大小的圆圈

制作陨石撞击动画

本小节原文链接及内容

到目前为止,我们已经设法从CSV文件中获得数据,并使用WebGL进行了渲染,但地图展示的效果看起来依旧不是很好。我们使用陨石的质量来确定圆的半径,但我们没有使用陨石撞击日期,我们需要在点要素中将其解析为year属性。

我们陨石撞击数据的year属性的值从1850到2015年不等。我们将设置一个动画循环,该循环会递增当前年份,在陨石的影响的年份渲染陨石,然后随着时间的推移减小其大小和不透明度。

第一步,我们将开始执行动画循环,并将当前年份呈现为地图顶部的<div>,在index.html中的地图容器(即<div id="map-container"></div>)后面添加以下内容:

1
<div id="year"></div>

编辑<style>样式标签,添加如下样式:

1
2
3
4
5
6
7
8
9
#year {
position: absolute;
bottom: 1em;
left: 1em;
color: white;
-webkit-text-stroke: 1px black;
font-size: 2em;
font-weight: bold;
}

现在,打开main.js文件,我们将声明一些变量来表示数据的时间范围和动画进行的速度,在main.js添加以下代码:

1
2
3
4
5
6
7
8
9
10
const minYear = 1850;
const maxYear = 2015;
const span = maxYear - minYear;
const rate = 10; // years per second

const start = Date.now();

const styleVariables = {
currentYear: minYear,
};

为了能够在样式表达式中访问当前年份,我们需要为图层的style对象上的variables属性配置为styleVariablesstyle对象中的variables是可用于计算的表达式中的数值。

1
variables: styleVariables,

接下来,我们需要将Map的实例分配给一个可以稍后引用的map变量:

1
const map = new Map({

在地图配置下面,添加以下render函数来启动动画循环。

1
2
3
4
5
6
7
8
9
10
11
12
const yearElement = document.getElementById('year');

function render() {
const elapsed = (rate * (Date.now() - start)) / 1000;
styleVariables.currentYear = Math.round(minYear + (elapsed % span));
yearElement.innerText = styleVariables.currentYear;

map.render();
requestAnimationFrame(render);
}

render();

如果你做对了上述这些工作,你应该会在地图的左下角看到年份的流逝。

年份的流逝

我们还向meteorites图层添加了另一个图层选项,这将提高动画的性能,这样便不需要在每个动画步骤中处理命中检测数据。

1
disableHitDetection: true,

现在为了在地图上显示时间背景,我们只想从陨石撞击的时间开始,每颗陨石将显式10年的时间。为此,我们将向meteorites图层的style对象添加filter属性:

1
filter: ['between', ['get', 'year'], periodStart, ['var', 'currentYear']],

这个过滤器引用了一个periodStart变量,它本身就是一个表达式,我们需要定义它。在这里,我们定义了一些更多的样式对象,让我们在const meteorites图层定义中添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
const period = 10;
const periodStart = ['-', ['var', 'currentYear'], period];
const decay = [
'interpolate',
['linear'],
['get', 'year'],
periodStart,
0,
['var', 'currentYear'],
1,
];

decay是一个表达式,我们将使用它来减小圆圈的大小和不透明度,以使这些圆圈在地图上逐渐淡出。decay为我们提供了一个0到1之间的值,我们可以将其应用为褪色效果的乘数,若要使用它随着时间减小圆圈的大小,我们必须修改style对象中的size属性:下面新的size表达式的第四行就是之前的表达式。

1
2
3
4
5
size: [
'*',
decay,
['+', ['*', ['clamp', ['*', ['get', 'mass'], 1 / 20000], 0, 1], 18], 8],
],

同样地,为了随着时间的推移减少不透明度,我们还将decay应用于opacity属性:

1
opacity: ['*', 0.5, decay],

最终实现的效果如下:

流星雨的效果

大功告成!

部署

本小节原文链接及内容

在整个研讨会中,我们一直使用开发服务器来查看示例。这类似于使用ol包开发应用程序时使用的设置。当你准备好部署应用程序时,你将希望使用构建步骤创建应用程序入口点的精简捆绑包。

在开发期间,我们一直在使用Vite进行模块捆绑。当我们使用npm start启动开发服务器时,我们在development模式下运行Vite。在production模式下,捆绑包被压缩。

要构建用于部署的资源,我们将运行package.json中的build脚本:

1
npm run build

这将运行vite build,它会将data目录复制到dist/文件夹。

构建完成后,dist目录中将会有打包后的资源,这些资源便是你要部署到生产服务器(如S3,或使你希望托管应用程序的任何位置)的资源。你可以通过运行本地的http服务器来查看应用程序的样子,命令如下所示:

1
npx serve dist

现在可以打开http://localhost:3000/查看应用程序在生产环境中的效果。

就是这样,你已经完成了!

数据切片

本小节原文链接及内容

WebGL切片图层渲染器允许我们在渲染之前处理像素值。在接下来的练习中,我们会将高程数据编码为RGB值的切片集合。在这一部分中,我们还将创建一张地图,让用户可以控制海平面,使用滑块控件来改变的海平面数值,我们将在底图上呈现用户调整后的海平面。

创建地图

本小节原文链接及内容

编辑index.html文件,以便开始在整个页面渲染一幅地图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>OpenLayers</title>
<style>
@import "node_modules/ol/ol.css";
</style>
<style>
html, body, #map-container {
margin: 0;
height: 100%;
width: 100%;
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="map-container"></div>
<script src="./main.js" type="module"></script>
</body>
</html>

清除main.js并添加以下导入:

1
2
3
4
5
import Map from 'ol/Map.js';
import TileLayer from 'ol/layer/WebGLTile.js';
import View from 'ol/View.js';
import XYZ from 'ol/source/XYZ.js';
import {fromLonLat} from 'ol/proj.js';

在本章节中,我们将使用MapTeller中的切片数据。如果你还没有账户,你可以注册一个免费的账户,并在这些例子中使用你的密钥。注册账号后,找到你的默认API密钥,并将其添加到main.js中:

1
const key = '<your-default-api-key>';

为了验证一切是否正常工作,我们将使用你的API密钥创建一幅具有单个图层的地图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const attributions =
'<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>';

new Map({
target: 'map-container',
layers: [
new TileLayer({
source: new XYZ({
url: 'https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=' + key,
attributions: attributions,
crossOrigin: 'anonymous',
tileSize: 512,
}),
}),
],
view: new View({
center: fromLonLat([-58.3816, -34.6037]),
zoom: 11,
}),
});

效果如下:

一幅显示布宜诺斯艾利斯的地图

渲染高程数据

本小节原文链接及内容

MapTiler提供了一个全球的切片数据集,其中的高程数据被编码为PNG文件。

让我们将这些切片添加到地图上。在main.js中,创建一个使用MapTilerterrain-rgb切片数据集的新图层:

1
2
3
4
5
6
7
8
9
10
const layer = new TileLayer({
opacity: 0.6,
source: new XYZ({
url:
'https://api.maptiler.com/tiles/terrain-rgb/{z}/{x}/{y}.png?key=' + key,
maxZoom: 10,
tileSize: 512,
crossOrigin: 'anonymous',
}),
});

将该图层添加到地图后,通过重新加载页面,在你的基础图层上会显示一些奇怪的颜色切片数据。Terrain-RGB切片中的高程数据会通过rgb通道进行编码。因此,虽然这些数据并不是直接被渲染,但它看起来很有趣。

Terrain-RGB切片在地图上的渲染效果

渲染海平面

本小节原文链接及内容

在上节小节中,我们直接在地图上渲染了Terrain-RGB切片数据,接下来我们要做的是在地图上渲染海平面,我们希望用户能够调整海平面以上的高度,并在地图上看到调整后的高度。为此,我们将使用WebGL切片图层直接处理高程数据,并通过在页面上的拖动滑块控件获取用户的输入。

让我们先将控件添加到页面,打开在index.html,添加以下标签和滑块控件:

1
2
3
4
5
<label id="slider">
Sea level
<input id="level" type="range" min="0" max="100" value="1"/>
+<span id="output"></span> m
</label>

然后给这些控件添加一些样式(在index.html<style>中):

1
2
3
4
5
6
7
#slider {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
text-shadow: 0px 0px 4px rgba(255, 255, 255, 1);
}

我们希望在渲染之前操作像素值,而不是直接渲染从Terrain-RGB切片数据获取的R、G、B、A值。WebGL切片图层会在渲染样式表达式之前处理它们中的像素值,此表达式会针对输入源中的每个像素进行求值。

首先,导入WebGLTile图层的类(在main.js中):

1
import TileLayer from 'ol/layer/WebGLTile.js';

terrain-rgb切片数据中的高程值会根据以下公式进行编码,并且会被编码为的红色、绿色和蓝色值(从0到255):

1
elevation = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)

我们可以使用WebGL风格的表达式语言来表示该公式,将下面的表达式添加到main.js中。此表达式会对输入的高程数据进行解码,即将红色、绿色和蓝色值转换为单个高程测量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// band math operates on normalized values from 0-1
// so we scale by 255 to align with the elevation formula
// from https://cloud.maptiler.com/tiles/terrain-rgb/
const elevation = [
'+',
-10000,
[
'*',
0.1 * 255,
[
'+',
['*', 256 * 256, ['band', 1]],
['+', ['*', 256, ['band', 2]], ['band', 3]],
],
],
];

使用上面的表达式创建一个WebGL切片图层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const layer = new TileLayer({
opacity: 0.6,
source: new XYZ({
url:
'https://api.maptiler.com/tiles/terrain-rgb/{z}/{x}/{y}.png?key=' + key,
maxZoom: 10,
tileSize: 512,
crossOrigin: 'anonymous',
}),
style: {
variables: {
level: 0,
},
color: [
'case',
['<=', elevation, ['var', 'level']],
[139, 212, 255, 1],
[139, 212, 255, 0],
],
},
});

上面的color表达式使用case表达式将海平面或海平面以下的值着色为蓝色,而高于海平面的高程值将是透明的(alpha值为0)。

接下来,我们需要监听滑块控件上值的更改,并在用户调整该值时更新seaLevel样式变量。

1
2
3
4
5
6
7
8
9
const control = document.getElementById('level');
const output = document.getElementById('output');
const listener = function () {
output.innerText = control.value;
layer.updateStyleVariables({level: parseFloat(control.value)});
};
control.addEventListener('input', listener);
control.addEventListener('change', listener);
output.innerText = control.value;

一切都准备就绪,现在地图上应该有一个滑块,用户可以通过拖动滑块来控制海平面的变化。

海平面上升


评论
avatar
风停在左肩
生活原本沉闷,但跑起来就有风
公告
近期一直在复现、学习Cesium的官方示例,先快速过一遍示例,复杂的或看不懂的可以放之后再过一遍。
最新文章
网站资讯
文章数目 :
386
已运行时间 :
本站总字数 :
300.2k
本站访客数 :
本站总访问量 :
最后更新时间 :