上一次我们拆了鹰眼小地图,结尾我埋了个话头:这家人还有一位不写字、也不放图的成员没出场。
下一个登场的是右键菜单控件:它既不写一行字、也不放一张图,而是在你右键的那一点弹出一个菜单------和鹰眼、状态栏一起,把"屏幕一侧怎么承接用户操作"这道题补全。
顺带把进度摆一下:地图控件这条线一路走来,罗盘读相机朝向(#68)、气泡写浮层追地表点(#69)、图表焊图到地球(#70)、缩放挪相机对准焦点(#71)、提示浮层跟光标报坐标(#72)、位置信息栏钉角落并排报状态(#73)、鹰眼小地图分身一只复眼(#74),今天右键菜单控件当第八位。前面七位各有各的脾气,但真要论"能反过来指挥地球干活"的,只有今天这位------前七位全是单向地把地球的信息搬到屏幕上,右键菜单是头一个把屏幕上的一个动作、翻译成地球上的一个结果。
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
先说个几乎所有人都踩过的直觉:你在网页里右键,浏览器会弹自己的菜单;那在 Cesium 里右键,是不是得先自己 preventDefault 一下?
答案是------不用,Cesium 早就替你吞了 。打开未压缩产物能看到这么一句(CesiumWidget 构造里,L255523):
js
canvas.oncontextmenu = function() {
return false;
};
oncontextmenu 返回 false,等价于 preventDefault()。也就是说只要你的事件挂在 canvas 上,浏览器原生那张菜单永远不会出来 。这一行也顺手解释了后面一个容易迷惑的现象:你把 demo 的开关关掉、摘掉 RIGHT_CLICK 监听后再右键,页面什么都不弹------不是你的移除代码失灵,是原生菜单早就被这行按死了。
但别高兴太早:这份"免费"只覆盖 canvas。一旦你把菜单挂到别处、或者自己在外层 div 上监听 contextmenu,原生菜单该弹还是弹,那行代码管不着你。这个边界先记下,第五节细说。
一、先拆穿:右键菜单不是新物种,是"一个 div + 一个 RIGHT_CLICK"
和前面七篇的剧本一模一样,第一件事还是拆穿------Cesium 的公开 API 里根本没有 ContextMenu / PopupMenu 这种控件类 ,Viewer 的构造选项里也翻不出任何和右键菜单有关的一项。你在各种 GIS 站点上看到的那个右键菜单,全是各家自己撸的 DOM。

那它到底是什么?拆成两半看,一半是 DOM,一半是 Cesium。
DOM 这一半 ,就是一个普通的绝对定位 div 里套一个 ul:
html
<div ref="menuEl" v-show="menuVisible" class="cesium-context-menu"
:style="{ left: menuLeft + 'px', top: menuTop + 'px' }">
<ul class="menu-list">
<li v-if="currentInfo" class="menu-item menu-info">{{ currentInfo }}</li>
<li v-for="item in menus" :key="item.label" class="menu-item">
<a href="javascript:void(0)" @click.prevent="runMenu(item)">{{ item.label }}</a>
</li>
</ul>
</div>
菜单项不是写死的,而是一份数据驱动的数组 ------每条带一个 label 和一个 callback:
ts
interface ContextMenuEntry {
label: string
callback: (data: ContextMenuData) => void
}
demo 默认给五条:飞行到此位置、在此添加标记点、清除全部标记、飞到默认位置、取消飞行。这个结构值得你直接抄走------把菜单项抽成数组、回调统一收一个 data 参数,后面要按不同图层给不同菜单,只要换数组,不动 DOM。
Cesium 这一半 ,只有一个东西:ScreenSpaceEventHandler 的 RIGHT_CLICK。
ts
handler = new ScreenSpaceEventHandler(viewer.canvas)
handler.setInputAction(onRightClick, ScreenSpaceEventType.RIGHT_CLICK)
handler.setInputAction(onLeftClick, ScreenSpaceEventType.LEFT_CLICK)
注意 new ScreenSpaceEventHandler(viewer.canvas)------挂在 canvas 上,不是 document。这个选择决定了后面几乎所有坐标算术的口径,第二节展开。
所以整件事的本质是:Cesium 只负责"告诉你鼠标在 canvas 的哪个像素上右键了",剩下的弹什么、弹在哪、点了干什么,全是 DOM 与几何活 。这和 #69 气泡、"#72 提示浮层是同一道题的又一种解法------都是"屏幕像素 ↔ 地球坐标"那道翻译,只不过这次翻完之后,你要拿结果去指挥地球(飞行、打点、清标记),而不只是显示一行字。
二、右键这一下怎么接住:只给 position 的那个 movement
这一节讲"怎么接住右键",有三个细节和 #72 的 MOUSE_MOVE 都不一样,混着用必错。
第一个:RIGHT_CLICK 到底什么时候触发? 不是按下,是抬起 。翻源码能看到,RIGHT_CLICK 是在 cancelMouseEvent 里被触发的(L238322 定义、L238385 调用),而且和 RIGHT_UP 成对发:
js
if (screenSpaceEventHandler._buttonDown[MouseButton.RIGHT]) {
cancelMouseEvent(
screenSpaceEventHandler,
ScreenSpaceEventType_default.RIGHT_UP,
ScreenSpaceEventType_default.RIGHT_CLICK, // ← 这里
event
);
screenSpaceEventHandler._buttonDown[MouseButton.RIGHT] = false;
}
枚举值我用探针把整张表打出来了:RIGHT_DOWN = 5、RIGHT_UP = 6、RIGHT_CLICK = 7(对照 LEFT_CLICK = 2、MOUSE_MOVE = 15)。顺带一提,这张表的编号不是连续的------4、8、9、13、14 都是空的(历史上有过又被移除的事件),别按序号去推。
第二个(最容易写错):movement 的形状。 源码里 click 类事件用的是这么一个对象(L238319-238321):
js
var mouseClickEvent = {
position: new Cartesian2_default()
};
只有 position,没有 startPosition / endPosition。 这条和 #72 提示浮层完全相反------MOUSE_MOVE 给的是 { startPosition, endPosition },你得取 endPosition;而 RIGHT_CLICK / LEFT_CLICK 给的是 { position }。demo 的类型签名写得清清楚楚:
ts
function onRightClick(movement: { position: Cartesian2 }): void {
把 RIGHT_CLICK 的回调写成 movement.endPosition,拿到的是 undefined,菜单会静默不弹------不报错,最难查的那种。
第三个:position 是哪个坐标系? 是相对 canvas 的 CSS 像素 。同一份源码里的 getPosition2(L238121-238132)给了答案:
js
function getPosition2(screenSpaceEventHandler, event, result) {
const element = screenSpaceEventHandler._element;
if (element === document) {
result.x = event.clientX;
result.y = event.clientY;
return result;
}
const rect = element.getBoundingClientRect();
result.x = event.clientX - rect.left;
result.y = event.clientY - rect.top;
return result;
}
挂 document 就给客户端坐标,挂具体元素(这里是 canvas)就减掉 rect.left/top。这正好是 #72 那条结论的同源印证 ------#72 的 MOUSE_MOVE 走的是同一个 getPosition2,所以两篇的 left/top 算术能直接对上。"挂 canvas 还是挂 document"这个选择,比看起来重要得多。
三、菜单顶上那行坐标:两套坐标、三条拾取路径
demo 弹出的菜单,最上面有一行灰蓝色的字:经度 118.800000° / 纬度 32.050000° / 高 0.0 m。这行字看着平平无奇,背后却藏着两套坐标 和三条拾取路径。

先看数据结构------ContextMenuData 有四个字段,两两成对:
ts
interface ContextMenuData {
windowPosition?: { x: number; y: number } // 屏幕像素
position?: Cartesian3 // ① 深度缓冲反算的世界坐标
wgs84Position?: { lng: number; lat: number; alt: number }
surfacePosition?: Cartesian3 // ② 地表坐标
wgs84SurfacePosition?: { lng: number; lat: number; alt: number }
}
第一条路径(position)------读深度缓冲,能捡到"你看见的那个东西":
ts
if (scene.pickPositionSupported) {
data.position = scene.pickPosition(movement.position)
}
if (data.position) {
const c = Ellipsoid.WGS84.cartesianToCartographic(data.position)
if (c) {
data.wgs84Position = {
lng: CesiumMath.toDegrees(c.longitude),
lat: CesiumMath.toDegrees(c.latitude),
alt: c.height
}
}
}
第二条路径(surfacePosition) ------只打地面,而且按场景模式分岔:
ts
let surfacePosition: Cartesian3 | undefined
if (scene.mode === SceneMode.SCENE3D) {
const ray = scene.camera.getPickRay(movement.position)
surfacePosition = ray ? scene.globe.pick(ray, scene) ?? undefined : undefined
} else {
surfacePosition = scene.camera.pickEllipsoid(movement.position, Ellipsoid.WGS84) ?? undefined
}
三条路径的分工,和 #72 里那三条完全一样,只是这次被拆成了两套坐标来用:
| 路径 | 落在哪 | 认不认 3D 物体 | 高度 |
|---|---|---|---|
scene.pickPosition |
深度缓冲反算 | 认(楼顶、树上都能捡) | 真实渲染高度 |
globe.pick(3D) |
地球表面 | 不认 | 有地形给地形高,无则 h≈0 |
camera.pickEllipsoid(2D/Columbus) |
纯椭球面 | 不认 | 永远 h=0 |
SceneMode 的取值也一并实测了:SCENE3D = 3、SCENE2D = 2、COLUMBUS_VIEW = 1------所以那个 if 只在 3D 成立,2D 与哥伦布视图都走 pickEllipsoid。
那为什么要两套? 关键在取值顺序。显示和所有动作都这么写:
ts
const shown = data.wgs84SurfacePosition ?? data.wgs84Position
地表优先,深度兜底。 这个顺序是有道理的:pickPosition 会捡到你"看见的最前面那个东西",场景里若有一栋白模,你在楼旁边的地面右键,pickPosition 可能给你楼顶;拿楼顶的坐标去"在此添加标记点",标记就浮在半空了。所以默认优先地表更稳妥。
但反过来想------如果你要的就是"在楼顶放点"呢? 那就该把顺序倒过来,先用 wgs84Position。这里没有对错,只有"你的菜单打算让标记落在哪一层"。demo 选了地表,因为它默认场景里没有 3D 物体,两套在裸椭球上其实是同一个值。
实测基线。 探针 _probe75.cjs 跑出的裸椭球基线,和 #72、#73 完全一致:相机 (118.8, 32.05) 上方 260 km 垂直俯视 → rayEllipsoid 命中 start = 260000.000 m,交点 lng 118.800000 / lat 32.050000 / h 0.000000 ;视线朝太空则 rayEllipsoid = undefined。所以没加载地形时,菜单顶上那个"高"永远是 0.0 m------和 #72 浮层报 0.000 是同一个原因,不是代码错。
顺手还做了一个交叉验证:Cartesian3.fromDegrees(lon, lat, h) 是否等于"地表点 + h × 大地法线"?实测误差 0.000e+0 m 。这说明 Cesium 的"高度"是沿椭球法线量的,不是沿地心径向------和 #63 那条"抬高 N 米要沿法线"是同一件事。
这里插一句我自己踩的探针坑 ,挺有代表性:第一版探针里我把法线取在了相机位置 上(E.geodeticSurfaceNormal(camPos)),结果纬度算出来是 32.049722,和 #72/#73 的 32.050000 基线差了 0.000278°(约 31 m)。查下来是我自己的错------geodeticSurfaceNormal 是把坐标按 1/radii² 缩放后归一化,必须取在地表点上 ;在 260 km 高处取,加的那一段高度会改变归一化方向。交叉验证差点让我把一个假数字写进正文------这正是"同一组数字至少用两条路径算"这条纪律的价值。
四、把菜单钉在鼠标那一点:定位算术、z-index 和两种关法
接下来说"菜单弹在哪"。这里有一条和 #72 提示浮层恰好相反的选择,是本篇最反直觉的地方。
定位算术:直接取用,不做任何偏移。
ts
menuLeft.value = movement.position.x
menuTop.value = movement.position.y
menuVisible.value = true
对比一下 #72 提示浮层的 left = x + 12、top = y − 15(光标右下方,避开挡视线)------右键菜单一个像素都不让 。原因很直白:提示浮层是"跟着光标走的小提示",怕挡住你正在看的东西;右键菜单是你主动唤出来要点的,它必须老老实实长在你右键的那一点上,偏一点手感就废了。
为什么能直接用 movement.position? 因为两个坐标系刚好重合:
movement.position是相对 canvas 的 CSS 像素 (第二节的getPosition2);- 菜单 div 是
position: absolute,挂在position: relative的.widget-shell里; .cesium-container用width: 100%; height: 100%铺满.widget-shell。
于是 canvas 的左上角 = 定位祖先的左上角,position.x/y 可以直接当 left/top 用。但这个前提是脆的 :只要 canvas 不铺满定位容器、或者你把菜单挂到了 body 上,就必须补上 canvas 的偏移(rect.left / rect.top),否则菜单会整体错位。

z-index:菜单必须压过面板。 demo 里三层叠着:菜单 z-index: 20、控制面板 10、状态提示 9。菜单在最上面------这不是随手写的,你在面板上方右键时,菜单得盖住面板,否则会被压在下面点不到。
最反直觉的一条:菜单绝不能加 pointer-events: none。 还记得 #72 反复强调的那句吗------"浮层必须 pointer-events: none,否则吞掉 MOUSE_MOVE"。到这儿整个反过来 :菜单是要被点击 的,菜单项上的 @click 需要真实命中;你要是顺手把 #72 的习惯搬过来,菜单就变成一块看得见点不着的玻璃。cursor: pointer 那行 CSS 也是这个意思------它在提醒你"这里能点"。
一句话记住这个分野:纯展示的浮层(#72 提示、#73 状态条)关掉 pointer-events;要交互的浮层(#69 气泡、本篇菜单)必须留着。
两种关菜单的方式,demo 都做了:
ts
function onLeftClick(): void {
menuVisible.value = false // ① 左键点别处,收起
}
function runMenu(item: ContextMenuEntry): void {
item.callback(currentData.value ?? {}) // ② 点了菜单项,先执行再收起
menuVisible.value = false
}
缺了 ①,菜单会一直挂着不走;缺了 ②,点完菜单项它还在原地。两个都要有 ,这也是为什么要同时监听 LEFT_CLICK------别忘了在开关里把这两个 action 一起摘掉(第五节)。
最后一条省心的地方:菜单用 v-show 控制显隐,但它不量自己的尺寸 (宽高交给 CSS 的 min-width: 150px 和内容撑开),所以不踩 #69 气泡那个"首帧 display:none 量得全零"的坑 。定位只依赖 movement.position,和自身尺寸无关------这一点它和 #73 状态条是一伙的。
五、五个坑:原生菜单、打空、模式分支、贴边溢出、开关卸载
拆完机制,把边界补齐。
坑一:原生菜单只替你在 canvas 上挡了。 开头那行 canvas.oncontextmenu = () => false 是 CesiumWidget 给 canvas 加的。你把菜单挂到外层容器、或者自己在别处监听,原生菜单照弹不误,得自己 preventDefault。反过来也别"过度防御"------在 canvas 上再写一遍 preventDefault 纯属多余。
坑二:打空必须判 undefined。 三条拾取路径打空时都返回 undefined(朝太空时 rayEllipsoid 直接是 undefined,探针已实测)。demo 的处理分两层:显示层给占位文案------
ts
if (shown) {
currentInfo.value = `经度 ${shown.lng.toFixed(6)}° / 纬度 ${shown.lat.toFixed(6)}° / 高 ${shown.alt.toFixed(1)} m`
} else {
currentInfo.value = '当前位置未拾取到地面点'
}
动作层在每个回调里各判一次:
ts
const pos = data.wgs84SurfacePosition ?? data.wgs84Position
if (!viewer || viewer.isDestroyed() || !pos) return
少判那一下,打空时点"飞行到此位置"就会拿 undefined 去 fromDegrees,轻则抛错、重则飞到 NaN。注意菜单照样会弹出来------demo 特意保留了菜单,只是坐标那行变成提示文字。这个设计比"打空就不弹"体验好,读者能明确知道"这里没拾到"。
坑三:2D / 哥伦布视图要走另一条路。 scene.mode === SceneMode.SCENE3D 才用 globe.pick,否则 pickEllipsoid。在 2D 下 getPickRay 那套不适用,硬走会拿到错位置。分岔那三行看着啰嗦,但缺了它在 2D 模式下右键就废了。
坑四:菜单贴边溢出(demo 没做)。 left = x、top = y 是不设上限 的。你在画布右下角右键,菜单会有一半跑到容器外面被 overflow: hidden 裁掉。demo 没处理(容器小、跑不出大问题),生产里必须补:量一下菜单的 offsetWidth/offsetHeight,与容器宽高比一比,超了就往回翻------
ts
// 生产补法的思路(demo 未实现)
const w = menuEl.value?.offsetWidth ?? 0
const h = menuEl.value?.offsetHeight ?? 0
if (x + w > containerWidth) menuLeft.value = x - w
if (y + h > containerHeight) menuTop.value = y - h
顺带一提:这一量尺寸,就会踩回 #69 那个 v-show 首帧测零的坑 ------量之前得先让它显示出来(nextTick 之后再量)。本篇菜单之所以能躲开,恰恰是因为它不量;你一旦为了防溢出去量,就得把 #69 的解法一起搬过来。控件这几篇的坑是连环的。
坑五:开关与卸载,两个 action 要一起处理。
ts
function onEnableChange(value: boolean): void {
if (!handler || !viewer || viewer.isDestroyed()) return
if (value) {
handler.setInputAction(onRightClick, ScreenSpaceEventType.RIGHT_CLICK)
handler.setInputAction(onLeftClick, ScreenSpaceEventType.LEFT_CLICK)
} else {
handler.removeInputAction(ScreenSpaceEventType.RIGHT_CLICK)
handler.removeInputAction(ScreenSpaceEventType.LEFT_CLICK)
}
}
只摘 RIGHT_CLICK 会留下一个"点左键收起一个不存在的菜单"的孤儿监听;真正的销毁在 onBeforeUnmount 里走 handler.destroy()(并先判 !handler.isDestroyed()),同时把 points 里的 entity 从 viewer.entities 摘干净。两个监听一起摘、entity 一起清,少一样就泄漏------这条和 #73"MOUSE_MOVE + camera.changed 两个都要摘"是同一个教训。
Bonus:那个"+1500 米"其实常年没生效。 看"飞行到此位置"的写法:
ts
destination: Cartesian3.fromDegrees(pos.lng, pos.lat, Math.max(pos.alt + 1500, 2000))
看起来是"在拾取点上方 1500 米停住,最低 2000 米"。但探针把这段纯算术跑一遍(地表高度取 -500 到 5000 米一整排),结果是:
| 地表高度 | 目标高度 | 谁生效 |
|---|---|---|
| −500 m | 2000 m | 下限截断 |
| 0 m | 2000 m | 下限截断 |
| 500 m | 2000 m | 下限截断(刚好持平) |
| 501 m | 2001 m | +1500 生效 |
| 1000 m | 2500 m | +1500 生效 |
| 5000 m | 6500 m | +1500 生效 |
地表高度不超过 500 米时,2000 的下限恒生效,那个"+1500"形同虚设。 而我们绝大多数右键都落在海拔几百米以内的地方(没地形时更是恒为 0),所以实际飞过去永远是 2000 米。这不是 bug(下限本来就是保险),但你若以为"每次都会抬高 1500 米",那就被自己写的表达式骗了------想让它在低海拔也有区分度,得把下限调低或改成纯增量。
六、写在最后
把这一篇收一下,右键菜单的真身可以压成三句话:先接住右键 (ScreenSpaceEventHandler 挂 canvas、RIGHT_CLICK = 7 在抬起 时触发、回调只给 { position } 不给 endPosition,别忘了原生菜单已被 canvas.oncontextmenu = () => false 挡掉)、再翻两套坐标 (深度缓冲的 position 认 3D 物体、地表的 surfacePosition 不认,3D 走 globe.pick、2D/Columbus 走 pickEllipsoid,取值一律"地表优先、深度兜底")、最后钉住菜单 (left/top 直接取 movement.position 不偏移、z-index 压过面板、绝不能 pointer-events: none、左键与点项两种关法都要有)。
它和 #72 提示浮层是这八位里最像的一对------都锚在鼠标那一点、都靠 getPosition2 的 canvas 相对像素------却在三件事上全部反着来 :一个读 endPosition、一个读 position;一个要 pointer-events: none、一个绝不能加;一个偏 12/15 像素躲视线、一个分毫不差地钉住。同一道题("屏幕像素 ↔ 地球坐标"),换个交互意图,答案就整个翻面。
控件家族这条线写到今天算是凑齐了八位常客:罗盘读朝向(#68)、气泡追地表点(#69)、图表焊地球(#70)、缩放砸焦点(#71)、浮层跟光标(#72)、状态条钉角落(#73)、鹰眼分身(#74)、右键菜单承操作(#75)。八位看似各管一摊,底层全是同一道题的变体:如何在"屏幕像素"和"地球坐标"之间反复横跳,且每次都不许漂、不许反、不许在边界翻车。前七位把地球的信息搬到屏幕上,最后这位补上了反向的那一步------把屏幕上的一个动作,翻译成地球上的一个结果。
这条线写到这儿,控件家族算是收官了。 下一段路要换条线走------CASE_LIST 里的下一站是空间分析-比例符号面:不再是"屏幕上摆个控件",而是要让数据的大小自己决定符号的大小,把"量"画进地图里。换个方向,咱们下回再拆。
如果你也在 Cesium 上撸过右键菜单,欢迎留言说说:你有没有顺手把 pointer-events: none 加上去、然后对着一张点不动的菜单怀疑人生?

🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/widget-context-menu/ 目录下,核心就一个 WidgetContextMenuDemo.vue。搬去自己项目时,盯住四件事:① RIGHT_CLICK 的回调收的是 { position },不是 endPosition (写错菜单静默不弹);② 菜单是交互元素,绝不能加 pointer-events: none (和 #72 提示浮层正好相反),z-index 要压过面板;③ 两套坐标的取值顺序是"地表优先、深度兜底" (wgs84SurfacePosition ?? wgs84Position),想在楼顶打点就把顺序倒过来;④ 三个拾取结果都要判 undefined ,且开关/卸载时 RIGHT_CLICK 与 LEFT_CLICK 两个 action 一起摘、handler.destroy() 前先判 isDestroyed()。
Cesium酱の百宝箱 · 第 75 篇
一点右键落菜单,半屏像素换山河
2026 年的秋夜,与你共探「屏幕这一下,怎么让地球动起来」