上次我们钻进地形里一铲一铲算土方这次回到「地图控件」这条线。角落里那个会跟着视角转的小圆盘平时谁都不会多看一眼可一旦你真去拖它、双击它就会发现它干的事和它自己写的说明根本对不上。本篇目标把罗盘控件「外环为什么转、两个圈分别管什么手势、双击到底做了什么」讲清楚并揪出它提示文案里那两处实实在在的误导。 本文涉及的全部代码均已开源完整的仓库地址见文末可自行取用、随意魔改。一、它到底由哪几块拼成先认脸。WidgetCompassDemo.vue里那个 62px 的小圆盘是三层 SVG 叠出来的template部分外环compass-outer深蓝圆 一圈刻度 顶部N标记这一层每帧都会被整体旋转。中央陀螺盘compass-gyro26px 的小圆它代表「相机自身的朝向」也就是屏幕正上方永远不转。旋转标记compass-rotation-marker橙蓝扇形默认visibility: hidden只有你按下拖拽时才显形用来提示「你正抓着哪一圈、抓在哪」。三层的尺寸关系也值得记一下外层容器.widget-compass是 62×62里面那个外环 SVG 用viewBox0 0 162 162画了半径 81 的圆再被 CSS 缩放到 62px中央陀螺盘 26×26旋转标记 56×56位置top:3px; left:3px正好卡在外环内侧。也就是说视觉上「大圆盘」是外环「小圆」是陀螺盘中间那圈橙蓝扇形才是手势提示三者各司其职。外环那圈「刻度」其实是几个固定不动的形状顶部一个N字母的 path、左右两条E/W短横线外加四个小圆点画在 45° 方向坐标(129.5, 128)、(30.8, 35.5)之类。它们全部贴在外环这一层所以外环一转N、E、W和那四个点就一起转到正确方位——刻度是「死」的转的是整层。让外环转起来的是挂在scene.postRender上的一个处理函数WidgetCompassDemo.vueL39-43functionpostRenderHandler(){if(!viewer||viewer.isDestroyed()||!outerRef.value)returnconstheadingviewer.camera.heading outerRef.value.style.transformrotate(${-heading}rad)}就这一行-heading决定了整个控件的指向逻辑。后面几节全是围绕它和它周围的手势展开的。除了这三层控件上还挂着两件外围配件右上角的控制面板.control-panel一个「启用罗盘」开关toggle加一段hint提示。开关走onEnableChange关掉时把compassEl设成display: none并摘掉postRender监听——注意它是只藏不销毁重新打开时再挂回去。居中的状态遮罩.status-maskBing 影像加载时的文字提示statusMessage为空就自动消失。整个控件的生命周期很短onMounted里建场景、载影像、setView到南京上空 220 km然后挂上postRenderonBeforeUnmount里把两个监听摘干净再destroyScene。从头到尾没有一个地方「自己画圆」——外环的旋转、标记的显隐全是改 DOM 的style这让它比在 canvas 里逐帧重画一遍轻得多。二、外环为什么转的是-heading而不是heading这是整篇文章最该先想通的一点。很多人直觉里觉得「相机转向东罗盘就该跟着转向东」于是写成rotate(heading)。写反了。约定是这样的camera.heading是相机「看向哪个方向」0 表示朝北向东转是 90°顺时针增加。当你把相机转向东heading 90°时你眼前的大地相对屏幕是逆时针转了 90° 的——也就是说真北在你的左手边。所以罗盘外环必须把N标记转到左边去才和「屏幕上的北」对得上。要做到这一点外环得转-heading而不是heading。我用探针按 CSS 的顺时针为正约定把「顶端N标记初始在 (0, -r)」绕中心转-heading后落点算了一遍r 31即 62px 罗盘的半径相机航向N标记在屏上落点直觉对不对0°朝北看顶12 点北在正前落顶 ✅90°朝东看左9 点面朝东真北在左手边 → 落左 ✅180°朝南看底6 点背对北北在身后 → 落底 ✅270°朝西看右3 点面朝西真北在右手边 → 落右 ✅这表里的每一格都是「负号」换来的。如果把负号去掉朝东看时N会跑到右边和肉眼看到的北完全反掉——一个看似随手的符号决定整个控件是「指北」还是「指南」。想自己在控制台验一遍也很简单两行就够heading用弧度CSS 顺时针为正// 顶端 N 标记初始在 (0, -r)rotate(-heading) 后落点constx-r*Math.sin(heading)consty-r*Math.cos(heading)把heading 0 / π/2 / π / 3π/2代进去落点依次是(0, −r)、(−r, 0)、(0, r)、(r, 0)——正好是顶、左、底、右就是上表那四格。顺带一句本 demo 初始视角是camera.setView({ destination: fromDegrees(118.8, 32.05, 220000) })只给了位置没给姿态——按 Cesium 的默认Camera.jsL1529pitch 默认-π/2它上来就是俯视朝北所以罗盘一开始N就在顶和直觉一致。这也解释了「为什么 demo 第一眼看起来没问题」最不容易暴露旋转方向错误的姿态恰好就是它的初始姿态。heading 不是「世界转角」是「相机朝向」容易误会的一点camera.heading描述的是相机自己朝哪看不是世界转了多少。它属于 Cesium 的 ENU东-北-天局部坐标系约定站在相机位置朝正北看是 heading 0向右东转为正到正东是 π/2顺时针转一圈回到北。跟它配套的还有两个角pitch俯仰。水平看是 0朝下看为负正下方是 −π/2朝上看为正roll绕视线方向的滚转地图场景里通常保持 0。所以外环那行rotate(-heading)读起来应该是「把世界按相机航向的反方向转一下让屏幕上的方向标签回到真实方位」。相机往右转heading 增世界相对屏幕就往左转外环跟着转左负角度N才停在真北该在的方位。这个符号搞反控件就从「指北」直接变成「指南」。三、两个圈两种手势内圈翻、外圈转罗盘不是「一整块都能拖」的。鼠标按下时代码先量一下你点在了离圆心多远的地方onMouseDownL246-255constmaxDistancecompassRect.width/2// 31constvectorgetVector(e)constdistanceFractionCartesian2.magnitude(vector)/maxDistanceif(distanceFraction0.45){orbit(vector)// 内圈}elseif(distanceFraction1){rotate(vector)// 外圈}// distanceFraction 1点在罗盘外两个手势都不触发这条0.45的临界线把圆盘劈成了两半两半干的事完全不同内圈f 0.45走orbit在三维里只要地面能拾取到焦点就执行camera.rotateLeft(x)camera.rotateUp(y)orbitTickFunctionL113-114——同时改航向和俯仰等于绕着那个焦点把视角「翻」过去。外圈0.45 ≤ f 1走rotate只执行camera.rotateRight(Δ)rotateMouseMoveL236绕焦点只转航向俯仰纹丝不动——等于抓着外环把世界「拧」着转。把两区并排看区域判据调用的相机方法改的是绕的是内圈f 0.45rotateLeftrotateUp航向和俯仰地面焦点无焦点时为相机自身外圈0.45 ≤ f 1rotateRight只航向地面焦点罗盘外f ≥ 1不进任何分支————这里有个常被忽略的事实两种手势里都没有「平移 / 漫游」。无论是翻还是转相机都绕着某个焦点运动从来不会沿地表平移。也就是说这个罗盘压根不提供「平移视野」的能力。那么0.45是拍脑袋定的吗不是。罗盘 62px中央陀螺盘 26px → 半径 1313 / 31 ≈0.42。取0.45只是比陀螺盘边缘多留一点余量让「抓中央小圆盘往外一点」和「抓最外圈」能被干净分开。所以这条临界线画的就是陀螺盘的边。我把几个光标落点丢进这段判据跑了一遍罗盘 62px圆心 (31, 31)maxDistance 31分区的边界感非常干净光标落点离圆心距离比 f归属(31, 25) 正上方 6px0.194内圈 → 翻(40, 40) 斜向内0.411内圈 → 翻擦着 0.45 的边(31, 45) 正下方 14px0.452外圈 → 转刚越过线(31, 15) 正上方 16px0.516外圈 → 转(10, 31)、(52, 31) 左右各 21px0.677外圈 → 转(58, 58) 罗盘外1.232不触发注意f ≥ 1那一行点在罗盘外时两个分支都不进这一记按下等于被彻底吞掉——既不传给地图也不触发任何相机动作。所以贴着罗盘边缘外一点按住拖是什么都不会发生的。还有一件事值得单独提罗盘的手势不是独立于引擎的相机控制器的。orbit进门先查sscc.enableInputs三维下还要求enableTilt enableRotate同时为真rotate则要求enableRotate或enableLook。也就是说一旦你把scene.screenSpaceCameraController.enableRotate关掉外圈拖拽会立刻失效。这种「手势跟着引擎开关走」的做法是好习惯但也意味着哪天罗盘突然拖不动先去查这几个 enable 开关而不是怀疑罗盘自己。它自己的提示把功能说反了现在来看控件右上角那段说明文字template里的hint拖拽外圈内侧旋转视角航向拖拽外圈外侧远离中心漫游移动视野把它和上面读出来的代码对着看两处都对不上说反了提示说「内侧 旋转航向」可代码里内侧f 0.45走的是orbit是航向俯仰一起翻真正「只转航向」的是外圈0.45~1的rotate。里外说反了。凭空多出一个词提示说外侧是「漫游移动视野」可代码里外侧是rotateRight转航向根本不是平移。整个控件都没有漫游这个词是凭空写上去的。换句话说这个罗盘自己写的说明书既把两个圈的功能写反了又编了一个它根本不具备的能力。读者照着提示去「外侧漫游」只会发现视野既不平移、手势还和预期相反。这类「代码对、文档错」的坑比代码本身写错还阴——因为你看代码之前已经被那行字带偏了。外圈那一记拖拽是怎么跟手的rotate的手感是「抓住哪儿哪儿就跟着手指走」。它靠三个量把光标角度映射成相机航向rotateMouseMoveL223-240按下瞬间记下光标相对圆心的角度rotateInitialCursorAngle atan2(-vector.y, vector.x)——注意y前有个负号因为屏幕坐标 y 向下取负才和数学上「逆时针为正」对齐同时记下此时的rotateInitialCameraAngle -camera.heading拖动过程中实时光标角与初始角之差记作angleDifference新的航向角等于zeroToTwoPi(rotateInitialCameraAngle - angleDifference)再让相机rotateRight追上去。zeroToTwoPi这一步不能省角度差可能为负、也可能超过 2π归一到 [0, 2π) 后相机才不会「绕远路」。举个探针算过的例子初始航向 0°、光标从 3 点位置拖到 6 点位置光标角差 Δ 270°新航向角 zeroToTwoPi(0 − 270°) 90°相机航向随之变到 −90°朝西——你抓的那个点一直黏在光标底下这就是「拧世界」的跟手感来源。一个容易忽略的对称性orbit里算角度用的是zeroToTwoPi(angle − π/2)L80rotate里用的是zeroToTwoPi(initCam − diff)两处都拿zeroToTwoPi收口。写法不同目的却一样——让所有角度都活在同一个 [0, 2π) 的圆里不然两个手势的方向会各自漂。这种「归一同一个区间」的收口是这类角度手势少踩坑的关键。顺带说一句那块橙色扇形它的旋转用的是marker.style.transform rotate(-orbitCursorAngle)L86——和相机角度反着来所以你看到的那道扇形始终指向「真正能让相机转过去」的那一侧标记方向和手感始终一致。四、双击「回正俯视」——名不副实的那个按钮罗盘还接了一个双击onDoubleClickL257-277。它的本意你从提示里也能读出来——「双击罗盘将相机回正至俯视视角」。听起来很美歪着看久了双击一下啪俯视。但代码实际做的是用flyToBoundingSphere飞向地面焦点偏移量这么写L273-276camera.flyToBoundingSphere(focusBoundingSphere,{offset:newHeadingPitchRange(0,// 航向 0归正到北CesiumMath.PI_OVER_TWO-Cartesian3.angleBetween(surfaceNormal,camera.directionWC),Cartesian3.distance(cameraPosition,center)// 距离 当前距离),duration:1.5})问题全在那个pitch上。offset 的 pitch 是π/2 − angleBetween(法线, 视线方向)。把它和「相机当前俯角」比一比就清楚了相机的俯角pitch满足pitch asin(directionWC · surfaceNormal)水平看时 0正下看时 −π/2正上看时 π/2。而angleBetween(法线, 视线方向) arccos(directionWC · surfaceNormal)。两者差一个arccos(x) π/2 − asin(x)的关系于是offset pitch π/2 − arccos(dot) π/2 − (π/2 − asin(dot)) asin(dot) 当前俯角也就是说双击只把航向归零俯角和距离原封不动地保留下来。它做的是「北向上复位」不是「俯视复位」。验证两个极端你本来就俯视视线朝下dot ≈ −1offset pitch ≈ −π/2双击后还是俯视——这时候提示碰巧没撒谎。你是 45° 斜着看的视线与法线成 135°dot ≈ −0.707offset pitch ≈ −π/4双击后你还是 45° 斜着只是朝北了。把几种典型姿态列出来一眼就能看出「双击只是归正航向」双击前的视线与法线夹角当前俯角offset 算出的 pitch双击之后朝正下方180°−90°−90°还是俯视斜 45° 往下看135°−45°−45°还是斜 45°只是朝北水平看远方90°0°0°还是水平所以那句「回正至俯视视角」是过誉了。除非你双击前就已经俯视否则双击完你依旧是歪的只是「歪得朝北」而已。一个按钮的文案承诺了它没做的事。至于为什么用flyToBoundingSphere而不是flyTo焦点是一个点用一个半径 0 的包围球去包它就能复用「围绕目标飞」的既有路径HeadingPitchRange直接描述相机相对这个点的角度和距离比手算一个目标destination清爽得多duration: 1.5则给了它一秒半的补间双击时视角是「滑」过去的不是瞬移。还有个配套的小坑聚焦点来自getCameraFocus一条从相机位置沿视线方向的射线打globe.pick。如果你看向的是天空、或那片瓦片还没加载出来getCameraFocus返回undefinedonDoubleClick在 L269 直接return——双击悄无声息地失效连个提示都没有。焦点从哪来getCameraFocus 的三条岔路上面说的「绕焦点翻 / 转」那个焦点不是相机自己而是getCameraFocusL55-76算出来的。它一共三条岔路跟随时viewer.trackedEntity存在直接取被跟随实体在clock.currentTime时刻的位置跳过一切拾取。三维场景本 demo 的路径从相机位置沿视线方向发一条射线Ray(positionWC, directionWC)交给scene.globe.pick(ray, scene)打地面。打中就得到世界坐标的地面点打空看天空、看太空、瓦片没加载就返回undefined。2D / 2.5D本 demo 用不到二维投影下globe.pick给出的不是真实经纬度所以要先worldToCameraCoordinatesPoint转回相机系、再mapProjection.unproject反投影成地理点、最后cartographicToCartesian重新生成椭球面坐标——三步缺一不可。第三条岔路正是前面说的「死分支」本 demoscene3DOnly: true永远走第 2 条。但如果你把这套罗盘搬进支持模式切换的 Viewer2D 下焦点的那三步转换就成了正确性的关键漏掉任何一步拖动都会绕着一个错位的点转。也正因为焦点依赖globe.pick它和下一节那两个暗坑无焦点退化、双击静默失效其实是同一个根因地面一旦拾取不到整条手势链条就没有支点。五、几个容易踩的暗坑把上面散落的细节收一下这些都是真上手会绊你的地方。暗坑 1罗盘靠postRender驱动按需渲染会把它冻住。外环旋转写在postRender里意味着「每渲染一帧转一次」。本 demo 的 Viewer 没开requestRenderMode连续渲染循环所以静止时也在刷罗盘跟手。可一旦你把项目改成按需渲染requestRenderMode true相机不动就不会触发渲染postRender不跑罗盘就定格在最后一帧的角度看着像坏了。要改按需渲染记得在相机变化后主动scene.requestRender()。暗坑 2抓不到地面时同一记内圈拖拽会变「原地转头」。orbit里getCameraFocusL151-158能拾取到焦点 →orbitIsLook false走rotateLeft/rotateUp绕点翻拾取不到看天空→orbitIsLook true改成camera.look(...)原地转。同一个手势落点不同行为从「绕远处的点翻」变成了「站在原地转头」手感差异很大但界面上毫无提示。暗坑 3旋转标记的透明度是个二次函数。orbitCursorOpacity 0.5 · f² 0.5L84从圆心 0.5 到边缘 1.0f离圆心距离比opacity0圆心0.5000.45内圈边界0.6010.70.7451.0外圈边缘1.000拖得越靠外那个橙色扇形越实——算是个轻量的「用力反馈」但也说明它本质是装饰不参与任何几何。暗坑 4本 demo 的 2D 分支是死代码。createMapScene里写死了scene3DOnly: true所以源码里那一堆SCENE2D / COLUMBUS_VIEW分支2D 下的拖拽平移、2.5D 下的worldToCameraCoordinatesPoint焦点换算等在这个 demo 里永远不会进。但如果你要把这套罗盘搬进支持 2D/2.5D 的 Viewer这些分支才是生效路径且它们的lookAtTransform逻辑和 3D 对称——别以为它们没用就顺手删了。上线前 checklist提示文案对得上实际手势内圈 翻航向俯仰外圈 转只转航向没有漫游双击的文案别许「俯视」的诺老老实实写「北向上复位」或干脆把双击改成真的俯视pitch 写死 −π/20.45这个临界线别随手改成别的数它约等于陀螺盘半径占比改了内圈外圈就串味用了按需渲染记得给罗盘补requestRender否则静止时冻住无焦点时orbit会退化成look心里要有数双击前先确认地面能拾取否则静默失效罗盘外环的旋转方向是-heading搬去别的相机封装时别顺手改成六、写在最后一个 62px 的小圆盘藏着四条值得记住的结论外环转-heading才是指北不是heading内圈翻、外圈转、没有漫游而它自己的提示把前两者写反、还编了个不存在的平移双击只是北向上复位、不俯视文案许了它没做到的诺它靠postRender活着按需渲染会把它冻在最后一帧。这些都不是「写错了」的大 bug而是「代码对、文档错、边界静默」那一类的暗坑——偏偏这种坑最容易在验收时因为「看着能转」就放过去。盘一下本篇最有价值的三个「反直觉」第一罗盘转的是负角度因为相机转正、世界就转负这是整控件指北的命脉第二两个圈的分工和提示正好相反0.45这条线画的其实是陀螺盘的边而「漫游」是凭空多出来的能力第三双击不是俯视它保留了你当前的俯角只把航向归零——一个典型的「按钮承诺了它没做的事」。这条地图控件线的下一站是那个点了地物就弹出来的小气泡——气泡弹窗控件#69。它和罗盘一样也是「角落里不起眼、真去点就发现门道」的一类我们下篇见。如果你也在项目里自己写过导航罗盘欢迎留言说说你是怎么处理「内圈翻、外圈转」这条分工的——是照搬 Cesium 的 0.45还是按自己的陀螺盘尺寸另算一个 项目已开源欢迎 Star你的 Star ⭐ 就是对我最大的支持仓库地址https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/widget-compass/目录下核心就一个WidgetCompassDemo.vue。搬去自己项目时盯住两行就够了postRenderHandler里的-heading和onMouseDown里的0.45。Cesium酱の百宝箱 · 第 68 篇外环转负才指北内外手势各分边2026 年的秋夜与你共探「那枚小罗盘里究竟谁在替谁指北」