上一次我们拆了缩放控制器,结尾我埋了个话头:控件家族的老熟人还没走完,下一个登场的是提示浮层控件——鼠标悬停时,怎样在地球表面某个对象上方浮出一行说明文字,而它和气泡(#69)的"点哪弹哪"又差着一层"悬停"的微妙。今天就把这层"悬停"拆开。
顺带把进度摆一下:地图控件这条线一路走来,罗盘管"朝哪翻"、气泡管"点哪咬哪"、图表管"把图焊哪"、缩放管"往哪推",现在轮到提示浮层管"鼠标走到哪、字就报到哪"。它们四个全是纯 DOM 浮层,不是 Cesium 的内部对象——这一点到这篇会更明显。
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
你大概率在无数 GIS 网站见过它:把鼠标移到地球上,光标旁边立刻浮出一行"经度 118.8° / 纬度 32.05°"。看起来天经地义,仿佛 Cesium 自带一个Tooltip类,你只要viewer.tooltip.show()就完事。
但和前面几篇一样,第一件事还是先拆穿:Cesium 的公开 API 里,根本没有 Tooltip / TooltipWidget 这种东西。
屏幕上那行字,本质是一个普通的<div>,加上一段"监听鼠标移动 → 把屏幕坐标翻译成经纬度 → 把 div 摆到光标旁边"的逻辑。Cesium 从头到尾只负责中间那一步——"屏幕像素 → 地球坐标"的翻译。其余全是你的 DOM 手艺。
一、先拆穿:提示浮层不是 Cesium 画的,是"鼠标 + 一个 div"
先看 demo 里那行浮层到底长什么样。它就是一个挂在地图容器里的<div>:
<divref="tooltipEl"v-show="tooltipVisible"class="cesium-tool-tip":style="{ left: tooltipLeft + 'px', top: tooltipTop + 'px' }">{{ tooltipContent }}</div>cesium-tool-tip的样式也很朴素:position: absolute、pointer-events: none、深灰半透明底、一行白字。没有任何 Cesium 特有的标签——它就是网页里最普通的绝对定位浮层。
关键在它怎么出现、怎么移动。气泡弹窗(#69)是"点一下 → 弹出一个钉死在地表点的浮层",靠postRender每帧把地理锚点重投影成屏幕坐标。而提示浮层是"鼠标移到哪 → 浮层跟到哪",它连postRender都不需要——因为鼠标移动事件本身就直接给你屏幕坐标,浮层用的是同一套坐标系,根本不用绕回地球再绕回来。
这就引出它和气泡最重要的血缘差异:气泡锚定的是"地球上的点"(点不动,地球转了它也得跟着转,所以要 postRender);提示浮层锚定的是"鼠标光标"(光标在屏幕里动,坐标天然是屏幕坐标,地球转不转都无所谓)。一个是追地球、一个是追光标,看似都是浮层,底层那道坐标题的解法却不一样。
所以"提示浮层控件"的真身可以压成一句话:一个 DOM div + 一个 MOUSE_MOVE 监听 + 一道"屏幕坐标 → 经纬度"的翻译 + 把 div 摆到光标旁边。Cesium 只卖中间那道翻译,浮层和监听都是你自己搭的。
顺带把这个 demo 的两个开关讲清楚,因为它们正好对应"浮层要不要动"的两种层级。启用提示开关切的是enabled——它为真时onEnableChange才把MOUSE_MOVE监听挂上canvas,为假时直接removeInputAction摘掉监听并隐藏浮层:这是"彻底不干活"。而跟随鼠标开关切的是followMouse——它只是让onMouseMove一进来就return并隐藏浮层,监听还在,只是不更新内容:这是"挂着但装死"。两种关法体验上都是"浮层没了",底层的资源占用和事件链路却不一样,生产里你常要的是第一种(彻底摘监听,省得空转)。
还有个细节:demo 允许在输入框填customText,填了之后浮层不再只报坐标,而是显示你写的文字、再在后面自动追加(经度, 纬度)。这说明提示浮层的文本完全由你定义——它只是个 div,你往里塞什么它都照显示,坐标只是最经典的那个用例。
把提示浮层和气泡(#69)摆在一起对照,差异会更刺眼:
| 维度 | 气泡弹窗(#69) | 提示浮层(#72) |
|---|---|---|
| 锚定对象 | 固定的地球表面点 | 鼠标光标 |
| 需要 postRender | 是(每帧重投影锚点) | 否(坐标直接来自鼠标) |
| 定位算术 | top = y - h - 14(锚点上方) | left = x + 12, top = y - 15(光标右下方) |
| 触发方式 | 点击 | 鼠标移动 |
| 典型坑 | v-show 首帧测零错位 | 浮层吞掉鼠标事件 |
一句话:气泡是"点了才弹、钉死不动",提示浮层是"移了就报、跟着光标"。它们共用"div + 屏幕→地球翻译"的内核,却在锚定策略和随之而来的坑上分道扬镳。
顺带说一句"为什么 Cesium 不内置"。Cesium 的定位是"渲染引擎 + 一堆地理原语"——它给你球、相机、图元、拾取这些底层能力,但 UI 层(按钮、面板、浮层)从来不是它的活。浮层本质是 HTML/CSS 的事:在哪显示、什么样式、跟不跟鼠标,全是浏览器 DOM 的管辖范围。Cesium 即便真给你一个Tooltip类,最后也得往 DOM 塞文字、调定位——和你自己写 div 没两样,还多一层 API 约束。所以"没有 Tooltip 类"不是疏漏,是边界划得清楚:翻译坐标归我,怎么摆字归你。这一条对前面所有控件都成立,气泡、图表、缩放、罗盘,无一例外都是"引擎给坐标、DOM 负责长相"。
二、MOUSE_MOVE 给你的到底是什么:CSS 像素的 endPosition
要摆浮层,先得搞清楚"鼠标在哪"这件事 Cesium 是怎么告诉你的。
ScreenSpaceEventHandler监听MOUSE_MOVE时,回调拿到的movement对象里有个endPosition,它就是鼠标当前的位置。但它是相对谁的坐标?是相对整个浏览器窗口,还是相对那块 canvas?
读ScreenSpaceEventHandler.js的getPosition(L11 起)能确认:当事件处理器挂在某个具体元素(这里是viewer.canvas)上时,它做的是
constrect=element.getBoundingClientRect();result.x=event.clientX-rect.left;result.y=event.clientY-rect.top;也就是相对 canvas 左上角的偏移,而且单位是 CSS 像素。这一点和前面比例尺(#66)、气泡(#69)完全同源——endPosition用的是canvas.clientWidth/clientHeight那一套 CSS 像素,Retina 屏默认安全,不用自己乘devicePixelRatio。
为什么强调"相对 canvas 左上角"?因为如果你的地图不是铺满整个窗口(旁边有侧边栏、顶部有标题栏),用clientX/clientY(相对窗口)去摆浮层就会整体偏移一个侧边栏宽度。demo 把handler直接挂在viewer.canvas上,拿到的endPosition天然是"画布内部坐标",浮层又是绝对定位于画布容器,两者坐标系对齐,才摆得准。这点和 #66 卷帘控件踩过的"handler 挂错元素导致坐标漂移"是同一类坑,只是这里恰恰挂对了。
顺带记住:endPosition是 CSS 像素、是相对画布的、是屏幕空间坐标。它和后面要翻译出的"地球坐标"是两套完全不同的东西,整篇控制浮层的难,都难在把这两套别搞混。
这里还能顺手点破一个源码细节:getPosition还有个element === document的分支——如果你的事件处理器不是挂在 canvas、而是挂在document上,它就不会减rect.left/top,直接把clientX/clientY(相对整个浏览器窗口)交给你。所以"endPosition 是相对 canvas 还是相对窗口"这件事,完全取决于你把ScreenSpaceEventHandler的第一参数传成了谁。demo 传的是viewer.canvas,所以稳稳拿到画布内部坐标;你若改成document,就得自己再减一次 canvas 的偏移,否则浮层会整体错位一个侧边栏宽度。这点和 #66 卷帘控件踩过的"handler 挂错元素导致坐标漂移"是同一类坑,只是这里恰恰挂对了。
三、从屏幕到地球:三条拾取路径各管一摊
拿到endPosition后,真正的核心是把它翻成经纬度。Cesium 给了你三条路,而且它们天差地别——这是整篇最该实测、也最容易写错的地方。
demo 主路径(widget-tooltip)是这样写的:
if(scene.mode===SceneMode.SCENE3D){constray=scene.camera.getPickRay(movement.endPosition);surfacePosition=ray?scene.globe.pick(ray,scene)??undefined:undefined;}else{surfacePosition=scene.camera.pickEllipsoid(movement.endPosition,Ellipsoid.WGS84)??undefined;}三条路径逐一拆:
路径 A:getPickRay+globe.pick(3D 最常用)getPickRay(endPosition)从屏幕点发一条射线穿进场景,globe.pick(ray, scene)求这条射线和地球表面的交点。读Globe.js的Globe.prototype.pick(L823)能确认它最终落到pickWorldCoordinates——射线打的是地球表面:地形瓦片加载了就返地形真实高度,没加载就落在椭球面(高度≈0);但它只认地球,认不得 3D Tiles、Primitive 这些"贴在地球上"的物体。
路径 B:scene.pickPosition(深度拾取,最全能)mouse-tip那个进阶 demo 把这条路放在最前面:
if(scene.pickPositionSupported){try{position=scene.pickPosition(event.endPosition);}catch{position=undefined;}}读Picking.js的pickPosition(L802 →pickPositionWorldCoordinatesL691)能确认:它依赖scene.context.depthTexture(L702,没有深度纹理就抛"不支持",也就是pickPositionSupported === false),从深度缓冲里反算世界坐标。这意味着它能"捡"到任何被渲染出来的东西上的真实坐标——3D Tiles 的楼顶、Primitive 的模型、以及加载了地形后的真实海拔。一句话:pickPosition带真实高度,且能越过地球去抓 3D 物体,是三者里信息最全的。
路径 C:camera.pickEllipsoid(2D/Columbus 用,纯几何)pickEllipsoid(endPosition, Ellipsoid.WGS84)直接求射线和参考椭球的交点。它看不见地形、看不见任何物体,只是一道纯几何相交——射线打中椭球就返一个点(高度永远 0),打不中(比如看向太空)就返undefined。它最大的优点是"稳":不依赖深度纹理、不依赖地形瓦片,2D 和 Columbus 视图下也能用,平滑好算。
把三条路径摆成一张表,差异一眼就清:
| 路径 | 返回高度 | 认 3D 物体 | 依赖 | 适用 |
|---|---|---|---|---|
| getPickRay + globe.pick | 地形加载则有、否则 0 | 否 | 地球表面 | 3D 下最省心,只想知道地表经纬度 |
| scene.pickPosition | 真实海拔 | 是 | 深度纹理(pickPositionSupported) | 要高度、要抓 3D Tiles/模型 |
| camera.pickEllipsoid | 永远 0 | 否 | 无(纯几何) | 2D/Columbus,或不想依赖任何纹理 |
再往深一层看pickPosition为什么"全能":pickPositionWorldCoordinates(Picking.js L691)先用pickDepth.getDepth从深度缓冲读出这一像素的深度值(L753),再判断0 < depth < 1(L761,超出范围说明没命中任何东西),最后用这个深度把屏幕坐标反算回世界坐标(L782)。也就是说,它读的是"这一像素实际画了多深",自然能还原出真实海拔和任何被画出来的物体——这正是它和globe.pick的本质区别:后者只问"射线打没打到地球",前者问"屏幕这块像素底下到底画了什么、离相机多远"。代价是它必须有一张深度纹理,而深度纹理在部分老设备、或你主动关掉context深度相关选项的环境里可能缺失,那时pickPositionSupported就是 false,必须回退。
如果你担心pickPosition在不支持环境翻车,最稳的写法是mouse-tip那种三级回退:先trypickPosition,catch 或undefined再globe.pick,再undefined再pickEllipsoid。这样无论在 3D 带地形、3D 裸球、还是 2D,都能至少拿到一个经纬度。
三者的关系用一句话点破:在裸椭球、没加载地形、也没 3D 物体的情况下,三者给你的经纬度一致、高度都是 0;可一旦叠了地形或 3D Tiles,pickPosition透过深度缓冲返出带真实海拔的坐标,globe.pick返出地形面(前提是瓦片已加载),而pickEllipsoid依旧永远是 0——它根本看不见地形。
我用探针实测过这组几何:相机在 (118.8°, 32.05°) 上方 260 km 垂直俯视,裸椭球上rayEllipsoid的近交点start = 260.0 km(正好等于相机高度),交点算回经纬度118.8000 / 32.0500、高度0.000——和期望的地表点分毫不差;而把射线改成朝太空(方向+z),rayEllipsoid直接返undefined。这正好解释为什么你的浮层在"看向太空"时会失联:不是代码坏了,是几何上那道射线确实没打到地球。
也正是这个裸椭球基线,藏着另一个新手最容易误判的现象:很多 demo 明明飞到了高山上空,浮层报的高度却始终是0.000。原因就在这——只要没加载地形,globe.pick落点永远是椭球面(h≈0),pickEllipsoid更不用说了。你以为"高度没算对",其实它压根没去量山,只是老老实实报了椭球面。要看到真实海拔,两条路:要么globe.pick之前先sampleTerrainMostDetailed把地形瓦片加载出来,要么直接用pickPosition走深度缓冲(前提是pickPositionSupported为真)。这也是为什么我反复说——"高度对不对"不取决于你的浮层代码,而取决于你选了哪条拾取路径、以及那条路径此刻有没有真实地形可量。
至于选哪条,demo 给了一个务实的答案:3D 用globe.pick(够用、最省心),2D/Columbus 用pickEllipsoid(别无选择),把"带真实高度、能抓 3D 物体"的pickPosition留给那些真的需要海拔和物体拾取的进阶场景——mouse-tip就是这么做的,它把pickPosition放第一优先级,拾取失败再依次回退globe.pick、pickEllipsoid。
四、把字摆到光标旁边:定位算术与"pointer-events:none"的命门
坐标翻出来了,最后一步是把 div 摆对位置。气泡是"钉在地表点上方"(所以top = y - h - 14,往锚点上方抬、留个尾尖),而提示浮层是"跟在光标旁边",方向正好反过来:
functionupdateWindowCoord(pos){tooltipLeft.value=pos.x+12;// 光标右侧 12pxtooltipTop.value=pos.y-15;// 光标上方 15px}left = x + 12、top = y - 15,让浮层落在光标的右下方,既不被光标本身挡住,也不压住你想看的那片地。CSS 里还用::before画了一个朝左的小三角(border-right实心三角),尖角正对光标,视觉上像"从光标冒出来一句话"。这套摆法和气泡(上方尾尖)是镜像的两套审美,别抄串了。
但这里藏着一个比摆位更要命的坑,很多新手第一次写浮层都会栽:浮层必须pointer-events: none。
想想看,浮层就摆在光标右下方,光标动一下、浮层跟着动——如果浮层能接收鼠标事件,那它每时每刻都"盖"在光标前方一点点,会把本该传给 canvas 的MOUSE_MOVE吞掉,于是浮层一出现、鼠标事件就时断时续,甚至自己触发自己的 hover,陷入"动了才显示、显示了又不动"的死循环。pointer-events: none这一句,就是让浮层"看得见摸不着",鼠标事件直接穿透它落到下面的 canvas 上,监听链才不断。
我特意把这点拎出来说,是因为它不是"优化项",而是"不开就废"的开关。cesium-tool-tip的样式里那行pointer-events: none,分量比它的背景色、圆角、字体加起来都重。
再补两个摆位相关的细节,免得你抄代码时漏掉。cesium-tool-tip还设了z-index: 30——它必须压在地图 canvas(默认层级)和左侧控制面板(z-index 20)之上,否则浮层会被面板盖住、或半透明叠在地球上糊成一团。另一个细节是那个朝左的小三角::before:它用border-right: 10px solid rgba(0,0,0,0.72)画一个实心向右的三角、再用left: -10px推到浮层左边缘之外、top: 5px让它落在文字行高的中间——视觉上像"从光标方向戳过来一句话"。这套右下方 + 左三角的摆法不是随便定的:浮层在光标右下,三角朝左指光标,读者的视线从字顺着三角回到光标、再落到你想看的那片地,动线最短;要是把浮层摆在光标左上或正上,三角方向和阅读动线就会打架。
为什么不在光标正中、而是偏到右下方?因为浮层若压在光标正下方那片地,恰恰挡住你想看的坐标点——你移动鼠标是为了"看那里是什么",浮层却盖住那里,本末倒置。右下方是"既报得出、又不挡视线"的折中,这也是大多数 GIS 网站的默认摆法,不是这个 demo 拍脑袋定的。
还有个容易被忽略的使用场景:浮层靠MOUSE_MOVE驱动,而MOUSE_MOVE是鼠标悬停事件——在触屏设备上根本没有"悬停"这回事。手机上你手指拖地图时触发的是touchmove而不是mousemove,原样搬这套逻辑到移动端,浮层要么不出现、要么卡在最后一次鼠标位置。要兼容触屏,得额外挂ScreenSpaceEventHandler的PINCH/touchmove或监听 pointer 事件,把"移动"的来源从"鼠标"扩成"任意指针"。这也是为什么我说提示浮层"锚定光标"——它锚的是"指针位置",而指针在触屏上是个比鼠标更宽泛、也更难伺候的概念。你若哪天要把这套浮层搬上平板或手机,第一件要改的就是这条事件源,别等到上线了才发现安卓上"浮层失灵"。
还有一个对比值得点破:提示浮层不需要量自身尺寸。气泡为了"居中"要getBoundingClientRect量宽高、于是踩了v-show首帧display:none测零的坑(#69 讲过);而提示浮层直接跟鼠标坐标定位、不居中,从根上就没碰那个坑。这也侧面说明:浮层的摆位策略决定了你会不会撞上哪一类坑,选对策略比事后打补丁重要。
五、三个常被忽略的坑
把前面几节串起来,有三个坑几乎每篇讲浮层都要单独点名,因为太容易想当然。
坑一:光标移出地球,globe.pick会返undefined,不处理就出 NaN。
射线没打到地球(看向太空、或移到地球边缘之外)时,globe.pick返回undefined。demo 两种处理风格:widget-tooltip 用else if (!content)显示"未拾取到地面点"占位;mouse-tip 用if (!position) tooltip.hide()直接藏掉。两种都行,但绝不能拿undefined去cartesianToCartographic——那会抛错或给 NaN,浮层瞬间显示一串NaN°。记住 #66 比例尺那篇的教训:凡是"打空"的分支都要显式处理,要么占位、要么隐藏。
坑二:pickPosition不是永远能用,要先问pickPositionSupported。pickPosition依赖深度纹理,老设备、某些禁用深度缓冲的环境会返回false。如果你像 mouse-tip 那样把pickPosition当第一优先级,务必先if (scene.pickPositionSupported)判断、再try/catch兜底,然后回退globe.pick、pickEllipsoid。widget-tooltip 干脆没走pickPosition——它只需要地表经纬度、不需要高度和 3D 拾取,少一条路就少一份兼容负担,这也是一种务实。选哪条路,取决于你到底要不要那点海拔和物体信息。
坑三:提示浮层不需要postRender,但代价是"地球转了它不跟地表点走"。
这是和气泡最本质的取舍。气泡为了钉死地表点,挂了postRender每帧重投影;提示浮层因为锚定光标,只在MOUSE_MOVE时更新一次,地球自转或你拖动视角、光标没动,浮层就停在原地——它报的是"光标此刻屏幕位置对应的经纬度",不是"某个固定地标的经纬度"。这反而有个隐藏好处:开了requestRenderMode(按需渲染)时,静止画面不重绘,但浮层是 DOM、照样跟着鼠标更新,比气泡省心(气泡在那种模式下会被冻住,#69 讲过)。所以"要不要 postRender"不是优劣题,而是"你锚定的是光标还是地球点"这道题的答案。
把三个坑压成一句提醒:打空要显式处理、pickPosition先问支不支持、锚光标就不挂 postRender——这三条漏掉任何一条,浮层都会在你看不见的地方悄悄失灵。
还有个容易被混为一谈的点:enabled和followMouse两个开关在"浮层消失"这件事上效果一样,但enabled关掉时是真把MOUSE_MOVE监听摘了(removeInputAction),followMouse关掉时监听还在、只是onMouseMove一进来就 return——前者在"完全不用提示"时更省,后者在"临时不想看、但随时可能想看"时更跟手。这个区分在 #71 缩放按钮那篇也出现过(v-show只是切 display、元素始终在 DOM),控件家族里"显隐到底摘不摘监听"是个反复出现的设计题。
最后提醒一句:浮层文本里出现NaN°是新手最常见的翻车现场。根因几乎都是把undefined的拾取结果拿去cartesianToCartographic了——globe.pick打空、或pickPosition在不支持环境抛错、或pickEllipsoid看向太空,这三种情况都会给你undefined,务必在翻译前if (!position)挡一道。demo 里 widget-tooltip 用占位文字、mouse-tip 用直接隐藏,都是挡法,但"先判空"这一步谁都不能省。
六、写在最后
把这一篇收一下,提示浮层的真身可以压成三句话:先接住鼠标(MOUSE_MOVE 的endPosition是相对 canvas 的 CSS 像素,和气泡同源)、再翻成坐标(三条拾取路径各管一摊——globe.pick 打地表、pickPosition 带真实高度和 3D 物体、pickEllipsoid 纯椭球永远 h=0)、最后摆到光标旁(left=x+12, top=y-15右下方避开光标,且务必pointer-events: none让事件穿透)。它和气泡(#69)最像——都是 div 浮层、都靠"屏幕→地球"的翻译——却又因为一个锚光标、一个锚地表点,在 postRender、定位算术、踩坑清单上走上两条不同的路。
控件家族这条线现在凑齐了五位常客:罗盘读相机、气泡写浮层追地球坐标、图表焊图到地球坐标、缩放挪相机对准坐标、提示浮层跟光标报坐标——前三个站在地球一侧把信息贴上去或读出来,后两个站在相机一侧把镜头推过去或把坐标读出来。看似各管一摊,底层全是同一道题的变体:如何在"屏幕像素"和"地球坐标"之间反复横跳,且每次都不许漂、不许反、不许在边界翻车。
落到工程上,选哪条拾取路径其实有一句口诀:只想要地表经纬度、且跑在 3D,globe.pick最省心;要真实海拔或要抓 3D Tiles/模型,pickPosition是唯一的准;2D/Columbus 或不想依赖任何纹理,pickEllipsoid最稳。把这句口诀和"打空必判空、浮层必 pointer-events:none、endPosition 是相对 canvas 的 CSS 像素"这三句护身符一起记住,提示浮层基本就焊死了。它和气泡、图表、缩放、罗盘一样,看着是个小控件,背后全是你和"屏幕 ↔ 地球"那道翻译的拉锯——控件家族写到这,这条主轴你已经摸得差不多了。
把五位常客往深里看,其实是一根主轴的不同切面。这跟线还没走到头——下一个常客是位置信息栏控件:它不跟光标、而是常驻在角落,把当前相机的经纬度、视角、高度钉成一行常显的状态条,和提示浮层这种"鼠标走到哪报哪"刚好是浮层的另一张脸。这条线往后的站,咱们下回再拆。
如果你也在 Cesium 上做过鼠标提示,欢迎留言说说你选了哪条拾取路径——是图省心用globe.pick,还是为了那点真实海拔和高度直接上pickPosition?这两个选择,基本决定了你后面要填多少"打空分支"和"兼容性"的坑。
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在src/cases/widget-tooltip/目录下,核心就一个WidgetTooltipDemo.vue。搬去自己项目时,盯住三件事:MOUSE_MOVE的endPosition是相对 canvas 的 CSS 像素、globe.pick打空要显式处理、浮层那句pointer-events: none一个字都不能省。
Cesium酱の百宝箱 · 第 72 篇
浮层本是寻常 div,光标走到字便来
2026 年的秋夜,与你共探「鼠标怎么把地球读给眼睛听」