Mapbox‑GL JS 多边形折点编辑工具:实现原理、功能拆解与踩坑总结

背景:在 WebGIS 图斑编辑业务中,除了整形、分割、合并,折点(顶点)编辑 是高频需求:拖拽修改顶点位置、在线段增加折点、删除折点、套索批量删除折点、撤销 / 重做。本文基于项目自研MapVertexEditTools工具类,讲解整套折点编辑的实现思路、核心原理、完整能力、关键坑点,适合需要自研顶点编辑,不想重度依赖mapbox‑gl‑draw的同学参考。

1、业务需求背景

在遥感图斑解译系统中,用户经常需要微调面的轮廓:

为什么不完全使用mapbox‑gl‑draw? 项目已经自研了分割、合并、整形工具;draw 直接操作草图图层,很难和前端内存缓存、瓦片图层过滤逻辑、捕捉管理器深度打通,因此自研一套轻量折点工具类。

二、整体架构设计

2.1 分层结构

  1. 底层:MapVertexEditTools TS 工具类 封装地图事件、顶点渲染、拖拽、增删顶点、撤销栈,向外暴露回调事件,不耦合 Vue 业务逻辑。
  2. 中间层 Vue 组件 (map‑inference.vue) 实例化工具类;处理业务层逻辑:editingGids隐藏原始瓦片图斑、内存fullAiFC维护编辑后的全部图斑集合、AI 临时 GeoJSON 图层渲染、工具互斥切换。
  3. 上层 UI 按钮:编辑折点、增加折点、删除折点、撤销、重做、完成、取消。

2.2 关键回调契约(工具类向外抛出事件)

  1. 拖拽单个折点,微调多边形边界;
  2. 在两条折点之间的边上插入新折点;
  3. 删除多余折点(保护规则:单个环最少保留 3 个顶点,防止多边形失效);
  4. 支持套索框选,批量删除一批折点;
  5. 编辑过程支持撤销、重做;
  6. 区分「临时编辑休眠」和「取消编辑丢弃修改」两种状态;
  7. 兼容Polygon普通面、MultiPolygon多面、支持内环洞;
  8. 和上层业务联动:编辑时底层瓦片原图斑隐藏,使用前端内存GeoJSON FeatureCollection渲染编辑后的图斑。
  9. 底层:MapVertexEditTools TS 工具类 封装地图事件、顶点渲染、拖拽、增删顶点、撤销栈,向外暴露回调事件,不耦合 Vue 业务逻辑。
  10. 中间层 Vue 组件 (map‑inference.vue) 实例化工具类;处理业务层逻辑:editingGids隐藏原始瓦片图斑、内存fullAiFC维护编辑后的全部图斑集合、AI 临时 GeoJSON 图层渲染、工具互斥切换。
  11. 上层 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

重点区分两个上层状态:

  1. blur():休眠模式。只隐藏折点圆点 UI,不销毁 currentFeature,不触发 onExitEdit,editingGids 保留,原图斑继续隐藏;切换到分割 / 整形工具时调用。
  2. cancel():真正取消编辑。清空编辑快照、触发onExitEdit通知上层移除 editingGids,丢弃本次所有修改。

三、核心实现原理

3.1 折点控制点怎么渲染?

不使用 Marker 逐个渲染(marker 大量顶点性能差),采用独立 GeoJSON Source + Circle 图层渲染所有顶点Mapbox。

  1. 拿到待编辑Feature,解析Polygon/MultiPolygon所有环(外环、洞内环);
  2. 将每一个坐标点转为 Point 要素,properties 携带元信息:polyIndex(多面序号)、ringIndex(环索引)、pointIndex(点索引);

注意:GeoJSON 的 Polygon 环是闭合的,最后一个坐标和第一个坐标完全重复,渲染顶点时过滤掉重复闭合点,避免重复渲染圆点。

  1. 将点集合设置给独立顶点 source,circle 图层渲染橙色控制点圆点。
javascript 复制代码
//伪代码,geometryToVertexFC函数核心逻辑
function geometryToVertexFC(geom){
    const features = []
    //遍历Polygon/MultiPolygon所有环,把每个坐标转为Point,带上索引信息
    //过滤闭合重复点
    return {type:'FeatureCollection',features}
}
source.setData(vertexFc);

.2 拖拽折点原理

依靠地图原生鼠标事件:mousedown、mousemove、mouseup。

  1. mousedown:在顶点 circle 图层命中控制点;读取 properties 中的polyIndex/ringIndex/pointIndex,标记拖拽状态;拖拽开始压入撤销快照;
  2. mousemove:鼠标移动,拿到e.lngLat经纬度;根据索引修改当前 feature 对应环数组的坐标;

⚠️多边形闭合坑:第一个点与最后一个点坐标必须保持同步。拖拽索引 0 的点,同步修改 ring 数组末尾闭合点;拖拽末尾点同步修改 ring 0。

  1. 每帧更新完成,调用onGeometryChange回调通知上层 Vue 组件,上层更新内存 fullAiFC,刷新 AI 图层渲染;
  2. mouseup结束拖拽,清空拖拽标记。

关键点:工具类内部维护一份currentFeature编辑副本,不直接修改上层原始对象,全部使用structuredClone深度拷贝,避免原地污染原始 GeoJSON 对象。

3.3 添加折点(在线段上插入顶点)实现原理

需求:鼠标悬浮多边形边线,出现黄色悬浮圆点;点击线段,在鼠标投影位置插入新折点。

  1. 模式切换:进入【添加折点模式】,关闭删除模式;
  2. mousemove拿到鼠标经纬度,屏幕像素距离算法pointSegmentProjectScreen :
    • 将线段两个端点project()转为屏幕像素;
    • 计算鼠标屏幕点到线段的垂直像素距离;设置阈值(例如 12 像素);
    • 如果距离小于阈值,计算鼠标在线段上的投影经纬度;使用临时 Marker 渲染悬浮小黄点;
  3. 鼠标 click:获取投影坐标,在对应 ring 数组segIdx+1位置splice插入新坐标;压入撤销快照;刷新顶点图层。

为什么用屏幕像素距离,不用经纬度距离?地图缩放级别变化,经纬度距离阈值会失效;屏幕像素阈值不受缩放影响,交互体验稳定。

3.4 删除折点(单点点击删除 + 套索批量删除)

单点删除
  1. 进入删除模式,hover 顶点,蓝色悬浮 marker 高亮;
  2. click 命中顶点;
  3. 保护校验:调用getRingPointCount统计环有效顶点,环顶点不能小于 3 个,否则弹窗提示禁止删除,避免生成非法 GeoJSON;
  4. splice 删除该点;同步处理闭合首尾点;压入撤销快照,刷新图层。
套索批量删除折点(特色能力)
  1. 在 canvas DOM 层监听 mousedown/mousemove/mouseup;

注意:套索不能用 mapbox 图层事件,需要监听 canvas 原生 DOM 事件;拖拽套索期间临时关闭地图拖拽平移map.dragPan.disable();绘制完成恢复。

  1. 鼠标按下(且没有命中顶点圆点),开始收集屏幕点;绘制红色虚线套索临时 line 图层;
  2. 鼠标抬起,把屏幕点unproject转回经纬度,构建 turf 多边形;
  3. 使用turf.booleanPointInPolygon,判断每一个顶点是否在套索多边形内部;收集待删除索引;
  4. 批量过滤环坐标;同样执行最少 3 点保护;内环如果删除后点数不足直接移除整个内环洞。

3.5 撤销与重做实现

简单栈模型:

  1. undoStack:保存历史快照;每次修改(拖拽、增、删顶点)执行pushSnapshot(),把当前 feature 深度拷贝压入 undoStack,清空 redoStack;
  2. undo:弹出 undoStack 顶部,存入 redoStack;设置为 currentFeature;刷新顶点图层,抛出 onGeometryChange;
  3. redo:弹出 redoStack 顶部存入 undoStack,恢复;
  4. 拖拽进行中禁止撤销重做,防止状态错乱。

四、和上层 Vue 业务组件的协同逻辑(重点业务设计)

4.1 瓦片图层隐藏机制

原始图斑保存在后端矢量瓦片图层;一旦图斑进入折点编辑:

  1. 工具类触发onEnterEdit(gid),上层 Vue 把 gid 加入数组editingGids;
  2. updateLayerFilter()给瓦片 fill/line/ 标注图层设置 filter,['!',['in',['get','gid'],literal(editingGids)]],原始瓦片图斑被隐藏;
  3. 编辑后的几何,存入内存fullAiFC,由前端 GeoJSON 源AI_SOURCE_ID渲染;
  4. 如果是blur()休眠,不会触发 onExitEdit,editingGids 数组保留,原图斑持续隐藏;切换别的工具回来可以继续编辑;
  5. 如果调用cancel()取消编辑,触发onExitEdit(gid),上层从 editingGids 移除 gid,瓦片 filter 更新,原图斑重新显示。

业务坑:很多人实现顶点编辑,编辑完成直接修改瓦片源;瓦片是服务端矢量切片,前端不能直接修改;因此采用「瓦片隐藏 + 前端 GeoJSON 临时图层覆盖」这套方案。

4.2 完成编辑 vs 取消编辑

  1. 完成编辑 editVertexFinish () : 把工具类currentFeature最新 geometry 写入内存fullAiFC,emitai‑fc‑update通知父组件;调用vertexEditTools.blur()休眠,保留 editingGids,图斑继续处于编辑待保存状态,等待整体业务保存提交后端。
  2. 取消编辑 editVertexCancel () : 调用vertexEditTools.cancel(),触发 onExitEdit;editingGids 移除 gid;丢弃本次全部编辑,恢复原始瓦片图斑。

五、项目开发中踩过的典型坑点

  1. Polygon 闭合首尾点不同步 拖拽第 0 号顶点,忘记同步修改 ring 数组最后闭合点;拖拽末尾点忘记同步 ring 0,几何变成非闭合,渲染错乱。
  2. MultiPolygon、内环洞处理遗漏 早期只处理单层 Polygon;MultiPolygon 多面、内环洞没有遍历,编辑多面 / 带洞要素直接报错;需要完整遍历polyIndex / ringIndex。
  3. 混淆 blur 休眠和 cancel 取消编辑 切换分割 / 整形工具时,错误调用cancel(),触发 onExitEdit,editingGids 被清除,底层瓦片原图斑冒出来,覆盖 AI 编辑后的图斑; ✅切换其他工具必须调用blur(),仅隐藏 UI 控制点,保留编辑数据。
  4. 直接原地修改 GeoJSON 对象 没有使用structuredClone深度拷贝;修改工具内 currentFeature,直接污染上层 fullAiFC 原始对象,状态不可控;所有编辑操作必须操作副本。
  5. 经纬度距离做线段 hover 判断 地图缩放不同,同样像素对应的经纬度距离差异巨大,hover 判定时灵时不灵;改用屏幕像素距离计算投影点,交互稳定。
  6. 删除顶点不做最少 3 点保护 删除后环顶点小于 3,生成非法 Polygon GeoJSON,mapbox 渲染报错。
  7. 套索使用 mapbox 图层事件而不是 canvas DOM 事件 套索绘制需要在空白地图拖拽;mapbox 图层事件只在要素上触发;套索监听必须绑定 canvas 原生 DOM mousedown/mousemove/mouseup。
  8. 顶点渲染时没有过滤闭合重复点 Polygon 环首尾坐标完全一样,不做过滤,同一个位置渲染两个圆点,视觉上重复。
  9. 事件没有销毁,组件销毁后内存泄漏 工具类提供 destroy 方法,解绑 map 事件、canvas dom 事件,移除 source/layer;Vue 组件onBeforeUnmount必须调用 destroy。

六、工具类对外暴露能力总览

javascript 复制代码
//设置待编辑要素
setEditFeature(feature:GeoJSON.Feature|null)
//增加折点模式开关
addVertex()
//删除折点模式开关,支持单点删除+套索批量删除
deleteVertex()
//撤销
undo()
//重做
redo()
//仅隐藏折点UI,保留编辑状态(切换其它工具调用)
blur()
//彻底取消,丢弃修改,通知上层恢复原图斑
cancel()
//清空控制点显示
clearDisplay()
//销毁实例,解绑事件,释放资源
destroy()

七、整体交互流程完整梳理

  1. 用户点击【编辑折点】按钮,进入editPointMode;
  2. 用户点击地图图斑,把图斑写入fullAiFC,调用vertexEditTools.setEditFeature(feat);
  3. 工具类触发onEnterEdit(gid),上层将 gid 加入 editingGids,瓦片原图斑隐藏,AI 图层渲染,地图出现橙色折点圆点;
  4. 用户操作:拖拽顶点 / 开启增加折点,点击边线插入 / 开启删除折点,点击点删除,空白拖拽套索批量删;每一次修改,onGeometryChange回调触发,上层更新 fullAiFC,刷新 AI 图层;
  5. 用户切换分割 / 整形工具:执行closeAllEditTools(),调用blur(),折点圆点消失,但 editingGids 不变,图斑继续使用 AI 临时图层;切回折点编辑可继续编辑;
  6. 用户点【完成】:把工具内最新几何写入 fullAiFC,emit 事件通知父组件,待整体业务保存提交后端;
  7. 用户点【取消】:调用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、批量删除点

相关推荐
YSL07012421 分钟前
C++基础
开发语言·c++
若亦_28 分钟前
Java中关于this的三种用法
java·开发语言·intellij-idea
2601_968793141 小时前
Python Requests 轻量自动化:无需浏览器,实现真人级账号浏览模拟
开发语言·python·自动化
FungLeo1 小时前
成为全栈·基础补充·Node.js 运行时:事件循环与异步
javascript·node.js·事件循环·后端开发·异步编程·成为全栈
eggwyw1 小时前
关于C++为什么不加入垃圾回收机制解析
开发语言·c++
Penge6662 小时前
《Go 后端开发者学 TypeScript:类型别名》
javascript
Penge6669 小时前
《Go 后端开发者学 TypeScript:数组和元组》
javascript
Penge66610 小时前
《Go 后端开发者学 TypeScript:export关键字大全》
javascript
Penge66610 小时前
《Go 后端开发者学 TypeScript:从零理解 async、await 与 Promise》
javascript