最近我们团队,越来越多地有一些对地图进行可视化的需求。基本上都是基于 Mapbox GL JS 去做的,但翻阅网上的一些资料,发现对它的源码分析比较少,而且比较粗略。因此准备整理分享一下,增进自己以及大家对 Mapbox GL JS 的认识。
此一篇是从一个概况的视角,解析总共有哪些模块,之间的相互关系。希望有后续章节逐渐细致下来。如有不准确,或有所补充的希望大家多多留言,不吝赐教。
本文要点
- Camera Control: 相机,及其 UI 控制
- Tiled Sources:按瓦片加载的数据源
- Layers & Expressions:Layers 和其灵活的表达式属性
- Main Workflow:串起整体的工作流
Camera Control
关于相机的控制,我们可以看到 Mapbox GL JS 提供了很多的 API 和 示例,比如:
以下是 fitBounds 到某条线的代码用例:
// 创建一个 Map 实例var map = new mapboxgl.Map({container: 'map',style: 'mapbox://styles/mapbox/light-v10',center: [-77.0214, 38.897],zoom: 12})// 根据线的坐标计算出它的 Bounds 边界范围var bounds = coordinates.reduce(function(bounds, coord) {return bounds.extend(coord);}, new mapboxgl.LngLatBounds(coordinates[0], coordinates[0]));// 调用 fitBounds 将 Camera 视角对准到这条 line 的边界范围map.fitBounds(bounds, {padding: 20});
那么这些在 Mapbox GL JS 内部是怎么实现的呢?
让我们来看下 Map 类的类图:
可以看到 Map 类继承了 Camera 类,而 Map 上的 fitBounds 之类的相机控制方法,也直接继承自 Camera。而 Camera 由组合了一个 Transform 实例,它负责根据当前的 zoom (缩放级别)以及 pitch (倾斜度)计算相应的 matrices,用于 WebGL 的坐标投影转换中。
另外,还有一个类,它是 Camera 的在 Mapbox GL JS 代码内部的主要用户 HandlerManager,它负责提供一些默认的 UI 交互对 Camera 的控制,比如 ClickZoomHandler 双击时放大视图,BoxZoomHandler 会在框选某个区域时放大视图到那个区域 之类的很多 Handers。
同时为了让用户可以直接开启或关闭某个具体 UI hander ,MapboxGLJS 将具体的 handers 挂在 Map 实例下,比如:
map.boxZoom = new BoxZoomHandler(map, options)
因此用户可以调用 map.boxZoom.enable() / disable() 来启用或关闭 BoxZoomHandler。
Tiled Sources
Source 代表 Map 需要用到的数据源。仅仅有 Source 还不足让数据显示在地图上,Layer 将会应用某个 Source 并赋予它一个可视化的表达。这样的组合关系,可以让我们对同一个 Source 指定多种表达方式,多个 Layers。
Source 目前有这些可选的类型:vector, raster, raster-dem, geojson, image, video, canvas。
其中 vector 和 raster source 属于 Tiled Sources,瓦片化的。这对于可规模化的地图是一种很重要的实现对数据源按需加载的机制。接下来着重讲下这部分的源码。
比如,当用户添加一个 vector source 时:
map.addSource('mapbox-terrain', {type: 'vector',url: 'mapbox://mapbox.mapbox-terrain-v2'})// 其中的 url 会指定请求到一个 json 具体地描述这个 source 的属性,所以上面的这个代码大概地等同于map.addSource('mapbox-terrain', {type: 'vector',tiles: [// {z}/{x}/{y} 会被实际替代为 每个瓦片所处的 zoom 层级和它在 x 和 y 方向的一个序号'https://a.tiles.mapbox.com/v4/mapbox.mapbox-terrain-v2/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoiZXhhbXBsZXMiLCJhIjoiY2p0MG01MXRqMW45cjQzb2R6b2ptc3J4MSJ9.zA2W0IkI0c6KaAhJfk9bWg']})
在源码上,则涉及以下模块:
一个 Map 会拥有一个 Style 实例,并委托它真正管理了 Sources 和 Layers。
其中在真正的 Source 上又一个 SourceCache 层,用于处理一些与缓存相关的事情,比如对从接口上可以看出,外部只需要告诉 SourceCache 是否需要添加某个瓦片(addTile)了,而 SourceCache 内部会根据它的缓存策略决定是否真的要加载瓦片(Source.loadTile),还是说缓存里已经有了,而 TileCache 主要使用了最近最少使用(LRU, Least Recently Used)的缓存置换策略。
最后真正加载某个瓦片时,又分为两种主要的实现路线:
- 一种是直接在具体 Source.loadTile 里直接加载
- 一种是传递到 Worker 线程中去完成瓦片加载任务,完成以后在返回给主线程
直接加载
直接加载,最典型的例子就是 RasterTileSource
loadTile(tile: Tile, callback: Callback<void>) {const url = this.map._requestManager.normalizeTileURL(tile.tileID.canonical.url(this.tiles, this.scheme), this.tileSize);// 它直接在主线程中加载这个瓦片的栅格图片tile.request = getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), (err, img) => {// 图片加载完成后的回调...callback();}});}
Worker 加载
通过 Worker 加载,最典型的例子就是 VectorTileSource
可以看到 Worker 就像一个镜面一样划分着 主线程和 worker 线程。在 worker 中都有相应的 WorkerSource 和 WorkerTile 去做在它们主线程中相应的逻辑模块的实际工作。
而 Actor 它就像一个上层一些的通信协议一样标准化并简化着 主线程到 worker 线程之间的通信。比如
在 GeoJSONSource 中可以发送一个指令 geojson.getClusterExpansionZoom 来调用 GeoJSONWorkerSource 中的 getClusterExpansionZoom 方法。
getClusterExpansionZoom(clusterId: number, callback: Callback<number>) {
this.actor.send('geojson.getClusterExpansionZoom', {clusterId, source: this.id}, callback);
return this;
}
另外 Actor 还负责维护了一个任务队列,这部分还没有具体看,可能后续再补充。
Layers & Expressions
前面已经提到过,Layer 代表对某个 Source 数据源的一种可视化表达。它可以是 点、线、多边形、图标等等,也内置支持了很多种类型,而且还支持自定义 CustomLayer。
那 Layer 是通过什么来定义丰富的样式类型的呢?主要是它的一些属性配置 paint properties 和 layout properties,以及灵活的值的表达 Expressions。
比如,当用户添加一个 FillLayer 来画填充的多边形时:
// The feature-state dependent fill-opacity expression will render the hover effect
// when a feature's hover state is set to true.
map.addLayer({
'id': 'state-fills',
'type': 'fill',
'source': 'states',
'layout': {},
'paint': {
'fill-color': '#627BC1',
'fill-opacity': [
'case',
['boolean', ['feature-state', 'hover'], false],
1,
0.5
]
}
})
其中 fill-color 和 fill-opacity 都是 paint 属性,而 fill-opacity 的值是一个复合表达式 CompositeExpression,代表 如果 feature state 的 hover 如果为 true 则透明度为1,如果为 false 则透明度为 0.5。
那这部分是如何实现的呢?
StyleLayer 类也就代表了 Layer 的概念,其中 paint 和 layout 的都是 ProssibleEvented 的,它包括以下 PossiblyEvaluatedPropertyValue 的键值对,而其中 PossiblyEvaluatedValue 包含了四种类型 ConstantExpression、SourceExpression、CameraExpression、CompositeExpression。
Map 还用到了一个重要的模块是 painter,它负责渲染的部分,从 Mapbox GL JS 的命名中我们也可以 get 到它是使用 WebGL 进行渲染的,其中有个 draw 的方法接口,各种具体的 drawCircles、drawLines 都满足这个方法的类型约束,它们会在 Tile.buckets 的 Bucket 模块里拿 WebGL 渲染时需要用到的物料,比如 layoutVertexBuffer(布局顶点坐标)、indexBuffer 、uniformValues (配置常量参数)等等,而这些数据很多都是根据 ProgramConfiguation 生成的,这里面就涉及了对 PossiblyEvaluatedValue 的评估,也就是各种表达式起作用的地方。
需要注意的是,这里关于 PossiblyEvaluatedValue、各种类型的 Expression 以及 Expression Interface 的关系,在代码中还没有直接的关系,而是有很多中间类型参与在里面,需要等后续再梳理清楚了。
Main Workflow
如果说从添加了 Source 和其相应的 Layer 以后,到可视化出来的一个主要工作流,可以大概表示如下:
- triggerRepaint -> addTile:根据当前相机视角范围,添加相应的瓦片
- Load Tile (May in worker):加载瓦片数据(放到 Bucket 中),并且可能会通过 Worker 来加载
- Event Propagate:当瓦片数据加载完成以后,通过事件冒泡机制(非原生,由 Evented 基类实现)通知到 Map
- Painter draw:调用 Painter 进行绘制,通过 draw 方法绘制瓦片 Bucket 中的数据。
小结
本文大概分析了一下 MapboxGLJS 中一些比较重要的模块,组成情况,以及之间的联系。但对其中一些具体的逻辑运转还有很多不尽清楚的地方,希望后面逐渐补足,也请大家多多指教。
