做政务大屏、WebGIS 可视化项目的时候,“行政区划地图掩膜”这个需求我几乎每次都会遇到。客户不会跟你提“掩膜”这么专业的词,他们只会说:把山东这块区域突出显示,其他地方压暗一点。听起来很简单,但真正动手你就会发现,这个遮罩并不是一个普通的半透明黑图层,它是一块“带洞的遮罩”——洞的位置刚好是你需要突显的省份轮廓,而且这个洞还要随着地图缩放、平移始终保持准确贴合。这篇文章就是我用 SpringBoot 提供行政区划数据、Leaflet 做前端渲染,完整跑通掩膜效果后的一个实战记录,适合正在做地图可视化、大屏项目,或者想把 SpringBoot 和 Leaflet 前后端串起来的小伙伴参考。
1. 掩膜效果到底是什么,先想清楚再动手
1.1 掩膜的本质与应用场景
掩膜(Mask)这个词借自图像处理,指用一块遮罩挡住不需要的区域,只露出感兴趣的区域。放在地图场景里,就是让全国地图整体压暗,只有选中的某个行政区保持正常亮度甚至高亮,视觉焦点一下就锁定了,非常适合政务大屏里的重点区域展示、指挥调度平台里的受灾范围圈选、以及园区网格化管理。
很多人一开始会想着“我直接在地图上盖一层半透明黑色 Div 不就行了”,但这样目标区域也会跟着变暗,达不到“突出”的目的。所以掩膜的真正难点在于:遮罩要覆盖全图,又要在目标区域镂空。镂空区域还必须跟随地图的拖动和缩放实时变化,因为行政区边界是地理坐标,而屏幕上是像素坐标,两者之间的映射关系一直在变。
1.2 三种实现方案对比
我在动手之前对比过三条技术路线,这里先分享出来,免得你走弯路。
第一种是用 Leaflet 自带的 L.geoJSON 叠加多边形遮罩层。思路是把全国画成一个覆盖视野的大矩形,再把目标行政区的轮廓画上去,两个图元一叠加,视觉上就出现了镂空。这个方案实现最快,但本质上是两个独立图层的叠放,缩放过程中边界容易闪缝,而且半透明区域叠加后颜色会不均匀,严格来说不算真正的镂空。
第二种是引入 Turf.js 做空间差集运算。用 Turf 把“全国矩形”和“目标行政区多边形”做一次 difference 计算,得到真正的差集多边形,再交给 Leaflet 渲染。这条路听起来很正规,但 Turf 的差集对城市级、省级这种大范围面数据的计算比较耗时,首次加载会有明显卡顿,而且 Turf 库体积不小,为了一个视觉效果引入一个重型空间计算库,性价比偏低。
第三种是自定义 Canvas 图层,用 Canvas 的 Path2D 把矩形外框和目标行政区轮廓放进同一个路径里,最后用 evenodd 填充规则一次绘制完成。这是我在实测后最终采用的方式,原因很直接:它不依赖额外库,绘制完全可控,拖拽缩放时重绘速度飞快,而且 evenodd 是 Canvas 原生支持的填充规则,镂空效果天然就是对的。
1.3 为什么后端要选 SpringBoot 做数据出口
至于后端用 SpringBoot,并不是因为它有多花哨,而是实际项目里行政区划数据大概率不能直接放在前端静态目录里。一方面,这些 GeoJSON 数据往往要经过权限校验才能访问,另一方面,很多底图供应商或数据服务商不允许前端直接跨域调用。SpringBoot 在这里扮演的是一个数据代理层:前端请求我们的接口,后端去拿行政区划 GeoJSON,再统一返回,跨域、权限、缓存全都在后端收敛掉。前端开发只需要关注地图交互,不用处理乱七八糟的数据源问题。
2. 数据准备与 SpringBoot 数据服务落地
2.1 行政区划数据从哪来:DataV GeoAtlas
行政区划边界数据是掩膜的地基,边界数据不准,后面全部白干。我用的数据源是阿里云 DataV 的 GeoAtlas 公共接口,这个接口提供了全国、省、市、区县各级行政区的 GeoJSON 数据,结构很规整,更新也及时,关键是省去了自己处理 GDB、SHP 转 GeoJSON 的麻烦。
它的 URL 规则很有规律,核心是行政区划代码 adcode:
| 层级 | 示例 adcode | 请求地址 |
|---|---|---|
| 全国 | 100000 | https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json |
| 山东省 | 370000 | https://geo.datav.aliyun.com/areas_v3/bound/370000_full.json |
| 济南市 | 370100 | https://geo.datav.aliyun.com/areas_v3/bound/370100_full.json |
这里有个小知识:加_full后缀的才是带完整 geometry 的边界数据,不带后缀的一般只包含属性列表和中心点,很适合做下钻菜单,但画不了图。返回的 GeoJSON 里,features 数组的每个元素都有 properties.adcode、properties.name、properties.center 这些字段,center 的格式是 [经度, 纬度],这一点后面写前端代码时要特别留意。
2.2 SpringBoot 工程搭建与依赖版本
项目骨架我用的是 SpringBoot 3.x,配合 JDK 17。如果你手头还在用 JDK 8,建议老老实实选 SpringBoot 2.7.x,不要硬上 3.x,否则javax和jakarta包名差异会让你在启动阶段被各种 ClassNotFound 折磨。
pom.xml 里最核心的就一个 web starter,因为我们的需求很单纯:把远程 GeoJSON 代理回来。不需要数据库,不需要 MyBatis,不需要安全框架,加一堆用不到的依赖只会拖慢启动速度。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>很多新人容易在这一步栽跟头,所以我要强调一句:SpringBoot 最省心的地方就是自动装配,引入 starter-web 后,内嵌 Tomcat、Jackson 序列化、基础 MVC 配置全部自动生效,我们真正要写的代码其实很少。
2.3 数据代理接口与 CORS 配置
后端我设计了一个非常精简的 REST 接口:GET /api/geo/{adcode},传入 adcode,后端拼出 DataV 的完整地址去请求,再把响应文本原样返回给前端。为什么原样返回字符串而不是解析成对象?因为 Leaflet 前端本来就支持直接接收 GeoJSON 字符串或对象,后端做 JSON 解析再序列化完全是多余开销,字符串透传反而是性能最好的方式。
@Service public class GeoBoundaryService { private static final String DATA_SERVER = "https://geo.datav.aliyun.com/areas_v3/bound/"; private static final Map<String, String> cache = new ConcurrentHashMap<>(); public String getBoundary(String adcode) { return cache.computeIfAbsent(adcode, code -> { String url = DATA_SERVER + code + "_full.json"; try { HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("remote data error: " + response.statusCode()); } return response.body(); } catch (Exception e) { throw new RuntimeException("fetch boundary failed: " + code, e); } }); } }这里我用的是 JDK 自带的 java.net.http.HttpClient,没有额外引入 RestTemplate 或 OkHttp。对于“转发一个请求拿回字符串”这种场景,它足够轻量,连接超时也有明确控制。
Controller 层同样简洁:
@RestController @RequestMapping("/api/geo") public class GeoBoundaryController { private final GeoBoundaryService geoBoundaryService; public GeoBoundaryController(GeoBoundaryService geoBoundaryService) { this.geoBoundaryService = geoBoundaryService; } @GetMapping(value = "/{adcode}", produces = "application/json;charset=UTF-8") public ResponseEntity<String> boundary(@PathVariable String adcode) { return ResponseEntity.ok(geoBoundaryService.getBoundary(adcode)); } }注意produces = "application/json;charset=UTF-8"这个细节,不显式声明的话,Spring 对 String 类型返回值默认可能走 StringHttpMessageConverter,历史版本默认编码不是 UTF-8,返回的中文名称很容易乱码。这一行能省掉你排查编码问题的大量时间。
前后端分离开发时,前端跑在 5173 端口,后端跑在 8080 端口,跨域是躲不掉的。我在项目里用一个全局配置类统一处理:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .maxAge(3600); } }简单解释一下为什么这么配:allowedOrigins 我这里开发环境直接放开,不开启 allowCredentials,因为浏览器端不需要携带 Cookie;maxAge 设置 3600 秒,让预检请求的结果可以缓存一小时,减少 OPTIONS 请求的浪费。
2.4 缓存与传输层优化
DataV 的100000_full.json全国数据大概接近 1MB,省级数据小一些也有几百 KB。每次进页面都让前端重新请求、后端重新从远程拉一遍,既慢又没礼貌。我在 Service 里直接用 ConcurrentHashMap 做了一层内存缓存,adcode 维度,第一次请求拉远程,之后全部走缓存。生产环境如果想更专业,可以换成 Spring Cache 注解配合 Caffeine,但 demo 阶段这个手动缓存完全够用。
传输层还有一个容易被忽略的优化:内嵌 Tomcat 的 GZIP 压缩。GeoJSON 是纯文本,压缩率通常能达到 70% 以上。在 application.yml 里加上这段:
server: port: 8080 compression: enabled: true min-response-size: 1024 mime-types: application/json,text/html,text/plain前端浏览器请求时会自动带上 Accept-Encoding: gzip,Tomcat 压缩后响应体明显变小,地图首次加载速度提升肉眼可见。这个配置对所有文本类接口都有效,不是只服务于地图场景。
3. Leaflet 掩膜图层实现:全场硬核
3.1 地图初始化与前端工程骨架
前端核心就是 Leaflet。如果只是做技术验证,不需要引入 Vue 或 React,一个 HTML 加一个 app.js 就够了;后面的逻辑都是地图 API 层面的东西,迁移到任何工程化框架里只是换一层封装。
我画了一个非常干净的基础页面:全屏地图容器,右上角放一个语言切换下拉框和一个重置按钮,地图底图用 OpenStreetMap 的公开瓦片,方便演示。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>行政区划地图掩膜实战</title> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <style> html, body, #map { height: 100%; margin: 0; } .controls { position: absolute; top: 12px; left: 12px; z-index: 1000; background: rgba(255, 255, 255, 0.92); padding: 10px 14px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .mask-canvas { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 300; } .label-div { background: transparent; border: none; color: #333; font-size: 13px; text-shadow: 0 0 4px #fff; white-space: nowrap; } </style> </head> <body> <div id="map"></div> <div class="controls"> <select id="langSwitch"> <option value="cn">中文标注</option> <option value="en">英文标注</option> </select> <button id="resetBtn">重置视图</button> </div> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="app.js"></script> </body> </html>这里有点必须提前说清楚:.mask-canvas的pointer-events: none不是可有可无的。Canvas 遮罩是铺满整个地图容器的,如果不把这个属性关掉,鼠标点击全被 canvas 拦截,下面的省界多边形永远接收不到 click 事件,整个交互直接失灵。
地图初始化和底图加载很直白:
const map = L.map('map', { zoomControl: true }).setView([35.8, 104.0], 4); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(map);全国视野的中心我设成 [35.8, 104.0],这是中国几何中心附近,缩放到 4 级刚好看到完整国界。实际项目里你换高德、天地图或者其他企业瓦片服务,完全是同一套逻辑,只是 tileLayer 的 URL 换掉。
3.2 自定义 Canvas 掩膜图层:evenodd 填充规则与实现
现在进入正题:掩膜图层。前面说过,最终方案是自定义 Canvas 图层,用 Path2D 同时放入外框矩形和目标行政区轮廓,再用 evenodd 规则填充。
先讲下 evenodd 到底是怎么回事。Canvas 的 fill 方法除了默认的 nonzero 规则外,还支持 evenodd。它判断一个点是否在填充区域内的方式,是“从这个点向外画一条射线,统计射线与所有子路径的交点个数,奇数则在填充区,偶数则不在”。当一个覆盖整个屏幕的矩形路径和行政区的闭合轮廓放在同一个 Path2D 里时,行政区内部的点会被矩形路径和行政区轮廓各穿过一次,交点数为偶数,因此不被填充;行政区外部的点只被矩形路径穿过一次,交点数为奇数,被填充。一个恰到好处的洞就这样出来了。
这个思路一旦理解,代码就非常顺理成章。我继承 Leaflet 的 L.Layer 来写一个 MaskLayer:
const MaskLayer = L.Layer.extend({ onAdd: function (map) { this._map = map; this._canvas = L.DomUtil.create('canvas', 'mask-canvas'); map.getPanes().overlayPane.appendChild(this._canvas); map.on('move zoom viewreset', this._redraw, this); this._redraw(); }, onRemove: function (map) { map.off('move zoom viewreset', this._redraw, this); L.DomUtil.remove(this._canvas); }, setTarget: function (feature) { this._target = feature || null; this._redraw(); }, _redraw: function () { const map = this._map; const canvas = this._canvas; const size = map.getSize(); const dpr = window.devicePixelRatio || 1; // 处理高分屏,避免边界发虚 canvas.width = size.x * dpr; canvas.height = size.y * dpr; canvas.style.width = size.x + 'px'; canvas.style.height = size.y + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, size.x, size.y); if (!this._target) return; const bounds = map.getBounds(); const nw = map.latLngToContainerPoint(bounds.getNorthWest()); const se = map.latLngToContainerPoint(bounds.getSouthEast()); // 同一个 Path2D:外框矩形 + 行政区划轮廓 const maskPath = new Path2D(); maskPath.rect(nw.x, nw.y, se.x - nw.x, se.y - nw.y); const geometry = this._target.geometry; const rings = []; if (geometry.type === 'Polygon') { rings.push(...geometry.coordinates); } else if (geometry.type === 'MultiPolygon') { geometry.coordinates.forEach(poly => rings.push(...poly)); } rings.forEach(ring => { ring.forEach((coord, idx) => { const pt = map.latLngToContainerPoint([coord[1], coord[0]]); if (idx === 0) { maskPath.moveTo(pt.x, pt.y); } else { maskPath.lineTo(pt.x, pt.y); } }); maskPath.closePath(); }); ctx.fillStyle = 'rgba(0, 20, 45, 0.45)'; ctx.fill(maskPath, 'evenodd'); } });这段代码有四个关键点,缺一个效果就崩:
第一,map.getPanes().overlayPane是 Leaflet 内部专门放覆盖物的容器,canvas 加到这里层级最合适。第二,latLngToContainerPoint是把经纬度转成当前屏幕像素坐标的核心 API,每帧重绘都要重新计算,因为用户拖一下地图、滚一下滚轮,这个映射就变了。第三,GeoJSON 的坐标是 [经度, 纬度],Leaflet 的 API 接受的是 [纬度, 经度],这里必须换位,写反了边界会飞到海里去。第四,外框矩形用视野边界来画,比用经纬度硬编码一个全球矩形更稳,因为不管用户拖到哪里、缩放到多少级,矩形永远能覆盖整个画面。
掩膜图层加进地图的方式就是一行:
const maskLayer = new MaskLayer().addTo(map);没有选中任何区域时,_target为空,直接返回空白画布,不影响正常地图浏览。
3.3 目标区域高亮与子级边界联动
掩膜只是第一步,客户还要看到“全国变暗、全省变亮”的对比效果。我在掩膜图层之外,又加了一个 L.geoJSON 高亮层,用来给目标省填充亮色和描边。注意,这个高亮层用的是 Leaflet 自带的矢量渲染,不需要走 Canvas,因为它只画一个省,性能没有压力。
点击省级边界时,交互逻辑是这样一个完整链路:点击省份 -> 设置掩膜目标 -> 高亮该省 -> 动态请求该省的市级数据并渲染子级边界。
let currentAdcode = null; async function selectRegion(feature) { const adcode = feature.properties.adcode; if (currentAdcode === adcode) { resetView(); return; } currentAdcode = adcode; maskLayer.setTarget(feature); highlightLayer.clearLayers(); highlightLayer.addData(feature); const resp = await fetch(`/api/geo/${adcode}`); const childCollection = await resp.json(); childrenLayer.clearLayers(); childrenLayer.addData(childCollection); childrenLayer.eachLayer(layer => { if (layer.feature.properties.adcode === adcode) { childrenLayer.removeLayer(layer); } }); map.flyTo([feature.properties.centroid[1], feature.properties.centroid[0]], 6); }这里有一个很典型的坑:请求山东省的370000_full.json返回的 features 里,不仅包含山东省自身,还包含它下辖的 16 个地级市。如果你不把山东省自身这条数据过滤掉,它的边界会被重复画两次,线宽和颜色会叠加得特别脏。所以我每次把子级数据加入图层后,会把和当前 adcode 相同的那一条移除掉。
省级边界底图本身也是一个 L.geoJSON 图层,加载全国数据后,给每个省挂上 click 事件:
let boundaryLayer = null; async function loadProvinces() { const resp = await fetch('/api/geo/100000_full'); const collection = await resp.json(); boundaryLayer = L.geoJSON(collection, { style: { color: '#a0a0a0', weight: 1, fillColor: '#ffffff', fillOpacity: 0.02 }, onEachFeature: (feature, layer) => { layer.on('click', () => selectRegion(feature)); } }).addTo(map); buildLabels(collection.features, 'cn'); }高亮层的样式我单独控制,让选中的省有一个明显的光晕感:
const highlightLayer = L.geoJSON(null, { style: { color: '#1890ff', weight: 2, fillColor: '#1890ff', fillOpacity: 0.15 } }).addTo(map);配合掩膜的深色遮罩,选中省份的辨识度非常高。你还可以在掩膜 Path2D 上对镂空边缘做一次 stroke,用亮色勾出行政区的轮廓线——就是在ctx.fill(maskPath, 'evenodd')之后加一段ctx.stroke(maskPath)。不过要注意,这里 stroke 的 maskPath 同时包含矩形外框和行政区轮廓,描出来的边除了目标省边界,还有一整个矩形框,是否接受这视觉效果要看你的审美,我一般在实现里会把矩形外框的描边去掉,只对行政区轮廓单独构建一个 Path 再描边,这样边缘更干净。
3.4 中英文标注切换实战
行政区划地图十有八九要支持中英文切换,特别是政务大屏对外展示的时候。我的做法是基于 Leaflet 的 divIcon 做标注点,而不是把文字画在掩膜 Canvas 上。
原因很简单:Canvas 文字一旦定死了,想切换语言必须整帧重绘,而且缩放时中文和英文长度不一样,排版会乱;用 divIcon 是 DOM 元素,样式灵活,切换语言只需要更换 HTML 内容,Leaflet 会自动帮我们管理图层。
先维护一份 adcode 到中英文名称的字典,以常用省级行政区为例:
const labelDict = { '100000': { cn: '中国', en: 'China' }, '110000': { cn: '北京市', en: 'Beijing' }, '120000': { cn: '天津市', en: 'Tianjin' }, '370000': { cn: '山东省', en: 'Shandong' }, '370100': { cn: '济南市', en: 'Jinan' } };然后创建两个图层组,一个放中文标注,一个放英文标注:
const cnLayer = L.layerGroup().addTo(map); const enLayer = L.layerGroup(); function buildLabels(features, lang) { const layer = lang === 'cn' ? cnLayer : enLayer; layer.clearLayers(); features.forEach(f => { const props = f.properties; const item = labelDict[props.adcode]; const text = item ? item[lang] : props.name; L.marker([props.center[1], props.center[0]], { icon: L.divIcon({ className: 'label-div', html: `<span>${text}</span>`, iconSize: [80, 20] }) }).addTo(layer); }); }切换触发逻辑就是监听下拉框的值:
document.getElementById('langSwitch').addEventListener('change', e => { const lang = e.target.value; if (lang === 'en') { map.removeLayer(cnLayer); enLayer.addTo(map); } else { map.removeLayer(enLayer); cnLayer.addTo(map); } });因为图层组内部维护了所有 marker,整体移除和添加不会影响其他图层,这个方案在工程上非常干净。实际项目里如果地名数量巨大,不建议在前端写死字典,更好的做法是把多语言名称表存在后端数据库,前端遍历 properties.adcode 去接口取,但核心原理一模一样。
重置按钮的代码也顺手写一下:
document.getElementById('resetBtn').addEventListener('click', resetView); function resetView() { currentAdcode = null; maskLayer.setTarget(null); highlightLayer.clearLayers(); childrenLayer.clearLayers(); map.flyTo([35.8, 104.0], 4); }4. 实操中遇到的坑与排查记录
4.1 坐标偏移问题
这是地图开发的老大难。DataV 提供的 GeoJSON 坐标是 WGS84 坐标系,而 OpenStreetMap 瓦片使用的也是 WGS84 标准,两人能对上,所以我的 demo 里一切正常。但如果你把底图换成高德或者百度,就要特别注意了,高德用的是 GCJ-02 火星坐标系,百度更是有自己的一套 BD-09,行政区划边界叠加上去后会有明显的偏移,放大到市级视角时偏差能到几百米。
排查方法很直观:在 Leaflet 里把边界图层和底图同时显示,放大到最大级别,看省界线是不是沿着道路、河流这些真实地物走。如果发现偏移,两个解决办法,一是在后端统一做坐标转换,二是换一套和高德底图同样坐标系的边界数据。千万别在前端拿两个不同坐标系的数据硬叠。
4.2 SpringBoot 中文乱码和跨域问题
前文提到过produces = "application/json;charset=UTF-8",这里强调下它多重要:SpringBoot 返回 String 时会根据 StringHttpMessageConverter 处理,不同的 Spring 版本默认字符集不一样,一旦走了 ISO-8859-1,Leaflget 不管前端怎么设置 UTF-8 meta,地名照样是问号。我排查这个问题时一开始看前端代码看了半天,后来用 curl 直接请求接口才发现是后端响应头里 Content-Type 没带 charset。
跨域配置里也有个隐蔽问题:如果你在 CORS 配置里同时设置了allowCredentials(true)和allowedOrigins("*"),部分浏览器会直接拒绝请求,因为白名单和携带凭证不能同时生效。我的项目里前端不需要用 Cookie,所以干脆不开启 allowCredentials,开发阶段用allowedOrigins("*")最省事。如果生产环境迫不得已要带凭证,必须把 origin 明确写出来,不能用星号。
4.3 高分屏模糊与大数据量卡顿
掩膜 Canvas 第一次在地图上画出来时,我发现在 Retina 屏上边界锯齿特别明显,这是因为 CSS 像素和设备物理像素不是一一对应的。解决方式就是前面代码里的 devicePixelRatio 适配:把 Canvas 的实际宽高乘上 dpr,再用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)放大绘制坐标系。这一步不做,界面上所有线条都是糊的,严重影响质感。
大数据量卡顿出现在两个地方:一是后端每次返回的省级数据量大,二是掩膜重绘时每次都要把所有坐标点从经纬度转成像素坐标。针对前者,GZIP 压缩加上接口缓存基本能解决;针对后者,我发现无论如何都不要在缩放级别很低的时候把几千个点全部投到屏幕上,可以做一个简单的抽稀:相邻两个像素点之间的距离小于 1 或 2 个像素时就跳过当前点,只保留关键转折点,省界形状完全不受影响,绘制耗能却大幅下降。注意最后闭合点一定要保留,否则多边形会缺一个角。
4.4 版本兼容与其他印象深刻的坑
SpringBoot 3.x 要求 JDK 17 起步,这个版本兼容问题劝退了不少照搬网上教程的新手。如果你打开项目发现编译时报 Unsupported class file major version,不要怀疑是自己的代码问题,去看看 JDK 版本是不是低于 17。同理,Leaflet 我用的是 1.9.4,如果你用了老版本 1.6 之类的,部分 CSS 类名和事件行为有差异,掩膜 Canvas 的 overlayPane 获取方式也可能不同。
再说一个和 Leaflet 周边生态有关的冷门问题:如果你在项目里用了地图旋转插件,比如 leaflet-rotate,掩膜 Canvas 很可能对不上底图。原因很简单,旋转是通过 CSS transform 实现的,底图转了一层,而latLngToContainerPoint计算出来的坐标并没有同步感知这个旋转矩阵。我实测下来,要么放弃 Canvas 方案改用 SVG,要么给 Canvas 挂上和底图相同的旋转角度,但后者要处理的边界情况很多。所以除非需求确实明确必须旋转,否则我通常不建议在做掩膜功能的页面上启用地图旋转。
后端部署这里也有个小提醒:如果你用 IDEA 直接跑 SpringBoot 没问题,但打成 jar 包部署后发现接口 404,先检查是不是 context-path 或端口配置被 application.yml 里的环境变量覆盖了。实在要看 jar 里装的什么,可以用反编译工具拆开确认下 resource 目录,但正常开发流程里一般用不上。
写在最后的一点体会
这套基于 SpringBoot + Leaflet 的行政区划掩膜方案,我在多个可视化项目里验证过,稳定性是没问题的。个人感受最深的一点是,掩膜效果看起来像个小技巧,但真正牵扯到的点比想象中多得多——数据源的组织方式、后端代理与缓存、坐标系的统一、Canvas 的渲染细节、高分屏适配、事件联动,任何一个环节没处理好,最终效果都会露馅。把 evenodd 这条核心思路吃透以后,你会发现不只是省级掩膜,街道级、园区级、网格级的掩膜本质上全是同一套逻辑,换一个面数据、改一条 click 事件就完事了。希望这篇文章能帮你把这块硬骨头啃下来,少走我走过的弯路。