写这篇文章的起因,是我在去年接手的一个水利信息化项目里被提了个需求:地图上要支持画点、画线、画矩形、画圆,还要能标箭头和集结地这类军标,最好还能让用户拖拽编辑。当时项目是基于Cesium的,第一反应是拿Cesium原生Entity API手写一个标绘类。结果写了三分之一就收手了——重复代码太多,取消编辑、拖拽顶点、序列化回显这些逻辑全都得重造轮子。后来换了cesium-plot-js,很快就把全流程跑通了。这篇文章就围绕"cesium怎么用cesium-plot-js实现多种图形标绘"展开,内容包括库的核心设计、环境接入、API使用逻辑、完整工具条实现,以及我在实际项目里踩过的坑。适合刚接触Cesium标绘、或者正准备二次封装标绘组件的开发者参考。
1. 为什么选cesium-plot-js而不是自己写标绘类
1.1 官方API的短板在哪里
Cesium本身提供了非常丰富的图形能力,Entity可以画点、线、面、圆、矩形,CallbackProperty可以实时更新位置,这些都是标绘的基础。但"标绘"和"画一个图形"不是一回事。标绘意味着用户要能在画布上交互式地绘制,画完还能选中、拖动、编辑顶点,最后要把图形数据序列化存到数据库,下一次打开页面重新渲染出来。
用原生API做这些事情,面临几个问题:
- 绘制过程的状态机要自己维护:等待点击、记录点位、右键结束、ESC取消,一套流程写下来至少要一两百行,而且每种图形都要写一遍。
- 编辑功能基本从零实现:选中图形后要显示出控制点、每个控制点要可拖拽、拖完要实时更新图形几何、结束拖拽要清理临时对象。Cesium原生没有提供这套交互框架。
- 图形类型的扩展性差:画个最简单的矩形还好,要画进攻箭头、集结地这种复杂军标,用Entity组合非常吃力,几何逻辑要自己算。
我最初手写的时候,光是"矩形——平行四边形——多边形"三种图形的编辑逻辑就写了两个晚上,而且Bug还一堆。那时候我意识到,这个方向不值得重复造轮子,社区里一定有更成熟的方案。
1.2 cesium-plot-js解决的核心问题
cesium-plot-js是国内开发者开源的一套基于Cesium的标绘扩展库,核心设计思路是把标绘流程拆成三层:覆盖物图层(OverlayLayer)管理所有已绘制的对象,标绘图层(PlotLayer)处理绘制过程中动态演算的临时几何,编辑图层(EditLayer)管理选中对象上的控制点。三个图层叠加在地图上,互不干扰,交给用户统一操作入口就行。
它内置的图形类型覆盖了大多数标绘需求,包括:
| 图形类型 | 类名 | 适用场景 |
|---|---|---|
| 文本标注 | Text | 地图上的说明文字 |
| 点标记 | Marker、Point | 点位标注 |
| 折线 | Polyline | 路线、边界 |
| 多边形 | Polygon | 区域范围 |
| 矩形 | Rectangle | 规则区域框选 |
| 圆 | Circle | 圆形范围、辐射区 |
| 椭圆 | Ellipse | 不规则圆形区域 |
| 贝塞尔曲线 | Bezier | 平滑路径 |
| 集结地 | GatheringPlace | 军标中的人员/物资聚集符号 |
| 进攻箭头 | AttackArrow | 军标标绘中常用,箭头包含多个控制点 |
| 自由线/自由面 | FreehandPolyline、FreehandPolygon | 随手画的轨迹和区域 |
| 钳击/直箭头等 | TailedAttackArrow、StraightArrow等 | 更丰富的军标形态 |
这张表看起来简单,但对项目来说意味着一件事:90%的标绘需求不需要你写一行几何算法。库内部已经把图形的顶点计算、控制点生成、编辑时的事件绑定都封装好了。
1.3 什么时候不建议用这个库
也不是所有场景都适合引入cesium-plot-js。我的建议是,如果项目只需要"画出静态图形"而不需要用户交互,比如纯粹展示一批预定义的多边形区域,那原生Cesium Entity就够用了,没必要引入额外的依赖。如果项目需要复杂的标绘交互、尤其是军标符号,那这个库能省下大量开发时间。此外,它对Cesium的版本有一定要求,团队使用的Cesium版本如果太新或太旧,需要先验证兼容性再决定。
2. 环境准备与引入方式里的坑
2.1 从npm到模块引入的完整链路
cesium-plot-js的安装方式很简单,npm包名就叫cesium-plot-js,命令行执行:
npm install cesium-plot-js但引入它有个前置条件:它依赖Cesium的全局变量。也就是说,你的项目里得先有一个全局的Cesium对象,库才能正常工作。最常见的做法是在HTML里直接通过script标签引入Cesium,然后再引入cesium-plot-js:
<script src="https://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Cesium.js"></script> <link href="https://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Widgets/widgets.css" rel="stylesheet"/> <script src="node_modules/cesium-plot-js/dist/cesium-plot.js"></script>这个方案在传统多页应用里最省事,window.Cesium和window.Plot马上就有了。但在Vite、Webpack这类工程化项目里,如果Cesium是走npm安装的,你需要在入口文件里先把它挂到window上:
import * as Cesium from 'cesium'; window.Cesium = Cesium; import Plot from 'cesium-plot-js';这里有个容易忽略的细节:如果把import语句都写在文件顶部,window.Cesium = Cesium这行代码是在import Plot from 'cesium-plot-js'之后的代码执行顺序中生效的,而import有提升机制,Plot模块在加载阶段就尝试访问window.Cesium,于是你会在控制台看到"Cesium is undefined"的报错。解决办法是使用动态import:
import * as Cesium from 'cesium'; window.Cesium = Cesium; // 等Cesium挂载完毕再加载Plot import('cesium-plot-js').then(({ default: Plot }) => { window.Plot = Plot; // 初始化标绘功能 });或者用require写法,确保顺序:
const Cesium = require('cesium'); window.Cesium = Cesium; const Plot = require('cesium-plot-js');这个问题我在一开始接入时没意识到,查了好一会儿才找到原因。如果你使用Vite且遇到打包报错,也可以检查一下optimizeDeps配置,把cesium-plot-js加入排除列表,避免预构建时出问题。
2.2 版本兼容性实测
cesium-plot-js本身更新频率不算高,它对Cesium的版本适配集中在几个核心API变化上。我实测下来,Cesium 1.95到1.107左右的版本跑得都比较稳,Cesium 1.100之后官方把不少API做了废弃处理,但cesium-plot-js主要用的还是底层Viewer、ScreenSpaceEventHandler这类稳定的接口,所以影响不大。
需要重点注意的是:如果你用到的是Cesium 1.110以上的新版本,建议先跑通一个最小demo再大规模接入。因为新版本在部分渲染机制上有调整,标绘时的临时几何(点、线、面)如果出现闪烁或绘制延迟,大概率是版本适配问题。
如果你只是想找个稳定的组合方案,我建议锁定一套版本组合用于生产:
"dependencies": { "cesium": "1.107.2", "cesium-plot-js": "^1.0.0" }实测这个组合非常平稳,动态标绘、编辑、回显都没遇到离奇问题。
3. 核心API到底是怎么组织标绘流程的
3.1 搞清楚Plot对象和内部图层的关系
cesium-plot-js把整个标绘过程封装在Plot类中。创建一个Plot实例,需要传入Cesium的Viewer对象,以及一个配置项对象:
const plot = new Plot({ viewer: viewer, plotConfig: { // 覆盖物图层配置,控制已绘制图形的样式 overlayLayer: { enable: true, style: { color: '#FF0000', opacity: 0.8 } }, // 编辑图层配置,控制选中后控制点的样式 editLayer: { enable: true, // 控制点大小等配置 } } });Plot实例内部维护着三个关键图层:
- overlayLayer:所有绘制完成、固定在场景中的图形都放这里,可以理解成"成品区"。
- plotLayer:绘制过程中的临时预览图形,比如你正在画多边形时,鼠标移动产生的实时预览线/面,就渲染在这一层,绘制完成或取消后自动清空。
- editLayer:选中某个图形后,控制点、包围框、旋转手柄等都放这一层,编辑结束时清空。
我刚开始用的时候总是搞混overlayLayer和plotLayer,导致想清除某个图形时不知道应该去哪个图层找。后来总结了一句记忆口诀:plotLayer是"正在画"的草稿,overlayLayer是"画完"的成品,editLayer是"选中后"的把手。
3.2 核心流程:激活绘制工具,完成,再激活下一个
库的使用流程非常统一。要启用某种图形的标绘,直接调用对应的方法即可:
// 开始绘制矩形 plot.startPlot('rectangle'); // 开始绘制圆 plot.startPlot('circle'); // 开始绘制进攻箭头(军标) plot.startPlot('attackArrow');用户在地图上点击完成后,图形会自动添加到overlayLayer并结束绘制状态。如果需要取消当前绘制,调用:
plot.stopPlot();绘制完成的对象,会以Overlay对象的形式存在。库为每种图形类型都生成了对应的Overlay类,例如Overlay.Rectangle、Overlay.Circle、Overlay.AttackArrow。
Overlay对象有非常实用的属性和方法,我最常用的是这几种:
// 获取图形的所有点位坐标(经纬度数组) const positions = overlay.geometry.getPoints(); // 获取图形的中心点位置 const center = overlay.center; // 设置图形样式,比如改颜色、透明度 overlay.setStyle({ style: { color: '#00FF00', opacity: 0.5 } }); // 把图形序列化为JSON,用于存储 const jsonData = overlay.toGeoJson(); // 把图形状态恢复到某个坐标集合 overlay.reInitialize(positions);这套设计让我在后端存储环节变得很轻松:直接把Geometry数据转成JSON存入数据库,下次进入页面再反序列化并重新添加到场景中。
3.3 选中、编辑、删除的关键方法
标绘库最有价值的地方在于编辑能力。它默认绑定了鼠标事件:单击某个已绘制的图形,会进入选中状态并显示控制点,拖动控制点就能调整形状。
如果你需要控制显隐,可以通过鼠标事件的开关控制。在某些场景下(比如展示模式),你需要禁用点击选中:
plot.config.mouseEvent.mouseClick = false; plot.config.mouseEvent.mouseMove = false;删除一个图形,常用的方式是通过事件的回调拿到Overlay对象再处理。比如在"双击删除"场景里,可以监听双击事件:
plot.config.mouseEvent.dblClick = (overlay) => { // 双击图形时移除该覆盖物 plot.removeOverlay(overlay); };这里要提醒一点:双击事件的回调参数要写对。如果你在函数里不写参数,你会发现双击后图形纹丝不动——因为库认为是"编辑完成"而不是"删除"。
4. 把散装API组装成可用的标绘工具条
4.1 设计图形分类表
我一般不会在页面上把几十个按钮全部铺开,而是按业务场景分类。比如一个通用标绘面板,我会分成三组:
| 分组 | 图形 | 应用场景 |
|---|---|---|
| 基础标注 | 点、文字、折线、多边形 | 日常地图标注、区域圈画 |
| 几何形状 | 矩形、圆、椭圆、贝塞尔曲线 | 规则范围、路线规划 |
| 军标符号 | 进攻箭头、集结地、直箭头 | 指挥演示、应急预案 |
如果你是做应急、水利、电力这类业务,军标符号大概率是刚需;如果只是通用数据标注,前面两组就够用了。
4.2 一个可直接抄的完整实现
下面是我在项目里实际使用并精简过的核心代码,实现了从工具条点击按钮到完成绘制的完整闭环。这段代码可以直接放进Vue组件或React组件的生命周期钩子里运行:
// 工具条按钮绑定:点击"画矩形"按钮后调用 activatePlot('rectangle') const activatePlot = (type) => { // 如果有编辑状态,先停止 plot.stopPlot(); // 根据按钮类型激活对应绘制工具 const typeMap = { point: 'point', text: 'text', polyline: 'polyline', polygon: 'polygon', rectangle: 'rectangle', circle: 'circle', ellipse: 'ellipse', attackArrow: 'attackArrow', gatheringPlace: 'gatheringPlace' }; const plotType = typeMap[type]; if (plotType) { plot.startPlot(plotType); } }; // 绘制完成回调:拿到Overlay对象,可以做自定义保存 plot.onAddOverlay = (overlay) => { console.log('新增覆盖物', overlay); // 这里可以弹出属性编辑框,让用户填写业务信息 const overlayData = { type: overlay.type, id: overlay.id, positions: overlay.geometry.getPoints(), style: overlay.getStyle?.() || {} }; // 调用后端接口保存 // saveOverlay(overlayData); }; // 页面销毁时释放资源 const destroy = () => { plot.stopPlot(); plot.removeAllOverlay(); };实际开发里,我还会把plot实例存到全局或状态管理器中,方便其他模块在需要时调用。比如在"一键清除全部标绘"按钮里:
const clearAll = () => { plot.removeAllOverlay(); // 同时清空后端暂存的标绘数据 overlayList = []; };4.3 为什么把事件绑定放在onAddOverlay而不是startPlot
这里分享一个我自己走过的弯路。最早接手这个库时,我以为在startPlot('rectangle')之后,函数里直接处理图形完成事件就行,结果发现startPlot只是激活了"绘制状态",你还需要在某个回调里拿到最终的Overlay对象。我后来确认了库的事件设计:OnAddOverlay、OnRemoveOverlay、OnEditOverlay是独立的事件钩子。你可以在初始化Plot后统一注册回调,不必每次绘制时单独监听。
还有个细节:onAddOverlay里的回调参数是覆盖物对象本身,而不是事件对象。如果你不熟悉这个设计,很容易写错成(event) => { event.xxx },结果拿不到数据。正确写法是:
plot.onAddOverlay = (overlay) => { const positions = overlay.geometry.getPoints(); };这个坑我印象很深,因为当时我在崩溃调试时发现控制台打印出来一个很长的对象,顺着字段才摸清结构。
5. 高频踩坑与对应解法
5.1 图形点选不中,或点一次直接被吃掉
很多首次使用的人在点击图形时发现:要么没反应,要么第一次点击被当作"绘制完成",第二次点击才进入选中状态。这通常是因为你在工具条点击激活了绘制后,没有先停止上一轮的绘制状态。库的设计是:绘制状态下,单击地图点位是"加顶点";绘制完成后,单击图形才能"选中"。所以正确顺序是:
// 激活新工具前,务必先停止当前绘制 plot.stopPlot(); plot.startPlot('rectangle');如果你切换工具太频繁,建议在工具条按钮的统一入口处每次都调一次stopPlot。
5.2 样式配置里到底有哪些字段可用
我在项目里用到的style字段大概是这样:
const style = { color: '#FF0000', // 填充颜色 opacity: 0.8, // 填充透明度 outlineColor: '#FFFFFF', // 边框颜色 outlineWidth: 2, // 边框宽度 show: true };需要特意提醒的是:不同图形类型的style字段名不一定完全一致。有的图形用outlineColor,有的用borderColor;有的用outlineWidth,有的用width。我在统一配置时吃过一次亏——给一个图形配了所有可能字段,结果发现部分字段不生效。由于库文档更新不及时,建议你在初始化后打印一个overlay对象的style属性,看看实际哪些字段有效。
5.3 标绘数据存储和回显的最佳姿势
标绘功能做完后,紧接着就要考虑数据持久化。我的方案是:
- 在onAddOverlay中,把Overlay对象序列化:
const overlayData = { id: overlay.id, type: overlay.type, geometryJSON: JSON.stringify(overlay.geometry) // 或使用toGeoJson() };- 从后端拿到数据后,重建覆盖物:
const rebuildOverlay = (overlayData) => { // 动态import获取Plot库后,根据type创建对应覆盖物 const OverlayClass = Plot.Overlay[overlayData.type]; const newOverlay = new OverlayClass({ positions: JSON.parse(overlayData.geometryJSON).positions }); plot.addOverlay(newOverlay); };这里有个关键点:Plot.Overlay这个命名空间在不同版本里可能大小写不一样,有的大写Overlay,有的小写Overlay。建议接入后先打印一下Object.keys(Plot)确认。
5.4 与地图视角操作、模型节点点选的冲突
标绘场景下,地图的鼠标事件会和Cesium默认的相机控制冲突。最典型的问题是:用户想通过左键拖拽旋转视角,结果每次拖拽都被当成标绘的点位提交。
cesium-plot-js的处理方式是区分左键和右键:左键用于标绘,右键用于取消。如果你希望用户能通过左键拖拽视角,同时保留标绘能力,可以这样配置:
plot.config.mouseEvent.mouseLeftClick = true; // 左键绘制加点 plot.config.mouseEvent.mouseLeftDblClick = true; // 双击完成 plot.config.mouseEvent.mouseMove = true; // 移动预览另外,如果你的项目中同时使用了3D Tiles模型节点点选、雷达扫描叠加等交互(这是我在项目中遇到的真实组合场景),要注意事件优先级。cesium-plot-js内部绑定的是ScreenSpaceEventHandler,而模型点选通常也会绑定同类事件。两者的执行顺序不一定符合预期。我的解决办法是:
- 把标绘功能做成可启用/禁用的模式。启用标绘时,禁用模型点击选择;模型点选时,先
plot.stopPlot()。 - 在标绘模式下,Cesium的相机操作建议改成中键或右键拖拽,避免误操作。
5.5 一个容易被忽略的细节:对象销毁与复用的清理
在单页应用中频繁切换页面时,如果不销毁Plot实例,内存占用会越来越高,严重时会导致地图卡顿。正确做法是在组件卸载时调用销毁方法:
const destroyPlot = () => { plot.stopPlot(); plot.removeAllOverlay(); plot = null; };我踩过的一个坑是:切换页面时忘了清理overlayLayer,结果返回上一页时,之前标绘的图形还在画面上。这个特征会让用户误以为数据被重复提交,实际上是内存中的Overlay对象还在。养成上面写法的习惯就没这个问题了。
5.6 编辑模式的开启与退出
cesium-plot-js默认支持选中图形后直接拖拽编辑,但有时候我们需要手动控制编辑状态,比如只在特定按钮被点击后才允许编辑。我用的方案是:
// 手动开启编辑 const enableEdit = (overlay) => { plot.selectedOverlay(overlay); }; // 退出编辑 const disableEdit = () => { plot.selectedOverlay(null); };实测中,调用selectedOverlay(null)能清理编辑态的控制点,避免出现"图形已经取消选中但控制点还留在屏幕上"的视觉Bug。
写在最后的小技巧
我在多个项目里反复使用cesium-plot-js之后,最大的感受是:这个库的价值不在它的封装有多炫,而在它让你把精力从"几何算法"挪到"业务逻辑"上。比如我们做过一个应急指挥的大屏,标绘只是入口,后面还有态势推演、路径规划,如果连标绘这一层都要自己造轮子,整个项目周期根本扛不住。
最后再分享一个实用经验:如果后期要接入军标动画,比如"军标标绘动画"里常见的闪烁、移动箭头效果,不要直接在cesium-plot-js的Overlay上改,而是在overlayLayer之上再加一个专门做动画的图层。比如给进攻箭头加一个按固定时间间隔闪烁的效果,可以监听Clock的tick事件,在tick里切换箭头实体的show属性。这样不会和库的编辑状态互相干扰,动画逻辑也独立可维护。这个思路同样适用于"cesium动态光照"、模型节点高亮和雷达扫描叠加这类扩展需求——它们都可以叠加在独立的图层里,而不是和标绘核心逻辑缠在一起。