简介:这是一份基于Leaflet 1.0的Leaflet.PM前端插件项目源码包,面向Web GIS开发者,用于在地图上快速实现点、线、面等几何对象的创建、编辑、拖拽与删除,同时提供测量、坐标捕捉和WKT数据导入导出能力。压缩包共73个文件,以43个JS脚本和3个CSS样式为核心代码,辅以SVG图标资源、HTML示例页面、JSON/配置文件及Markdown说明文档,整体仅200KB,轻量易集成。目前已有755人学习浏览,适合想为地图应用加入交互编辑能力的前端工程师或GIS爱好者。包内除完整源码外,还包含index.html、demo.js等可直接运行的演示页面,以及README、LICENSE、CONTRIBUTING等引导文档,解压后参考demo即可快速上手;同时附有webpack构建配置、eslint及cypress测试相关文件,便于二次开发和工程化接入。
1. Leaflet.PM 到底解决什么问题:给 Leaflet 补上一套“画图交互”
Leaflet.PM 这个名字,很多第一次接触 GIS 前端的人会误以为它是个地图组件。实际上它是 Leaflet 的绘图插件,解决的是“用户在地图上圈范围、画路线、标点位”这一整类需求。做过地图项目的人都有这种体验:Leaflet 默认只负责展示瓦片和缩放,想让它支持鼠标画多边形,你得自己处理 mousedown、mousemove、dblclick 三种事件,再手动维护顶点数组,还要处理拖拽、删除、避让,一套写下来少说几百行。模拟项目X里我第一次做区域圈选时,就靠手写交互撑了两周,后来换到 Leaflet.PM,半天就把核心绘图链路跑通了。这篇笔记适合三类人:正在给 Leaflet 项目加绘图的开发者、评估绘图插件选型的团队、以及想摸清 PM 事件机制和数据回流的老手。
2. 绘图功能的前置认知:Leaflet.PM 的核心机制与选型理由
2.1 为什么 Leaflet 本身不带绘图:框架边界与插件生态
要理解 Leaflet.PM,先得清楚 Leaflet 的定位。Leaflet 是一个“地图展示”框架,它的核心能力是加载瓦片、管理图层、处理缩放平移,而不是“编辑几何图形”。官方把交互能力限定在点击、缩放这些基础事件上,至于单击加顶点、双击结束绘制这种业务级交互,则需要开发者自己组装。
这个边界其实是合理设计,因为绘图交互的复杂度远超想象。一个看起来简单的画多边形操作,背后涉及:鼠标位置到经纬度的换算、临时顶点与最终几何的切换、双击和右击的结束手势、以及编辑模式下顶点的拖拽反馈。如果 Leaflet 把这些全部内置,框架会变得臃肿且难维护。于是插件生态来补位,Leaflet.PM 就是这个生态里最常用的一环。
从选型角度,手写绘图逻辑不是不能做,而是维护成本高。我自己的经验是:如果只需要“点个点、画条线”这种最简交互,手写确实快;但只要需求里出现“多边形要能编辑顶点”“画完之后能拖拽整体移动”“不允许自交图形”,手写的工作量会指数上升。Leaflet.PM 把这套交互封装成了配置项和事件钩子,开发者不需要关心顶点拖拽的底层实现。
2.2 绘制模式、编辑模式与图层包装:PM 的三个核心概念
Leaflet.PM 的架构可以用三个词概括:模式(Mode)、图层包装(Layer.PM)、事件钩子(Events)。这三个概念贯穿所有使用场景,理解了它们,后面配置参数时就不会一头雾水。
模式是 PM 的行为状态。地图默认处于浏览模式,调用enableDraw('Polygon')后进入绘制模式,此时鼠标变成画笔,单击加顶点、双击或右击结束;调用enable()后进入编辑模式,图层上的顶点变成可拖拽的小圆点。模式之间是互斥的,同一时间地图只处于一种状态。底层实现上,PM 通过监听 map 的mousemove、click、dblclick事件来更新顶点,并把最终结果封装成一个 Leaflet 图层实例。
图层包装是指每个被 PM “接管”的图层都会挂一个.pm属性。这个属性上挂着该图层自己的编辑方法,比如layer.pm.enable()、layer.pm.disable()、layer.pm.toggleEdit()。这样设计的好处是:一个地图上可以同时存在多个图层,每个图层独立管理自己的编辑状态,互不干扰。我在实际项目中经常用这个特性,让业务区域图层可编辑、背景参考图层不可编辑。
事件钩子是 PM 与业务代码通信的通道。最常用的是pm:create(绘制完成)、pm:edit(编辑结束)、pm:update(顶点拖拽中)、pm:remove(删除图层)这四个。事件可以通过map.on()或layer.on()监听,区别在于:挂在 map 上是全局捕获,挂在 layer 上只针对特定图层。例如需要统计用户画了多少个区域,就监听 map 的pm:create;需要知道某个地块修改了,就监听该 layer 的pm:edit。
2.3 手写 vs 插件:什么时候选 Leaflet.PM
我把这个选型问题拆成三个判断条件,供读者对照自己的项目。第一,交互复杂度是否超过“单击画点”?只要涉及多边形、矩形、圆形、折线的编辑或删除,插件优势就明显了。第二,是否有时间压力?地图绘图属于“看起来简单、实际细节多”的功能,产品经理通常只给两三天,手写容易翻车。第三,是否需要数据标准化输出?PM 直接产出 GeoJSON,后端和 GIS 工具链都能无缝消费;手写方案往往输出自定义数组结构,还要自己补转换层。
当然,Leaflet.PM 也有边界。它不负责存储数据,画完之后的保存、回显、权限控制都要自己接;它也不处理坐标系转换,GeoJSON 里是经纬度、投影坐标混合的场景需要额外逻辑。这些边界不是缺陷,而是插件应有的分工——绘制交互做到极致,业务逻辑留给开发者。我一般建议团队把 PM 定位成“交互层”,把数据层独立封装,这样后续换组件或加需求都不至于推倒重来。
3. 把 Leaflet.PM 装进前端项目:最小工程、工具栏配置与坐标回流
3.1 安装与最小初始化工序
接入 Leaflet.PM 的常规路径是 npm 安装。打开终端执行:
npm install leaflet @makinacorpus/leaflet.pm这里有两个包,leaflet是基础地图库,@makinacorpus/leaflet.pm是 PM 插件本体。有些老项目可能装了leaflet.pm,那是旧版本包名,功能一致但事件名和样式路径略有差异,新项目优先用带组织的包名。
然后在入口文件引入插件和样式。这一步最容易出问题的是忘引 CSS:
import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import '@makinacorpus/leaflet.pm'; import '@makinacorpus/leaflet.pm/dist/leaflet.pm.css'; const map = L.map('map').setView([30.65, 104.07], 12);引入顺序有讲究:leaflet 核心 css 在前,PM 的 css 在后,这样 PM 的样式才能覆盖 Leaflet 默认样式。如果你用的是 webpack 或 vite,import 顺序就是最终打包顺序,写反了会出现工具栏按钮样式错乱。引入完成之后,插件会自动往map实例上挂map.pm对象,不需要额外初始化调用。
我习惯在初始化地图后立刻打印一次map.pm,确认插件生效。这个习惯帮我排查过不少“装了没反应”的问题:很多情况下是打包工具把 CSS 过滤掉,或者插件包被 tree-shaking 剪掉,而打印方法能最快暴露真相。
3.2 按业务配置工具栏:只保留需要的绘图按钮
map.pm.addControls()是工具栏的开关,参数是每个按钮的布尔值。下面是一份比较完整的配置:
map.pm.addControls({ position: 'topleft', drawMarker: false, // 标点按钮 drawPolygon: true, // 多边形按钮 drawRectangle: true, // 矩形按钮 drawCircle: true, // 圆形按钮 drawLine: true, // 折线按钮 editMode: true, // 编辑顶点按钮 dragMode: true, // 拖拽图层按钮 cutPolygon: false, // 多边形切割按钮 removalMode: true // 删除图层按钮 });参数说明:position控制工具栏在哪个角,可选topleft、topright、bottomleft、bottomright,我用得最多的是topleft,因为默认缩放控件在左上角,两者重叠时 PM 会自动避让;drawMarker到drawLine是五个绘制按钮,按业务需求开关;editMode和dragMode是编辑状态入口,前者针对顶点、后者针对整体移动;cutPolygon用于把一个多边形切成多个,注意它依赖 turf 计算,如果没引入额外依赖不要开启。
配置语义上要注意:这里只是“显示按钮”,不代表必须通过按钮才能绘制。你可以把按钮全关掉,只保留 API 调用,做成“点击业务按钮后按指定类型绘制”的模式。我遇到过产品要“先选地块类型,再在地图上画框”的需求,就是关掉drawPolygon,业务侧触发map.pm.enableDraw('Polygon'),这样一个按钮控制多种绘制类型,工具栏也干净。
3.3 绘制结果怎么拿:事件、图层与 GeoJSON 坐标顺序
绘制结束后的数据获取是整个流程的核心。PM 把结果放在pm:create事件里,通过e.layer拿到完整的 Leaflet 图层对象,再转成 GeoJSON 交给业务层:
map.on('pm:create', (e) => { const geojson = e.layer.toGeoJSON(); console.log('形状类型:', geojson.geometry.type); console.log('坐标数组:', geojson.geometry.coordinates); // 把 GeoJSON 存到自己的数据数组里 shapeStore.push(geojson); });这段代码里,e.layer是一个L.Polygon、L.Polyline或L.Marker实例,toGeoJSON()是 Leaflet 内置方法,返回标准的 GeoJSON 对象。这里要特别强调坐标顺序:GeoJSON 规范里坐标是[经度, 纬度],而 Leaflet 内部用的是[纬度, 经度]。如果后端存储的是[纬度, 经度],要在这里就做转换,不要在接口层来回猜。
另一个常见坑是直接使用e.layer.getLatLngs()。这个方法的返回值层级和图层类型强相关:多边形是三层嵌套数组,折线是两层嵌套,Marker 根本没有这个方法。写通用逻辑时,用toGeoJSON()统一出口最稳。如果要给后端送简化结构,我会这样处理:
map.on('pm:create', (e) => { const gj = e.layer.toGeoJSON(); const payload = { type: gj.geometry.type, // 'Polygon' / 'LineString' / 'Point' coordinates: gj.geometry.coordinates, // 标准 GeoJSON 顺序 area: L.GeometryUtil.geodesicArea(gj.coordinates) // 计算面积备用 }; // 发送到后端保存 });注意L.GeometryUtil.geodesicArea需要引入 Leaflet 的 geodesic 支持,并且它接收的是 GeoJSON 坐标数组,不是[lat, lng]。如果你只在绘制完成时用了[lat, lng],算出来的面积会是负数或巨大误差,这个我在第 5 章的避坑里单独展开。
4. 编辑、拖拽与清除:Leaflet.PM 的图层生命周期管理
4.1 用代码开启与关闭编辑模式
图层画完之后,用户经常要做两件事:移动整个图形、修改顶点位置。PM 把这两个行为拆成了dragMode和editMode。工具栏按钮只是入口,更灵活的做法是通过代码控制:
// 为某个图层开启编辑,snappable 让顶点吸附到其他图层顶点 layer.pm.enable({ allowSelfIntersection: false, // 不允许自交多边形 snappable: true, // 开启吸附 snapDistance: 20, // 吸附像素距离 preventMarkerEdit: false // 是否禁止拖拽 mark 点 }); // 编辑完成后关闭编辑模式,恢复普通图层状态 layer.pm.disable();参数说明:allowSelfIntersection默认为 false,防止用户画出一个“8”字形区域,这个参数在区域选择类业务里很重要,自交多边形在面积计算时会产生不可预期的结果;snappable配合snapDistance使用,让新顶点自动吸附到附近已有点上,画相邻地块时能避免细缝,20 像素是个比较稳妥的默认值;preventMarkerEdit在实际业务里很少用到,除非你的 marker 是地图标注而非业务数据。
编辑模式的关闭时机很关键。我见过不少新手只在编辑结束时调用disable(),却忽略了“用户编辑一半直接切走了页面”的场景。正确的做法是在组件销毁或功能切换时统一清理,避免图层一直处于可编辑状态,鼠标随手一碰就改变了图形数据。
4.2 编辑事件与数据回写:pm:update、pm:edit 的配合
编辑模式下的数据回写比绘制要复杂,因为编辑过程包含多次中间状态:顶点拖动中、拖动结束、整个编辑模式退出。如果只在某个单一事件里同步数据,一定会漏数据或重复同步。我常用的配合方式是:
// 拖动过程中:实时预览更新(不写库) layer.on('pm:update', (e) => { previewLayer.setLatLngs(e.layer.getLatLngs()); }); // 编辑模式完全退出时:一次性同步到后端 layer.on('pm:edit', (e) => { const latest = e.layer.toGeoJSON(); syncShapeToServer(latest); });这段代码里,pm:update在每次拖动顶点时触发,适合做 UI 实时刷新,比如同步旁边的测量工具;pm:edit在用户点击“完成编辑”按钮或调用disable()时触发,适合做最终数据同步。要注意pm:update的频率很高,如果每次都发起网络请求,后端会被打爆,所以要明确职责:实时事件做预览,结束事件做持久化。
pm:edit事件里拿到的e.layer是同一个图层对象,这里有个隐蔽细节:PM 编辑是基于原始图层实例包装的,不是新建图层,所以layer.toGeoJSON()拿到的坐标就是当前界面上的最终结果。如果业务里曾对 layer 做过 clone 或 data 绑定,要确认监听的是同一个引用,否则会出现“改了画布,后端数据没变”的玄学问题。
4.3 删除图层与重置地图:removalMode 和 disableDraw 的正确用法
删除操作有两个层面:工具栏的“垃圾桶”按钮和代码里的程序化清除。垃圾桶按钮对应removalMode,开启后用户点击任意图层即可删除。但这里有个坑:removalMode是全局模式,开启后所有图层都能被删,包括你不希望用户动的基础底图标注。所以在业务中我一般关闭按钮,改由业务侧指定可删图层:
// 只允许删除用户自己画的区域,不允许删底图标注 map.on('pm:create', (e) => { e.layer.options.customId = currentUserId; }); map.pm.toggleRemoval(false); // 关闭全局删除模式 // 业务自定义删除入口 removeSelectedArea(layer) { map.pm.removeLayer({ layer }); layer.remove(); }map.pm.removeLayer()会触发pm:remove事件,同时完成图层从地图上的移除。程序化清除整张地图的图形,要用eachLayer遍历图层组并逐个移除。这里最容易遗漏的是绘制模式的退出:如果当前正处于enableDraw('Polygon')状态,直接清图层会导致绘制状态残留,鼠标仍处于“画笔”状态,需要先调map.pm.disableDraw()再清理:
function resetAllShapes() { map.pm.disableDraw('Polygon'); map.pm.disableDraw('Line'); shapeLayerGroup.eachLayer((layer) => { map.pm.removeLayer({ layer }); }); }注意disableDraw传的参数对应绘制的形状类型,官方文档里的类型名是'Polygon'、'Line'、'Marker'、'Rectangle'、'Circle',写错类型不会报错但也不会退出绘制,这个静默行为我踩过一次,排查了半天才发现是类型字符串拼错了。
5. Leaflet.PM 避坑清单:样式、事件、坐标三个重灾区
5.1 画出来的图形看不见或样式丑陋:CSS 引入与全局样式覆盖
现象:点击地图有反馈,绘图提示文字也能看到,但画出的多边形没有边框、没有填充色,或者是一条黑色的粗线。
原因:两种情况最常见。一是leaflet.pm.css没引进来,PM 的图形默认样式依赖这套样式表,缺失时图形可能渲染为无样式状态;二是项目全局样式或 Leaflet 主题样式的优先级高于 PM 的 pathOptions,把fillColor和color覆盖掉了。
解决:先确认 CSS 已正常引入,再通过pathOptions显式声明样式,不要依赖默认值:
map.pm.enableDraw('Polygon', { pathOptions: { color: '#2d7ff9', // 边框色 weight: 2, // 边框粗细 fillColor: '#2d7ff9', // 填充色 fillOpacity: 0.15 // 填充透明度 } });显式声明之后,大多数“看不见图形”的问题都能解决。如果仍然有覆盖,检查项目的全局 CSS 是否对.leaflet-overlay-pane path或svg path设置了属性,这两类选择器优先级很高。浏览器开发者工具里选中绘制的图形元素,查看 computed 样式,一眼就能看出是哪条规则覆盖了。
5.2 绘制事件拿不到数据:图层类型与坐标取值的边界
现象:pm:create事件触发了,但打印e.layer.getLatLngs()得到一层套一层的数组,取[0][0]又报错;或者拿到的是经纬度顺序和预期相反的数字。
原因:普通 Polygon 的getLatLngs()返回组织结构是[[[lat, lng], [lat, lng], ...]],因为多边形可能带洞;折线是[[lat, lng], ...];Marker 没有这个方法。用一个统一写好的通用函数处理所有类型时,经常会在这个结构中翻车。
解决:不要直接操作getLatLngs(),统一走toGeoJSON()再解析:
map.on('pm:create', (e) => { const gj = e.layer.toGeoJSON(); const coords = gj.geometry.coordinates; if (gj.geometry.type === 'Polygon') { const outerRing = coords[0]; // 外环坐标,顺序是 [lng, lat] } else if (gj.geometry.type === 'LineString') { const points = coords; // 折线顶点数组 } });坐标顺序上,GeoJSON 输出的是[lng, lat],而 Leaflet 内部是[lat, lng]。如果你的后端接口已经定义成[lat, lng],在封装数据时做一次 map 转换。我在这件事上吃过一次亏,后端拿到的坐标经度和纬度全反,都市里的坐标还好,到了高纬度地区误差能到几十公里,排查时极难定位。建议在数据封装层写个固定的转换函数,全项目统一调用,不要在每个业务里各转一次。
5.3 编辑之后数据没更新:监听对象和事件时机的错配
现象:用户在编辑模式下拖着顶点,地图上的图形确实变了,但接口里的数据还是旧的,刷新页面后图形回到原样。
原因:一是监听对象错了,编辑事件挂在map上而不是具体的layer上;二是只在pm:create里同步了一次数据,编辑结束后没有重新同步。这两种情况都不报错,事件静默丢失,很迷惑。
解决:编辑事件的监听挂在图层实例上,同时区分“拖拽中”和“编辑结束”两个阶段:
layer.on('pm:update', (e) => { // 拖拽中:更新界面上的测量值,不触发网络请求 updateMeasurementPanel(layer); }); layer.on('pm:edit', (e) => { // 编辑结束:保存到后端 const latest = layer.toGeoJSON(); saveShape(latest); });如果遇到pm:edit不触发的场景,检查是不是编辑模式还没真正开启。调用layer.pm.enable()之后,PM 会内部创建一个编辑图层,原始图层在编辑期间的状态有些字段会被临时替换。我踩过的另一个坑是在enable()之后立刻读layer.toGeoJSON(),拿到的是开启前的旧数据,正确姿势是先进入编辑模式、完成操作、退出编辑模式后再读取。
5.4 地图容器隐藏导致工具栏错位或绘制位置不准
现象:用户切到某个 tab,页面 div 初始化时是display: none,切回来之后工具栏挤在角落,在地图上点击时绘制的图形位置和鼠标有明显偏移。
原因:Leaflet 在容器尺寸为 0 时完成了初始化,鼠标事件计算的像素坐标和经纬度换算用的是初始化时的尺寸。PM 的工具栏定位和绘制交互都依赖 map 的尺寸状态,尺寸错误时所有交互都会跑偏。
解决:在容器显示之后调用map.invalidateSize(),这是 Leaflet 官方提供的尺寸重建方法:
// 切换到地图 tab 时 setTimeout(() => { map.invalidateSize(); }, 100);setTimeout的原因是要等容器完成页面布局。如果容器是异步加载的,可能还要多等一个渲染周期。这个 100 毫秒的经验值我用了很久,有时换成 0 也能工作,但在低端移动设备上还是留点余量更稳。invalidateSize调用时机不对是 Leaflet 项目里最常见的初始化问题之一,PM 只是继承了这个问题,套用同样的解法即可。
5.5 删除图层后数据残留:pm:remove 事件没接住
现象:用户通过工具栏垃圾桶删除某块区域,地图上图形消失了,但用户刷新页面后图形又出现。
原因:removalMode删除操作只处理了前端图层,没有同步后端数据。删除事件是pm:remove,如果没监听它,后端存储里的坐标数据就完全没动。
解决:在pm:remove事件的回调里主动调删除接口:
map.on('pm:remove', (e) => { const shapeId = e.layer.feature?.properties?.shapeId; if (shapeId) { fetch(`/api/shapes/${shapeId}`, { method: 'DELETE' }); } });这里e.layer.feature是 PM 写入到图层上的 GeoJSON 引用。如果绘制时没有主动设置shapeId,删除事件里就拿不到业务主键。我的做法是:在pm:create时就把后端返回的 id 写入layer.feature.properties.shapeId,这样删除时能直接关联上。这个习惯我后来一直保留,pm:remove不触发的情况比较少见,但万一遇到,先确认监听是map.on而不是layer.on—— 删除后的图层实例已经销毁,再挂 layer 上的监听就永远触发不了。
6. 把 Leaflet.PM 用得更顺:状态管理、数据校验与自定义样式的组合
Leaflet.PM 只负责“画”,剩下的数据链路需要自己搭。我目前的主力方案是一个 FeatureStore,把所有绘制的图形统一管理,而不是分散在组件状态里。
const featureStore = { shapes: new Map(), // key: shapeId, value: { geojson, layerRef } add(geojson, layer) { const id = crypto.randomUUID(); layer.feature = layer.feature || {}; layer.feature.properties = { ...layer.feature.properties, shapeId: id }; this.shapes.set(id, { geojson, layerRef: layer }); return id; }, update(id, geojson) { const item = this.shapes.get(id); item.geojson = geojson; }, remove(id) { return this.shapes.delete(id); } };这个 store 把所有图层引用和 GeoJSON 放在一处,绘制、编辑、删除都能拿到全局视图。配合前面的 5.2 避坑案例,pm:create里调用store.add(),pm:edit里调用store.update(),pm:remove里调用store.remove(),整个数据生命周期就闭环了。
数据校验也是不能跳过的一环。PM 默认不校验多边形面积和顶点数量,用户误点一下也可能画出一个退化多边形。我在pm:create回调里加了一个最小顶点数和最小面积的过滤:
map.on('pm:create', (e) => { const gj = e.layer.toGeoJSON(); if (gj.geometry.type === 'Polygon') { const ring = gj.geometry.coordinates[0]; if (ring.length < 4) return notifyError('多边形至少需要 3 个顶点'); const area = Math.abs(L.GeometryUtil.geodesicArea(gj.coordinates)); if (area < 500) return notifyError('圈选区域太小,请重新绘制'); } featureStore.add(gj, e.layer); });geodesicArea计算的是地球表面的真实面积,单位是平方米,500 平方米的阈值是我在业务里调的,你可以按自己的场景改。这个校验放这里,比后端校验用户体验好很多,毕竟让用户返工画图是交互上最降级的事情。
样式自定义上,我建议用map.pm.setGlobalOptions做全局统一,而不是每个绘制入口单独传pathOptions:
map.pm.setGlobalOptions({ pathOptions: { color: '#2d7ff9', weight: 2, fillColor: '#2d7ff9', fillOpacity: 0.12 } });这样所有通过 PM 创建的图形初始样式都一致,后续如果要区分不同业务的图层,再在pm:create里针对图层类型单独覆盖。整套链路跑通之后,我给这个方案的真实体验是:绘图交互在一到两天内稳定,剩余的时间都花在数据回交互和校验上——所以每接一个绘图需求,我都先打通“绘制→存储→回显→编辑→删除”这五环,确认闭环可靠了再做界面美化。先把最脏的数据链路理顺,再谈样式和交互细节。希望帮到你。
本文还有配套的精品资源,点击获取