亲测有效!!!!,如有疑问请联系博主!!!!
这是优化后的代码
也可以参考博主的这篇文章:基于 Turf.js 实现高精度多边形修整工具(模拟 ArcGIS 修整功能)-CSDN博客
前言
在 GIS Web 端项目里,面要素局部修边、裁剪整形是自然资源、土地确权、烟田地块采集里最高频的编辑需求。
Mapbox GL Draw 自带只有常规点线面绘制、简单拆分合并,没有专业的整形修边能力:想要对已有多边形做局部裁切、边界修整,只能删了重画,效率极低。
于是基于 Mapbox GL JS + mapbox-gl-draw + Turf.js,自研一套交互式多边形整形工具 :选中面 → 绘制整形线 → 自动裁切修整 → 返回新面要素,完整闭环,开箱即用。
本文从业务场景、设计思路、核心流程、状态管理、关键踩坑、完整架构做一次博客复盘
一、业务场景痛点
- 外业采集 GPS 漂移,地块面边界多余、凹凸不齐,需要局部修边;
- 已有多边形不能整体重绘,只需要按一条线裁切整形;
- 原生 Draw 不支持线裁切面,只能自己封装拓扑裁切逻辑;
- 需要严格的模式状态管理,防止多次点击、事件重复绑定、图层残留;
- 操作要有高亮提示、消息反馈、撤销重置、退出机制。
核心诉求:不重绘整个面,画线即可局部整形修边。
二、工具整体设计思路
核心流程
- 进入整形模式 → 监听业务面图层点击事件
- 点击选中多边形 → 高亮选中要素
- 自动切换为「绘制线模式」,让用户画整形裁切线
- 画线完成后,调用 Turf 工具做面沿线裁切整形
- 裁切成功回调返回旧要素 & 新要素,供业务层保存
- 自动重置状态、清除高亮、清空绘制图层,可连续操作或退出
状态机设计
用三个核心变量管控整个流程,避免状态混乱:
isPlasticSurgery:是否进入整形总模式isDrawingTrimLine:是否正在绘制整形线targetPlasticFeature:当前待整形的目标面要素
能力亮点
- 封装 Class 化管理,解耦业务代码
- 自动过滤非面图层,只可点击 Polygon/MultiPolygon
- 选中要素高亮描边,视觉提示清晰
- 事件手动绑定 / 解绑,防止事件叠加重复触发
- 内置状态重置、重新进入模式、安全退出
- 统一异常捕获 + UI 消息提示
- 外部回调
onResult,业务层只需监听结果即可
三、核心实现流程拆解
1. 模式入口与初始化
进入整形模式时,先安全退出上一次状态,再绑定图层点击事件,给出操作指引提示。自动过滤掉标注、点、绘制层、高亮层等无关图层,只监听业务面图层点击。
2. 面要素选中逻辑
- 过滤非 Polygon / MultiPolygon 要素
- 选中后进行紫色高亮描边
- 锁定状态,禁止重复选面
- 切换 Draw 为线绘制模式,等待用户画整形线
3. 整形线绘制与裁切
监听draw.create画线完成事件:
- 校验整形线合法性
- 调用封装的
trimPolygon工具函数做拓扑裁切 - 裁切成功通过回调抛出旧要素、新要素集合
- 失败给出文案提示:整形线无效、未穿过面等
4. 高亮图层管理
单独创建临时高亮图层 / 数据源,专门用于选中面描边;重置或退出时自动移除图层和数据源,避免地图图层堆积、内存泄漏。
5. 状态重置与安全退出
封装统一resetPlasticState:清空目标要素、关闭绘制状态、清除高亮、清空 Draw 绘制、切回简单选择模式。退出模式时解绑点击事件、移除绘制监听、重置所有状态。
6. 支持重新进入整形模式
编辑完一次后无需重新实例化,内置reEnterPlasticMode:强制重置 Draw 模式、清空绘制、重置内部状态、重新绑定事件,可连续整形操作。
四、开发中关键踩坑记录
坑 1:mapbox-gl-draw 事件重复绑定
多次操作后 draw.create 事件叠加,画一次线执行多次裁切逻辑。解决方案 :每次监听前先off解绑旧事件,再once单次监听。
坑 2:地图图层、数据源残留
频繁操作后堆积大量临时高亮图层,导致地图卡顿、图层 id 冲突。解决方案:自定义唯一高亮 id,重置时主动 removeLayer、removeSource。
坑 3:状态混乱,可连续选面、连续画线
没有状态锁,用户乱点导致流程错乱。解决方案 :双状态变量isPlasticSurgery/isDrawingTrimLine做流程拦截。
坑 4:非面要素误触发整形
点击点、线、标注也会进入流程。解决方案 :严格判断geometry.type只允许面要素。
坑 5:异常无捕获,报错直接卡死流程
裁切算法、地图实例异常会导致整个工具挂掉。解决方案:全链路 try-catch,异常自动重置状态并弹窗提示。
五、代码
1、封装工具:mapPlasticTools.ts
javascript
import { ElMessage } from 'element-plus'
import { trimPolygon } from '@/utils/polygonTrimmer'
import { nextTick } from 'vue'
export class MapPlasticTools {
private map: any
private draw: any
private isPlasticSurgery = false
private isDrawingTrimLine = false
private targetPlasticFeature: any = null
private clearPlasticHighlight: () => void = () => { }
public onResult: (oldFeature: any, newFeatures: any) => void = null
constructor(map: any, draw: any) {
this.map = map
this.draw = draw
}
enterPlasticMode() {
if (!this.map || !this.draw) {
ElMessage.warning('地图未初始化')
return
}
this.exitPlasticMode()
this.isPlasticSurgery = true
ElMessage.info('请选择需要整形的面')
this.bindLayerClick()
}
// ==============================
// 【提取】更新后重新进入整形
// ==============================
reEnterPlasticMode() {
if (!this.map || !this.draw) return
nextTick(() => {
try {
// 强制重置
this.draw.changeMode('simple_select')
this.draw.deleteAll()
// 重置内部状态
this.isDrawingTrimLine = false
this.isPlasticSurgery = true
// 重新绑定事件
this.unbindLayerClick()
this.bindLayerClick()
ElMessage.info('已重新进入整形模式')
} catch (err) {
console.error('重新进入整形失败', err)
}
})
}
private bindLayerClick() {
const layers = this.getValidLayers()
layers.forEach(id => {
this.map.off('click', id, this.onFeatureClick)
this.map.on('click', id, this.onFeatureClick)
})
}
private unbindLayerClick() {
const layers = this.getValidLayers()
layers.forEach(id => {
this.map.off('click', id, this.onFeatureClick)
})
}
private onFeatureClick = (e: any) => {
if (this.isDrawingTrimLine || !e.features?.length) return
const feature = e.features[0]
if (!['Polygon', 'MultiPolygon'].includes(feature.geometry.type)) return
this.targetPlasticFeature = feature
ElMessage.info('已选中,请绘制整形线')
this.highlightFeature(feature)
this.isDrawingTrimLine = true
this.draw.changeMode('draw_line_string')
this.map.once('draw.create', this.onTrimLineCreated)
}
private onTrimLineCreated = async (e: any) => {
try {
const line = e.features[0]
if (!line) {
ElMessage.error('整形线无效')
this.resetPlasticState()
return
}
const result = trimPolygon(this.targetPlasticFeature, line)
if (!result) {
ElMessage.error('整形失败')
this.draw.delete(line.id)
this.resetPlasticState()
return
}
ElMessage.success('整形完成!')
if (this.onResult) {
this.onResult(this.targetPlasticFeature, result)
}
this.resetPlasticState()
} catch (err) {
console.error('整形出错:', err)
ElMessage.error('整形失败')
this.resetPlasticState()
}
}
private highlightFeature(feature: any) {
const id = 'plastic-highlight'
if (this.map.getLayer(id)) this.map.removeLayer(id)
if (this.map.getSource(id)) this.map.removeSource(id)
this.map.addSource(id, { type: 'geojson', data: feature })
this.map.addLayer({
id, type: 'line', source: id, paint: { 'line-color': '#ff00ff', 'line-width': 3 }
})
this.clearPlasticHighlight = () => {
if (this.map.getLayer(id)) this.map.removeLayer(id)
if (this.map.getSource(id)) this.map.removeSource(id)
}
}
resetPlasticState() {
this.isDrawingTrimLine = false
this.targetPlasticFeature = null
this.clearPlasticHighlight?.()
if (this.draw) {
this.draw.deleteAll()
this.draw.changeMode('simple_select')
}
}
exitPlasticMode() {
this.unbindLayerClick()
this.isPlasticSurgery = false
this.resetPlasticState()
this.map.off('draw.create', this.onTrimLineCreated)
}
private getValidLayers(): string[] {
if (!this.map) return []
const exclude = [/draw/, /hover/, /outline/, /point/, /label/, /symbol/, /plastic-highlight/]
return this.map.getStyle().layers.map(l => l.id).filter(id => !exclude.some(r => r.test(id)))
}
}
2、刷新工具mapLayerRefresh.ts
javascript
import { ElMessage } from 'element-plus'
/**
* 分割、整形 通用地图图层刷新工具
* 逻辑:匹配图层 → 先隐藏 → 延时恢复可视 → 强制地图重新渲染
*/
export class MapLayerRefresh {
private map: any
constructor(mapInstance: any) {
this.map = mapInstance
}
/**
* 通用刷新图层
* @param layerName 图层名称
* @param closeDrawLayer 可选:关闭绘制弹窗方法
*/
refreshLayer(layerName: string, closeDrawLayer?: (val: boolean) => void) {
try {
// 关闭绘制弹窗
if (closeDrawLayer) {
closeDrawLayer(false)
}
const layerIds = this.map.getStyle().layers
// 隐藏对应业务图层(排除栅格)
layerIds.forEach((item: any) => {
if (item.id.includes(layerName) && !item.id.includes('_raster')) {
this.map.setLayoutProperty(item.id, 'visibility', 'none')
}
})
// 延迟恢复,触发地图刷新渲染
setTimeout(() => {
layerIds.forEach((item: any) => {
if (item.id.includes(layerName) && !item.id.includes('_raster')) {
this.map.setLayoutProperty(item.id, 'visibility', 'visible')
}
})
}, 500)
} catch (error) {
ElMessage.error(`图层刷新失败:${(error as Error).message}`)
}
}
}
export default MapLayerRefresh
3、提交后端工具plasticSubmitService.ts
javascript
import baseService from '@/service/baseService'
import { ElMessage } from 'element-plus'
/**
* 独立工具方法:提交整形数据到后端
* @param oldData 旧图斑
* @param newData 新图斑
* @returns Promise<{ success: boolean, data?: any, msg?: string }>
*/
export const savePlasticToServer = async (oldData: any, newData: any) => {
try {
// 构造请求参数
const params = {
layerName: oldData.layer.id,
gid: oldData.properties.gid,
features: newData
}
// 请求后端
const response = await baseService.post('/data/boundary/trimming', params, {
headers: {
'Content-Type': 'application/json'
}
})
// 成功
if (response.data.code === '200') {
ElMessage.success('整形保存成功')
return {
success: true,
data: response.data,
layerName: oldData.layer.id
}
} else {
ElMessage.error(response.data.message || '保存失败')
return {
success: false,
msg: response.data.message
}
}
} catch (err) {
console.error('整形提交失败:', err)
ElMessage.error(`提交失败:${err.message || '未知错误'}`)
return {
success: false,
msg: err.message
}
}
}
4、页面应用,其他页面引用:mapRef.value.plasticSurgery()
javascript
1、
onMounted(() => {
document.body.style.backgroundColor = ''
document.body.style.background = ''
map = new mapboxgl.Map({
container: mapContainer.value,
style: { version: 8, sources: {}, layers: [] },
center: [106.7132, 26.5728],
zoom: 5
})
// 地图加载完成后 才会执行!!!
map.on('load', () => {
console.log("地图加载完成")
// 1. 先初始化 draw
draw = new MapboxDraw({
displayControlsDefault: false,
controls: { polygon: false, trash: false }
})
map.addControl(draw)
// 2. 再初始化编辑工具
editTools = useMapEditTools(map)
// 3. 整形工具(此时 map 和 draw 都存在)
plasticTools = new MapPlasticTools(map, draw)
//刷新整形/分割
layerRefresh = new MapLayerRefresh(map)
})
})
2.
// 整形按钮
const plasticSurgery = () => {
// 已激活 → 直接退出
if (!plasticTools) return
// 已经开启 → 直接彻底关闭
if (isPlasticMode) {
plasticTools.exitPlasticMode() // 关闭工具
closeDrawLayer() // 清理地图
isPlasticMode = false
return
}
// 未开启 → 只进入一次
isPlasticMode = true
plasticTools.enterPlasticMode()
// 回调只绑一次
if (!plasticTools.onResult) {
plasticTools.onResult = async (oldFeature, newFeatures) => {
//要点击保存或者快捷的ctyl+s才进行保存操作
submitPlasticSurgery(oldFeature, newFeatures)
closeDrawLayer() // 完成后自动关闭
}
}
}
const submitPlasticSurgery = async (oldData, newData) => {
const res = await savePlasticToServer(oldData, newData)
if (res.success) {
// 成功 → 刷新图层
layerRefresh.refreshLayer(oldData.layer.id, closeDrawLayer)
plasticTools?.reEnterPlasticMode()
}
};
// 取消整形
const cancelPlastic = () => {
plasticTools?.exitPlasticMode()
closeDrawLayer()
isPlasticMode = false
}
const closeDrawLayer = () => {
if (!draw || !map) return
// 1. 退出模式
plasticTools?.exitPlasticMode()
// 1. 切回普通选择模式
draw.changeMode('simple_select')
// 2. 清空地图上画的线/面
draw.deleteAll()
// 3. 彻底关闭状态
isPlasticMode = false
}
defineExpose({
plasticSurgery,
cancelPlastic,
saveEditFeature,
closeDrawLayer,
undoEdit,
get editElements() { return editTools?.editElements },
get exitEditMode() { return editTools?.exitEditMode },
get startAddPointMode() { return editTools?.startAddPointMode },
get startDeletePointMode() { return editTools?.startDeletePointMode },
})
六、代码架构设计优势
- Class 面向对象封装:高内聚低耦合,可直接实例化复用;
- 状态驱动流程:流程靠状态流转,不易乱;
- 事件手动管理:精准绑定解绑,无内存泄漏、无重复触发;
- UI 交互完整:消息提示、高亮、操作指引全覆盖;
- 业务解耦 :通过
onResult回调把结果抛给外层,工具层只负责编辑逻辑; - 健壮性拉满:空值校验、异常捕获、自动重置、安全退出。