Mapbox GL JS 自研面要素整形工具开发实录

亲测有效!!!!,如有疑问请联系博主!!!!

这是优化后的代码

也可以参考博主的这篇文章:基于 Turf.js 实现高精度多边形修整工具(模拟 ArcGIS 修整功能)-CSDN博客

前言

在 GIS Web 端项目里,面要素局部修边、裁剪整形是自然资源、土地确权、烟田地块采集里最高频的编辑需求。

Mapbox GL Draw 自带只有常规点线面绘制、简单拆分合并,没有专业的整形修边能力:想要对已有多边形做局部裁切、边界修整,只能删了重画,效率极低。

于是基于 Mapbox GL JS + mapbox-gl-draw + Turf.js,自研一套交互式多边形整形工具选中面 → 绘制整形线 → 自动裁切修整 → 返回新面要素,完整闭环,开箱即用。

本文从业务场景、设计思路、核心流程、状态管理、关键踩坑、完整架构做一次博客复盘

一、业务场景痛点

  1. 外业采集 GPS 漂移,地块面边界多余、凹凸不齐,需要局部修边;
  2. 已有多边形不能整体重绘,只需要按一条线裁切整形
  3. 原生 Draw 不支持线裁切面,只能自己封装拓扑裁切逻辑;
  4. 需要严格的模式状态管理,防止多次点击、事件重复绑定、图层残留;
  5. 操作要有高亮提示、消息反馈、撤销重置、退出机制。

核心诉求:不重绘整个面,画线即可局部整形修边

二、工具整体设计思路

核心流程

  1. 进入整形模式 → 监听业务面图层点击事件
  2. 点击选中多边形 → 高亮选中要素
  3. 自动切换为「绘制线模式」,让用户画整形裁切线
  4. 画线完成后,调用 Turf 工具做面沿线裁切整形
  5. 裁切成功回调返回旧要素 & 新要素,供业务层保存
  6. 自动重置状态、清除高亮、清空绘制图层,可连续操作或退出

状态机设计

用三个核心变量管控整个流程,避免状态混乱:

  • 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 },
})

六、代码架构设计优势

  1. Class 面向对象封装:高内聚低耦合,可直接实例化复用;
  2. 状态驱动流程:流程靠状态流转,不易乱;
  3. 事件手动管理:精准绑定解绑,无内存泄漏、无重复触发;
  4. UI 交互完整:消息提示、高亮、操作指引全覆盖;
  5. 业务解耦 :通过onResult回调把结果抛给外层,工具层只负责编辑逻辑;
  6. 健壮性拉满:空值校验、异常捕获、自动重置、安全退出。
相关推荐
我的世界洛天依1 小时前
胡桃讲编程|续篇!用高数 + JS ES262 硬核解构:求乐正绫的值
javascript
超级小星星2 小时前
C 语言结构体内存对齐深度解析:从概念到实战
c语言·开发语言
狮子座明仔2 小时前
AgentSPEX:当 Agent 框架开始把“控制流“从 Python 里抠出来
开发语言·python
笨笨饿2 小时前
74_SysTick滴答定时器中断
c语言·开发语言·人工智能·单片机·嵌入式硬件·算法·学习方法
科芯创展2 小时前
XZ4058B/C,20V,外置MOS,8.4V/8.7V开关充电芯片 宽范围电源电压:8.9V~20V-(电池充电电压:8.4V/8.7V)
c语言·开发语言
AI玫瑰助手3 小时前
Python流程控制:break与continue语句的区别与应用
开发语言·python·信息可视化
棉猴3 小时前
python海龟绘图之画布与窗口
javascript·python·html·setup·turtle·海龟绘图·screensize
AI_paid_community3 小时前
25k Star 登顶 GitHub:这个专门吃 K 线图长大的 AI,让我意识到之前三年都在裸奔
javascript·claude