做GIS和前端地图开发的朋友,对“地图遮罩”这个词应该不陌生,核心目的就是把地图上非目标区域压暗,让用户的视线集中到某个点、某个范围上,产品里常见的聚光灯、暗角高亮、区域雷达圈基本都是这个思路。上一篇我用OpenLayers4(后面统一叫OL4)实现了最基础的矢量要素遮罩,这篇接着往下写:在OL4里把遮罩做得更灵活、更工程化,重点放在canvas的postcompose绘制、带内环多边形的矢量挖洞、多区域遮罩组合、动画过渡和性能优化上。
这篇内容适合已经跑通过OL4基础地图、想在地图上做区域高亮和交互效果的前端同学。不管你最后选哪条实现路线,只要把下面几个核心机制吃透,写出来的遮罩效果才不会一拖动地图就“露馅”。完整代码我会贴出来,版本基于OL4(4.6.x),其他4.x小版本通用。
1. 先别急着写代码,把遮罩的原理和路线选型搞清楚
1.1 遮罩解决的问题和应用场景
地图遮罩在不同业务里的叫法不太一样,GIS平台里叫“掩膜”,大屏项目里叫“聚光灯”,安防项目里叫“重点区域高亮”,但本质上都是同一件事:让地图大部分区域变暗,只保留若干区域保持原始亮度,从而形成视觉聚焦。这个交互的前提是——地图本身不能只在一个静态屏幕里看,用户会拖动、缩放,遮罩区域必须始终咬合在地理坐标上,而不是固定在屏幕某个像素点上。
我整理过几个常见的落地场景:数据中心展示机房的覆盖范围、园区系统高亮某栋楼的服务半径、疫情或灾害场景里标出风险区域、商业选址系统圈出候选商圈。这类需求通常还要配合点击交互、动画过渡,比如点一个列表项,地图上的高亮圆就平滑飞到对应位置并调整半径。所以遮罩不只是“画个半透明黑罩”,它还承担着状态切换和视觉引导的任务,实现方案必须兼顾功能扩展和性能。
1.2 两条实现路线:矢量挖洞与画布合成
目前OL4里实现遮罩效果,主流的路线有两条。
第一条是“矢量几何挖洞”路线。思路是用一个覆盖全球大范围的多边形作为外环,然后在内环位置放置一个圆形(或者其他任意形状)作为空洞,形成一个带洞的多边形要素,放进矢量图层,用半透明填充样式渲染。地图亮着的区域就是那个洞,暗掉的区域就是多边形填充区。这条路线的好处是:它是正儿八经的地图要素,天然支持命中检测、样式动态切换、与图层控制联动,逻辑直观,代码量少。
第二条是“canvas画布合成”路线。思路是监听地图渲染管线里的postcompose事件,在地图所有图层绘制完成之后,拿到渲染用的CanvasContext,手动在整个画布上铺一层半透明颜色,再利用canvas的合成模式(globalCompositeOperation)把高亮区域“挖掉”。这条路线的好处是:完全不依赖矢量要素,可以做到像素级精细控制,画渐变、加动画、做多个任意形状空洞都非常方便,而且不受OL要素几何精度的限制。
两条路线的本质区别在于:矢量路线是“先有几何,再被渲染器画出来”,canvas路线是“渲染完成后,在像素层面做后处理”。理解到这一层,你后面遇到的绝大多数问题都能自己定位。
1.3 选型判断标准
我做过几个项目之后,总结出一个相对实用的选型判断标准:
- 如果遮罩区域是静态的、数量不多、主要靠图层显隐控制,优先选矢量挖洞方案。它更符合OL的数据模型,后续如果要接点击事件、加弹窗、联动高亮,改造成本最低。
- 如果遮罩需要频繁变化,比如跟随鼠标移动、动画缩放、多个区域动态切换,优先选canvas postcompose方案。矢量方案在频繁修改几何时既要重建要素又要触发图层刷新,性能瓶颈很容易出现。
- 如果两种需求都有,可以以canvas方案为主,把静态区域缓存成离屏canvas,减少每帧重复绘制成本。这个优化技巧后面我会单独讲。
2. 搭建基础工程与地图画布
2.1 初始化一个干净的OL4地图
不管选哪条路线,底层地图基础是一样的。先搭一个最简工程,这里我直接通过CDN方式引入OL4,不引框架,方便你直接复制到HTML里跑。
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>OpenLayers4 地图遮罩(二)</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/openlayers@4.6.5/ol.css" /> <style> html, body, #map { margin: 0; width: 100%; height: 100%; } </style> </head> <body> <div id="map"></div> <script src="https://cdn.jsdelivr.net/npm/openlayers@4.6.5/dist/ol.js"></script> <script> var map = new ol.Map({ target: 'map', layers: [ new ol.layer.Tile({ source: new ol.source.OSM() }) ], view: new ol.View({ center: ol.proj.fromLonLat([116.397428, 39.90923]), zoom: 10 }) }); </script> </body> </html>这段代码本身没什么可说的,重点在view的center坐标。OL4默认投影是EPSG:3857(Web墨卡托),单位是米,所以如果用经纬度坐标,必须通过ol.proj.fromLonLat做转换。这个习惯如果没养成,后面所有坐标相关的遮罩位置都会偏到天上去,这是遮罩项目里最高频的翻车点之一,后面我会专门列一个排查清单。
2.2 把遮罩参数做成可配置对象
我建议从一开始就不要把遮罩的中心、半径、透明度、颜色这些值写死在绘制函数里,而是抽成一个全局配置对象,这样后续做动画、做交互、做多状态切换时只需要改配置,然后触发重绘。
var maskConfig = { center: ol.proj.fromLonLat([116.397428, 39.90923]), radius: 1200, // 半径,单位:米(投影单位) color: 'rgba(0, 0, 0, 0.65)', strokeColor: '#ffffff', strokeWidth: 3 };radius这里我特意用“米”而不是“像素”,原因是遮罩要跟着地理坐标走,当地图缩放时,同样1200米在屏幕上占的像素数会变化,这才能体现出“该区域实际覆盖范围”的语义。如果只是想要一个固定屏幕尺寸的圆,那也可以直接把单位改成像素,但你要知道这两者的区别,不要混用。
2.3 换算幕后的像素与坐标关系
遮罩绘制最难理解的一环,就是地理坐标、投影坐标、屏幕像素这三者的关系。OL4里有一个非常核心的转换函数map.getPixelFromCoordinate(coordinate),它能把地图投影坐标转成当前屏幕上的像素坐标,返回值是[x, y]数组,原点在画布左上角。你只要在绘制遮罩时拿到这个像素坐标,再用canvas的arc画圆就行。
但是有一个关键点必须注意:postcompose事件触发时,当前帧的视图状态已经更新完成,这时候调用getPixelFromCoordinate得到的像素坐标才是准确的。如果你在其他时机调用,比如地图还在动画过渡中、或者resize之后还没更新画布,就可能拿到错位的坐标。我见过很多新手把坐标转换放在事件回调里算好存起来,然后绘制时直接用,结果地图一拖动就发现遮罩在“遛弯”,原因就在这里——你存的是旧坐标,不是新帧的坐标。
3. 画布遮罩实操:postcompose绘制高亮区域
3.1 用globalCompositeOperation画圆形挖洞
canvas路线最核心的一段代码,就是在地图的postcompose事件里做事后绘制。OL4的地图对象支持监听postcompose,事件对象里带一个context属性,这就是地图渲染画布的2D上下文。监听它,在图层合成完毕后再画我们的遮罩。
绘制思路分两步:先用fillRect把整个画布铺满半透明色,再用globalCompositeOperation = 'destination-out'在高亮圆形的区域做“擦除”,把那一块恢复成原始地图。代码如下:
map.on('postcompose', function (evt) { var ctx = evt.context; var size = map.getSize(); if (!size) return; // 遮罩中心:地理坐标 -> 像素坐标 var pixel = map.getPixelFromCoordinate(maskConfig.center); // 遮罩半径:将指定的地理半径(米)换算成像素半径 var eastPoint = [maskConfig.center[0] + maskConfig.radius, maskConfig.center[1]]; var eastPixel = map.getPixelFromCoordinate(eastPoint); var radius = Math.sqrt( Math.pow(eastPixel[0] - pixel[0], 2) + Math.pow(eastPixel[1] - pixel[1], 2) ); ctx.save(); // 第一步:全图覆盖半透明黑 ctx.fillStyle = maskConfig.color; ctx.fillRect(0, 0, size[0], size[1]); // 第二步:挖掉高亮区域 ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); ctx.arc(pixel[0], pixel[1], radius, 0, Math.PI * 2); ctx.fill(); // 第三步:给高亮圆加一圈描边 ctx.globalCompositeOperation = 'source-over'; ctx.beginPath(); ctx.lineWidth = maskConfig.strokeWidth; ctx.strokeStyle = maskConfig.strokeColor; ctx.arc(pixel[0], pixel[1], radius, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); });这里有三处容易出问题的地方。第一,map.getSize()在地图还没初始化完成时可能返回空,要加防御判断。第二,radius的计算用了“中心点向东平移radius米后再转像素”的方式,这在Web墨卡托投影下足够精确,但如果你的投影不是以米为单位的(比如EPSG:4326),就需要先统一单位再算。第三,save和restore一定要成对使用,否则globalCompositeOperation的状态会污染后续的地图绘制。
如果你不想用destination-out这种两步方式,也可以一次性画出带洞的路径,然后用evenodd填充规则:
ctx.beginPath(); ctx.rect(0, 0, size[0], size[1]); ctx.arc(pixel[0], pixel[1], radius, 0, Math.PI * 2); ctx.fillStyle = maskConfig.color; ctx.fill('evenodd');evenodd规则会按照“射线穿越路径次数”判断内外,天然支持挖洞,不用关心路径绕向,代码更简洁。不过evenodd是较新的canvas特性,在老旧浏览器上有兼容性风险,项目要求兼容老环境时还是用destination-out更稳。
3.2 让遮罩紧贴地理坐标而不是屏幕像素
上一节的代码里,遮罩中心是通过map.getPixelFromCoordinate(maskConfig.center)实时换算的,这句话是整个canvas方案不漂移的生命线。很多人会想当然地在地图初始化时算一次像素坐标,然后一直用这个值去画,结果地图一拖动,遮罩就停留在原来的屏幕位置,完全不跟地理走。
原理其实很简单:postcompose是每帧渲染完成后的回调,地图每次移动、缩放、加载瓦片,都会触发新一轮渲染。我们在回调里用最新的视图状态把地理坐标转成当前帧的像素坐标,这一帧的像素位置就和当前视图完全对应。换句话说,只要中心坐标是地理坐标、转换发生在回调内部,遮罩就永远钉在地理位置上。
如果你希望遮罩的半径也严格对应地理距离(比如“周边两公里范围”),一定要用3.1节里那种“同帧中心坐标换算半径”的方式。不要试图自己维护一个半径换算表,因为不同纬度、不同缩放级别下,单位的比例关系都在变化。我试过用一个固定的scale参数去乘,结果在低纬度地区看起来还行,一到高纬度地区就偏差得离谱,因为Web墨卡托的纬向变形非常大。
3.3 加一点细节:渐变、半透明高亮
基础遮罩做完后,你会发现产品需求通常还要“好看一点”。遮罩区域内部加一个径向渐变高亮,能让视觉中心更明显。做法是在挖洞之后,用createRadialGradient在洞口画一层从透明到白(或浅色)的渐变。
// 在挖洞之后,洞内画一层弱化高亮 var gradient = ctx.createRadialGradient( pixel[0], pixel[1], radius * 0.2, pixel[0], pixel[1], radius ); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(pixel[0], pixel[1], radius, 0, Math.PI * 2); ctx.fill();注意这里渐变坐标用的是像素,所以在回调里每帧重建渐变对象是正常的,不用担心性能——canvas创建渐变对象的开销极小,真正吃性能的是频繁绘制大区域,这个后面讲优化时再展开。
4. 矢量遮罩实操:用带内环的多边形“抠洞”
4.1 将圆形转为多边形并构造内环
矢量路线的核心,是构造一个“外环覆盖地图全局、内环是高亮区域”的多边形要素。OL4里圆形几何不能直接作为内环,必须先通过ol.geom.Polygon.fromCircle把圆形转成多边形,再取出它的第一条边环坐标,作为多边形内环使用。
这里有个必须避开的坑:canvas绘制多边形的填充默认用的是非零环绕规则(nonzero winding rule),外环和内环的绕向必须相反,空洞才能成立;如果两个环绕向相同,填充区域会覆盖掉空洞,整个遮罩会变成一个实心色块。fromCircle生成的圆环绕向在不同版本里并不保证一致,所以我写了一个确保内环绕向正确的辅助函数:
function ensureHoleRing(ring) { // 用鞋带公式计算有向面积 var area = 0; for (var i = 0, len = ring.length - 1; i < len; i++) { var p1 = ring[i]; var p2 = ring[i + 1]; area += p1[0] * p2[1] - p2[0] * p1[1]; } // 内环需要有向面积为正(逆时针),否则反转 if (area <= 0) { ring.reverse(); } return ring; }然后构建带洞多边形,加入矢量图层:
var maskLayer = new ol.layer.Vector({ source: new ol.source.Vector() }); map.addLayer(maskLayer); function buildMaskFeature(center, radiusMeters) { var circle = new ol.geom.Circle(center, radiusMeters); var circlePolygon = ol.geom.Polygon.fromCircle(circle, 128); var innerRing = ensureHoleRing(circlePolygon.getCoordinates()[0]); // 外环:覆盖全球范围的矩形 var outerRing = [ [-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90] ]; var geometry = new ol.geom.Polygon([outerRing, innerRing]); return new ol.Feature({ geometry: geometry }); } var maskFeature = buildMaskFeature( maskConfig.center, maskConfig.radius ); maskLayer.getSource().addFeature(maskFeature);这里fromCircle的第二个参数128是圆的逼近边数。默认是64,但当地图放大到一定程度后,64边形的高亮圆边缘会出现明显的“棱角感”,改成128之后视觉上基本就是圆了。如果你要画的圆非常小,边数太多反而增加顶点处理的负担,64就够用;如果半径大且对边缘要求高,可以直接给256,现代浏览器处理几万个顶点没什么压力。
4.2 控制遮罩样式与鼠标交互
矢量要素的好处是可以用样式函数做非常灵活的控制。比如让遮罩外圈的描边只在洞的边缘显示,整体暗色填充保持不变。这个需求在样式里实现起来很直接:
maskFeature.setStyle(new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(0, 0, 0, 0.65)' }), stroke: new ol.style.Stroke({ color: '#ffffff', width: 2 }) }));注意,ol.style.Stroke会作用于多边形外环也作用于内环,一般我们只需要洞的边缘有描边,外环因为超出视野范围,即使描边了也看不到,所以可以直接这样用,不用额外处理。
矢量要素还可以直接挂事件。比如我们希望鼠标移动到遮罩区域时,遮罩透明度降低一点,给用户“这块是可以交互”的暗示,就可以这样:
maskFeature.on('change', function () { // 当要素几何更新时,可根据需要刷新样式 }); map.on('pointermove', function (evt) { var hit = map.forEachFeatureAtPixel(evt.pixel, function (feature) { return feature === maskFeature; }); if (hit) { map.getViewport().style.cursor = 'pointer'; } else { map.getViewport().style.cursor = ''; } });不过有一点要提醒:遮罩多边形是一个覆盖全球的巨大几何体,forEachFeatureAtPixel在命中检测时会遍历要素几何,如果遮罩区域很大,命中检测的耗时可能会比普通要素高。实测下来正常交互还好,但如果你同时有几十个这样的巨型要素,建议用canvas方案做交互检测,或者把多边形外环缩小到当前视野范围。
4.3 动态改变遮罩的位置和半径
矢量方案要做动态变化,本质是更新要素的几何。OL4里可以调用feature.setGeometry(newPolygon)整体替换,也可以修改原几何的坐标后调用feature.changed()。后者的好处是不用重建对象,但要注意坐标修改的细节。
function updateMask(center, radiusMeters) { var circle = new ol.geom.Circle(center, radiusMeters); var circlePolygon = ol.geom.Polygon.fromCircle(circle, 128); var innerRing = ensureHoleRing(circlePolygon.getCoordinates()[0]); var outerRing = [ [-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90] ]; maskFeature.setGeometry(new ol.geom.Polygon([outerRing, innerRing])); } updateMask(map.getView().getCenter(), 2000); maskLayer.changed();调用setGeometry之后,通常还需要手动调用一下maskLayer.changed(),确保渲染管线知道这个图层需要重绘。我在实际项目里踩过这个坑:输出了新的几何,但图层没刷新,屏幕上的遮罩纹丝不动,排查了半天才发现是少了这个触发。实际上OL在要素geometry变化时会自动标记渲染需要更新,但在某些OL4小版本里,特别是要素已经加入图层之后再替换几何,偶发不会自动触发,手动changed()一次是最保险的。
5. 进阶玩法:多区域遮罩与动画过渡
5.1 多个高亮区域,做“聚光灯”效果
业务上经常不止一个高亮区域,比如同样一张城市地图上,要同时高亮三个重点商圈。这件事在两条路线里实现方式不同,我分别说一下。
canvas路线最简单,在同一帧的postcompose里,把多个区域的路径都加到同一个path里,然后用evenodd规则一次填充:
map.on('postcompose', function (evt) { var ctx = evt.context; var size = map.getSize(); if (!size) return; ctx.save(); ctx.fillStyle = maskConfig.color; ctx.beginPath(); ctx.rect(0, 0, size[0], size[1]); regions.forEach(function (region) { var p = map.getPixelFromCoordinate(region.center); var east = [region.center[0] + region.radius, region.center[1]]; var ep = map.getPixelFromCoordinate(east); var r = Math.sqrt(Math.pow(ep[0] - p[0], 2) + Math.pow(ep[1] - p[1], 2)); ctx.moveTo(p[0] + r, p[1]); ctx.arc(p[0], p[1], r, 0, Math.PI * 2); }); ctx.fill('evenodd'); ctx.restore(); });矢量路线则是在同一个多边形几何里放多个内环,这样整个遮罩仍然是一个要素,方便整体控制:
var holes = regions.map(function (region) { var poly = ol.geom.Polygon.fromCircle( new ol.geom.Circle(region.center, region.radius), 128 ); return ensureHoleRing(poly.getCoordinates()[0]); }); var outerRing = [ [-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90] ]; var multiHoleFeature = new ol.Feature({ geometry: new ol.geom.Polygon([outerRing].concat(holes)) });两种方式都能实现多高亮区域,但如果你想让每个高亮区域拥有不同的描边颜色,canvas路线更好控制,因为你可以对每个圆单独画描边;矢量路线则需要对每个内环单独设置样式,但OL4的样式中Stroke是作用于整个要素的,做不到内环各自不同,除非拆成多个要素。
5.2 动画切换:平滑过渡到目标区域
遮罩动画是我认为canvas路线真正拉开差距的地方。矢量方案做动画需要逐帧替换几何,绕向修正、顶点重建、图层刷新这些操作叠加起来,在低端机上很容易掉帧。canvas方案只需要逐帧修改配置对象里的center和radius,然后调用map.render()请求下一帧,绘制逻辑完全不变,动画就只是数据在变。
function animateMaskTo(targetCenter, targetRadius, duration) { var startCenter = maskConfig.center.slice(); var startRadius = maskConfig.radius; var startTime = Date.now(); function step() { var t = Math.min(1, (Date.now() - startTime) / duration); // 缓动函数:先慢后快再慢 var ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; maskConfig.center = [ startCenter[0] + (targetCenter[0] - startCenter[0]) * ease, startCenter[1] + (targetCenter[1] - startCenter[1]) * ease ]; maskConfig.radius = startRadius + (targetRadius - startRadius) * ease; map.render(); if (t < 1) { requestAnimationFrame(step); } } step(); }使用方法也很直观,比如点击列表中的某个园区,让遮罩平滑飞到该园区并调整到指定半径:
animateMaskTo(ol.proj.fromLonLat([121.4737, 31.2304]), 800, 600);这里我用了requestAnimationFrame驱动,每帧修改一次数据再触发一次map.render()。render()是异步请求渲染,不会阻塞主线程,所以动画过程中用户依然可以拖拽地图。实测下来,在普通笔记本上跑这种动画,帧率能稳定在50fps以上,完全够用。
5.3 地图平移缩放时遮罩的跟随处理
这里要专门讲一个问题:为什么canvas方案在地图平移、缩放时不需要额外监听事件?因为OL4在每次视图变化时都会重新渲染所有帧,postcompose每一帧都会触发,我们在这个回调里读取的是当前帧的最新坐标,所以遮罩天然跟随。这是canvas方案比矢量方案省心的地方——矢量方案如果要遮罩外环始终覆盖视野,你还得监听moveend去更新外环几何。
不过有一个例外:如果你在postcompose里做了非常重的计算,导致每一帧都超过16ms,那么动画和拖拽都会卡顿。这时候的优化思路不是去减少map.render()的调用,而是要优化postcompose回调本身。常见的手段是把不需要每帧重新计算的值缓存起来,比如多个区域的像素坐标,如果区域中心没变化,就不需要每帧重新算getPixelFromCoordinate,可以在地图moveend时更新一次缓存,平时直接用缓存值。
6. 高频踩坑与性能调优实录
6.1 遮罩位置偏移,最常见的原因
这是我被问得最多的一个问题:“为什么我的遮罩圆跑到别的地方去了?”绝大多数情况是坐标系混用了。OL4默认投影是EPSG:3857,而业务拿到的数据通常是经纬度(EPSG:4326)。如果你把一个经纬度数组直接当作center传给圆形,或者直接作为几何坐标用,遮罩位置就会偏到非洲西海岸附近(经纬度0,0附近)。所有坐标在进入OL4之前,必须走ol.proj.fromLonLat或ol.proj.transform做转换。
第二个常见原因是转换时机不对。上一节强调过,getPixelFromCoordinate必须在postcompose回调内部调用,不要在外部预先算好像素坐标存起来。地图每帧视图状态都在变,像素坐标是瞬时值,不是恒定值。第三个原因是地图容器尺寸变化后没有更新,OL虽然监听window resize,但在一些埋藏容器内(比如tab切换、折叠面板),容器尺寸变化时OL并不一定感知到,需要手动调用map.updateSize(),否则postcompose里拿到的map.getSize()和实际画布尺寸不一致,遮罩也会跟着错位。
6.2 地图拖动后遮罩没刷新的问题
如果你发现遮罩在拖动后没有跟着走,或者拖动了半天遮罩还在原地,大概率是postcompose回调没有被触发。OL4的渲染是“按需”的:地图静止时不会持续渲染,只有发生交互、图层变化或主动调用map.render()时才渲染。如果遮罩参数变了而你没有触发渲染,画面自然不会更新。
canvas路线的修复方式很简单,每次修改maskConfig后调用一次map.render()。我一般会封装一个更新函数,把修改配置和触发渲染绑在一起,避免遗漏。
function setMask(config) { Object.assign(maskConfig, config); map.render(); }矢量路线则要确保要素几何变化后调用了maskLayer.changed()或map.render()。这两个调用会让渲染管线重新执行,遮罩才会更新到新位置。
6.3 Retina高分屏模糊、位移的修复
高分屏(devicePixelRatio大于1)上,OL4会自动把渲染画布放大对应的像素比,以保证地图文字和矢量要素清晰。如果你在postcompose里直接按CSS像素坐标绘制,理论上OL4的上下文已经做了坐标变换,绘制出来的内容会根据当前变换自动映射到物理像素。但我实测发现,不同浏览器里这个行为并不完全一致,尤其是在自定义绘制较细的描边时,会出现线条发虚、宽度不一致的问题。
我的建议是:如果项目明确要兼容高分屏,不要用OL4内部的canvas上下文做精细绘制,而是自己创建一层与地图同尺寸的canvas叠加在map容器上,并手动处理设备像素比:
var overlayCanvas = document.createElement('canvas'); overlayCanvas.style.position = 'absolute'; overlayCanvas.style.left = '0'; overlayCanvas.style.top = '0'; map.getViewport().appendChild(overlayCanvas); function resizeOverlayCanvas() { var size = map.getSize(); var dpr = window.devicePixelRatio || 1; overlayCanvas.width = size[0] * dpr; overlayCanvas.height = size[1] * dpr; overlayCanvas.style.width = size[0] + 'px'; overlayCanvas.style.height = size[1] + 'px'; var ctx = overlayCanvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } map.on('moveend', resizeOverlayCanvas);这样你的绘制坐标始终是CSS像素,清晰度由canvas.width和setTransform保证。缺点是需要自己监听moveend和postrender,逻辑上比直接postcompose更重。我的习惯是:简单效果直接用postcompose,精细视觉效果用独立叠加层。
6.4 性能优化:从脏矩形到按需渲染
最后聊性能。遮罩本身是一个全屏覆盖的操作,最忌讳的就是在postcompose回调里做大量重复计算。我用的优化套路按优先级排是这样的:
第一,减少每帧不必要的计算。把静态数据(比如多个区域的中心坐标、提前换算好的像素位置)缓存起来,只在moveend或手动切换时更新。第二,把渐变对象、离屏canvas等创建开销大的对象缓存起来,不要每帧重建。第三,如果遮罩区域和数量长时间不变,可以考虑把遮罩整体绘制到一个离屏canvas上,postcompose时直接drawImage过去,这一步能把绘制成本从“多次canvas操作”降到“一次位图拷贝”。第四,如果动画过程中还需要同时更新大量其他地图要素,建议把动画帧率和地图渲染解耦,用独立的requestAnimationFrame控制数据变化,然后以较低的频率触发map.render(),比如每两帧触发一次,肉眼很难察觉区别。
还有一个小技巧:当遮罩中心超出当前视野范围较远时(比如点击了视野外的一个点位),这时候遮罩画在屏幕外,绘制操作没有意义,可以提前判断并跳过。判断方法是把中心坐标转成像素后,看它是否落在[-radius, size[0] + radius]和[-radius, size[1] + radius]这个矩形范围外,如果是就直接return。这个优化在高缩放级别下特别明显,能省掉一整块全屏填充加挖洞的开销。
我在实际项目里测试过一个包含4个动态遮罩区域、每帧还带渐变的场景,用上述优化后,地图拖动的帧率从30fps左右提升到稳定60fps。遮罩效果这类交互其实对性能极其敏感,因为用户的视线焦点就在遮罩区域上,一旦出现掉帧,视觉体感会很糟糕。
最后再分享一个小经验:遮罩的透明度不要调得太死。rgba(0, 0, 0, 0.65)这个值在大多数地图底图下都合适,既能压暗周边,又能隐约看到暗区的路网和地名,保持用户的空间方位感。透明度超过0.8的话,暗区几乎看不清任何底图信息,用户会有一种“地图被蒙住”的窒息感,反而影响体验。遮罩不是把地图藏起来,而是把用户的注意力引导到该看的地方,这个度一定要拿捏好。