在 GIS 前端开发中,多边形分割是高频需求(如图斑拆分、地块划分)。本文基于 Turf.js 封装了一套高精度多边形分割工具类,支持普通模式 / 兼容模式,可处理带空洞的多边形,且能 100% 保留原始坐标,避免 Turf.js 内置方法的坐标偏移问题。
一、功能亮点
- 双模式支持
- 普通模式:要求分割线与多边形外环恰好产生 2 个交点,适用于精准分割场景。
- 兼容模式:自动过滤分割线,保留与多边形交集部分,支持≥2 个有效交点,容错性更强。
- 坐标无损切割:纯手动切割算法,完全保留原始坐标点,无插值偏移,适合对精度要求高的 GIS 系统。
- 空洞自动归属:分割后自动将原多边形的空洞分配到对应的子多边形中。
- 完善的错误处理:针对参数异常、交点不足等场景抛出明确错误,便于调试。
二、完整代码实现
依赖:确保项目已安装 @turf/turf
html
npm install @turf/turf --save
工具类代码:
javascript
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 地图为例,演示如何分割多边形:
javascript
<template>
<div ref="mapContainer" class="map-container" id="map"></div>
</template>
<script setup lang='ts'>
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 差异。
效果展示:



