背景:在 WebGIS 图斑编辑业务中,除了整形、分割、合并,折点(顶点)编辑 是高频需求:拖拽修改顶点位置、在线段增加折点、删除折点、套索批量删除折点、撤销 / 重做。本文基于项目自研MapVertexEditTools工具类,讲解整套折点编辑的实现思路、核心原理、完整能力、关键坑点,适合需要自研顶点编辑,不想重度依赖mapbox‑gl‑draw的同学参考。
1、业务需求背景
在遥感图斑解译系统中,用户经常需要微调面的轮廓:
为什么不完全使用
mapbox‑gl‑draw? 项目已经自研了分割、合并、整形工具;draw 直接操作草图图层,很难和前端内存缓存、瓦片图层过滤逻辑、捕捉管理器深度打通,因此自研一套轻量折点工具类。
二、整体架构设计
2.1 分层结构
- 底层:MapVertexEditTools TS 工具类 封装地图事件、顶点渲染、拖拽、增删顶点、撤销栈,向外暴露回调事件,不耦合 Vue 业务逻辑。
- 中间层 Vue 组件 (map‑inference.vue) 实例化工具类;处理业务层逻辑:
editingGids隐藏原始瓦片图斑、内存fullAiFC维护编辑后的全部图斑集合、AI 临时 GeoJSON 图层渲染、工具互斥切换。 - 上层 UI 按钮:编辑折点、增加折点、删除折点、撤销、重做、完成、取消。
2.2 关键回调契约(工具类向外抛出事件)
- 拖拽单个折点,微调多边形边界;
- 在两条折点之间的边上插入新折点;
- 删除多余折点(保护规则:单个环最少保留 3 个顶点,防止多边形失效);
- 支持套索框选,批量删除一批折点;
- 编辑过程支持撤销、重做;
- 区分「临时编辑休眠」和「取消编辑丢弃修改」两种状态;
- 兼容
Polygon普通面、MultiPolygon多面、支持内环洞; - 和上层业务联动:编辑时底层瓦片原图斑隐藏,使用前端内存
GeoJSON FeatureCollection渲染编辑后的图斑。 - 底层:MapVertexEditTools TS 工具类 封装地图事件、顶点渲染、拖拽、增删顶点、撤销栈,向外暴露回调事件,不耦合 Vue 业务逻辑。
- 中间层 Vue 组件 (map‑inference.vue) 实例化工具类;处理业务层逻辑:
editingGids隐藏原始瓦片图斑、内存fullAiFC维护编辑后的全部图斑集合、AI 临时 GeoJSON 图层渲染、工具互斥切换。 - 上层 UI 按钮:编辑折点、增加折点、删除折点、撤销、重做、完成、取消。
javascript
//拖拽顶点实时发生几何变更
public onGeometryChange?: (updatedFeat: GeoJSON.Feature) => void
//进入编辑某一个gid,上层把gid加入editingGids,过滤隐藏瓦片原图斑
public onEnterEdit?: (gid: string | number) => void
//真正退出/取消编辑,上层移除editingGids,恢复瓦片图斑显示
public onExitEdit?: (gid: string | number | null) => void
重点区分两个上层状态:
blur():休眠模式。只隐藏折点圆点 UI,不销毁 currentFeature,不触发 onExitEdit,editingGids 保留,原图斑继续隐藏;切换到分割 / 整形工具时调用。cancel():真正取消编辑。清空编辑快照、触发onExitEdit通知上层移除 editingGids,丢弃本次所有修改。
三、核心实现原理
3.1 折点控制点怎么渲染?
不使用 Marker 逐个渲染(marker 大量顶点性能差),采用独立 GeoJSON Source + Circle 图层渲染所有顶点Mapbox。
- 拿到待编辑
Feature,解析Polygon/MultiPolygon所有环(外环、洞内环); - 将每一个坐标点转为 Point 要素,properties 携带元信息:
polyIndex(多面序号)、ringIndex(环索引)、pointIndex(点索引);
注意:GeoJSON 的 Polygon 环是闭合的,最后一个坐标和第一个坐标完全重复,渲染顶点时过滤掉重复闭合点,避免重复渲染圆点。
- 将点集合设置给独立顶点 source,circle 图层渲染橙色控制点圆点。
javascript
//伪代码,geometryToVertexFC函数核心逻辑
function geometryToVertexFC(geom){
const features = []
//遍历Polygon/MultiPolygon所有环,把每个坐标转为Point,带上索引信息
//过滤闭合重复点
return {type:'FeatureCollection',features}
}
source.setData(vertexFc);
.2 拖拽折点原理
依靠地图原生鼠标事件:mousedown、mousemove、mouseup。
mousedown:在顶点 circle 图层命中控制点;读取 properties 中的polyIndex/ringIndex/pointIndex,标记拖拽状态;拖拽开始压入撤销快照;mousemove:鼠标移动,拿到e.lngLat经纬度;根据索引修改当前 feature 对应环数组的坐标;
⚠️多边形闭合坑:第一个点与最后一个点坐标必须保持同步。拖拽索引 0 的点,同步修改 ring 数组末尾闭合点;拖拽末尾点同步修改 ring 0。
- 每帧更新完成,调用
onGeometryChange回调通知上层 Vue 组件,上层更新内存 fullAiFC,刷新 AI 图层渲染; mouseup结束拖拽,清空拖拽标记。
关键点:工具类内部维护一份
currentFeature编辑副本,不直接修改上层原始对象,全部使用structuredClone深度拷贝,避免原地污染原始 GeoJSON 对象。
3.3 添加折点(在线段上插入顶点)实现原理
需求:鼠标悬浮多边形边线,出现黄色悬浮圆点;点击线段,在鼠标投影位置插入新折点。
- 模式切换:进入【添加折点模式】,关闭删除模式;
mousemove拿到鼠标经纬度,屏幕像素距离算法pointSegmentProjectScreen:- 将线段两个端点
project()转为屏幕像素; - 计算鼠标屏幕点到线段的垂直像素距离;设置阈值(例如 12 像素);
- 如果距离小于阈值,计算鼠标在线段上的投影经纬度;使用临时 Marker 渲染悬浮小黄点;
- 将线段两个端点
- 鼠标 click:获取投影坐标,在对应 ring 数组
segIdx+1位置splice插入新坐标;压入撤销快照;刷新顶点图层。
为什么用屏幕像素距离,不用经纬度距离?地图缩放级别变化,经纬度距离阈值会失效;屏幕像素阈值不受缩放影响,交互体验稳定。
3.4 删除折点(单点点击删除 + 套索批量删除)
单点删除
- 进入删除模式,hover 顶点,蓝色悬浮 marker 高亮;
- click 命中顶点;
- 保护校验:调用
getRingPointCount统计环有效顶点,环顶点不能小于 3 个,否则弹窗提示禁止删除,避免生成非法 GeoJSON; - splice 删除该点;同步处理闭合首尾点;压入撤销快照,刷新图层。
套索批量删除折点(特色能力)
- 在 canvas DOM 层监听 mousedown/mousemove/mouseup;
注意:套索不能用 mapbox 图层事件,需要监听 canvas 原生 DOM 事件;拖拽套索期间临时关闭地图拖拽平移
map.dragPan.disable();绘制完成恢复。
- 鼠标按下(且没有命中顶点圆点),开始收集屏幕点;绘制红色虚线套索临时 line 图层;
- 鼠标抬起,把屏幕点
unproject转回经纬度,构建 turf 多边形; - 使用
turf.booleanPointInPolygon,判断每一个顶点是否在套索多边形内部;收集待删除索引; - 批量过滤环坐标;同样执行最少 3 点保护;内环如果删除后点数不足直接移除整个内环洞。
3.5 撤销与重做实现
简单栈模型:
undoStack:保存历史快照;每次修改(拖拽、增、删顶点)执行pushSnapshot(),把当前 feature 深度拷贝压入 undoStack,清空 redoStack;- undo:弹出 undoStack 顶部,存入 redoStack;设置为 currentFeature;刷新顶点图层,抛出 onGeometryChange;
- redo:弹出 redoStack 顶部存入 undoStack,恢复;
- 拖拽进行中禁止撤销重做,防止状态错乱。
四、和上层 Vue 业务组件的协同逻辑(重点业务设计)
4.1 瓦片图层隐藏机制
原始图斑保存在后端矢量瓦片图层;一旦图斑进入折点编辑:
- 工具类触发
onEnterEdit(gid),上层 Vue 把 gid 加入数组editingGids; updateLayerFilter()给瓦片 fill/line/ 标注图层设置 filter,['!',['in',['get','gid'],literal(editingGids)]],原始瓦片图斑被隐藏;- 编辑后的几何,存入内存
fullAiFC,由前端 GeoJSON 源AI_SOURCE_ID渲染; - 如果是
blur()休眠,不会触发 onExitEdit,editingGids 数组保留,原图斑持续隐藏;切换别的工具回来可以继续编辑; - 如果调用
cancel()取消编辑,触发onExitEdit(gid),上层从 editingGids 移除 gid,瓦片 filter 更新,原图斑重新显示。
业务坑:很多人实现顶点编辑,编辑完成直接修改瓦片源;瓦片是服务端矢量切片,前端不能直接修改;因此采用「瓦片隐藏 + 前端 GeoJSON 临时图层覆盖」这套方案。
4.2 完成编辑 vs 取消编辑
- 完成编辑 editVertexFinish () : 把工具类
currentFeature最新 geometry 写入内存fullAiFC,emitai‑fc‑update通知父组件;调用vertexEditTools.blur()休眠,保留 editingGids,图斑继续处于编辑待保存状态,等待整体业务保存提交后端。 - 取消编辑 editVertexCancel () : 调用
vertexEditTools.cancel(),触发 onExitEdit;editingGids 移除 gid;丢弃本次全部编辑,恢复原始瓦片图斑。
五、项目开发中踩过的典型坑点
- Polygon 闭合首尾点不同步 拖拽第 0 号顶点,忘记同步修改 ring 数组最后闭合点;拖拽末尾点忘记同步 ring 0,几何变成非闭合,渲染错乱。
- MultiPolygon、内环洞处理遗漏 早期只处理单层 Polygon;MultiPolygon 多面、内环洞没有遍历,编辑多面 / 带洞要素直接报错;需要完整遍历
polyIndex / ringIndex。 - 混淆 blur 休眠和 cancel 取消编辑 切换分割 / 整形工具时,错误调用
cancel(),触发 onExitEdit,editingGids 被清除,底层瓦片原图斑冒出来,覆盖 AI 编辑后的图斑; ✅切换其他工具必须调用blur(),仅隐藏 UI 控制点,保留编辑数据。 - 直接原地修改 GeoJSON 对象 没有使用
structuredClone深度拷贝;修改工具内 currentFeature,直接污染上层 fullAiFC 原始对象,状态不可控;所有编辑操作必须操作副本。 - 经纬度距离做线段 hover 判断 地图缩放不同,同样像素对应的经纬度距离差异巨大,hover 判定时灵时不灵;改用屏幕像素距离计算投影点,交互稳定。
- 删除顶点不做最少 3 点保护 删除后环顶点小于 3,生成非法 Polygon GeoJSON,mapbox 渲染报错。
- 套索使用 mapbox 图层事件而不是 canvas DOM 事件 套索绘制需要在空白地图拖拽;mapbox 图层事件只在要素上触发;套索监听必须绑定 canvas 原生 DOM mousedown/mousemove/mouseup。
- 顶点渲染时没有过滤闭合重复点 Polygon 环首尾坐标完全一样,不做过滤,同一个位置渲染两个圆点,视觉上重复。
- 事件没有销毁,组件销毁后内存泄漏 工具类提供 destroy 方法,解绑 map 事件、canvas dom 事件,移除 source/layer;Vue 组件
onBeforeUnmount必须调用 destroy。
六、工具类对外暴露能力总览
javascript
//设置待编辑要素
setEditFeature(feature:GeoJSON.Feature|null)
//增加折点模式开关
addVertex()
//删除折点模式开关,支持单点删除+套索批量删除
deleteVertex()
//撤销
undo()
//重做
redo()
//仅隐藏折点UI,保留编辑状态(切换其它工具调用)
blur()
//彻底取消,丢弃修改,通知上层恢复原图斑
cancel()
//清空控制点显示
clearDisplay()
//销毁实例,解绑事件,释放资源
destroy()
七、整体交互流程完整梳理
- 用户点击【编辑折点】按钮,进入
editPointMode; - 用户点击地图图斑,把图斑写入
fullAiFC,调用vertexEditTools.setEditFeature(feat); - 工具类触发
onEnterEdit(gid),上层将 gid 加入 editingGids,瓦片原图斑隐藏,AI 图层渲染,地图出现橙色折点圆点; - 用户操作:拖拽顶点 / 开启增加折点,点击边线插入 / 开启删除折点,点击点删除,空白拖拽套索批量删;每一次修改,
onGeometryChange回调触发,上层更新 fullAiFC,刷新 AI 图层; - 用户切换分割 / 整形工具:执行
closeAllEditTools(),调用blur(),折点圆点消失,但 editingGids 不变,图斑继续使用 AI 临时图层;切回折点编辑可继续编辑; - 用户点【完成】:把工具内最新几何写入 fullAiFC,emit 事件通知父组件,待整体业务保存提交后端;
- 用户点【取消】:调用
cancel(),触发 onExitEdit,editingGids 移除 gid,瓦片原图斑恢复,丢弃本次全部修改。
八、完整工具代码mapVertexEditTools.ts
javascript
import mapboxgl from 'mapbox-gl'
import { ElMessage } from 'element-plus'
import * as turf from '@turf/turf'
const VERTEX_SOURCE_ID = '_vertex_edit_source'
const VERTEX_LAYER_ID = '_vertex_edit_circle'
// 套索图层
const _lassoGeoSourceId = '_vertex_lasso_source'
const _lassoGeoLayerId = '_vertex_lasso_layer'
export type VertexEditOptions = {
circleRadius?: number
circleColor?: string
circleStrokeColor?: string
circleStrokeWidth?: number
}
/** 浮点坐标相等容差判断 */
const coordEqual = (c1: number[], c2: number[], eps = 1e-8) => {
return Math.abs(c1[0] - c2[0]) < eps && Math.abs(c1[1] - c2[1]) < eps
}
/**
* 屏幕像素版本:计算屏幕点到线段的像素距离
* @param map map实例
* @param screenPt 鼠标屏幕点
* @param a 线段起点经纬度 [lng,lat]
* @param b 线段终点经纬度 [lng,lat]
*/
function pointSegmentProjectScreen(map: mapboxgl.Map, screenPt: mapboxgl.Point, a: number[], b: number[]): { distPx: number; projLngLat: number[] } | null {
const pA = map.project(a as [number, number])
const pB = map.project(b as [number, number])
const px = screenPt.x
const py = screenPt.y
const ax = pA.x, ay = pA.y
const bx = pB.x, by = pB.y
const abx = bx - ax
const aby = by - ay
const apx = px - ax
const apy = py - ay
const denom = abx * abx + aby * aby
const t = denom < 1e-12 ? 0 : Math.max(0, Math.min(1, (apx * abx + apy * aby) / denom))
const projX = ax + t * abx
const projY = ay + t * aby
const distPx = Math.hypot(px - projX, py - projY)
const projLngLatObj = map.unproject(new mapboxgl.Point(projX, projY))
return {
distPx,
projLngLat: [projLngLatObj.lng, projLngLatObj.lat]
}
}
/** 获取环内有效顶点数量,过滤重复闭合点 */
function getRingPointCount(ring: number[][]): number {
let cnt = 0
for (let i = 0; i < ring.length; i++) {
const next = ring[(i + 1) % ring.length]
if (!(Math.abs(ring[i][0] - next[0]) < 1e-8 && Math.abs(ring[i][1] - next[1]) < 1e-8)) {
cnt++
}
}
return cnt
}
export class MapVertexEditTools {
private map: mapboxgl.Map
private opts: Required<VertexEditOptions>
public currentFeature: GeoJSON.Feature | null = null
public undoStack: GeoJSON.Feature[] = []
public redoStack: GeoJSON.Feature[] = []
// blur休眠状态(仅隐藏控制点UI,要素仍处于编辑占用,editingGids保留)
private _isBlurred = false
// 新增编辑模式
private _addVertexMode = false;
private _deleteVertexMode = false;
// 添加折点悬浮marker
private _hoverSegment: { ringIdx: number; segIdx: number; polyIdx: number; pt: number[] } | null = null;
private _hoverMarker: mapboxgl.Marker | null = null;
// 删除折点悬浮marker
private _hoverVertexMarker: mapboxgl.Marker | null = null;
private _hoverVertexInfo: { polyIdx: number; ringIdx: number; pointIndex: number } | null = null;
// 套索成员
private _isLassoDrawing = false;
private _lassoScreenPoints: mapboxgl.Point[] = [];
private _lassoMouseDown?: (evt: MouseEvent) => void;
private _lassoMouseMove?: (evt: MouseEvent) => void;
private _lassoMouseUp?: (evt: MouseEvent) => void;
// 向外回调:几何发生变更(拖拽顶点触发,上层更新AI面图层)
public onGeometryChange?: (updatedFeat: GeoJSON.Feature) => void
// 进入编辑某个gid:上层把gid加入editingGids,隐藏底层瓦片原图斑
public onEnterEdit?: (gid: string | number) => void
// 退出编辑(取消/清空):上层移除editingGids,恢复原图斑
public onExitEdit?: (gid: string | number | null) => void
// 拖拽状态
private isDragging = false
private dragPolyIndex = -1 // MultiPolygon 第几个多边形
private dragRingIndex = -1 // 环索引(外环/洞)
private dragPointIndex = -1 // 点索引
private _onMouseDown: ((e: mapboxgl.MapMouseEvent) => void) | null = null
private _onMouseMove: ((e: mapboxgl.MapMouseEvent) => void) | null = null
private _onMouseUp: (() => void) | null = null
private _onAddVertexMouseMove: ((e: mapboxgl.MapMouseEvent) => void) | null = null;
private _onAddVertexClick: ((e: mapboxgl.MapMouseEvent) => void) | null = null;
constructor(map: mapboxgl.Map, options: VertexEditOptions = {}) {
this.map = map
this.opts = {
circleRadius: 8,
circleColor: '#ff6622',
circleStrokeColor: '#fff',
circleStrokeWidth: 2,
...options
}
this.initLayers()
this.bindDragEvents()
}
public initLayers() {
if (!this.map.getSource(VERTEX_SOURCE_ID)) {
this.map.addSource(VERTEX_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
})
}
if (!this.map.getLayer(VERTEX_LAYER_ID)) {
this.map.addLayer({
id: VERTEX_LAYER_ID,
type: 'circle',
source: VERTEX_SOURCE_ID,
paint: {
'circle-radius': this.opts.circleRadius,
'circle-color': this.opts.circleColor,
'circle-stroke-color': this.opts.circleStrokeColor,
'circle-stroke-width': this.opts.circleStrokeWidth
}
})
}
// 初始化套索图层
if (!this.map.getSource(_lassoGeoSourceId)) {
this.map.addSource(_lassoGeoSourceId, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
})
}
if (!this.map.getLayer(_lassoGeoLayerId)) {
this.map.addLayer({
id: _lassoGeoLayerId,
type: 'line',
source: _lassoGeoSourceId,
paint: {
'line-color': '#ff4444',
'line-width': 2,
'line-dasharray': [4, 2]
}
})
}
this.map.moveLayer(VERTEX_LAYER_ID)
this.map.moveLayer(_lassoGeoLayerId)
setTimeout(() => {
if (this.map.getLayer(VERTEX_LAYER_ID)) {
this.map.moveLayer(VERTEX_LAYER_ID)
}
if (this.map.getLayer(_lassoGeoLayerId)) {
this.map.moveLayer(_lassoGeoLayerId)
}
}, 80)
}
private _clearHoverMarker() {
if (this._hoverMarker) {
this._hoverMarker.remove();
this._hoverMarker = null;
}
this._hoverSegment = null;
}
private _clearHoverVertexMarker() {
if (this._hoverVertexMarker) {
this._hoverVertexMarker.remove();
this._hoverVertexMarker = null;
}
this._hoverVertexInfo = null;
}
private _clearLassoGraphic() {
const src = this.map.getSource(_lassoGeoSourceId) as mapboxgl.GeoJSONSource
if (src) {
src.setData({ type: 'FeatureCollection', features: [] })
}
this._isLassoDrawing = false;
this._lassoScreenPoints = [];
}
private bindDragEvents() {
const map = this.map
const canvas = map.getCanvas()
this._onMouseDown = (e) => {
if (this._addVertexMode || this._deleteVertexMode) return;
if (!this.currentFeature) return
const hitFeats = map.queryRenderedFeatures(e.point, { layers: [VERTEX_LAYER_ID] })
if (hitFeats.length === 0) return
e.preventDefault()
map.getCanvas().style.cursor = 'grabbing'
const hit = hitFeats[0]
this.dragPolyIndex = Number(hit.properties.polyIndex ?? 0)
this.dragRingIndex = Number(hit.properties.ringIndex)
this.dragPointIndex = Number(hit.properties.pointIndex)
this.isDragging = true
this.pushSnapshot() //拖拽开始保存撤销快照
}
this._onAddVertexMouseMove = (e: mapboxgl.MapMouseEvent) => {
if (this._addVertexMode && !this._deleteVertexMode && this.currentFeature) {
const lngLat = [e.lngLat.lng, e.lngLat.lat];
this._detectHoverSegment(lngLat);
this._clearHoverVertexMarker();
return;
}
if (this._deleteVertexMode && this.currentFeature && !this._isLassoDrawing) {
this._detectHoverVertex(e.point);
this._clearHoverMarker();
return;
}
this._clearHoverMarker();
this._clearHoverVertexMarker();
}
this._onAddVertexClick = (e: mapboxgl.MapMouseEvent) => {
if (this._addVertexMode && !this._deleteVertexMode && this.currentFeature) {
e.preventDefault();
// 兜底:mousemove未捕获时点击瞬间重新检测线段
if (!this._hoverSegment) {
this._detectHoverSegment([e.lngLat.lng, e.lngLat.lat]);
}
if (this._hoverSegment) {
this._insertVertexOnSegment();
}
return;
}
if (this._deleteVertexMode && this.currentFeature) {
e.preventDefault();
// 兜底:重新检测顶点
if (!this._hoverVertexInfo) {
this._detectHoverVertex(e.point);
}
if (this._hoverVertexInfo) {
this._removeHoverVertex();
}
return;
}
}
this._onMouseMove = (e) => {
if (this._addVertexMode || this._deleteVertexMode) return;
if (!this.isDragging || !this.currentFeature) return
const lng = e.lngLat.lng
const lat = e.lngLat.lat
const featCopy = structuredClone(this.currentFeature)
const geom = featCopy.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon
if (geom.type === 'Polygon') {
const ring = geom.coordinates[this.dragRingIndex]
ring[this.dragPointIndex] = [lng, lat]
const lastIdx = ring.length - 1
if (this.dragPointIndex === 0) ring[lastIdx] = [lng, lat]
if (this.dragPointIndex === lastIdx) ring[0] = [lng, lat]
} else if (geom.type === 'MultiPolygon') {
const poly = geom.coordinates[this.dragPolyIndex]
const ring = poly[this.dragRingIndex]
ring[this.dragPointIndex] = [lng, lat]
const lastIdx = ring.length - 1
if (this.dragPointIndex === 0) ring[lastIdx] = [lng, lat]
if (this.dragPointIndex === lastIdx) ring[0] = [lng, lat]
}
this.currentFeature = featCopy
this.setEditFeature(this.currentFeature)
this.onGeometryChange?.(structuredClone(this.currentFeature))
}
this._onMouseUp = () => {
if (!this.isDragging) return
this.isDragging = false
this.dragPolyIndex = -1
this.dragRingIndex = -1
this.dragPointIndex = -1
map.getCanvas().style.cursor = ''
}
map.on('mousedown', VERTEX_LAYER_ID, this._onMouseDown!)
map.on('mousemove', this._onMouseMove!)
map.on('mouseup', this._onMouseUp!)
map.on('mousemove', this._onAddVertexMouseMove);
map.on('click', this._onAddVertexClick);
// 套索DOM事件
const canvasDom = map.getCanvas()
this._lassoMouseDown = (domEvt: MouseEvent) => {
if (!this._deleteVertexMode || domEvt.button !== 0 || !this.currentFeature) return;
const rect = canvasDom.getBoundingClientRect();
const px = domEvt.clientX - rect.left;
const py = domEvt.clientY - rect.top;
const pt = new mapboxgl.Point(px, py);
const hits = map.queryRenderedFeatures(pt, { layers: [VERTEX_LAYER_ID] })
if (hits.length > 0) return;
domEvt.preventDefault();
this._isLassoDrawing = true;
this._lassoScreenPoints = [new mapboxgl.Point(px, py)];
map.dragPan.disable();
}
this._lassoMouseMove = (domEvt: MouseEvent) => {
if (!this._isLassoDrawing) return;
const rect = canvasDom.getBoundingClientRect();
const px = domEvt.clientX - rect.left;
const py = domEvt.clientY - rect.top;
const screenPt = new mapboxgl.Point(px, py);
this._lassoScreenPoints.push(screenPt);
const src = map.getSource(_lassoGeoSourceId) as mapboxgl.GeoJSONSource;
const lngLats = this._lassoScreenPoints.map(p => {
const ll = map.unproject(p);
return [ll.lng, ll.lat];
})
src.setData({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: { type: 'LineString', coordinates: lngLats },
properties: {}
}]
})
}
const finishLasso = () => {
if (!this._isLassoDrawing) return;
this._isLassoDrawing = false;
map.dragPan.enable();
const src = map.getSource(_lassoGeoSourceId) as mapboxgl.GeoJSONSource;
src.setData({ type: 'FeatureCollection', features: [] })
const points = this._lassoScreenPoints;
this._lassoScreenPoints = [];
if (points.length < 3) return;
const polyCoords = points.map(p => {
const ll = map.unproject(p);
return [ll.lng, ll.lat] as [number, number];
})
polyCoords.push([...polyCoords[0]]);
const lassoPolygon = turf.polygon([polyCoords]);
this._batchRemoveVerticesByLasso(lassoPolygon);
}
this._lassoMouseUp = finishLasso;
canvasDom.addEventListener('mousedown', this._lassoMouseDown);
canvasDom.addEventListener('mousemove', this._lassoMouseMove);
canvasDom.addEventListener('mouseup', this._lassoMouseUp);
canvasDom.addEventListener('mouseleave', this._lassoMouseUp);
}
private unbindDragEvents() {
const map = this.map
const canvasDom = map.getCanvas()
if (this._onMouseDown) map.off('mousedown', VERTEX_LAYER_ID, this._onMouseDown)
if (this._onMouseMove) map.off('mousemove', this._onMouseMove)
if (this._onMouseUp) map.off('mouseup', this._onMouseUp)
if (this._onAddVertexMouseMove) map.off('mousemove', this._onAddVertexMouseMove);
if (this._onAddVertexClick) map.off('click', this._onAddVertexClick);
if (this._lassoMouseDown) canvasDom.removeEventListener('mousedown', this._lassoMouseDown);
if (this._lassoMouseMove) canvasDom.removeEventListener('mousemove', this._lassoMouseMove);
if (this._lassoMouseUp) {
canvasDom.removeEventListener('mouseup', this._lassoMouseUp);
canvasDom.removeEventListener('mouseleave', this._lassoMouseUp);
}
this._clearHoverMarker();
this._clearHoverVertexMarker();
this._clearLassoGraphic();
map.getCanvas().style.cursor = ''
}
/**使用屏幕像素阈值检测线段hover,避免频繁重建marker */
private _detectHoverSegment(mouseLngLat: number[]) {
const geom = this.currentFeature!.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
const thresholdPx = 12; // 屏幕像素命中阈值
const mouseScreenPt = this.map.project(mouseLngLat as [number, number]);
let best: { polyIdx: number; ringIdx: number; segIdx: number; proj: number[]; distPx: number } | null = null;
const ringsList: { polyIdx: number; ringIdx: number; ring: number[][] }[] = [];
if (geom.type === 'Polygon') {
geom.coordinates.forEach((ring, ri) => {
ringsList.push({ polyIdx: 0, ringIdx: ri, ring })
})
} else {
geom.coordinates.forEach((poly, pi) => {
poly.forEach((ring, ri) => {
ringsList.push({ polyIdx: pi, ringIdx: ri, ring })
})
})
}
for (const item of ringsList) {
const { polyIdx, ringIdx, ring } = item;
for (let i = 0; i < ring.length - 1; i++) {
const a = ring[i];
const b = ring[i + 1];
const res = pointSegmentProjectScreen(this.map, mouseScreenPt, a, b);
if (!res) continue;
if (res.distPx < thresholdPx && (!best || res.distPx < best.distPx)) {
best = { polyIdx, ringIdx, segIdx: i, proj: res.projLngLat, distPx: res.distPx };
}
}
}
if (best) {
// 判断位置是否变化,不变化就不重建marker
const samePos = this._hoverSegment
&& Math.abs(this._hoverSegment.pt[0] - best.proj[0]) < 1e-9
&& Math.abs(this._hoverSegment.pt[1] - best.proj[1]) < 1e-9;
if (!samePos) {
this._clearHoverMarker();
this._hoverSegment = {
polyIdx: best.polyIdx,
ringIdx: best.ringIdx,
segIdx: best.segIdx,
pt: best.proj
};
// 自定义DOM元素 pointer‑events:none,点击事件穿透map canvas
const hoverEl = document.createElement('div');
hoverEl.style.width = '12px';
hoverEl.style.height = '12px';
hoverEl.style.borderRadius = '50%';
hoverEl.style.backgroundColor = '#ffdd00';
hoverEl.style.transform = 'translate(-50%,-50%) scale(0.6)';
hoverEl.style.pointerEvents = 'none';
this._hoverMarker = new mapboxgl.Marker({ element: hoverEl }).setLngLat(best.proj).addTo(this.map);
}
this.map.getCanvas().style.cursor = 'crosshair';
} else {
this._clearHoverMarker();
this.map.getCanvas().style.cursor = '';
}
}
private _insertVertexOnSegment() {
if (!this._hoverSegment || !this.currentFeature) return;
const { polyIdx, ringIdx, segIdx, pt } = this._hoverSegment;
const featCopy = structuredClone(this.currentFeature);
const geom = featCopy.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
let ring: number[][];
if (geom.type === 'Polygon') {
ring = geom.coordinates[ringIdx];
} else {
ring = geom.coordinates[polyIdx][ringIdx];
}
ring.splice(segIdx + 1, 0, pt);
this.pushSnapshot();
this.currentFeature = featCopy;
this.setEditFeature(this.currentFeature);
this.onGeometryChange?.(structuredClone(this.currentFeature));
// 不再立刻 _clearHoverMarker(),鼠标移动mousemove自动清除
}
private _detectHoverVertex(screenPoint: mapboxgl.Point) {
this._clearHoverVertexMarker();
if (!this.currentFeature) return;
const hitList = this.map.queryRenderedFeatures(screenPoint, { layers: [VERTEX_LAYER_ID] })
if (hitList.length === 0) {
this.map.getCanvas().style.cursor = '';
return;
}
const hit = hitList[0];
const polyIdx = Number(hit.properties.polyIndex ?? 0);
const ringIdx = Number(hit.properties.ringIndex);
const pointIndex = Number(hit.properties.pointIndex);
this._hoverVertexInfo = { polyIdx, ringIdx, pointIndex };
// 自定义DOM,pointer‑events:none,点击穿透
const delHoverEl = document.createElement('div');
delHoverEl.style.width = '14px';
delHoverEl.style.height = '14px';
delHoverEl.style.borderRadius = '50%';
delHoverEl.style.backgroundColor = '#04aef1';
delHoverEl.style.transform = 'translate(-50%,-50%) scale(0.8)';
delHoverEl.style.pointerEvents = 'none';
this._hoverVertexMarker = new mapboxgl.Marker({ element: delHoverEl })
.setLngLat(hit.geometry.coordinates as [number, number])
.addTo(this.map);
this.map.getCanvas().style.cursor = 'pointer';
}
private _removeHoverVertex() {
if (!this._hoverVertexInfo || !this.currentFeature) return;
const { polyIdx, ringIdx, pointIndex } = this._hoverVertexInfo;
const featCopy = structuredClone(this.currentFeature);
const geom = featCopy.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
let ring: number[][];
if (geom.type === 'Polygon') {
ring = geom.coordinates[ringIdx];
} else {
ring = geom.coordinates[polyIdx][ringIdx];
}
const realCount = getRingPointCount(ring);
if (realCount <= 3) {
ElMessage.warning("该环顶点数量不能少于3个,禁止删除!");
return;
}
this.pushSnapshot();
ring.splice(pointIndex, 1);
const lastIdx = ring.length - 1;
if (pointIndex === 0) {
ring[lastIdx] = [ring[0][0], ring[0][1]];
}
if (pointIndex === lastIdx) {
ring[0] = [ring[lastIdx][0], ring[lastIdx][1]];
}
this.currentFeature = featCopy;
this.setEditFeature(this.currentFeature);
this.onGeometryChange?.(structuredClone(this.currentFeature));
this._clearHoverVertexMarker();
}
/* @ts-ignore */
private _batchRemoveVerticesByLasso(lassoPolygon: turf.Polygon) {
if (!this.currentFeature) return;
const featCopy = structuredClone(this.currentFeature!);
const geom = featCopy.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
let modified = false;
const processRing = (ring: number[][], ringIdx: number, parentArr: number[][]) => {
const toDropIdx: number[] = [];
for (let i = 0; i < ring.length - 1; i++) {
const pt = turf.point(ring[i]);
if (turf.booleanPointInPolygon(pt, lassoPolygon)) {
toDropIdx.push(i);
}
}
if (toDropIdx.length === 0) return false;
const newRing = ring.filter((_pt, idx) => !toDropIdx.includes(idx));
if (newRing.length >= 1) {
newRing.push([...newRing[0]]);
}
const validCount = getRingPointCount(newRing);
if (ringIdx === 0) {
if (validCount >= 3) {
ring.splice(0, ring.length, ...newRing);
return true;
} else {
ElMessage.warning('套索选中外环过多顶点,外环不能少于3个点,该环跳过批量删除');
return false;
}
} else {
if (validCount >= 3) {
ring.splice(0, ring.length, ...newRing);
return true;
} else {
parentArr.splice(ringIdx, 1);
return true;
}
}
}
if (geom.type === 'Polygon') {
for (let i = geom.coordinates.length - 1; i >= 0; i--) {
/* @ts-ignore */
const ok = processRing(geom.coordinates[i], i, geom.coordinates);
if (ok) modified = true;
}
} else if (geom.type === 'MultiPolygon') {
for (const poly of geom.coordinates) {
for (let i = poly.length - 1; i >= 0; i--) {
/* @ts-ignore */
const ok = processRing(poly[i], i, poly);
if (ok) modified = true;
}
}
}
if (!modified) return;
this.pushSnapshot();
this.currentFeature = featCopy;
this.setEditFeature(this.currentFeature);
this.onGeometryChange?.(structuredClone(this.currentFeature));
}
public addVertex() {
if (!this.currentFeature) {
ElMessage.warning("请先选中编辑的图斑!")
return;
}
this._addVertexMode = !this._addVertexMode;
if (this._addVertexMode) {
this._deleteVertexMode = false;
ElMessage.info("添加折点模式:点击多边形边线新增顶点,再次点击按钮退出")
} else {
ElMessage.info("退出添加折点模式")
this._clearHoverMarker();
}
}
public deleteVertex() {
if (!this.currentFeature) {
ElMessage.warning("请先选中编辑的图斑!")
return;
}
this._deleteVertexMode = !this._deleteVertexMode;
if (this._deleteVertexMode) {
this._addVertexMode = false;
ElMessage.info("删除折点模式:点击红色折点圆点删除顶点,空白处拖拽画套索批量删除,再次点击按钮退出")
} else {
ElMessage.info("退出删除折点模式")
this._clearHoverVertexMarker();
this._clearLassoGraphic();
}
}
/** 几何转折点FeatureCollection,增加polyIndex,修复MultiPolygon索引BUG */
private geometryToVertexFC(geom: GeoJSON.GeometryObject, excludeCloseRing = true): GeoJSON.FeatureCollection {
const features: GeoJSON.Feature[] = []
if (!geom || !geom.type) return { type: 'FeatureCollection', features: [] }
if (geom.type === 'Polygon') {
for (let ringIdx = 0; ringIdx < geom.coordinates.length; ringIdx++) {
const rawRing = geom.coordinates[ringIdx]
const cleanRing: number[][] = []
for (const pt of rawRing) {
if (cleanRing.length === 0 || !coordEqual(cleanRing[cleanRing.length - 1], pt)) {
cleanRing.push(pt)
}
}
for (let i = 0; i < cleanRing.length; i++) {
const pt = cleanRing[i]
if (excludeCloseRing && i === cleanRing.length - 1) {
const first = cleanRing[0]
if (coordEqual(pt, first)) continue
}
features.push({
type: 'Feature',
properties: {
polyIndex: 0,
ringIndex: ringIdx,
pointIndex: i
},
geometry: { type: 'Point', coordinates: pt }
})
}
}
} else if (geom.type === 'MultiPolygon') {
for (let polyIdx = 0; polyIdx < geom.coordinates.length; polyIdx++) {
const polygon = geom.coordinates[polyIdx]
for (let ringIdx = 0; ringIdx < polygon.length; ringIdx++) {
const rawRing = polygon[ringIdx]
const cleanRing: number[][] = []
for (const pt of rawRing) {
if (cleanRing.length === 0 || !coordEqual(cleanRing[cleanRing.length - 1], pt)) {
cleanRing.push(pt)
}
}
for (let i = 0; i < cleanRing.length; i++) {
const pt = cleanRing[i]
if (excludeCloseRing && i === cleanRing.length - 1) {
const first = cleanRing[0]
if (coordEqual(pt, first)) continue
}
features.push({
type: 'Feature',
properties: {
polyIndex: polyIdx,
ringIndex: ringIdx,
pointIndex: i
},
geometry: { type: 'Point', coordinates: pt }
})
}
}
}
}
return { type: 'FeatureCollection', features }
}
public setEditFeature(feature: GeoJSON.Feature | null) {
this.initLayers()
this._clearHoverMarker();
this._clearHoverVertexMarker();
this._clearLassoGraphic();
const prevGid = this.currentFeature?.properties?.gid ?? null
this.currentFeature = feature
const source = this.map.getSource(VERTEX_SOURCE_ID) as mapboxgl.GeoJSONSource
if (!feature || !feature.geometry) {
source.setData({ type: 'FeatureCollection', features: [] })
if (prevGid != null) {
this.onExitEdit?.(prevGid)
}
this._isBlurred = false
return
}
const currGid = feature.properties?.gid
this.onEnterEdit?.(currGid)
const vertexFc = this.geometryToVertexFC(feature.geometry)
source.setData(vertexFc)
this._isBlurred = false
}
public clearDisplay() {
const source = this.map.getSource(VERTEX_SOURCE_ID) as mapboxgl.GeoJSONSource
if (!source) return
source.setData({ type: 'FeatureCollection', features: [] })
}
public pushSnapshot() {
if (!this.currentFeature) return
const copy = structuredClone(this.currentFeature)
this.undoStack.push(copy)
this.redoStack = []
}
public undo() {
if (this.isDragging) {
ElMessage.warning('拖拽进行中,不能执行撤销')
return
}
if (this.undoStack.length === 0) {
ElMessage.warning('无可撤销操作')
return
}
if (!this.currentFeature) return
this.redoStack.push(structuredClone(this.currentFeature))
const prevFeat = this.undoStack.pop()!
this.currentFeature = prevFeat
this.setEditFeature(this.currentFeature)
this.onGeometryChange?.(structuredClone(this.currentFeature))
}
public redo() {
if (this.isDragging) {
ElMessage.warning('拖拽进行中,不能执行重做')
return
}
if (this.redoStack.length === 0) {
ElMessage.warning('无可重做操作')
return
}
if (!this.currentFeature) return
this.undoStack.push(structuredClone(this.currentFeature))
const nextFeat = this.redoStack.pop()!
this.currentFeature = nextFeat
this.setEditFeature(this.currentFeature)
this.onGeometryChange?.(structuredClone(this.currentFeature))
}
/**
* 【切换别的工具调用】:仅仅关闭折点交互,不丢弃编辑结果
* 只清控制点UI、拖拽状态,保留currentFeature,不触发onExitEdit,editingGids保持不变
*/
public blur() {
this.isDragging = false
this._clearHoverMarker();
this._clearHoverVertexMarker();
this._clearLassoGraphic();
this._addVertexMode = false;
this._deleteVertexMode = false;
this.clearDisplay()
this.dragPolyIndex = -1
this.dragRingIndex = -1
this.dragPointIndex = -1
this._isBlurred = true
}
/**
* 【用户点取消按钮,真正丢弃本次编辑】
* 清空全部,触发onExitEdit,通知上层移除gid过滤,恢复原图斑
*/
public cancel() {
this.isDragging = false
this._clearHoverMarker();
this._clearHoverVertexMarker();
this._clearLassoGraphic();
this._addVertexMode = false;
this._deleteVertexMode = false;
const prevGid = this.currentFeature?.properties?.gid ?? null
this.currentFeature = null
this.undoStack = []
this.redoStack = []
this.clearDisplay()
this._isBlurred = false
if (prevGid != null) {
this.onExitEdit?.(prevGid)
}
}
// 原来的exit 保留别名做兼容,后续全部改用 blur / cancel
public exit() {
this.cancel()
}
public destroy() {
const prevGid = this.currentFeature?.properties?.gid ?? null
this.unbindDragEvents()
if (this.map.getLayer(VERTEX_LAYER_ID)) this.map.removeLayer(VERTEX_LAYER_ID)
if (this.map.getSource(VERTEX_SOURCE_ID)) this.map.removeSource(VERTEX_SOURCE_ID)
if (this.map.getLayer(_lassoGeoLayerId)) this.map.removeLayer(_lassoGeoLayerId)
if (this.map.getSource(_lassoGeoSourceId)) this.map.removeSource(_lassoGeoSourceId)
this.currentFeature = null
this.undoStack = []
this.redoStack = []
this._isBlurred = false
if (prevGid != null) {
this.onExitEdit?.(prevGid)
}
}
}
地图页面应用
javascript
// ========== 编辑折点模式状态 =========
let isVertexEditMode = false
let vertexEditTools: MapVertexEditTools | null = null
初始化,根据自己的数据格式进行改造
// 实例化折点工具
vertexEditTools = new MapVertexEditTools(map!)
// 绑定几何变更回调:拖拽顶点每帧都会进来
vertexEditTools.onGeometryChange = (updatedFeat) => {
if (!fullAiFC) return
const targetGid = updatedFeat.properties.gid
// 重建全新features数组,不要原地修改!
const newFeatures = fullAiFC.features.map(f => {
if (f.properties.gid === targetGid) {
return structuredClone(updatedFeat)
}
return structuredClone(f)
})
const newFC: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: newFeatures
}
const g = targetGid
if (!editingGids.includes(g)) {
editingGids.push(g)
}
if (!allSplitDeleteGids.includes(g)) {
allSplitDeleteGids.push(g)
}
updateLayerFilter()
renderAiPolygon(newFC)
emit('ai-fc-update', structuredClone(newFC))
emit('split-delete-gids', [...allSplitDeleteGids])
console.log('拖拽更新GID', g)
}
// 进入编辑该gid:加入editingGids,底层瓦片原图斑隐藏
vertexEditTools.onEnterEdit = (gid) => {
const g = gid
let changed = false
if (!editingGids.some(x => x === g)) {
editingGids.push(g)
changed = true
}
if (!allSplitDeleteGids.includes(g)) {
allSplitDeleteGids.push(g)
}
emit('split-delete-gids', [...allSplitDeleteGids])
// ✅数组发生变更,同步立刻刷新filter,不等待nextTick
if (changed) {
updateLayerFilter()
}
}
// ✅退出编辑该gid:移除editingGids,恢复底层瓦片原图斑显示
vertexEditTools.onExitEdit = (gid) => {
const g = gid
let changed = false
const pos = editingGids.findIndex(x => x === g)
if (pos > -1) {
editingGids.splice(pos, 1)
changed = true
console.log('[折点编辑onExitEdit]移除editingGids', gid, [...editingGids])
}
// ✅只要数组改动,同步立刻刷新filter
if (changed) {
updateLayerFilter()
}
}
抛出方法外部父组件调用
javascript
/** 进入折点编辑模式 */
const editPointMode = () => {
closeAllEditTools()
isVertexEditMode = true
startFeatureSelect()
const highlightFeats = featureSelector?.getHighlightFeatures() || []
if (highlightFeats.length > 0 && vertexEditTools) {
const selectedItem = highlightFeats[0]
const targetGid = selectedItem.properties?.gid
if (targetGid) {
let realGeom = selectedItem._geometry
if (!realGeom) {
const cacheFeat = fullAiFC?.features.find(f => f.properties?.gid === targetGid)
if (cacheFeat?.geometry) {
realGeom = cacheFeat.geometry
}
}
if (realGeom) {
const feat: GeoJSON.Feature = {
type: "Feature",
geometry: realGeom,
properties: { gid: targetGid }
}
// ========== 【BUG就在这里!!】 ==========
// 只调用 setEditFeature,没有写入 fullAiFC!AI图层没有这个要素,地图看不见AI面!
if (!fullAiFC) {
fullAiFC = { type: 'FeatureCollection', features: [] }
}
// 如果不存在才push,避免重复
const existIdx = fullAiFC.features.findIndex(f => f.properties.gid === targetGid)
if (existIdx === -1) {
fullAiFC.features.push(structuredClone(feat))
}
// 把fullAiFC数据刷到AI地图图层
renderAiPolygon(fullAiFC)
bringAiLayerToTop()
// ======================================
console.log('✅打开编辑折点工具,写入fullAiFC,渲染折点')
vertexEditTools.setEditFeature(feat)
}
}
}
ElMessage.info('折点编辑模式:点击图斑选中,显示折点控制点')
}
/** 退出折点编辑,丢弃变更 */
const editVertexCancel = () => {
isVertexEditMode = false
vertexEditTools?.exit()
startFeatureSelect()
}
/** 完成折点编辑(后续在这里拿 vertexEditTools.currentFeature 更新fullAiFC) */
const editVertexFinish = async () => {
if (!vertexEditTools?.currentFeature) {
ElMessage.warning('未选中编辑图斑')
return
}
const editedFeat = vertexEditTools.currentFeature
const targetGid = editedFeat.properties.gid
if (fullAiFC) {
const idx = fullAiFC.features.findIndex(f => f.properties.gid === targetGid)
if (idx >= 0) {
fullAiFC.features[idx].geometry = editedFeat.geometry
}
const copyFC = structuredClone(fullAiFC)
emit('ai-fc-update', copyFC)
emit('split-delete-gids', [...allSplitDeleteGids])
}
isVertexEditMode = false
vertexEditTools.clearDisplay() // ✅只清控制点UI,不cancel,不onExitEdit,gid继续留在editingGids!
vertexEditTools.currentFeature = null
if (featureSelector) {
featureSelector.clearAllHighlight()
}
startFeatureSelect()
}
const editVertexAdd = () => vertexEditTools?.addVertex()
const editVertexDelete = () => vertexEditTools?.deleteVertex()
const editVertexUndo = () => vertexEditTools?.undo()
const editVertexRedo = () => vertexEditTools?.redo()
九、完成效果
1、拖拽点,点击空白地方进行更新


2、增加点、减少点


3、批量删除点

