mapbox 基于 Turf.js 实现高精度多边形分割(支持带空洞 / 坐标无损)
在 GIS 前端开发中多边形分割是高频需求如图斑拆分、地块划分。本文基于 Turf.js 封装了一套高精度多边形分割工具类支持普通模式 / 兼容模式可处理带空洞的多边形且能 100% 保留原始坐标避免 Turf.js 内置方法的坐标偏移问题。一、功能亮点双模式支持普通模式要求分割线与多边形外环恰好产生 2 个交点适用于精准分割场景。兼容模式自动过滤分割线保留与多边形交集部分支持≥2 个有效交点容错性更强。坐标无损切割纯手动切割算法完全保留原始坐标点无插值偏移适合对精度要求高的 GIS 系统。空洞自动归属分割后自动将原多边形的空洞分配到对应的子多边形中。完善的错误处理针对参数异常、交点不足等场景抛出明确错误便于调试。二、完整代码实现依赖确保项目已安装turf/turfnpm install turf/turf --save工具类代码import * as turf from turf/turf; class ChunkUtil { constructor() { this.turf turf; this.EPS 1e-8; this.tolerance 1e-10; } clipPolygon(polygon, polyline, compatibleMode false) { if (!polygon) throw new Error(未传入目标多边形); if (!polyline) throw new Error(未传入分割线); if (polygon.geometry?.type ! Polygon) throw new Error(必须是Polygon当前为${polygon.geometry?.type}); if (polyline.geometry?.type ! LineString) throw new Error(必须是LineString当前为${polyline.geometry?.type}); let finalLine polyline; if (compatibleMode) { finalLine this._filterLineToPolygon(polyline, polygon); } return this._setFloors(polygon, finalLine, compatibleMode); } _setFloors(polygonFeature, polylineFeature, compatibleMode) { const polygonBoundary this.turf.polygonToLine(polygonFeature); let boundaryLines []; if (polygonBoundary.geometry.type LineString) { boundaryLines [polygonBoundary]; } else if (polygonBoundary.geometry.type MultiLineString) { boundaryLines polygonBoundary.geometry.coordinates.map(coords this.turf.lineString(coords)); } else { throw new Error(不支持的几何类型${polygonBoundary.geometry.type}); } let targetBoundary null; let validIntersections []; for (const line of boundaryLines) { const intersections this.turf.lineIntersect(line, polylineFeature); if (compatibleMode) { validIntersections this._filterValidIntersections(intersections.features, polygonFeature); if (validIntersections.length 2) { targetBoundary line; validIntersections validIntersections.slice(0, 2); break; } } else { if (intersections.features.length 2) { targetBoundary line; validIntersections intersections.features; break; } } } if (!targetBoundary) { const errMsg compatibleMode ? 分割失败分割线未与多边形外环产生至少2个有效交点 : 分割失败分割线未与多边形外环产生恰好2个交点; throw new Error(errMsg); } return this._singleClip(targetBoundary, polylineFeature, polygonFeature, validIntersections, compatibleMode); } _singleClip(polygonBoundary, splitLine, originalPolygon, validIntersections, compatibleMode) { const { turf } this; const [p1, p2] validIntersections; const lineCoords splitLine.geometry.coordinates; const startPoint turf.point(lineCoords[0]); const endPoint turf.point(lineCoords[lineCoords.length - 1]); const startIn turf.booleanPointInPolygon(startPoint, originalPolygon); const endIn turf.booleanPointInPolygon(endPoint, originalPolygon); if (compatibleMode) { if (startIn endIn) throw new Error(分割线不能完全在内部); } else { if (startIn || endIn) throw new Error(分割线起点/终点不能在内部); } // --- 纯手动切割边界完全保留阶梯坐标 --- const fullRingCoords polygonBoundary.geometry.coordinates; const [clippedBoundaryCoords, remainingCoords] this._manualSplitRing(fullRingCoords, p1, p2); const clippedBoundary turf.lineString(clippedBoundaryCoords); const remainingLine turf.lineString(remainingCoords); // --- 纯手动切割分割线 --- const splitLineCoords splitLine.geometry.coordinates; const clippedSplitLineCoords this._manualSplitLine(splitLineCoords, p1, p2); const clippedSplitLine turf.lineString(clippedSplitLineCoords); // 拼接线段 const mergedLine1 this._connectLine(clippedBoundary, clippedSplitLine); mergedLine1.geometry.coordinates.push(mergedLine1.geometry.coordinates[0]); const polygon1Outer mergedLine1.geometry.coordinates; const mergedLine2 this._connectLine(remainingLine, clippedSplitLine); mergedLine2.geometry.coordinates.push(mergedLine2.geometry.coordinates[0]); const polygon2Outer mergedLine2.geometry.coordinates; // 空洞归属 const holes originalPolygon.geometry.coordinates.slice(1); const polygon1Holes [], polygon2Holes []; const tempPoly1 turf.polygon([polygon1Outer]); const tempPoly2 turf.polygon([polygon2Outer]); for (const hole of holes) { const center turf.centroid(turf.polygon([hole])); const in1 turf.booleanPointInPolygon(center, tempPoly1); const in2 turf.booleanPointInPolygon(center, tempPoly2); if (in1 !in2) polygon1Holes.push(hole); else if (in2 !in1) polygon2Holes.push(hole); else if (in1 in2) { const d1 turf.distance(center, p1); const d2 turf.distance(center, p2); d1 d2 ? polygon1Holes.push(hole) : polygon2Holes.push(hole); } } const polygon1 turf.polygon([polygon1Outer, ...polygon1Holes], originalPolygon.properties); const polygon2 turf.polygon([polygon2Outer, ...polygon2Holes], originalPolygon.properties); [polygon1, polygon2].forEach((poly, idx) { poly.properties { ...poly.properties, split: true, splitTime: Date.now(), splitId: ${originalPolygon.properties.gid || poly}_split_${idx} }; }); return turf.featureCollection([polygon1, polygon2]); } // --- 纯手动切割外环100%保留原始坐标 --- _manualSplitRing(ringCoords, p1, p2) { const ring [...ringCoords.slice(0, -1)]; // 移除闭合点 const insertPoint (pt) { for (let i 0; i ring.length; i) { const a ring[i], b ring[i 1]; if (this._isPointOnSegment(pt, a, b)) { ring.splice(i 1, 0, pt.geometry.coordinates); return; } } ring.push(pt.geometry.coordinates); }; insertPoint(p1); insertPoint(p2); const idx1 this._findPointIndex(ring, p1.geometry.coordinates); const idx2 this._findPointIndex(ring, p2.geometry.coordinates); let part1, part2; if (idx1 idx2) { part1 ring.slice(idx1, idx2 1); part2 ring.slice(idx2).concat(ring.slice(0, idx1 1)); } else { part1 ring.slice(idx2, idx1 1); part2 ring.slice(idx1).concat(ring.slice(0, idx2 1)); } return [part1, part2]; } // --- 纯手动切割分割线 --- _manualSplitLine(lineCoords, p1, p2) { const line [...lineCoords]; const insertPoint (pt) { for (let i 0; i line.length; i) { const a line[i], b line[i 1]; if (this._isPointOnSegment(pt, a, b)) { line.splice(i 1, 0, pt.geometry.coordinates); return; } } line.push(pt.geometry.coordinates); }; insertPoint(p1); insertPoint(p2); const idx1 this._findPointIndex(line, p1.geometry.coordinates); const idx2 this._findPointIndex(line, p2.geometry.coordinates); return idx1 idx2 ? line.slice(idx1, idx2 1) : line.slice(idx2, idx1 1); } _findPointIndex(coords, pt) { for (let i 0; i coords.length; i) { if (this.turf.distance(turf.point(coords[i]), turf.point(pt)) this.EPS) return i; } return -1; } _isPointOnSegment(pt, a, b) { const cross (pt.geometry.coordinates[0] - a[0]) * (b[1] - a[1]) - (pt.geometry.coordinates[1] - a[1]) * (b[0] - a[0]); if (Math.abs(cross) this.EPS) return false; const minX Math.min(a[0], b[0]), maxX Math.max(a[0], b[0]); const minY Math.min(a[1], b[1]), maxY Math.max(a[1], b[1]); return pt.geometry.coordinates[0] minX - this.EPS pt.geometry.coordinates[0] maxX this.EPS pt.geometry.coordinates[1] minY - this.EPS pt.geometry.coordinates[1] maxY this.EPS; } // --- 兼容模式辅助方法 --- _filterLineToPolygon(line, polygon) { const intersection this.turf.intersect(line, polygon); if (!intersection) throw new Error(分割线与当前图斑无交集); if (intersection.geometry.type MultiLineString) { const lines intersection.geometry.coordinates.map(coords this.turf.lineString(coords)); lines.sort((a, b) this.turf.length(b) - this.turf.length(a)); return lines[0]; } return intersection; } _filterValidIntersections(points, polygon) { const inPolygonPoints points.filter(point this.turf.booleanPointInPolygon(point, polygon)); const uniquePoints []; for (const p of inPolygonPoints) { const isDuplicate uniquePoints.some(u this.turf.distance(u, p) this.EPS); if (!isDuplicate) uniquePoints.push(p); } return uniquePoints; } /** * 提取多边形边界中未被切割的剩余坐标 * param {Object} fullBoundary - 完整多边形边界线LineString * param {Object} clippedBoundary - 切割后的边界线段LineString * returns {Array} 剩余坐标数组 * private */ _getRemainingBoundaryCoords(fullBoundary, clippedBoundary) { const fullCoords fullBoundary.geometry.coordinates; const clippedCoords clippedBoundary.geometry.coordinates; const isFirstPointMatch this._isPointInLine(turf.point(fullCoords[0]), clippedBoundary); // 1切割段在边界线头部/尾部 if (isFirstPointMatch) { return fullCoords.filter(coord !this._isCoordInArray(coord, clippedCoords)); } // 2切割段在边界线中间 else { let startPush false; let skipCount 0; const remainingCoords []; for (const coord of fullCoords) { if (!this._isCoordInArray(coord, clippedCoords)) { if (startPush) { remainingCoords.push(coord); } else { skipCount; } } else { startPush true; } } for (let i 0; i skipCount; i) { remainingCoords.push(fullCoords[i]); } return remainingCoords; } } _connectLine(line1, line2) { const l1End line1.geometry.coordinates[line1.geometry.coordinates.length - 1]; const l2Coords line2.geometry.coordinates; const l2Start l2Coords[0], l2End l2Coords[l2Coords.length - 1]; const merged [...line1.geometry.coordinates]; if (this.turf.distance(turf.point(l1End), turf.point(l2Start)) this.turf.distance(turf.point(l1End), turf.point(l2End))) { merged.push(...l2Coords.slice(1)); } else { merged.push(...l2Coords.reverse().slice(1)); } return this.turf.lineString(merged); } _isPointInLine(point, line) { return line.geometry.coordinates.some(coord this.turf.distance(turf.point(coord), point) this.EPS); } _isCoordInArray(coord, coordArray) { return coordArray.some(c this.turf.distance(turf.point(c), turf.point(coord)) this.EPS); } } const chunkUtil new ChunkUtil(); export function splitPolygon(targetPolygon, splitLine, compatibleMode false) { try { const splitResult chunkUtil.clipPolygon(targetPolygon, splitLine, compatibleMode); return splitResult?.features || null; } catch (error) { console.error(多边形分割出错, error); return null; } } export function createSplitLine(start, end) { if (!start || !end || start.length ! 2 || end.length ! 2) return null; return turf.lineString([start, end], { name: split-line }); }三、使用示例以 Vue3 mapbox 地图为例演示如何分割多边形template div refmapContainer classmap-container idmap/div /template script setup langts import mapboxgl from mapbox-gl import { ref, reactive, onMounted, defineProps, watch, onBeforeUnmount, computed, nextTick, inject } from vue import mapbox-gl/dist/mapbox-gl.css import MapboxDraw from mapbox/mapbox-gl-draw import mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css import baseService from ../../service/baseService let map; const mapContainer ref() // 分割相关状态 const isSplitting ref(false); // 标记是否处于分割模式 let splitSourceId split-temp-source; // 分割临时数据源ID let targetSplitFeature ref(null); // 待分割的目标图斑 const isDrawingSplitLine ref(false); // 分割入口方法 const segmentation () { console.log(进入分割); if (!map) { ElMessage.warning(地图未初始化完成无法分割); return; } // 1. 重置分割状态 resetSplitState(); // 2. 标记为分割模式 isSplitting.value true; ElMessage.info(请先点击选中需要分割的图斑); // 3. 绑定图斑选中事件 bindFeatureSelectEvent(handleSplitFeatureClick, 分割); }; // 重置分割状态 const resetSplitState () { resetDrawState({ isModeActive: isSplitting, isDrawingLine: isDrawingSplitLine, targetFeature: targetSplitFeature, drawCompleteHandler: handleSplitDrawComplete, drawModeChangeHandler: handleDrawModeChange, modeDesc: 分割 }, splitSourceId, clearHighlight1); }; // 处理分割模式下的图斑点击 const handleSplitFeatureClick (e) { if (isDrawingSplitLine.value) return; if (!isSplitting.value || !e.features || e.features.length 0) return; const feature e.features[0]; console.log(获取选中的图斑, feature); if (![Polygon, MultiPolygon].includes(feature.geometry.type)) { ElMessage.warning(仅支持多边形/多多边形图斑分割); return; } targetSplitFeature.value feature; ElMessage.info(已选中图斑[ID:${feature.properties.gid || 未知}]请绘制分割线双击结束); // 高亮图斑 highlightFeature1(feature); // 激活绘制模式 isDrawingSplitLine.value true; draw.changeMode(draw_line_string); // 监听Draw事件 map.off(draw.create, handleSplitDrawComplete); map.off(draw.modechange, handleDrawModeChange); map.on(draw.create, handleSplitDrawComplete); map.on(draw.modechange, handleDrawModeChange); }; // 监听Draw模式变化分割 const handleDrawModeChange (e) { if (e.mode simple_select) { isDrawingSplitLine.value false; } }; // 处理分割线绘制完成 const handleSplitDrawComplete (e) { try { if (!e || !e.features || e.features.length 0) { ElMessage.error(分割线绘制失败); resetSplitState(); return; } const drawedLineFeature e.features[0]; const splitLineId e.features[0].id; const standardSplitLine turf.lineString(drawedLineFeature.geometry.coordinates); // 调用分割方法 const splitResult splitPolygon(targetSplitFeature.value, standardSplitLine); console.log(分割数据, splitResult); if (!splitResult) { ElMessage.error(分割失败请确保分割线完全贯穿图斑); if (draw splitLineId) { draw.delete(splitLineId); console.log(分割失败已清除绘制的分割线); } resetSplitState(); return; } // 提交分割结果 submitSegmentation(targetSplitFeature.value, splitResult); ElMessage.success(分割成功); resetSplitState(); } catch (error) { console.error(分割处理失败, error); ElMessage.error(分割异常请重试); resetSplitState(); } }; // 提交分割结果 const submitSegmentation (oldData, newData) { const data { layerName: oldData.layer.id, gid: oldData.properties.gid, features: newData } //提交数据到后端 baseService.post(/data/boundary/trimming, data, { headers: { Content-Type: application/json } }).then(response { // if (response.data.status 200) { //更新分割结果到地图 // updateSplitPolygonDataSource(oldData.layer.id, newData); updateMapSourceWithPlasticResult(oldData.layer.id, newData, oldData.layer.gid) updateSegmentationShaping(分割) // } ElMessage.success({ type: success, message: response.data.message }) }).catch(err { ElMessage.error(失败, err); }) }; const updateSegmentationShaping (s) { if (map draw) { // 2. 用nextTick确保Vue状态更新完成 nextTick(() { try { // 3. 先切回选择模式强制 draw.changeMode(simple_select); console.log(当前Draw模式, draw.getMode()); // 验证是否切成功 // 4. 清空残留的分割线同步执行避免异步残留 draw.deleteAll(); // 5. 同步重置状态确保响应式更新 isDrawingSplitLine.value false; isDrawingTrimLine.value false; if (s 分割) { isSplitting.value true; // 6. 验证状态后再绑定事件避免空绑定 if (isSplitting.value) { console.log(重新分割状态, isSplitting.value); // 先解绑旧事件再绑定新事件避免重复绑定 map.off(click, handleSplitFeatureClick); bindFeatureSelectEvent(handleSplitFeatureClick, 分割); } } else if (s 整形) { isPlasticSurgery.value true if (isPlasticSurgery.value) { map.off(click, handlePlasticFeatureClick); bindFeatureSelectEvent(handlePlasticFeatureClick, 整形); } } } catch (err) { } }) } } /** * param {string} layerName - 原图层名称 * param {Array} plasticFeatures - 新图斑数据Feature数组 * param {string|number} gid - 图斑ID * 更新数据 */ const updateMapSourceWithPlasticResult async (layerName, plasticFeatures, gid) { try { closeDrawLayer(false) console.log(更新参数, { layerName, plasticFeatures, gid }); let layerIds map.getStyle().layers layerIds.forEach((item) { if (item.id.includes(layerName) !item.id.includes(_raster)) { map.setLayoutProperty(item.id, visibility, none); } }) setTimeout(() { layerIds.forEach((item) { if (item.id.includes(layerName) !item.id.includes(_raster)) { map.setLayoutProperty(item.id, visibility, visible); } }) }, 500) } catch (error) { ElMessage.error(提交失败${error.message}); } }; // 高亮分割图斑 const highlightFeature1 (feature) { // 复用公共高亮方法返回清除方法 clearHighlight1 highlightFeatureCommon(feature, split-highlight-layer, #ff9222); }; /** * 公共方法高亮图斑分割/整形共用 * param {Object} feature - 要高亮的图斑Feature * param {string} highlightLayerId - 高亮图层ID * param {string} color - 高亮颜色 */ const highlightFeatureCommon (feature, highlightLayerId, color) { // 清除旧高亮 const clearHighlight () { if (map.getLayer(highlightLayerId)) { map.removeLayer(highlightLayerId); } if (map.getSource(highlightLayerId)) { map.removeSource(highlightLayerId); } }; clearHighlight(); // 添加高亮数据源 map.addSource(highlightLayerId, { type: geojson, data: feature }); // 添加高亮图层 map.addLayer({ id: highlightLayerId, type: line, source: highlightLayerId, layout: { line-join: round, line-cap: round, line-simplification: none, antialias: false }, paint: { line-color: color, line-width: 2, line-opacity: 1 } }); return clearHighlight; // 返回清除方法 }; /** * 公共方法重置绘制相关状态分割/整形共用 * param {Object} stateConfig - 状态配置对象 * param {string} sourceId - 临时数据源ID * param {Function} clearHighlight - 清除高亮的方法 */ const resetDrawState (stateConfig, sourceId, clearHighlight) { // 重置状态变量 stateConfig.isModeActive.value false; stateConfig.isDrawingLine.value false; stateConfig.targetFeature.value null; // 清理Draw事件监听 if (map) { map.off(draw.create, stateConfig.drawCompleteHandler); map.off(draw.modechange, stateConfig.drawModeChangeHandler); } // 移除临时图层/数据源 if (map map.getSource(sourceId)) { map.removeSource(sourceId); } if (map map.getLayer(${sourceId}-layer)) { map.removeLayer(${sourceId}-layer); } // 清空Draw绘制要素 if (draw) { draw.deleteAll(); draw.changeMode(simple_select); console.log(重置${stateConfig.modeDesc}状态已清空Draw所有绘制要素); } // 清除高亮 clearHighlight(); }; // 全局计数器分割/整形分开 let splitCounter 0; // 公共排除图层规则分割/整形共用 const COMMON_EXCLUDE_LAYER_PATTERNS [ /^g-draw-/, // Draw绘图图层 /.cold$/, // Draw冷态图层 /-hover$/, // hover高亮图层 /_road$/, // 道路图层 /raster/, // 栅格底图 /outline/, // 轮廓图层 /city-label/, // 文字标注图层 /.hot$/, // Draw热态图层 /^guizhou/, // 贵州边界等非业务图层 point, // 点图层 polyline // 线图层 ]; /** * 公共方法过滤有效业务图层分割/整形共用 * returns {Array} 过滤后的有效图层ID数组 */ const getValidBusinessLayerIds () { if (!map) return []; const layerIds map.getStyle().layers.map(layer layer.id); return layerIds.filter(layerId { return !COMMON_EXCLUDE_LAYER_PATTERNS.some(pattern { if (pattern instanceof RegExp) { return pattern.test(layerId); } else { return layerId pattern; } }); }); }; /** * 公共方法绑定图斑选中事件分割/整形共用 * param {Function} clickHandler - 点击事件处理函数 * param {string} modeDesc - 模式描述分割/整形用于日志 */ const bindFeatureSelectEvent (clickHandler, modeDesc) { const validLayerIds getValidBusinessLayerIds(); // console.log(所有图层ID, map.getStyle().layers.map(layer layer.id)); // console.log(过滤后可交互的业务图层${modeDesc}, validLayerIds); validLayerIds.forEach((layerId) { // 先解绑旧事件避免重复绑定 map.off(click, layerId, clickHandler); // 绑定新的点击事件 map.on(click, layerId, clickHandler); console.log(为业务图层绑定${modeDesc}选中事件${layerId}); }); }; /script四、注意事项坐标系统确保多边形和分割线使用同一坐标系如 WGS84 或墨卡托否则会导致交点计算错误。交点要求普通模式下分割线必须与多边形外环恰好相交 2 次且起点 / 终点不能在多边形内部。兼容模式下分割线可部分在多边形内但需保证与外环有≥2 个有效交点。推荐使用turf/turf^6.5.0版本低版本可能存在 API 差异。效果展示
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2458660.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!