拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenLayers 4高级地图遮罩:多区域、动态与异形

OpenLayers 4高级地图遮罩:多区域、动态与异形

先回答上一篇发布后大家问得最多的问题:遮罩能不能同时出现多个?鼠标扫过地图时遮罩能不能跟着走?挖出来的那块透明区域能不能是圆角?这些问题其实都落到同一个点上——基础遮罩能用,但真拿到项目里远远不够。这篇文章就把 OpenLayers 4 下地图遮罩的进阶用法一次讲透:多区域遮罩怎么做、动态遮罩怎么刷、异形遮罩怎么画,以及这个过程中我踩过的那些坑。

先说清楚,我会以 OL4 的矢量图层方案为主线,后面穿插一个 Canvas 方案和一个 Overlay 方案做对比,因为不同场景下的最优选型完全不同,不能指望一种写法吃遍所有需求。如果你只想要一个能跑的 Demo,直接跳到第 3 章看代码;如果你想搞懂为什么遮罩要这么写,建议从头读完,环的顺序和刷新机制这两个点,是后期排查问题的主要战场。

1. 回顾:基础遮罩的三种做法和它们的天花板

1.1 矢量 Feature 方案的基本写法

如果你看过上一篇,应该记得 OL4 里最省事的矢量遮罩写法:拉一个覆盖全图的 Polygon,填上半透明黑色,再用一个或多个内环把中央区域挖空。代码大概是这样的:

var outerRing = [ ol.proj.fromLonLat([-180, -85]), ol.proj.fromLonLat([180, -85]), ol.proj.fromLonLat([180, 85]), ol.proj.fromLonLat([-180, 85]), ol.proj.fromLonLat([-180, -85]) ]; // 构造一个圆形洞 var hole = []; for (var i = 0; i <= 64; i++) { var angle = (i / 64) * Math.PI * 2; hole.push(ol.proj.fromLonLat([ 116.4 + 0.5 * Math.cos(angle), 39.9 + 0.5 * Math.sin(angle) ])); } var polygon = new ol.geom.Polygon([outerRing].concat([hole])); var feature = new ol.Feature(polygon); feature.setStyle(new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(0, 0, 0, 0.65)' }) })); var maskSource = new ol.source.Vector({ features: [feature] }); var maskLayer = new ol.layer.Vector({ source: maskSource }); map.addLayer(maskLayer);

这段代码的思路是对的,后面的进阶操作全都在这个结构上演进。但只用它,你在真实项目里马上会碰到三个绕不开的问题:

第一,多个遮罩区域一旦叠加,透明填充会重复叠色。本来 65% 的暗色,两个区域交汇处会变成 88%,看起来像一块没揉匀的黑眼圈,用户一眼就能看出是两块布叠在一起。

第二,遮罩是死的。鼠标拖动、缩放、切换行政区,它都不跟,只能手动重新生成。

第三,矢量 Feature 的 fill 在 OL4 里基本只能给纯色,渐变、斜纹、径向光晕这类样式做不出来。而这些效果,在某些大屏可视化项目里反而是刚需。

1.2 三种遮罩方案的对比

我习惯把 OL4 地图遮罩的实现方式分成三类,各有各的脾气:

方案交互能力样式丰富度性能特点适用场景
矢量 Feature + 带洞多边形强,可随时改 geometry 跟随鼠标弱,基本是纯色填充几何对象多时渲染压力大,但单 Feature 可控行政区高亮、动态跟随、多区域遮罩
ImageCanvas 渲染图层弱,重绘成本高强,任意 canvas 2D 效果每次地图变动都重新执行 canvasFunction静态高光、渐变遮罩、纹理遮罩
DOM Overlay 遮罩中,但跟随地图有延迟强,纯 CSS 随便写无地图重绘开销,但 DOM 操作频繁简单弹窗式引导蒙层、短时间内效果

这篇的主角是矢量 Feature 方案,因为它是 OL4 生态里最可控、动态能力最强的一条路。Canvas 和 Overlay 我会在第 5 章拿出来做样式补充,讲清楚哪些效果必须交给它们。

1.3 上一篇方案的实际短板清单

在我自己的一次街区数据展示项目里,客户提出三个需求,直接把基础方案打回原形:一是周边三个重点片区要同时压暗,但片区之间允许有半透明连接带;二是鼠标在图上移动时,要有一个圆形高亮区跟着走,告诉用户当前正在查看哪个范围;三是遮罩的透明度要能按状态切换,选中片区时暗色变浅。这三个需求分别对应多区域、动态更新和样式控制,基础 Feature 方案全部搞不定,只能另起炉灶。

2. 带洞多边形是遮罩的真相:环的方向与顺序

2.1 Polygon 坐标数组的结构

很多初学者写 OL4 的 Polygon 只记住了“一组坐标围成一个面”,但遮罩这个场景必须吃透它的数组结构:一个 Polygon 的坐标数组,第一项是外环,从第二项开始全是内环。外环定义了这个面的外边界,内环则在面上挖出空洞。

所以那个看起来像“外框”的矩形,和那些挖空的小圆洞,在几何上不是分离的,它们同属于一个多边形。这个多边形在视觉上就是一个带洞的矩形,填充色只画在矩形和内环之间的环形区域。这也是为什么它能当遮罩用:矩形盖住全图,内环区域不填充,底图就从洞里露出来。

有一个隐藏细节值得注意:OL4 的 Polygon 坐标可以是[outer, hole1, hole2, ...]这种形式,你可以往数组里塞任意数量的洞,只要洞之间不重叠、不超出外环,这个几何就是合法的。

2.2 环方向的坑:为什么部分工具会报错

OL4 在渲染时其实不会强制要求外环内环的绕向,顺时针逆时针都能画出来。但一旦你把几何导出成 GeoJSON,或者丢给 JSTS、Turf 这类几何运算库做合并、求交,问题就来了。GeoJSON 规范明确要求外环逆时针、内环顺时针,很多校验工具拿这个标准卡你,一旦方向反了,union 的结果可能会变成“整个地图都被吞掉”的废几何。

我在 JSTS 合并两个洞的时候踩过一次:两个圆形洞的环方向全是顺时针,合并结果直接返回一个覆盖全世界的大矩形,当时排查了一下午,最后用 GeoJSON 校验工具一查才看到方向问题。从现在开始,凡是写遮罩几何,我都统一按规范来:外环逆时针,内环顺时针。OL4 里你可以通过polygon.getCoordinates()手动调整,也可以写完用ol.format.GeoJSON().writeGeometry()导出去验证一下。

2.3 内环与外环的边界关系

内环不能超出外环边界,这是几何有效性的底线。遮罩场景里我们通常把外环设成覆盖全图的矩形,洞怎么画都出不了边界,所以这条限制看起来无所谓。但如果你哪天把外环换成一个不规则的多边形,比如某个行政区的轮廓,内部再挖一个洞,那就要小心了,洞一旦伸到外面,这个环是无效的。更隐蔽的是两个内环不能有公共边,它们要么完全分离,要么用几何运算合并成一个环,否则 OL4 渲染时边缘会出现细线闪动。

2.4 构造遮罩的通用工厂函数

基于上面的规律,我写了一个通用函数,后续所有遮罩都会复用它:

function createMaskFeature(holes, style) { var outer = [ ol.proj.fromLonLat([-180, -85]), ol.proj.fromLonLat([180, -85]), ol.proj.fromLonLat([180, 85]), ol.proj.fromLonLat([-180, 85]), ol.proj.fromLonLat([-180, -85]) ]; var coordinates = [outer].concat(holes); var geometry = new ol.geom.Polygon(coordinates); var feature = new ol.Feature(geometry); feature.setStyle(style || new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(0, 0, 0, 0.65)' }) })); return feature; }

holes 是一个二维数组,每一项是一组经纬度转换后的坐标环。只要保证 hole 数组里的洞互不重叠、不越界,这个函数就能帮你在几行代码内构造任意数量的遮罩区域。

3. 多个遮罩区域怎么做:从多 Feature 到几何合并

3.1 为什么不能简单叠加多个 Feature

地图上有三个圆形重点区域,想把周边全部压暗,有些人图省事直接创建三个 Feature,每个 Feature 都是一个小圆洞对应的带洞矩形。结果就是第 1 章说的叠色问题:两个矩形在非目标区域重叠,半透明黑色叠加变成更深的黑色,整个画面黑一块灰一块。

原因在于矢量图层的填充走的是 Canvas 的fill()逻辑,同一位置被两个半透明 geometry 覆盖时,颜色是逐层叠加的。要解决,就得保证同一像素只被一个 geometry 覆盖,也就是说,遮罩必须是同一个 Feature 里的同一个几何。

3.2 最简单方案:一个矩形挖多个洞

如果多个区域之间不需要重叠、不需要连通,最省事的方法就是把所有洞的坐标数组全部塞进一个 Polygon。

var holeA = createCircleHole([116.4, 39.9], 0.5); var holeB = createCircleHole([116.8, 39.6], 0.4); var holeC = createCircleHole([116.2, 39.5], 0.6); var feature = createMaskFeature([holeA, holeB, holeC]); maskSource.clear(true); maskSource.addFeature(feature);

这里 createCircleHole 返回的是经纬度坐标环数组,createMaskFeature 内部会把每个环通过 ol.proj.fromLonLat 转换到适合显示的坐标系。这段代码跑起来,三个洞互不干扰,遮罩层也始终只有一个 Feature,渲染性能没有问题。

要注意的是,这种方案要求洞之间物理分离,圆心距离必须大于两个洞的半径之和。一旦洞与洞有交叉,这个 Polygon 的拓扑关系就不合法了,渲染结果会变得不可预期。

3.3 洞之间必须连通时,交给几何合并

如果产品要求两个高亮区域连成一片,比如地铁沿线的两个站区要作为一个整体高亮,这时就不能简单塞洞数组了,必须把两个圆形区域先做几何合并,再把合并结果作为一个内环。OL4 本身不提供几何合并算法,我需要借助 JSTS 这类工具库。

// 引入 jsts var jsts = require('jsts'); // 把两个圆洞的坐标转成 jsts 几何 var reader = new jsts.io.GeoJSONReader(); var circleA = reader.read({ type: 'Polygon', coordinates: [holeA] }); var circleB = reader.read({ type: 'Polygon', coordinates: [holeB] }); var union = circleA.union(circleB); var writer = new jsts.io.GeoJSONWriter(); var mergedGeoJSON = writer.write(union); // 转回 OL4 几何 var mergedGeometry = new ol.format.GeoJSON().readGeometry(mergedGeoJSON); mergedGeometry.transform('EPSG:4326', 'EPSG:3857'); // 仍要把合并结果作为洞塞进外环 var feature = createMaskFeature([mergedGeometry.getCoordinates()[0]], maskStyle);

这段操作我建议在 EPSG:4326 坐标系下做,也就是经纬度坐标,做完再转换到 3857。因为 JSTS 的运算不关心投影,但在 4326 下数值范围小,容易排查环的顺序是否正确;转回 3857 之后数值巨大,出错时肉眼根本看不出来。

3.4 多区域方案对比与选型建议

方案视觉效果性能实现复杂度适用场景
多个 Feature 叠加重叠区颜色加深,不可控差低基本不推荐
一个矩形挖多个洞洞之间必须分离,否则几何非法好低多个独立高亮区域
JSTS 几何合并洞可以连通,合并自然好中相邻区域合并高亮

我在实际项目里的判断标准很简单:区域之间没有相交关系,用多洞;有相交或需要背景连通,用 JSTS 合并。不要为了省一个依赖库强行用多洞方案去凑相邻区域,一旦产品改了需求,你会为这个“省事”付出几倍排查时间。

4. 让遮罩动起来:动态更新、区域联动与增量刷新

4.1 动态遮罩的三个关键动作

动态遮罩的本质就是三件事:拿到目标坐标,更新 Feature 的几何,然后通知图层刷新。听起来简单,但顺序搞错或者漏掉最后一步,画面纹丝不动,很多人就卡在这里。

OL4 里要注意:source.addFeature()、clear()、removeFeature()这类操作会自动触发图层重新渲染,但直接调用polygon.setCoordinates()改几何时,OL4 根本不知道几何变了,必须手动调用source.changed(),否则地图不会更新。这是从静态遮罩跨到动态遮罩最重要的一个认知门槛。

4.2 跟随鼠标的圆形遮罩

我用一个跟随鼠标的圆形遮罩来演示完整流程。思路是在 pointermove 事件里拿到鼠标对应的经纬度,再用这个经纬度生成一个圆,替换 Feature 原有的洞。

var maskFeature = createMaskFeature([createCircleHole([0, 0], 1)]); maskSource.addFeature(maskFeature); var lastTick = 0; map.on('pointermove', function (evt) { if (evt.dragging) return; var now = Date.now(); if (now - lastTick < 50) return; lastTick = now; var lonLat = ol.proj.toLonLat(evt.coordinate); var hole = createCircleHole(lonLat, 0.3); var geometry = maskFeature.getGeometry(); var outer = geometry.getCoordinates()[0]; geometry.setCoordinates([outer, hole]); maskSource.changed(); });

这个例子里有几个细节处理,都是我踩过之后补上的:

第一,pointermove事件在鼠标抖动时会高频触发,直接执行几何构造和坐标转换,低端机上会掉帧。我加了一个 50 毫秒的节流,既保证流畅度,又不至于太费 CPU。

第二,外环不需要每次重新生成。我从原来的 geometry 里把第一个环取出来复用,只替换洞的部分,减少数组构造压力。

第三,evt.dragging判断一定要加。否则鼠标按住拖拽地图时,遮罩会跟着鼠标乱跑,体验非常奇怪。拖拽过程中让遮罩停住,拖拽结束再更新,才是正常交互节奏。

4.3 动态更新时的性能控制

动态遮罩性能最大的敌人不是几何运算,而是频繁创建新对象。有人实现动态效果时,每次事件回调里都new ol.Feature()、new ol.geom.Polygon(),然后source.clear()再addFeature()。这样写,功能能跑,但内存里会产生大量等待垃圾回收的临时对象,时间一长,界面会越来越卡,帧率呈阶梯式下降。

正确做法是复用同一个 Feature 和同一个几何对象,只更新它的坐标数组。坐标数组本身也是可变的,甚至可以复用同一个数组,把坐标点一个个改掉。不过为了代码可读性,我通常只在洞的数量变化时才重新构造数组,否则就原地修改。

4.4 区域联动:点击列表,地图遮罩自动切到对应区域

动态遮罩还有一个高频场景:左侧是街道列表,鼠标悬停某一行,地图遮罩自动切换到该街道的范围。这种实现和在 pointermove 里更新洞本质上一样,区别在于坐标来源不同——从列表项拿到行政区的边界坐标,然后调用更新函数。

关键点是把行政区边界的 GeoJSON 预先解析成 OL4 几何对象,并缓存下来,不要每次悬停都重新读 GeoJSON。我在项目里是建立一个 id 到 geometry 的映射,鼠标悬停时直接从映射里取几何,取出geometry.getCoordinates()塞进遮罩多边形作为洞。这套操作在 OL4 里可以做到几乎无延迟切换,比重新请求后端数据然后渲染快一个数量级。

5. 异形与风格化遮罩:圆角、渐变、纹理的扩展玩法

5.1 圆角遮罩:用几何造一个圆角矩形

一旦两个功能需求摆在面前,你很快就会发现纯圆洞不够用。客户经常提“能不能让中间这块区域变成圆角矩形”,这种效果在遮罩上尤其常见。实现思路不复杂:圆角矩形就是四段直线加四段圆弧的组合,把圆弧离散成坐标点拼进环里就行。

function roundedRectHole(center, width, height, radius, segments) { var cx = center[0], cy = center[1]; var halfW = width / 2, halfH = height / 2; var coords = []; var seg = segments || 8; // 右上角圆弧 for (var i = 0; i <= seg; i++) { var angle = Math.PI / 2 * (i / seg); coords.push([cx + halfW - radius + radius * Math.cos(angle), cy + halfH - radius + radius * Math.sin(angle)]); } // 左上角圆弧 for (var j = 0; j <= seg; j++) { var angle2 = Math.PI / 2 * (j / seg) + Math.PI / 2; coords.push([cx - halfW + radius + radius * Math.cos(angle2), cy + halfH - radius + radius * Math.sin(angle2)]); } // 左下角圆弧 for (var k = 0; k <= seg; k++) { var angle3 = Math.PI / 2 * (k / seg) + Math.PI; coords.push([cx - halfW + radius + radius * Math.cos(angle3), cy - halfH + radius + radius * Math.sin(angle3)]); } // 右下角圆弧 for (var m = 0; m <= seg; m++) { var angle4 = Math.PI / 2 * (m / seg) + Math.PI * 1.5; coords.push([cx + halfW - radius + radius * Math.cos(angle4), cy - halfH + radius + radius * Math.sin(angle4)]); } coords.push(coords[0].slice()); return coords; }

这段代码生成的是经纬度平面上的圆角矩形,我会在调用处统一用ol.proj.fromLonLat()把每个点转成投影坐标。半径段的离散程度用 8 段已经能看出明显圆角,想要更丝滑可以提到 12 段,但不要超过 20,否则顶点数量爆炸,影响渲染。

5.2 渐变和纹理遮罩:ImageCanvas 的舞台

纯色遮罩在视觉上多少有点单调,尤其在大屏可视化场景里,客户要求遮罩边缘带一点光晕感。这种效果矢量 Feature 做不了,得请出 ImageCanvas。

var maskLayer = new ol.layer.Image({ source: new ol.source.ImageCanvas({ canvasFunction: function (extent, resolution, pixelRatio, size) { var canvas = document.createElement('canvas'); canvas.width = size[0]; canvas.height = size[1]; var ctx = canvas.getContext('2d'); var center = ol.proj.fromLonLat([116.4, 39.9]); var px = (center[0] - extent[0]) / resolution / pixelRatio; var py = (extent[3] - center[1]) / resolution / pixelRatio; var radius = 80 * pixelRatio; var gradient = ctx.createRadialGradient( px, py, 0, px, py, radius ); gradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); gradient.addColorStop(0.6, 'rgba(0, 0, 0, 0.4)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.8)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, size[0], size[1]); return canvas; } }) }); map.addLayer(maskLayer);

这是基础遮罩的高颜值版:整个视野被径向渐变覆盖,中心区域是透明的,往外逐渐变暗,像是聚光灯打在重点位置上。

这段代码有两个容易出错的细节:

像素坐标换算公式px = (coord - extent[0]) / resolution / pixelRatio和py = (extent[3] - coord) / resolution / pixelRatio,注意 y 轴方向和屏幕坐标是反的,我第一次写的时候把 py 算反了,结果渐变中心跑到画面外面去了。

radius 乘上 pixelRatio 是为了适配高 DPI 屏幕,否则在 Retina 屏上圆形光晕会偏小。如果你不需要这个精度,直接把 pixelRatio 相关计算全部去掉,代码更好懂。

5.3 DOM Overlay 方案:CSS 也能做遮罩

还有一种取巧的遮罩方式,用 Overlay 挂一个全屏 div,div 用box-shadow模拟中间的透明区域,比如box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.65)。这个方案的好处是可以用 CSS 的transition做淡入淡出动画,视觉体验非常顺滑,代码量也少。

但它的缺点也很明显:div 是 HTML 元素,不会跟着地图的平移和缩放做像素级同步,平移时透明区域会滞后一帧,在快速拖动地图时尤其明显。所以我只用它做“引导蒙层”这类临时需求,比如第一次进入页面时用圆框高亮某个按钮,地图只是背景,不需要实时交互。生产环境的核心遮罩,我坚持用矢量 Feature 方案。

5.4 遮罩图层的位置与层级

遮罩图层放在 map 的 layers 数组里,位置决定了它盖住谁、不被谁盖住。我通常把遮罩层放在底图正上方、业务图层正下方,这样底图被压暗,而点位、标注、弹窗都在遮罩上面保持清晰。如果你把遮罩放在 layers 数组最后,它会盖住所有图层,连地图控件都变暗,用户会以为页面出 bug 了。

6. 遮罩踩坑实录:六个问题一条排查链路

6.1 遮罩区域显示错位

最典型的现象:圆形高亮位置和鼠标点击位置差了好远,位置完全对不上。第一次遇到这个问题的同学,十有八九是忘了坐标系转换。OL4 默认视图投影是 EPSG:3857,范围是米制单位,而鼠标通过ol.proj.toLonLat()得到的是经纬度。拿经纬度坐标直接塞进 EPSG:3857 的几何里,画出来的位置自然会偏。

排查顺序是:先确认事件坐标是什么坐标系,再确认几何坐标是什么坐标系,最后确认中间有没有经过fromLonLat转换。这三个确认完,错位问题基本解决。

6.2 多个 Feature 叠加产生暗斑

这个问题会导致遮罩区域颜色不均匀,严重不均匀时中间会出现明显的分界线。原因是多个 Feature 的填充互相叠加,半透明色叠了两层。修复方式就是第 3 章的多洞方案,把所有洞塞进同一个 Polygon。

6.3 动态更新不生效

明明调用了setCoordinates,画面就是不动。先检查有没有调用source.changed(),再检查你是不是每次都新建了 Feature 而不是复用同一个。如果你每次创建一个新 Feature 却没 add 到 source,那不管你怎么改都不会出现在地图上。OL4 没有自动脏检查机制,几何变化需要手动告知。

6.4 几何合并后自相交

JSTS 合并两个圆形洞通常没问题,但如果你合并的是复杂多边形,比如带大量折点的行政区边界,结果很容易出现自相交。这种几何放进 OL4 渲染,会出现黑块、白线甚至整个图层崩溃。我的经验是,合并前先把环方向统一,合并后再检查一次 GeoJSON 结果,用jsts.operation.valid.IsValidOp判断几何是否合法。一旦自相交,不要硬修,退回去检查环方向。

6.5 高纬度地区圆形洞变成椭圆

在 EPSG:3857 投影下,按固定经纬度半径生成的圆环,纬度越高,视觉上越扁。根源在于 3857 在垂直方向做了拉伸,度数相同的跨度在高纬度对应的实际屏幕距离更大。如果你要求高精度视觉呈现,就不要用经纬度半径生成圆,换成在屏幕上算半径:拿到鼠标像素坐标,往右偏移 N 像素,再转回地图坐标,用这个距离当半径。或者直接用 Canvas 方案,在像素坐标系里画圆,完全绕开投影变形问题。

6.6 顶点数量过多导致帧率下降

遮罩动态更新时,如果每个圆洞用 128 个顶点生成,一秒更新 20 次,OL4 每次都要重算多边形填充路径,这在区域较大的时候表现明显。严格来说 64 个顶点已经足够平滑,32 个顶点在大多数缩放级别下肉眼看不出差别。可以先看 SVG/Canvas 的渲染调试工具,定位到顶点数和帧率的关系,再决定要不要降采样。对于不需要弧线的矩形洞,四个顶点就够了,不要再无脑 64 段。

7. 一点我自己的选型心得

做遮罩到现在,我的默认方案已经固定成单 Feature、带洞 Polygon、动态setCoordinates加手动changed()。这套组合在 OL4 里最稳定,动态能力最强,也不挑渲染环境。

只有两种例外会让我换成别的思路:一是需要渐变光晕,那直接上 ImageCanvas,不要尝试用矢量 Feature 硬凑;二是需要透明度平滑过渡的引导蒙层,那用 DOM Overlay 配合 CSS transition 体验最好。

如果你现在正被某个遮罩问题卡住,先把坐标系、环方向、source.changed()这三关过了,八成问题都能解决。遮罩不复杂,复杂的是你以为它只是盖一块布。实际上,几何、刷新机制和图层层级缺一不可。希望这篇能帮你少走点弯路。

返回列表