在高德地图上实现Polygon图层
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
前言
有朋友问我上一篇文章示例里行政区域的动画效果时怎么做实现的,那么今天就来分享它的开发过程以及遇到都问题。该方案通过将高德地图与three.js的功能相结合,实现了立体光感效果,并添加了动画效果,使行政区域在视觉上更加生动和引人入胜。这个方法可以为地理信息可视化提供更丰富和交互性的体验。通过使用地图的地理数据和three.js的渲染能力,我们实现了独特的方式来展示行政区域,并为用户提供了更多的信息和感知。
需求分析
我们先对这个效果的主体做分解,可以看到这个区块的主体,其实是由5个层叠加形成的,从下往上,最底下是2个相同的带边的多边形,第3层则是前面大多边形的分割细化,第4层为垂直于地图的边缘墙体,第5层是动态的点标记元素。
前面3层的类型本质上是一样的,就是可以设置海拔高度的PolygonLayer图层,第4层给它命名为BorderLayer图层,第5层命名为DomLayer。因此我们只要实现3个图层效果即可。
使用工具
名称 | 版本 | 作用 |
---|---|---|
水经注 | 下载行政区域边界数据 | |
QGIS | 3.34.3 | 进行数据处理,并导出GeoJSON文件 |
AMap JS API | 2.0 | 高德地图API,提供底图 |
Three.js | 0.157.0 | 负责图层内容渲染 |
实现思路
- 所有图层的实现还是依赖高德地图GLSCustomLayer自定义图层提供的JS API,这方面之前讲过很多次就不多赘述了,想了解的可以看高德自带的GLCustomLayer 结合 THREE 示例。我们可以把它封装为一个基础类Layer.js,实现高德地图与THREE的对接工作,并提供图层常用的公共方法即可,比如显示隐藏、销毁、更新数据、鼠标交互等。
- 实现PolygonLayer图层的开发是本次的重点。
-
首先我们需要获得到行政区域边界的geoJSON标准数据,基于GCJ20坐标系的。将边界坐标点转换为THREE的网格体Mesh时关键的一步是对做三角剖分,告诉THREE真个Polygon是如何由基础的三角片面组成的;
-
在图层渲染为相应的Polygon面,并根据properties中提供的样式属性(比如color,opacity等)创建材质。一个图层支持多个polygon。
-
绘制Polygon的边缘线,其实就是在水平面上具有宽度的带状几何体集合,边缘线可以设置颜色和粗细;
-
Polygon具有交互功能,比如鼠标悬浮时会改变自身透明度;
-
- 实现边界图层BorderLayer,这块已经在之前分享过了,有兴趣可以查看 在高德地图中进行THREE开发-边界墙图层
- 实现动态点标记,因为数量不多,使用高德提供自定义Marker就能实现,点标记内容用html+css编写,较为方便灵活。
代码实现
PolyLayer的实现步骤
-
创建构造函数,根据需求设计好相关的配置参数
class PolygonLayer extends Layer { /** * 创建一个实例 * @param {Object} config * @param {GeoJSON} config.data 基础数据 required * @param {Number} [config.altitude=0] 海拔高度 * @param {Number} [config.opacity=1.0] 多边形网格体的透明度 * @param {Number} [config.lineWidth=50.0] 边缘线宽度,如果值为0则不渲染边缘线 * @param {String} [config.lineColor='#ffffff'] 边缘线颜色 * @param {Number} [config.interact=false] 是否支持鼠标交互 */ constructor (config) { const conf = { data: null, altitude: 0, opacity: 1.0, interact: false, lineWidth: 50.0, lineColor: '#FFFFFF', sizeAttenuation: 1, // 线宽与镜头距离相关联 ...config } super(conf) this.initData(conf.data) } }
-
初始化数据,将提供的地理坐标值批量转换为THREE空间坐标值,这里要注意的是MultiPolygon比Polygon类型的元素多增加了一个数组维度。
/** * 处理转换图层基础数据的地理坐标为空间坐标 * @param geoJSON * @private */ initData (geoJSON) { const { features } = geoJSON features.forEach(({ geometry, properties }) => { switch (geometry.type) { case 'MultiPolygon': geometry.coordinates[0].forEach(item => { this._data.push({ path: this.customCoords.lngLatsToCoords(item), properties }) }) break case 'Polygon': this._data.push({ path: this.customCoords.lngLatsToCoords(geometry.coordinates[0]), properties }) break default: break } }) }
-
创建Mesh网格体,即多边形模型的本体,这里包括多边形和边缘线两部分。
/** * 创建网格体 * @private */ createMesh () { this._data.forEach((item) => { this.drawPolygon(item) if (this._conf.lineWidth > 0) { this.drawLines(item) } }) }
-
创建多边形,是通过多边形的顶点坐标把它剖分为独立的三角片面(组成Mesh的基本面单位),声明好每个片面的坐标和组合关系就就可以,计算法线关系。
/** * 绘制多边形 * @private * @param {Array} path 路径 * @param {Object} properties 属性 */ drawPolygon ({ path, properties }) { const { altitude, opacity } = this._conf // 将路径数据扁平化 const flatArr = path.map(v => { return [v[0], v[1], altitude] }).flat() // 三角剖分 const triangles = Earcut.triangulate(flatArr, null, 3) // 创建一个THREE.Geometry对象 const geometry = new THREE.BufferGeometry() // 将三角形的顶点添加到geometry对象 let faceList = [] for (let i = 0; i < triangles.length; i++) { const [x, y, z] = path[triangles[i]] faceList = [...faceList, x, y, altitude] } // 顶点三角面 geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(faceList), 3)) // 计算法线和顶点的面连接关系 geometry.computeVertexNormals() const material = new THREE.MeshBasicMaterial({ color: properties.color || '#0674F1', transparent: true, opacity }) // 创建多边形的网格对象 const polygon = new THREE.Mesh(geometry, material) // 将多边形网格对象添加到场景中 this.scene.add(polygon) }
-
绘制边缘线的原理与上一步大同小异,也是在仅有顶点坐标和水平面宽度值的情况下,构建一系列水平带状网格体Mesh,为了提高开发效率,这边直接用 MeshLine插件为我们实现这一需求。
import { MeshLineGeometry, MeshLineMaterial } from '../plugins/meshline' /** * 绘制边缘线网格体 * @private */ drawLines ({ path, properties }) { const points = [] path.forEach(([x, y, z]) => { // 高度增加偏移,避免与polygon重叠 points.push(new THREE.Vector3(x, y, this._conf.altitude + 10)) }) // console.log(points) const line = new MeshLineGeometry() line.setPoints(points) const mesh = new THREE.Mesh(line, this.getLineMaterial()) this.scene.add(mesh) } /** * 获取边缘线材质 * @private * @return {MeshLineMaterial} */ getLineMaterial () { if (this._lineMaterial == null) { const { sizeAttenuation, lineWidth, lineColor } = this._conf this._lineMaterial = new MeshLineMaterial({ useMap: 0, color: new THREE.Color(lineColor), opacity: 1, // transparent: true, depthTest: true, sizeAttenuation: sizeAttenuation ? 1 : 0, lineWidth }) } return this._lineMaterial }
-
实现鼠标交互,我们使用THREE自带的Raycaster实现碰撞检测功能,封装到Layer.js中了。鼠标拾取后样式改变逻辑很简单,看代码就能明白了。
// 最后一次高亮对象 _lastPick = { mesh: null, opacity: null } onPicked ({ targets, event }) { let attrs = null if (targets.length > 0) { // 拾取到网格体 const cMesh = this.getParentObject(targets[0]) if (cMesh) { // 高亮拾取目标 this.setLastPick(cMesh) attrs = cMesh._attrs } else { // 移除高亮 this.removeLastPick() } } else { // 移除高亮 this.removeLastPick() } } /** * 高亮最后的选中项 * @param {THREE.Mesh} mesh */ setLastPick (mesh) { if (mesh) { this.removeLastPick() this._lastPick = { mesh, opacity: mesh.material.opacity } mesh.material.opacity = 0.9 } } /** * 取消高亮最后的选中项 * @param {THREE.Mesh} mesh */ removeLastPick () { if (this._lastPick.mesh) { // debugger const { mesh, opacity } = this._lastPick mesh.material.opacity = opacity this._lastPick = { mesh: null, opacity: null } } }
DOMLayer的实现步骤
-
创建构造函数,根据需求设计好相关的配置参数
constructor (config) { // 图层唯一标识 this.id = config.id || new Date().getTime() // JSON格式的数据 {type: "FeatureCollection", features:[]} this.data = config.data // 可以用作唯一标识的属性 this.idField = config.idField || 'id' // 地图视图 this.map = config.map // 缓存标签 this.markerMap = new Map() // 偏移值 this.offset = [0, 0] // 海拔高度 this.altitude = config.altitude || 0 // marker内容模板 this.contentFn = config.contentFn || null this.zooms = config.zooms || [3, 22] this._visible = typeof config.visible === 'boolean' ? config.visible : true this.handleZoomChange = this.handleZoomChange.bind(this) this.init() }
-
渲染点标记,这里使用html+svg+css实现动画效果
render () { this.data.features.forEach(item => { const center = item.properties.center || item.geometry.coordinates const id = item.properties[this.idField] const marker = this.generateMarker({ id, ...item.properties }, center) this.map.add(marker) this.markerMap.set(id, marker) }) } // 创建自定义点标记 generateMarker (item, position) { const [lng, lat] = position const { altitude } = this const dom = document.createElement('div') dom.setAttribute('data-id', item.id) dom.className = 'area-marker' if (typeof this.contentFn === 'function') { dom.innerHTML = this.contentFn(item) } else { dom.innerHTML = ` <img src="${import.meta.env.BASE_URL}static/image/map/mapIcon/icon_area_effect.svg" class="area-marker-effect"> <img src="${import.meta.env.BASE_URL}static/image/map/mapIcon/icon_area_effect.svg" style="animation-delay:1s" class="area-marker-effect"> <img src="${import.meta.env.BASE_URL}static/image/map/mapIcon/icon_area_effect.svg" style="animation-delay:2s" class="area-marker-effect"> <img src="${import.meta.env.BASE_URL}static/image/map/mapIcon/icon_area_floor.svg" class="area-marker-floor"> <div class="area-marker-card" style="background-image: url('${import.meta.env.BASE_URL}static/image/map/mapIcon/icon_area_card.svg')">${parseInt(Math.random() * 2000)} </div> <div class="area-marker-label">${item.name}</div> ` } const marker = new AMap.Marker({ position: [lng, lat, altitude], content: dom, zIndex: 130, offset: [33, 56], anchor: 'bottom-center' }) return marker }
css动画的实现使用了animation制作一种涟漪扩散的效果。
.area-marker-effect { position: absolute; left: 8.5px; top: 63px; animation: effect-scale 3s infinite backwards } @keyframes effect-scale { 0% { transform: scale(0); opacity: 1 } to { transform: scale(2); opacity: 0 } }
-
设置行为监听,地图视角缩放时根据zooms设定的范围判断DOMLayer应该显示或隐藏
handleZoomChange () { // 更新图层显示状态 this.reviseVisible() } /** * 结合zooms和visible,修正图层的显示状态 * @param val {Boolean,undefined} 目标值 * @private * @return {Boolean} */ reviseVisible (val) { // 最终值 const targetValue = typeof val === 'boolean' ? val && this.isInZooms() : this.isInZooms() this.markerMap.forEach(marker => { if (targetValue === true) { marker.show() } else { marker.hide() } }) this._visible = targetValue return this._visible }
组合图层
-
创建地图完成后,初始化图层
async function initLayers () { await initPolygonLayer() await initBorderLayer() await initDOMLayer() }
-
创建PolygonLayer、DOMlyLayer、BorderLayer实例
import { fetchData } from '@/utils/mock.js' /** * 创建3层PolyLayer */ sync function initPolygonLayer () { const zooms = [4, 22] // 垫底图层1 const data0 = await fetchData('guangzhou-region-0.001') const layer0 = new PolygonLayer({ id: 'polygonLayer0', map, view, data: data0, zooms, altitude: 10, lineWidth: 300, lineColor: '#2e91cb', opacity: 0.2 }) // 垫底图层2 同上,调整altitude即可 // 顶部图层 const data = await fetchData('guangzhou-subRegion-0.001') const layer2 = new PolygonLayer({ map, view, data, zooms, altitude: 2000, opacity: 0.5, interact: true, lineWidth: 300 }) } /** * 创建BorderLayer实例 */ async function initBorderLayer () { const data = await fetchData('guangzhou-region-0.001') const layer = new BorderLayer({ id: 'borderLayer', map, view, wallColor: '#3dfcfc', wallHeight: 4000, data, speed: 0.6, animate: true, // zooms: [12, 22], altitude: 2000 }) } /** * 创建区域点标记 */ async function initDOMLayer () { const data = await fetchData('guangzhou-subRegion-center') const domLayer = new DOMLayer({ id: 'domLayer', map, data, zooms: [4, 22], altitude: 5500, idField: 'NAME' }) layerManger.add(domLayer) }
常见问题及解法
从哪里获取到行政区域边界数据?
网上其实提供了挺多的服务可以获取国内各省的行政区域数据,常用到的比如阿里云提供的国内行政区域JSON数据,但数据最小粒度绝大部分只到区县级,再往下镇街、村居这个级别,就得求助其他GIS数据下载工具了。
这里推荐使用水经注下载工具,GIS数据相对齐全,也提供了各主流引擎作为数据来源,部分高精度数据需要收费,经费允许就花点钱买个VIP账号终身授权。需要注意的一点是数据的版权属于各大数据服务商,水经注只是提供了便捷的获取途径而已。
获得的数据坐标系与地图引擎对不上怎么办?
网上的geoJSON编辑器导出功能基本都会带地理坐标系转化服务,像阿里系(可以使用L7提供的服务,如下图所示)的提供通用坐标系WGS84和火星坐标系GCJ20,谷歌和腾讯同样也是GCJ20,arcGIS和Cesium使用WGS84,百度用的BD-09。
如何做性能优化
在WebGL开发中,性能问题有一部分原因是模型的面数问题。在尽量保持外观效果不变的前提下,通过优化面数,就可以降低渲染负载并提高WebGL应用的帧率和响应速度。
减少面数的方法有多种,一种常见的方法是使用简化算法,例如道格拉斯-普克(Douglas-Peucker)算法或Lowe算法,来消除冗余的面片。我们不需要了解其原理,以下介绍两种方法解决该问题。
方法一. 使用QGIS做简化
在数据采集阶段,可以用QGIS进行精简。
-
在 QGIS中选择要进行简化的图层,可以在列表中找到并选择相应的行政区域边界图层。
-
搜索到界面右侧工具栏“简化”,双击打开设置弹窗。
-
根据需要选择合适的精简方法和参数进行操作。您可以根据数据的复杂性和精简要求来选择并尝试不同的工具和参数组合。
-
调整容差,容差越大简化得越厉害,换言之就是结果会变形得越严重。本文示例广州市区域2000多个顶点,容差值取默认值1时,简化结果仅剩4个顶点,经过多次调整最后取值是0.001。
-
点击运行后,就能得到一个新的顶点更少的polygon图层,我们可以通过以下步骤查看优化前后每个polygon分别都有多少顶点。
-
优化结果对比,可以看到效果还不错。
方法二. 使用turf简化数据
在前端开发阶段,可以使用turf.js简化数据。
这个方法优点是开发人员调整灵活,直接修改容差值就能得到数据;缺点是缺少直观的对比,并不能直接看到此次的精简让polygon产生了多大的变形,是否过于影响polygon最终形状。
// 导入 Turf.js 库
const turf = require('@turf/turf');
// 假设您已经获取到行政区域边界的 GeoJSON 数据
const originalGeoJSON = {
// ... 行政区域边界的 GeoJSON 数据 ...
};
// 设置精简参数
const tolerance = 0.01;
// 精简的容差值,可以根据需要进行调整
// 使用 Turf.js 的 simplify() 函数进行精简
const simplifiedGeoJSON = turf.simplify(originalGeoJSON, { tolerance });
console.log(simplifiedGeoJSON);
最后
至此基于高德地图的立体PolygonLayer实现方法就介绍完了,全文看下来会发现实现方法其实很简单,关键实在前期把功能需求分解好然后逐个实现,进阶的做法就是考虑好各个图层类型的独立性和灵活性,以便后面灵活组合出更多效果。如果遇到哪些有趣的GIS效果,欢迎留言共同探讨。
相关链接
原文链接:https://juejin.cn/post/7343915907940024347 作者:gyratesky