ARTICLE DETAIL

资讯详情

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

Leaflet多边形网格填充实战:三种方案对比与完整实现

Leaflet多边形网格填充实战:三种方案对比与完整实现 做了两年多WebGIS项目一直在和Leaflet打交道。前两天项目里有个需求要把地块多边形用斜纹网格填充用来区分权属状态。我第一反应是给Polygon加个fillColor就行结果发现Leaflet的Path只支持纯色填充根本不给你纹理图案的选项。翻了一圈插件生态也没有一个专门做“网格填充”的现成方案。最后只能自己动手把多边形切割成格子再用L.Rectangle一个个铺上去。这篇文章就是把我踩过的坑、试过的方案和最终的落地代码完整记录下来给遇到同样需求的同学一条能直接走的路。1. 网格填充到底解决什么问题原生Leaflet的边界在哪先说结论网格填充不是炫技是很多业务场景下的硬需求。搞清楚它为什么难做你才知道后面几条实现路径各自的价值在哪里。1.1 网格填充的典型应用场景最典型的是地块权属图。一块地里可能有甲、乙、丙三家的地纯色填充虽然能区分但一旦打印成黑白图纸或者叠加在卫星影像上视觉区分度就大幅下降。斜线网格、点阵网格这类纹理填充在这种情况下优势非常明显。第二个场景是区域选中态标注。比如在地图上画一个范围表示“这个区域正在分析中”用半透明网格盖在上方不会完全遮挡底图又能清楚传达“这里被选中了”的语义。第三个场景是军标和态势标绘。网格纹理是敌方区域、危险区域标注的通用符号。虽然Leaflet里做军标渲染一般会用专门的渲染库但如果只是简单标示网格填充多边形完全够用。1.2 Leaflet原生fill机制的天然限制Leaflet的Polygon本质上是L.Path渲染方式由renderer决定默认是SVG渲染也可以切换成Canvas。但无论哪种渲染器fill能力都局限在单一颜色上fillColor加fillOpacity仅此而已。SVG渲染时path元素的fill属性看起来可以填url(#patternId)但Leaflet的Path类在内部的_updateStyle方法里会通过setStyle重置样式你的pattern引用很可能在重绘时被覆盖掉。Canvas渲染器就更不用说了fillStyle本身就是颜色值根本没有图案对象的概念。所以这里有个重要认知在Leaflet里做网格填充本质上不是“让Polygon支持图案填充”而是绕开Polygon的原生渲染用其他方式来模拟这个效果。1.3 一条分界线哪些需求真需要网格哪些纯色就够了做技术选型之前先想清楚需求。如果只是要区分区域纯色加半透明叠加顶多再加个边框虚线在很多情况下已经能达到目视区分的要求。网格填充的代价是要么改渲染机制要么生成大量子对象无论哪条路径都比纯色fill多出不少工作量和性能开销。我的建议是如果这个多边形是静态展示的、数量很少的、视觉要求不高的先别上网格填充。网格填充真正不可替代的场景只有一个——需要在大面积的同色区域上叠加可辨别的纹理。比如几百个小地块每个地块只有编号文字不同如果不做纹理填充用户根本无法快速定位目标地块。2. 三条实现路线对比SVG Pattern、网格切分、Canvas自绘我把网上能找到的实现方案摸了一遍筛出三条可行的路线。每条路线都有各自的适用边界我分别拆解一下原理和实现成本。2.1 方案一SVG Pattern改fill属性的取巧路径原理Leaflet默认用SVG渲染polygon在SVG里定义pattern元素然后让path的fill引用这个pattern id就能画出斜纹、网格等纹理。思路是在图层add之后手动找到对应polygon的_path元素插入pattern定义并修改fill属性。polygon.on(add, function () { const path polygon._path; const renderer map.getRenderer(polygon); const svg renderer._container; let defs svg.querySelector(defs); if (!defs) { defs document.createElementNS(http://www.w3.org/2000/svg, defs); svg.appendChild(defs); } const patternId grid-pattern- polygon._leaflet_id; const pattern document.createElementNS(http://www.w3.org/2000/svg, pattern); pattern.id patternId; pattern.setAttribute(patternUnits, userSpaceOnUse); pattern.setAttribute(width, 8); pattern.setAttribute(height, 8); const line document.createElementNS(http://www.w3.org/2000/svg, path); line.setAttribute(d, M 8 0 L 0 8); line.setAttribute(stroke, #666); line.setAttribute(stroke-width, 1); pattern.appendChild(line); defs.appendChild(pattern); path.setAttribute(fill, url(# patternId )); });这个方案实现简单改动量很小渲染效果也非常好放大缩小不改变纹理密度看起来是“地图上的区域盖了一层图案”。但它有两个隐患。第一Leaflet在调用setStyle或者重新redraw时可能会把fill属性重置成你之前设置的fillColorpattern就白搭了。第二如果多边形数量多每个polygon都要生成独立的pattern定义虽然可以共用同一个pattern id但代码管理上容易乱。这个方案更适合一次性静态展示不需要动态切换样式的场景。2.2 方案二网格切分Leaflet对象组合的思路原理把多边形按照固定间距切割成若干小格判断哪些格子中心或多边形内部然后把这些格子用L.Rectangle添加到地图上。这个方法完全不触碰Leaflet内部渲染机制每个格子就是一个合法Leaflet对象能绑定事件、能参与图层管理自由度最高。const rectangles []; const bounds polygon.getBounds(); const southWest bounds.getSouthWest(); const northEast bounds.getNorthEast(); const gridSize 0.01; // 经纬度单位约等于1km const polygonLatLngs polygon.getLatLngs()[0]; // 外环坐标 for (let lat southWest.lat; lat northEast.lat; lat gridSize) { for (let lng southWest.lng; lng northEast.lng; lng gridSize) { const cellCenter L.latLng(lat gridSize / 2, lng gridSize / 2); if (isPointInPolygon(cellCenter, polygonLatLngs)) { rectangles.push( L.rectangle([[lat, lng], [lat gridSize, lng gridSize]], { color: #3388ff, weight: 0.5, fillColor: #3388ff, fillOpacity: 0.3, interactive: false }) ); } } } const gridLayer L.layerGroup(rectangles).addTo(map);这个方案的核心优势是复用Leaflet成熟的对象生命周期缩放、拖拽、移除都有现成方法不需要自己写渲染代码。缺点在后面细说主要就是格子数量大时的性能问题。2.3 方案三Canvas自绘最自由但最费事原理扩展L.Layer自己创建一个Canvas覆盖层在地图每次viewreset和zoomend时重绘多边形和纹理。纹理部分不再受Leaflet样式系统限制想画斜线、圆点、蜂窝都可以。L.GridPatternLayer L.Layer.extend({ onAdd: function (map) { const canvas L.DomUtil.create(canvas); canvas.style.position absolute; this._canvas canvas; map.getPanes().overlayPane.appendChild(canvas); map.on(moveend zoomend viewreset, this._redraw, this); this._redraw(); }, _redraw: function () { // 通过map.latLngToContainerPoint把经纬度转成canvas像素坐标 // 绘制多边形外环再用斜线纹理填充 } });这个方案在渲染大数据量时最有优势因为Canvas不需要维护成千上万个DOM节点。适合做那种几千个地块同时网格填充的强交互大屏。缺点是事件系统需要自己实现点击命中测试要自己写多边形的增删改也要有对应的数据管理机制工作量比方案二大不少。2.4 选型建议按你自己的场景对号入座评估维度SVG Pattern网格切分Canvas自绘实现成本低几十行代码中需要算法高需要管理渲染生命周期事件支持依附于原polygon每个格子可独立绑定需要手动命中检测大数据量性能一般pattern重绘开销较差格子数量过多会卡最优纹理类型斜线、网格等SVG pattern只能是色块网格任意图案动态样式切换容易被Leaflet重置通过setStyle统一修改完全可控如果只是临时加个纹理方案一就够了。如果是要做地块管理、业务联动方案二最平衡。如果数据量上千且纹理要求特殊老实上Canvas。3. 网格切分方案的完整实现从坐标计算到格子生成方案二最贴近大多数业务项目的需求我把完整的实现思路和代码展开写。3.1 射线法判断点面关系核心算法从零讲解判断一个点是否在多边形内部最常用的算法是射线法Ray Casting。从目标点发出一条水平射线计算与多边形每条边的交点个数如果是奇数点在多边形内部否则在外部。这个算法对凸多边形和凹多边形都适用只要多边形不自相交就行。function isPointInPolygon(point, polygon) { const [x, y] point; let inside false; const len polygon.length; for (let i 0, j len - 1; i len; j i) { const [xi, yi] polygon[i]; const [xj, yj] polygon[j]; // 判断点是否在边的y值范围内 const intersects (yi y) ! (yj y) x ((xj - xi) * (y - yi)) / (yj - yi) xi; if (intersects) { inside !inside; } } return inside; }这里有几个要注意的细节。第一(yi y) ! (yj y)确保边与射线只有一个交点处理了顶点落在射线上时的边界情况。第二分母(yj - yi)不能为0也就是水平边要被跳过但上面这个条件天然排除了水平边因为当yi yj时(yi y) ! (yj y)是false。第三这个算法假定射线方向是x轴正向所以条件里是x ...表示交点必须在目标点右边。我在实际测试中遇到一种情况格子中心正好落在地物边界上射线法可能因为浮点误差导致误判。做法是给判断结果做一层容错或者用格子的四个角判断只要有一个角在内部就算命中。3.2 经纬度与投影坐标的选择问题网格切分第一步是确定网格尺寸。如果直接用经纬度作为网格步长比如0.01度在赤道附近大约1.1公里但在北纬60度地区同样的经度跨度只有约550米网格在地图上看起来就会“扁”。更科学的做法是先把经纬度转成Web Mercator平面坐标以米为单位切网格再转回经纬度。Leaflet默认的CRS是EPSG:3857所以用map.project和map.unproject就能完成投影转换。function buildGridInMeters(polygonLatLngs, gridSizeM) { const zoom map.getZoom(); // 把经纬度数组转成平面坐标数组 const polygonPoints polygonLatLngs.map(latlng { const point map.project(latlng, zoom); return [point.x, point.y]; }); // 计算平面坐标的包围盒 const xs polygonPoints.map(p p[0]); const ys polygonPoints.map(p p[1]); const minX Math.min(...xs), maxX Math.max(...xs); const minY Math.min(...ys), maxY Math.max(...ys); // 以米为单位生成网格 const rectangles []; for (let x minX; x maxX; x gridSizeM) { for (let y minY; y maxY; y gridSizeM) { const center map.unproject(L.point(x gridSizeM / 2, y gridSizeM / 2), zoom); if (isPointInPolygon([center.lat, center.lng], polygonLatLngs)) { const sw map.unproject(L.point(x, y gridSizeM), zoom); const ne map.unproject(L.point(x gridSizeM, y), zoom); rectangles.push(L.rectangle([sw, ne])); } } } return rectangles; }注意这里引入了一个隐藏在暗处的变量——zoom。因为经纬度到平面坐标的转换依赖于缩放级别缩放级别一变投影坐标就变了。这带来一个问题如果在zoom级别18切好的网格用户缩放到zoom级别7网格位置和大小实际上还是对的因为L.Rectangle是地理坐标对象坐标不随缩放改变但你没法用简单的map.unproject返回结果去重新处理。我的建议是用固定zoom级别比如14做投影和切分切完的L.Rectangle坐标直接存数据库或变量后续操作都基于地理坐标而非投影坐标。这样缩放时只是视觉上格子变大变小不会出现格子错位。3.3 生成网格的完整代码从边界判断到图层添加把上面两节整合起来写一个可以直接跑的函数。假设你已经有一个leafletPolygon对象并且地图实例是全局的map。function generateGridLayer(leafletPolygon, gridSizeM, options {}) { const latLngs leafletPolygon.getLatLngs()[0]; // 取外环 const zoom 14; // 固定投影级别避免缩放带来的坐标漂移 const polygonPoints latLngs.map(latlng { const point map.project(latlng, zoom); return [point.x, point.y]; }); const xs polygonPoints.map(p p[0]); const ys polygonPoints.map(p p[1]); const minX Math.min(...xs), maxX Math.max(...xs); const minY Math.min(...ys), maxY Math.max(...ys); const rectangles []; for (let x minX; x maxX; x gridSizeM) { for (let y minY; y maxY; y gridSizeM) { const centerPoint L.point(x gridSizeM / 2, y gridSizeM / 2); const centerLatLng map.unproject(centerPoint, zoom); if (isPointInPolygon([centerLatLng.lat, centerLatLng.lng], latLngs)) { const sw map.unproject(L.point(x, y gridSizeM), zoom); const ne map.unproject(L.point(x gridSizeM, y), zoom); rectangles.push( L.rectangle([sw, ne], { color: options.color || #3388ff, weight: options.weight || 1, fillColor: options.fillColor || #3388ff, fillOpacity: options.fillOpacity || 0.25, interactive: false, ...options.rectangleOptions }) ); } } } return L.layerGroup(rectangles); }这段代码有几个小坑提前说明。第一getLatLngs()返回的是数组的数组最外层数组每个元素对应一个环。简单多边形只有一个环所以取[0]。带洞多边形环的索引后面会说。第二这里用的是中心点判断对于凹多边形靠近凹入部分的格子中心可能在多边形外但实际上有一部分格子面积在多边形内导致部分边缘格子漏生成。要想更准确就把网格四个角都测一遍任意一个角在内部就生成代价是计算量翻4倍。第三interactive: false很关键如果不设每个rectangle都会参与点击事件命中事件绑定和性能上都吃亏。3.4 带洞多边形和MultiPolygon的处理地理数据里经常有“一块地中间有个湖”这种形状反映到GeoJSON上就是Polygon带一个或多个inner ring。上面的代码只处理了外环内环会被错误地填充。处理方式是在判断时增加一层逻辑如果点在某个内环内部则判定为不在多边形内部。function isPointInPolygonWithHoles(point, rings) { // rings[0] 是外环其余是内环 if (!isPointInPolygon(point, rings[0])) { return false; } for (let i 1; i rings.length; i) { if (isPointInPolygon(point, rings[i])) { return false; } } return true; }MultiPolygon更简单它本质是多个Polygon的集合对每个Polygon单独做网格切分然后把结果合并到一个layerGroup里就行。Leaflet的getLatLngs()对MultiPolygon会返回三层嵌套遍历时注意层数。4. 性能卡顿的真相网格数量不是唯一瓶颈网格切分方案性能问题是最多人踩的坑我单独用一节来说。4.1 网格数量与渲染开销的量化关系一个边长10公里的多边形用100米网格切分刚好多边形面积约100平方公里一个方向100个格总共约一万个格子。10000个L.Rectangle对象放上去地图拖拽时的掉帧感非常明显。问题出在三个层面。第一DOM节点数默认SVG渲染器下每个L.Rectangle都是一个独立path元素10000个path元素对DOM的增删改查和重绘压力巨大。第二事件系统如果每个格子都绑定了事件Leaflet的事件处理机制会为每个对象注册监听器内存占用成倍增长。第三样式更新当你调用setStyle统一修改网格样式时Leaflet要遍历每一个path执行属性更新一次性更新10000个节点的样式必然卡顿。所以性能优化要从三个方向同时下手。4.2 水平合并算法把相邻格子压成Rectangle这是我自己实现的优化方案效果非常明显。相邻的格子如果满足“同一行、列号连续”就可以合并成一个矩形。比如第3行第5列到第10列共六个格子原来要创建6个L.Rectangle合并后只需要1个。function mergeHorizontalCells(cells, gridSizeM) { // cells: [{row, col, sw, ne}] const groupByRow new Map(); cells.forEach(cell { if (!groupByRow.has(cell.row)) { groupByRow.set(cell.row, []); } groupByRow.get(cell.row).push(cell); }); const mergedRectangles []; groupByRow.forEach(rowCells { rowCells.sort((a, b) a.col - b.col); let startCol rowCells[0].col; let prevCol rowCells[0].col; for (let i 1; i rowCells.length; i) { if (i rowCells.length || rowCells[i].col ! prevCol 1) { const startCell rowCells.find(c c.col startCol); const endCell rowCells.find(c c.col prevCol); mergedRectangles.push( L.rectangle([ [startCell.sw.lat, startCell.sw.lng], [endCell.ne.lat, endCell.ne.lng] ]) ); if (i rowCells.length) { startCol rowCells[i].col; } } prevCol rowCells[i - 1]?.col; } }); return mergedRectangles; }行合并算法比较麻烦但收益更大。把每行的矩形再纵向合并成更大的矩形。在规则的多边形里合并后的对象数量能减少到原来的十分之一甚至几十分之一。对于约10000个格子的多边形合并后通常只有几百个L.Rectangle性能问题基本消失。如果不想自己造轮子可以考虑用turf.js的turf.hexGrid等现成网格工具或者用topojson的拓扑压缩思路做简化和合并。但Leaflet项目里我更推荐直接在L.Rectangle层面合并处理逻辑直接、可调试性强。4.3 分级网格与缩放联动当缩放到zoom级别很小时很多东西都变成一个小像素团显示那么细的网格没有意义。反过来放大后原本的网格单元已经大到盖住整个屏幕网格成为巨大的色块也不利于阅读。解决方案是按缩放级别动态调整网格尺寸。zoom在15以上用25米网格zoom在10-14用100米网格zoom小于10直接用500米网格。缩放到不同级别时重新生成网格。这个优化不仅让视觉更合理还大幅减少不必要的格子数量。map.on(zoomend, function () { const currentZoom map.getZoom(); let gridSize; if (currentZoom 15) gridSize 25; else if (currentZoom 10) gridSize 100; else gridSize 500; if (gridSize ! lastGridSize) { map.removeLayer(gridLayer); gridLayer generateGridLayer(polygon, gridSize); gridLayer.addTo(map); lastGridSize gridSize; } });需要加个防抖不然连续缩放时会频繁重建图层。4.4 渲染器选SVG还是Canvas的取舍Leaflet的map可以通过preferCanvas: true选项强制使用Canvas渲染器。Canvas渲染器在大量path对象时性能通常优于SVG因为不需要维护那么多DOM节点。但Canvas渲染器有个坑Canvas虽然能画大量path但每个path的事件命中检测要遍历所有图形对象多时拖动和点击都会变卡。而且Canvas不支持单个path的CSS样式调试浏览器DevTools里看不到DOM结构。我的建议是对象数量在2000以下用SVG2000以上用Canvas。网格切分时如果做了水平合并一般情况下数量能控制在2000以内用SVG渲染器足够。5. 与业务系统集成的细节点击事件、弹窗、图层顺序网格不是画出来好看就完事了要真正接到业务系统里有几个集成细节特别重要。5.1 网格与原始多边形的双重事件问题如果你把原始多边形也保留在地图上同时又在上面叠加了网格层点击时网格层会挡住原始多边形的事件。这时候你会遇到两个问题点网格时原始多边形不响应点击不同格子click事件的回调里有重复的业务逻辑。我的做法是去掉原始多边形的交互把所有事件统一绑定在网格层上。gridLayer.eachLayer(function (rect) { rect.on(click, function (e) { // 通过rect上存储的businessId属性关联业务数据 const businessId rect.options.businessId; onGridClick(businessId, e); }); });每个L.Rectangle创建的时候在options里塞入业务字段比如地块ID、颜色、标记名称等。事件回调里直接读取这个字段去更新弹窗内容比每次去全局数据里索引快得多。5.2 图层分组与pane管理Leaflet默认的图层顺序是底图瓦片在最底层然后是overlayPane矢量图形再上面是popupPane和markerPane。如果网格要显示在普通多边形上面、标注下面就要设置自定义pane。map.createPane(gridPane); map.getPane(gridPane).style.zIndex 450; // 默认overlayPane的zIndex是400 gridLayer.eachLayer(function (rect) { rect.options.pane gridPane; });注意用pane管理后groupLayer的层级依然存在但pane决定最终的绘制顺序。网格层放在低zIndex的pane你的标记和弹窗放高zIndex的pane这样不会被网格盖住。5.3 网格的样式切换与业务ID关联做地块管理点击不同地块时网格样式要跟着变。比如当前选中的地块网格变成红色其他地块恢复蓝色。最直接的思路是每个地块的网格单独管理。const gridLayers {}; function setActiveBlock(blockId) { Object.keys(gridLayers).forEach(id { const layer gridLayers[id]; layer.eachLayer(rect { rect.setStyle({ fillColor: id blockId ? #e74c3c : #3388ff, color: id blockId ? #c0392b : #3388ff }); }); }); }这段代码的问题是eachLayer遍历时如果格子数量大开销不小。更好的方案是每个地块的网格单独用一个L.layerGroup存起来切换时直接对整个group设置样式甚至直接替换整个group。6. 踩坑记录与周边的实用技巧最后把我在实现和部署过程中踩过的坑集中写一下再补充几个相关但有用的扩展点。6.1 我只想说一遍的坑第一个坑是浮点误差导致的格间缝隙。网格切分时相邻格子的边缘坐标理论上应该完全重合但浮点运算会产生微小误差放大地图后格子之间会出现白色细线。解决办法是给每个格子设置weight: 0和透明stroke让格子只显示填充色即使有缝隙也看不出来。第二个坑是网格中心判断与凹多边形边界。中心点在多边形外部、但格子四角都在内部的凹角区域会被漏判。切分边缘地块时尽量用“四个角任一在内部则生成”的策略视觉上更贴近真实边界。第三个坑是Leaflet setStyle会覆盖pattern引用。如果你用了方案一的SVG Pattern每次调用setStyle方法都会把fill重置成固定颜色。唯一的解法是重写setStyle逻辑或者干脆用方案二/方案三。第四个坑是Web Mercator纬度变形。直接用经纬度做网格间距时越往北格子看起来越窄用map.project转平面坐标后切分才能得到正方形网格。6.2 从网格填充到数据导出WKT/GeoJSON/SHP的闭环网格切分出来的L.Rectangle本身就是Leaflet对象导出GeoJSON非常方便const features []; gridLayer.eachLayer(rect { features.push(rect.toGeoJSON()); }); const featureCollection { type: FeatureCollection, features: features };拿到GeoJSON之后要转成WKT可以用turf.js的turf.helpers.geometryToWkt或者手动拼接坐标串要转成SHP可以用shpjs库的shp.write。这样从地图上看到网格到把网格数据交回业务系统整个链路是通的。需要注意如果你在网格矩形里塞了业务字段导出GeoJSON时需要手动把rect.options里的业务字段复制到properties里toGeoJSON()默认只导出geometry不保留options。6.3 离线环境的部署注意项目数据涉密不能连外网这种事很常见leaflet和瓦片都得离线部署。网格切分的算法本身完全在本地跑不依赖网络所以离线环境没有任何影响。唯一要注意的是Leaflet的CSS和JS文件要一起下载到本地不要只存了JS忘了CSS不然地图容器会错位。瓦片源如果离线可以预先下载瓦片或者使用本地切片工具生成瓦片目录。网格填充用到的坐标投影转换逻辑跟瓦片无关底图缺失不会影响网格正确性这点可以放心。另外一个比较多问的“Leaflet离线加载”问题其实核心是leaflet库文件本地化瓦片数据本地化或用自定义tileLayer的url指向本地路径地理编码、逆地理编码等服务如果离线需要自建服务或用本地数据集。我在实际项目里遇到多的是瓦片加载慢导致网格先渲染、底图后出现视觉上看起来像网格错位。解决办法是在底图的load事件里再添加网格图层或者简单粗暴加个setTimeout延迟加载。不过更推荐在baselayerchange时同步重建网格形成联动。做完整套方案之后我回头看这个需求觉得最核心的经验就一句话别再试图让Leaflet的原生Polygon支持网格填充了绕过去用组合逻辑实现反而更稳、更可控。网格切分方案虽然要写几百行代码但它给每个格子赋予独立对象身份的能力是后面做地块交互、样式联动、数据导出的基础。如果你只是在做一个静态的纹理化展示推荐直接用SVG Pattern方案简单快速半小时搞定。
返回列表