拿到一份带岛洞的 GeoJSON 的时候,我一开始也以为这就是个“用L.geoJSON塞进去、配个样式、完事”的活儿。直到某次做行政区划数据展示,页面上本该是空心湖面的区域被整片填色糊死,飞地岛屿反而连成了一大块,我才意识到:岛洞数据的渲染,难点根本不在“加载”,而在“几何结构能不能被正确解释”。SuperMap iClient for Leaflet 在矢量解析和渲染控制上比裸 Leaflet 多了一整套处理机制,但如果你不清楚它到底帮你做了什么、没帮你做什么,照样踩坑。
这篇东西会从 GeoJSON 的环(ring)规则讲起,直接把岛洞结构拆开,然后给出一套用 SuperMap iClient for Leaflet 加载渲染的完整流程——包括环境准备、数据质检、核心代码、高频坑排查和性能优化。适合已经能跑通 Leaflet 基础地图、但被复杂多边形搞到头大的开发者。
1. 岛洞 GeoJSON 为什么一渲染就出问题:先从环规则说起
1.1 外环、内环、孔洞和岛屿:GeoJSON 的坐标结构
GeoJSON 里的 Polygon 和 MultiPolygon,结构上比大多数人想得更严谨。拿一个带洞的面来说,它的coordinates是一个二维数组:coordinates[0]是外环,也就是面最外圈的那条闭合边界;coordinates[1]、coordinates[2]这些后续环,统统是内环,代表从面上“挖掉”的孔洞。
举个例子,一个简单的带洞正方形长这样:
{ "type": "Feature", "properties": { "name": "demo-hole" }, "geometry": { "type": "Polygon", "coordinates": [ [ [0, 0], [0, 10], [10, 10], [10, 0], [0, 0] ], [ [3, 3], [7, 3], [7, 7], [3, 7], [3, 3] ] ] } }外环画出来是一块 10×10 的区域,内环画出来是 4×4 的区域。渲染的时候,正确的语义是“外环包含的区域减去内环包含的区域”,也就是一块中间镂空的正方形。这里的岛洞关系还要再往前推一步:如果这个洞里面又有一块小岛,那在 GeoJSON 里就不是“一个 Polygon 的第三个环”这么简单了,而是要用MultiPolygon表示——整体是“一个大多边形挖一个洞”,洞里再填充一个独立的小多边形。也就是说,“岛”本质上是另一个多边形,“洞”才是当前多边形里的内环。
这里面有一个非常容易理解错的地方:很多人以为洞和岛是同一个多边形的不同环,实际上它们是不同层面的东西。洞是一个 Polygon 内部的负空间,岛是另一个 Polygon 的正空间,两者靠“包含关系”在空间上叠加,而不是靠环的嵌套。
1.2 原生 Leaflet 对岛洞的渲染短板在哪里
Leaflet 本身当然支持带洞多边形——L.polygon的构造参数本来就可以传多个LatLng[],第一个数组当外环,后面的数组当内环。问题出在:你自己不处理,数据往往不会规规矩矩地按 Leaflet 期望的规则来。
RFC 7946 建议外环用逆时针(counterclockwise),内环用顺时针(clockwise)。这个“建议”在实际项目里基本等于“随缘”——从 CAD 转过来的数据、从 ArcGIS 导出的数据、或者手工拼接的数据,环的方向经常是乱的。Leaflet 的渲染底层走的是 SVG 或 Canvas 的 fill 规则,它对“哪边是内、哪边是外”的判断依赖环方向是否一致。一旦内环方向跟期望相反,fill 区域会错误地把洞补上,看起来就是“洞被糊死了”。
SuperMap iClient for Leaflet 在渲染层面做的事情,是帮你把服务端返回的几何对象解析成前端的L.Path系列对象时,会做更多的几何检查和 feature 拆分,尤其当你用它的FeatureService或DataService从 SuperMap 服务端取数的时候,返回的 geometry 已经是服务端整理过的结构。但如果你是用它加载本地静态 GeoJSON 文件,它走的还是 Leaflet 自身的几何解析逻辑。换句话说:工具能兜底,但不能替你打工。数据该修的还是要修,只不过用 SuperMap 的加载链路,定位问题会方便很多。
1.3 先看一眼数据里到底有没有“洞”
在写任何代码之前,我建议你先做一个最笨也最有效的检查——直接把 GeoJSON 文件扔进 QGIS 或者打开一个在线 GeoJSON 预览工具,用肉眼看。有洞的面,在 QGIS 里会显示成“空心面”;有岛的面,会在洞里再出现一个独立的填充块。这一步能让你确认数据里是否存在“洞中岛”这种复合结构。
如果文件太大打不开,可以用命令行工具geojsonhint快速校验格式:
npx geojsonhint ./data/region-with-holes.geojson它会告诉你坐标闭合、类型匹配之类的基础错误。注意它不会帮你检查环方向,方向问题要靠后面的数据质检验证。
2. 动手前先解决两件事:环境引入与数据质检
2.1 引入 SuperMap iClient for Leaflet 的两种方式
SuperMap iClient for Leaflet 本质上是 Leaflet 的扩展包,所以必须先有 Leaflet,再叠加 SuperMap 的脚本。
用 CDN 的话,直接在页面里按顺序引:
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <link rel="stylesheet" href="https://iclient.supermap.io/dist/leaflet/iclient-leaflet.min.css" /> <script src="https://iclient.supermap.io/dist/leaflet/iclient-leaflet.min.js"></script>用 npm 的话,在项目里安装:
npm install @supermap/iclient-leaflet leaflet然后在入口文件里导入:
import L from "leaflet"; import "@supermap/iclient-leaflet"; import "leaflet/dist/leaflet.css";这里有个容易忽略的细节:版本兼容性。SuperMap iClient 的版本对 Leaflet 版本有要求,一般它发布包会锁定配套版本。如果你手头项目里已经有一个指定版本的 Leaflet,建议先查一下 iClient for Leaflet 的发布说明,确认两者能配合,否则经常会碰到L.supermap is undefined这类报错。
引入完成之后,创建地图实例和平常写 Leaflet 没有区别:
const map = L.map("map").setView([30.5, 114.3], 10); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, }).addTo(map);地图能正常显示瓦片之后,再继续做数据加载。
2.2 渲染前必须走一遍的数据质检
数据质检是在写渲染代码之前必须做的事。我的建议是至少检查四个维度:环闭合性、环方向、自相交、几何类型。
环闭合性很容易理解:每个环的第一个坐标点和最后一个坐标点必须相同。很多从 CAD 转出来的数据,环不闭合,渲染时 Leaflet 会自动把首尾连起来,看似没问题,但如果中间有坐标跨越很大,填充区域就会变成“变形怪”。
环方向问题,推荐用@turf/rewind一次性重排:
npm install @turf/turfimport { rewind } from "@turf/turf"; const geojson = { ... }; // 你的 FeatureCollection const fixed = rewind(geojson, { reverse: true }); // reverse: true 表示按 RFC 7946 的要求重排 // 即外环逆时针,内环顺时针自相交是更难发现的坑。一个环自己跟自己交叉,视觉上会出现“蝴蝶结”,fill 区域会变得不可预测。可以用@turf/booleanValid检查:
import { booleanValid } from "@turf/turf"; console.log(booleanValid(fixed));如果你不想引入 Turf,也可以用 QGIS 的“矢量几何检查”插件,它能把自相交的位置标出来。
2.3 坐标系与投影:一个容易被忽略的错位来源
坐标参考系不对造成的“错位”,跟岛洞渲染本身不是同一个问题,但它会把问题掩盖得非常严重。最典型的场景是:数据是 EPSG:3857 投影坐标,但你直接把它当经纬度喂给了 Leaflet。
判断方法很简单:看坐标值的量级。经纬度在 -180 到 180 之间,投影坐标动辄几百万。如果数值是“百万级”,必须先转换成 EPSG:4326 再用。
QGIS 里转换很快:加载图层 → 右键导出 → 选择 EPSG:4326 → 保存成 GeoJSON。SuperMap iClient 在连接 SuperMap 服务时,可以通过Projection参数指定坐标系,但加载本地 GeoJSON 时,Leaflet 默认都按 EPSG:4326 处理。所以本地数据统一先转成 4326,省得后面排查定位问题的时候多一个变量。
另外注意编码。GeoJSON 文件强烈建议用 UTF-8 无 BOM 保存。BOM 会导致 Leaflet 读取 JSON 时偶尔报错,尤其是直接fetch后JSON.parse的场景,会报“Unexpected token in JSON at position 0”。
2.4 一份“能直接渲染”的 GeoJSON 应该长什么样
做完质检修复,一份能顺利渲染的岛洞数据大概需要满足这些条件:
| 检查项 | 要求 |
|---|---|
| 坐标系 | EPSG:4326,WGS84 经纬度 |
| 编码 | UTF-8 无 BOM |
| 环闭合性 | 每个环首尾坐标相同 |
| 环方向 | 外环逆时针,内环顺时针(RFC 7946) |
| 自相交 | 无自相交环 |
| 几何类型 | 洞用 Polygon 内环表示,岛用 MultiPolygon 或独立 Feature 表示 |
| 属性字段 | 尽量保留 FID,便于排查渲染问题 |
这六项过关之后,再上代码,基本就不会出现“数据把渲染搞挂”的情况了。
3. SuperMap iClient 加载渲染岛洞数据的核心步骤
3.1 最直接的做法:把 GeoJSON 当 FeatureCollection 加载
如果是静态 GeoJSON,最直接的方式还是 Leaflet 原生的L.geoJSON:
fetch("./data/region-with-holes.geojson") .then((res) => res.json()) .then((geojson) => { const layer = L.geoJSON(geojson, { style: { color: "#333333", weight: 1, fillColor: "#ffcc66", fillOpacity: 0.6, }, }).addTo(map); map.fitBounds(layer.getBounds()); });这段代码能跑通的数据,说明前面质检已经过关。在这个基础上,要体现 SuperMap iClient 的扩展能力,通常是配合它的动态查询服务来用。
3.2 复杂多环要素的安全渲染写法
如果数据里有特别复杂的岛洞结构,或者你想手动兜底环方向,那么可以绕过L.geoJSON的默认行为,自己遍历 Feature 构建L.polygon。Leaflet 的L.polygon接收一个包含多个环的数组,第一个是外环,其余都是洞:
function buildPolygon(feature) { const geometry = feature.geometry; if (geometry.type === "Polygon") { const latlngs = geometry.coordinates.map((ring) => ring.map(([lng, lat]) => [lat, lng]) ); return L.polygon(latlngs, { color: "#333333", weight: 1, fillColor: "#ffcc66", fillOpacity: 0.6, }); } if (geometry.type === "MultiPolygon") { return L.layerGroup( geometry.coordinates.map((polygon) => { const latlngs = polygon.map((ring) => ring.map(([lng, lat]) => [lat, lng]) ); return L.polygon(latlngs, { ... }); }) ); } }这段写法的好处是:你完全掌控环的顺序和方向。在把坐标交给L.polygon之前,可以先做一次重排:
import { rewind } from "@turf/turf"; const fixedGeometry = rewind(feature.geometry, { reverse: true });再强调一遍:外环逆时针、内环顺时针。这也是为什么我一直推荐加一个 Turf 依赖——它很小,但能省掉 90% 的几何问题排查时间。
3.3 从 SuperMap 服务动态取数并转 GeoJSON 的加载方式
SuperMap iClient for Leaflet 最有存在感的场景是接 SuperMap 的 GIS 服务。比如你有一份发布在服务端的工作空间,里面有带岛洞的面数据集,可以用L.supermap.featureService来查:
const featureService = L.supermap.featureService( "https://example.com/iserver/services/data-world/rest/data" ); featureService.getFeatures({ datasetNames: ["region@world"], returnContent: true, fromIndex: 0, toIndex: 1000, success: (result) => { const features = result.features; // 已转成 GeoJSON Feature 数组 const layer = L.geoJSON(features, { style: { color: "#333", fillColor: "#ffcc66", fillOpacity: 0.6 }, }).addTo(map); }, });这里result.features返回的就是标准 GeoJSON Feature 数组,因此后续的渲染逻辑跟本地 GeoJSON 完全一致。SuperMap 服务端在返回几何时,会处理掉很多底层拓扑问题,所以在接服务端数据的时候,前面说的环方向问题通常不会出现。但返回的数据量一超过几千个面,思路就要立刻切换到性能优化模式,这一块我放到第五节讲。
3.4 一个完整的可运行 Demo
把它们组合起来,写一个能直接在浏览器里跑的完整示例。假设你有一个data/holes.geojson:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>岛洞 GeoJSON 加载演示</title> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://iclient.supermap.io/dist/leaflet/iclient-leaflet.min.js"></script> </head> <body> <div id="map" style="height: 100vh"></div> <script> const map = L.map("map").setView([30.5, 114.3], 10); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, }).addTo(map); fetch("./data/holes.geojson") .then((res) => res.json()) .then((geojson) => { const layer = L.geoJSON(geojson, { style: { color: "#444", weight: 1, fillColor: "#ffcc66", fillOpacity: 0.6, }, }).addTo(map); map.fitBounds(layer.getBounds()); }); </script> </body> </html>这个 demo 能跑通,说明数据没问题,接下来再往工程化方向走。
4. 三个高频坑的完整排查链路
4.1 坑一:洞被填充色“糊死”了
现象:多边形整体显示成实心块,内环区域被填色覆盖,完全看不到“洞”。
排查链路:
- 先确认数据里确实存在内环。打开 GeoJSON,看
coordinates数组长度是否大于 1。 - 检查环方向。用 Turf 的
rewind重排后重新加载,观察洞是否出现。 - 检查坐标是否闭合。用脚本遍历所有环,对比首尾坐标:
function checkClosed(geojson) { const errors = []; geojson.features.forEach((f, i) => { const coords = f.geometry.coordinates; coords.forEach((ring, r) => { const first = ring[0]; const last = ring[ring.length - 1]; if (first[0] !== last[0] || first[1] !== last[1]) { errors.push(`Feature ${i} Ring ${r} 未闭合`); } }); }); console.log(errors); } - 最后检查
style.fillOpacity。如果fillOpacity没有设置或者被覆盖成 0,也会导致填充缺失,但这种情况下通常不会“糊死”,而更可能是只有描边。
这个坑里,最隐蔽的原因是第 2 步的环方向。Leaflet 的 Canvas/SVG 渲染器对环方向非常敏感,内环方向一旦错,fill 规则就会把内环参与的区域算成“内部”。
4.2 坑二:岛屿和外部边界粘连、分离错位
现象:洞里的岛,要么跟外面的多边形粘在一起,要么从洞里“跑”出来,位置对不上。
排查链路:
这个坑通常不是渲染代码的问题,而是数据几何结构的问题。
第一步,先看 Feature 类型。如果岛和外部多边形被放在同一个 Polygon 的第三个环、第四个环里,那它不是“岛”,它是“另一个洞”。正确的做法是:外部多边形是一个 Polygon(外环 + 洞内环),岛屿是另一个独立 Feature(Polygon 或 MultiPolygon),两者在空间上嵌套,但结构上分开。
第二步,检查坐标系。岛屿错位最常见的原因是数据混入了不同坐标系。例如外环是 4326,岛屿坐标却是投影坐标,数字量级差了几万倍,渲染出来自然是对不上的。建议把数据导入 QGIS,叠加一个在线底图,看看岛是不是能正确落在洞里。
第三步,检查 Feature 的几何类型。有些数据源会把“洞中岛”导出成 GeometryCollection,L.geoJSON遇到 GeometryCollection 不会报错,但要对每个 geometry 单独处理样式。如果你的数据里有 GeometryCollection,建议在数据准备阶段就拆分成普通 Feature。
4.3 坑三:几十兆 GeoJSON 直接卡死页面
现象:加载 30MB 的 GeoJSON,页面白屏或者滚动地图时帧率暴跌。
排查链路:
这个坑的根源通常不是岛洞结构,而是面要素数量过多或顶点数过多。第一步先做减法:把数据量缩小到 1MB 以内,看是否流畅。如果 1MB 也卡,问题可能在渲染层;如果 1MB 流畅、30MB 卡,那就是数量级问题。
对应的方案也分两条线:
数据侧:用 Turf 做简化:
import { simplify } from "@turf/turf"; const simplified = simplify(geojson, { tolerance: 0.001, highQuality: true, });tolerance 的选取要看业务精度要求。做省级边界展示,0.001 可能还嫌细;做城市级展示,0.01 就够。简化后岛洞结构会被保留,但洞的轮廓会变“方”,这就是数据量跟精度的博弈。
渲染侧:把 Canvas renderer 打开,避免几千个 SVG path 节点把 DOM 撑爆:
const layer = L.geoJSON(geojson, { renderer: L.canvas(), style: { ... }, });
如果数据量再上一个量级,比如一个省级的面数据集,顶点数十万级,这时候前端再怎么调也救不回来,得考虑切片方案。
4.4 排查工具与方法小结
把上面这些坑汇总成一张表,方便你遇到问题直接对照:
| 现象 | 可能原因 | 首选排查动作 | 解决方案 |
|---|---|---|---|
| 洞被填实 | 内环方向错误 | Turf rewind 重排 | 数据预处理阶段修复 |
| 洞被填实 | 环未闭合 | 脚本检查首尾坐标 | 补上闭合点 |
| 岛屿错位 | 坐标系混用 | QGIS 叠加底图 | 统一转 EPSG:4326 |
| 岛屿粘连 | GeometryCollection | 查看 JSON 结构 | 拆分为普通 Feature |
| 渲染卡顿 | 顶点过多 | 简化测试 | Turf simplify 或切片 |
| 数据读取失败 | BOM / 编码问题 | console 查看 JSON.parse 报错 | 另存为 UTF-8 无 BOM |
5. 数据量一大就卡?岛洞渲染的性能优化思路
5.1 先在数据层面瘦身:简化与拓扑修复
GeoJSON 的优势是通用,代价是体积臃肿。一个带岛洞的多边形,如果外环和内环各有几千个顶点,光坐标数组就能占到几十 KB。常见的数据源里,这些顶点大量是冗余的——沿着一条直线每隔几米打一个点,这种点对边界形状几乎没有贡献。
Turf 的simplify算法基于 Douglas-Peucker,能有效去掉这种冗余点。参数tolerance的单位跟数据坐标系一致,经纬度场景下建议从0.001开始试,然后逐步增大,直到视觉效果可以接受:
import { simplify } from "@turf/turf"; const simplified = simplify(geojson, { tolerance: 0.005, highQuality: true, });注意:highQuality: true会慢一点,但简化结果更稳健。简化之后,岛洞结构保留,但原来一些小洞可能会“消失”——当 tolerance 大于洞的尺寸时,内环会被简化成一个无效的小环,Leaflet 可能会直接忽略它。所以简化要小心,先小 tolerance 跑一遍,肉眼确认洞还在,再继续加大。
拓扑修复是另一件事。经过简化或者从第三方下载的数据,可能出现环自相交、相邻面重叠等问题。一个见效快的方法是turf.buffer(0):
const cleaned = buffer(geojson, 0);buffer 半径为 0 的“膨胀”操作,会把自相交的部位重建成合法的多边形。这个操作用得合适,能救回很多“渲染出来形状怪异”的面。
5.2 前端渲染参数与按需加载
如果数据量压到了 5MB 以内,前端渲染可以靠参数调优来提升体验。
首先是 renderer 选择。Leaflet 默认是 SVG,数据量一大,DOM 节点成千上万,交互卡顿是必然的。改为 Canvas 渲染器之后,图层是一块画布,不管有多少个面,DOM 节点都是恒定多个:
L.geoJSON(data, { renderer: L.canvas({ padding: 0.5 }), style: {...}, });然后是“按需加载”。如果整个数据集分布在很大范围,而用户只会看到局部,那就不该一次性把所有面全加载进来。可以监听地图的moveend事件,只把当前视野范围内的要素取出来:
map.on("moveend", () => { const bounds = map.getBounds(); const visibleFeatures = allFeatures.filter((f) => { // 用 turf.booleanIntersects 判断面是否与视野相交 return booleanIntersects(f, bounds.toBBoxString()); }); // 重建图层 });这个方案对岛洞数据尤其重要,因为每个带洞面的顶点数本来就多,全量渲染的内存开销非常夸张。
5.3 服务端出图:当浏览器实在扛不住时
当单个 GeoJSON 超过 20MB,或者面数量超过几万个,前端硬扛不是不行,但代价太高。更常规的路线是:把数据发布成地图服务,用瓦片来加载。
SuperMap iServer 可以直接把带岛洞的面数据集发布成地图服务,前端用L.supermap.tiledMapLayer加载:
const layer = L.supermap.tiledMapLayer({ url: "https://example.com/iserver/services/map-world/rest/maps/region", maxZoom: 18, }).addTo(map);这种方案下,岛洞渲染完全在服务端完成,客户端拿到的只是规整的图片瓦片。优点是性能稳定,缺点是交互能力弱(不能直接点选面要素拿属性)。需要交互的话,可以用“动态出图 + 属性查询”两条腿走:底图用瓦片,交互用 FeatureService 查询。
还有一种折中方案是矢量瓦片。SuperMap iServer 支持将面数据集发布成矢量瓦片服务,前端用矢量瓦片渲染,既保留交互能力,又不用加载全量数据。岛洞在矢量瓦片里的表现为:每个瓦片内,面要素按裁剪结果重新构建几何,洞依然会被保留为内环。
以我的经验,项目做到这一步,才真正解决了大数据量下的岛洞渲染问题。
最后分享一个实在的心得:处理岛洞 GeoJSON,第一优先级永远是数据质检,而不是写渲染代码。我踩过最大的一次坑,是把环方向有误的内环数据和正确数据混在一个文件里,结果有一只“岛”怎么都渲染不对,最后浪费了大量时间。后来我养成了一个习惯:拿到数据先跑一遍rewind+booleanValid,把几何问题清零,再进渲染流程。如果你也经常跟这类数据打交道,建议把这两个检查做成一个独立脚本,跑完输出一份质检报告,磨刀真不误砍柴工。