
上一次我们拆了位置信息栏结尾我埋了个话头这家人还有一张用图报状态的脸没出场。位置信息栏是钉在角落写一行字鹰眼小地图则是一句话都不写——它在角落放一个缩小的全局图再叠个矩形框标出你当前视口覆盖了哪块地。今天就把这只上帝视角的复眼拆开。顺带把进度摆一下地图控件这条线一路走来罗盘读相机朝向#68、气泡写浮层追地表点#69、图表焊图到地球#70、缩放挪相机对准焦点#71、提示浮层跟光标报坐标#72、位置信息栏钉角落并排报状态#73现在轮到鹰眼小地图当第七位——而且它和 #73 刚好是用字报状态对用图报状态的两张脸底层却都是屏幕像素 ↔ 地球坐标那道题。 本文涉及的全部代码均已开源完整的仓库地址见文末可自行取用、随意魔改。你大概率在无数 GIS 网站的左下角见过它一个圆形或方形的小窗里面是整个区域的缩小版叠了一张底图还框了个黄框标出主图现在看的是哪一块。看起来天经地义仿佛 Cesium 自带一个OverviewMap或HawkEye类你只要viewer.hawkEye.show()就完事。但和前面几篇一样第一件事还是先拆穿Cesium 的公开 API 里根本没有鹰眼 / OverviewMap / MiniMap 这种控件。左下角那只小窗本质是一次再new一个 Viewer 盖一个绝对定位的 div 画框的手艺。Cesium 从头到尾只负责一件事——把主相机的视野算出来computeViewRectangle剩下的同步、画框、裁圆角全是你的 DOM 与几何活。一、先拆穿鹰眼不是新物种是第二个 Viewer 一个遮罩 div先看 demo 里那只小窗到底怎么来的。它不是一个 Cesium 类而是两步手工第一步在角落再起一个独立的Viewer强制 2D 模式把所有交互控件关干净miniViewernewViewer(miniContainer.value,{animation:false,baseLayerPicker:false,baseLayer:false,fullscreenButton:false,geocoder:false,homeButton:false,infoBox:false,sceneModePicker:false,selectionIndicator:false,timeline:false,navigationHelpButton:false,navigationInstructionsInitiallyVisible:false,creditContainer:undefined,sceneMode:SceneMode.SCENE2D,shouldAnimate:false})miniViewer.imageryLayers.removeAll()// 隐藏版权条、禁掉双击放大、透明背景miniViewer.cesiumWidget.creditContainer.style.displaynoneminiViewer.cesiumWidget.screenSpaceEventHandler.removeInputAction(ScreenSpaceEventType.LEFT_DOUBLE_CLICK)miniViewer.scene.backgroundColorColor.TRANSPARENT// 把小地图自身的相机交互全部锁死constcminiViewer.scene.screenSpaceCameraController c.enableRotatec.enableTranslatec.enableZoomfalsec.enableTiltc.enableLookfalse注意这里的关键词是锁死小地图不是让你操作的它只是个被动展示窗口。容器 CSS 上又加了一道pointer-events: none加border-radius: 50%——所以它在视觉上是个圆窗在交互上是块玻璃你点它等于点穿到下面的主图。第二步在这个小 Viewer 的上层盖一个绝对定位的遮罩 divrangeOverlaydivrefminiContainerv-showenabledclasscesium-hawkeye-mapdivrefrangeOverlayclasshawkeye-range/div/divcesium-hawkeye-map是那个 150×150 的圆形小窗橙色描边、box-shadow、半透明深蓝底hawkeye-range就是那个黄框——它只靠left / top / width / height四个百分比定位没有任何 Cesium 标签。所以鹰眼的真身可以压成一句话在角落再起一个被动的 2D Viewer 一个绝对定位的遮罩 div 每帧把主相机视野算出来、同步过去、并在小窗里按比例画一个黄框。Cesium 只卖算视野这一步小窗长相、同步逻辑、画框算术全是你自己搭的。这和 #73 位置信息栏是同一个套路——控件家族里这几位从来都是 div 一点几何唯独不是某个神秘类。把鹰眼和前几位摆在一起差异会更清楚罗盘#68读相机朝向、气泡#69追地表点、图表#70焊图、缩放#71挪相机、提示浮层#72跟光标、位置信息栏#73钉角落写字——它们大多站在地球一侧或站在屏幕一侧而鹰眼是再放一台相机看同一颗地球是最接近分屏思路的一位。顺带把为什么是第二个 Viewer、不是在主图里开个视口说清楚Cesium 的Viewer封装了一整套相机、图层、控件与渲染循环想在主图里抠一块区域当独立小图并没有原生 viewport 接口——要么去 hackScene的多视口复杂还要和主渲染抢帧要么像 demo 这样直接再new一个轻量 Viewer 当哑巴显示器。后者虽然多一份 WebGL 上下文但隔离干净、不碰主图逻辑是社区里最省心的做法代价只是多占一点显存对 150×150 的小窗来说微不足道。二、同步主相机computeViewRectangle 抓视野camera.changed 触发刷新小窗要跟着主图动核心就一个函数viewer.scene.camera.computeViewRectangle(ellipsoid)。它返回当前视锥在地表截出来的一个Rectangle四个角west / south / east / north单位弧度。先说这个函数的脾气免得你用错。它量的是视锥与椭球表面的交矩形不是屏幕像素也不认 3D 实体。这一点和 #72 讲过的globe.pick正好互补globe.pick走射线、能吃到真实地形高度、pickPosition走深度缓冲、认 3D 物体而computeViewRectangle只在椭球表面上量一圈简单、快、但平。当你主图是正射俯视时它给出的矩形就是你屏幕上看到的那块地的经纬度包络当你倾斜时它给的是视锥打在地表上的投影包络——所以它会随朝向自然变胖变瘦这点很实用。我用探针在 demo 默认的 260km 俯视视角实跑了一下拿到的是这组真值computeViewRectangle 260km 俯视 west 117.1847° south 31.3569° east 120.4153° north 32.7222° spanX 3.2305° spanY 1.3653°这组数就是你主图此刻脚下那块地的精确包络后面画黄框、摆小地图相机全都从它出发。顺带解释一下这组数为什么宽 高探针量到主相机frustum.fov 60°、aspectRatio 2.0换算下来垂直张角fovy ≈ 32.2°。视锥打在椭球上水平方向按fov × aspect、垂直方向按fovy张开所以地表截矩形天然横向更宽——实测spanX 3.2305°对spanY 1.3653°比值 ≈ 2.37和fov / fovy × aspect的量级吻合。记住这点有用当你把主图窗口拉成正方形aspectRatio → 1截矩形会更接近正方你写死黄框比例时别假设它永远是扁的。拿到主矩形后syncMap()干两件事。第一件把主范围放大一圈得到小地图应该显示的范围——demo 的写法是取中心、再向两侧各扩展一个主跨度constmainCenterX(mainWestmainEast)/2constmainCenterY(mainSouthmainNorth)/2constmainSpanXmainEast-mainWestconstmainSpanYmainNorth-mainSouthconstextWestwrapLongitude(mainCenterX-mainSpanX)constextEastwrapLongitude(mainCenterXmainSpanX)constextSouthclampLatitude(mainCenterY-mainSpanY)constextNorthclampLatitude(mainCenterYmainSpanY)miniViewer.camera.setView({destination:Rectangle.fromDegrees(extWest,extSouth,extEast,extNorth)})看清楚ext范围 主范围中心 ± 主范围的跨度也就是边长 ×2、面积 ×4。换句话说小地图显示的范围是主图视野的 4 倍大——这正是 demo 文案里那句视野为 4 倍范围的几何来源。顺带较真一句demo 的提示文字写的是缩放级别比主地图低 2 级视野为 4 倍范围“。但按瓦片金字塔的语义每个 zoom level 把线性跨度减半所以4 倍面积 边长 2 倍 1 个 zoom level”真要低 2 级对应的是 16 倍面积。文案里的2 级和4 倍其实对不上——显示无碍只是注释精度提一句免得你抄去当真。第二件把小地图相机setView到这个放大后的范围上。于是小地图永远显示比主图大一圈的全局主图看哪小地图就把中心对到哪。还有个细节值得点破computeViewRectangle的参数是ellipsoid椭球不是globe带地形的球。所以即使你主图加载了高精度地形它返回的仍是视线打到裸椭球上的矩形不会因山峰突起而忽大忽小。这点和 #72 的globe.pick真地形高度形成对照——要屏幕到真实地表点用globe.pick要视野地理包络用computeViewRectangle两件事别混。也正因为只读椭球它在 2D / COLUMBUS 模式下同样能跑没有地形可言通用性反而比走 globe 的那套更好。最后是什么时候同步——viewer.camera.changed.addEventListener(syncMap)并且把死区调得很紧viewer.camera.percentageChanged 0.01。这里和 #73 讲过的camera.changed是同一个事件但 #73 用的是默认 0.5高空会迟钝这里为了主图轻微一动小图就跟上特意拧到了 0.01。一个容易忽略的细节你setView到迷你相机会触发miniViewer 自己的changed但 miniViewer 的changed我们根本没监听所以不会形成主→小→主的回环——这正是第二个 Viewer 当哑巴显示器设计的安全之处。三、黄框怎么画四个经纬度 → 两次投影 → 百分比定位实测踩坑这一步是整篇最容易翻车的地方也是探针真正立功的地方。黄框rangeOverlay是个绝对定位 div靠四个百分比left / top / width / height钉在小窗里。怎么把主范围那块地变成小窗里的百分比矩形demo 的算法分三段functionplaneExtent(w:number,s:number,e:number,n:number):PlaneExtent{constproject(lon:number,lat:number)miniScene.mapProjection.project(Cartographic.fromDegrees(wrapLongitude(lon),lat))constxs[project(w,s).x,project(e,s).x,project(w,n).x,project(e,n).x]constys[project(w,s).y,project(e,s).y,project(w,n).y,project(e,n).y]return{minX:Math.min(...xs),maxX:Math.max(...xs),minY:Math.min(...ys),maxY:Math.max(...ys)}}第一段把经纬度投影到平面坐标(x, y)。注意它走的是miniScene.mapProjection也就是小地图那个 Viewer 的投影器——这决定了后面所有算术的坐标系。实测发现①投影真相差点被骗demo 顶部有个常量MAX_LATITUDE 85.05112878一眼看像 Web Mercator 的纬度上限。我用探针量了WebMercatorProjection.MaximumLatitude得到85.05112877981°和这个常量差1.9e-10度——确认它就是 Web Mercator 的纬度极限值。但再探针一查 Cesium 源码L250006new GeographicProjection(this._ellipsoid)发现Scene默认的mapProjection是GeographicProjection等距圆柱根本不是 Web Mercator而 Geography 投影对纬度是线性的探针验证project(0,45).y × 2 project(0,90).y为真。于是那个85.05的常量在小地图这块完全是残留物没被用上——planeExtent走的是 Geography 线性投影一切都算得对。这个发现提醒一句老道理别被一眼像对的常量骗了先用探针查清楚它到底走哪条投影再决定信不信它。那这个残留常量到底什么时候才用得上只有当你的小地图底图是 Web Mercator 瓦片比如标准 XYZ 瓦片且你想手动把纬度 clamp 到瓦片覆盖范围时85.05才有意义。demo 走的是 Geographic 投影的线性映射纬度到 ±90 都不会炸所以clampLatitude只是防手抖不是防爆炸。结论很朴素先console.log(miniScene.mapProjection.constructor.name)看一眼真实投影器再决定信不信那些看起来很对的常量——这一手能替你挡掉不少假 bug。第二段对主范围和小范围各算一个 bounding boxminX/maxX/minY/maxY。第三段把主框在小框里的位置换算成百分比constpct(value,min,max)((value-min)/(max-min))*100rangeOverlay.value.style.left${pct(mainExtent.minX,extExtent.minX,extExtent.maxX)}%rangeOverlay.value.style.top${pct(extExtent.maxY,extExtent.minY,extExtent.maxY)}%// ← 这一行有坑rangeOverlay.value.style.width${(mainExtent.maxX-mainExtent.minX)/spanX*100}%rangeOverlay.value.style.height${(mainExtent.maxY-mainExtent.minY)/spanY*100}%left / width没问题主框左沿在小框里的水平百分比、主框宽度占小框宽度的百分比直接除就完事。top这一行却藏着全篇最大的坑。实测发现②黄框被顶到容器底探针当场抓现行注意top这行传的value是extent.maxY小范围的上界而不是主范围的mainExtent.maxY。代入pct一算(extMaxY - extMinY)/(extMaxY - extMinY)恒等于100%也就是说原 demo 的黄框top永远等于 100%——上沿钉在小窗最底部再叠加 50% 的高度整个黄框被顶到容器外、裁得只剩一条边。我用探针在默认视角下实算正确值应当是25%黄框上沿对应主范围北界mainMaxY而原代码算出的是 100%。为什么会错因为屏幕 Y 和投影 Y 是上下颠倒的Geography 投影里纬度越高y越大north 高 y但屏幕上北在上所以黄框的top距容器顶的距离应当用小框上界减去主框上界来反算——即pct(mainExtent.maxY, extExtent.minY, extExtent.maxY)。mainMaxY越靠北越接近extMaxYtop越接近 0越靠上越靠南top越大。原代码把被除数错写成extent.maxY等于永远取最北那条边当主框北界自然是 100%。修正后的正确写法只有一处之差// 修正top 必须用主范围上界反算north 在屏幕上沿Y 上下颠倒rangeOverlay.value.style.top${pct(mainExtent.maxY,extExtent.minY,extExtent.maxY)}%这一行就是鹰眼图十个人九个栽的地方。它不报错、不红屏只是黄框悄悄跑到了窗口底下——你肉眼看 demo 还以为黄框呢就是这个原因。下面这张对照图把正确 25%和错误 100%摆在一起下一次你一眼就能认出哪个对。把这一节压成一句提醒投影先查清楚是 Geographic 还是 Web Mercator别信残留常量黄框 top 反算时取主范围上界mainExtent.maxY不是小范围上界——屏幕 Y 与投影 Y 上下颠倒这是鹰眼最容易翻车的一步。四、三个必须堵死的洞越界、越极、越地平线黄框画对了还不够主图世界那么大有三种走极端的情况会让那套百分比算术崩掉。demo 用三道守卫把它们全堵了值得逐条记牢。洞一经度环绕跨 ±180° 经线。当主图视口横跨日界线computeViewRectangle返回的east可能小于west比如 west179、east-179实际跨度 2° 但数值上 eastwest。demo 的两手准备一是用wrapLongitude把所有经度归一化回[-180, 180)二是用if (viewRect.west viewRect.east) return直接放弃本次绘制。探针实测正常视角west117.18、east120.42不会触发只有真跨日界线才会。没有这层守卫黄框的宽度百分比会变成负数或爆炸框直接飞出小窗。wrapLongitude的数学很简单((lon 180) % 360 360) % 360 - 180把任意经度卷回[-180, 180)。它和west east守卫是搭档前者把179→-179这种跨线对归一化后者在归一化后仍出现逆序时放弃绘制。两者缺一不可——只归一化不守卫跨线那一帧仍可能算出负宽度只守卫不归一化正常视角下 east/west 可能因为浮点微差偶尔误判。把这对组合当成跨日界线套餐一起记。洞二纬度 clamp防极点退化。虽然上一节说了MAX_LATITUDE那个常量实际没参与投影但clampLatitude仍把南北限制在 ±85.05。Geography 投影本身是线性的、不会因为接近极点而爆所以这道 clamp 严格说不必但它能挡住你把小地图setView到极点附近时Rectangle退化成一条线的数值怪象——留着无害删了要在极点附近自己承担风险。洞三越地平线最凶险。当你把主图拉到能看见整个地球computeViewRectangle会返回一个特殊的Rectangle.MAX_VALUE——探针实测它的四个角是west-180, south-90, east-180, north90。注意这里west east -180刚好命中洞一那条west east守卫-180 -180为真于是syncMap直接return黄框定格不动不去画一个退化矩形。这层守卫是鹰眼的安全带——若没有它spanX会变成 0后面(mainMaxX - mainMinX)/spanX直接除以 0 →NaN→ 黄框要么消失要么乱飞。探针在看全地球的极端 case 下也验证了ext spanX 0demo 在if (spanX 0 || spanY 0) return这一行就截住了。把三道守卫串起来记经度环绕和地平线越界都靠west east这一条兜底前者数值反向、后者MAX_VALUE退化纬度靠clampLatitude兜底最后还有一道spanX/Y 0的除法保护。少任何一道你在跨境项目或全球俯视场景里都会看到黄框抽风。五、它能做什么、不能做什么边界与取舍拆完机制说点工程上的实话——鹰眼不是银弹得知道它的边界。它擅长什么。大范围浏览时给个全局定位一眼看出我当前在主区的哪一块尤其配合 #73 位置信息栏一个用字、一个用图能把你在哪讲得很满。它和主图是两台相机看同一颗地球天然同步不依赖任何外部状态。它不擅长什么。小地图是被动 2D强制SCENE2D而主图可能是 3D / 2D / COLUMBUS 任一模式当主图倾斜时小地图仍是平铺的 2D 俯视图不反映主图的倾角——别指望在小窗里看到主图的透视。它也只是位置参考不可交互所有screenSpaceCameraController都锁死、容器pointer-events:none点它没反应是设计如此。性能账。每次camera.changed触发都会跑一次computeViewRectangle 一次setView到迷你相机 重算黄框而 demo 把percentageChanged设到了0.01相当敏感主图轻轻一动就同步。若实测觉得卡可以调回0.1或给syncMap套一层节流。好消息是miniViewer.scene.requestRender()只重绘小窗那 150×150 的区域不拖累主图渲染。底图账。demo 用的是 Bing 街道图需要 key加载失败时try/catch吞掉异常、退回纯色背景——所以小地图没有底图也能跑只是少了底纹。生产里你可以换 OSM、天地图或自己的瓦片服务这一行是解耦的。圆形裁切账。容器border-radius: 50%overflow: hidden把方图裁成圆窗纯视觉黄框是按矩形百分比算的圆边只是把框的一角裁掉不影响定位逻辑——所以你可能看到黄框被圆弧啃掉一小块那是正常的不是 bug真正算错的 bug 是上一节那个top100%那才要修。设计账第二个 Viewer 的代价。前面说过小地图是独立 Viewer意味着它有自己的 WebGL 上下文和渲染循环。好处是彻底隔离、不碰主图代价是多占一份显存且两个 Viewer 各自请求底图瓦片demo 里 Bing 会被请求两次。在只放一个 150×150 小窗的场景下这点开销可忽略但如果你要在同一页塞五六个鹰眼就得考虑改成单 Viewer 多视口、或用Scene的viewport自己画——那是另一个量级的复杂度本篇不展开。记住这个权衡免得哪天页面里鹰眼多了才发现显存吃紧。六、写在最后把这一篇收一下鹰眼的真身可以压成三句话先分身在角落再起一个被动的 2D Viewer把所有交互锁死、只当显示器、再同步用computeViewRectangle抓住主图地表截矩形、camera.changed(0.01)触发、把范围放大一圈setView到小相机、最后画框用mapProjection把经纬度投成平面、按百分比在小窗里钉一个黄框且千万别把top算反。它和位置信息栏#73最像——都是屏幕一侧的状态控件——却一个用字、一个用图在投影坐标系、定位算术、极端守卫上走上两条路。控件家族这条线现在凑齐了七位常客罗盘读相机朝向#68、气泡写浮层追地表点#69、图表焊图到地球#70、缩放挪相机对准焦点#71、提示浮层跟光标报坐标#72、位置信息栏钉角落并排报状态#73、鹰眼小地图分身一只复眼#74。前几个把信息贴到地球或读出来后三个站在屏幕一侧把坐标、状态、全局位置亮出来。看似各管一摊底层全是同一道题的变体如何在屏幕像素和地球坐标之间反复横跳且每次都不许漂、不许反、不许在边界翻车。落到工程上写鹰眼有两句口诀投影先查清楚默认是 GeographicProjection别被 85.05 的 Web Mercator 残留常量骗了黄框 top 用mainExtent.maxY反算屏幕 Y 与投影 Y 上下颠倒原 demo 写extent.maxY会恒为 100% 把框顶到容器底三道守卫经度环绕 / 纬度 clamp / 地平线 MAX_VALUE缺一不可否则跨日界线或看全地球时框会乱飞。把这两句和小地图是被动 2D、不反映倾角一起记住鹰眼基本就焊死了。这只上帝视角的复眼背后全是你和屏幕 ↔ 地球那道翻译的又一次拉锯——这条主轴你现在已经摸得相当透了。把七位常客往深里看屏幕一侧的交互控件这家子还没写满。下一个登场的是右键菜单控件它既不写一行字、也不放一张图而是在你右键的那一点弹出一个菜单——和鹰眼、状态栏一起把屏幕一侧怎么承接用户操作这道题补全。这家人还没写到头咱们下回再拆。如果你也在 Cesium 上做过鹰眼欢迎留言说说你有没有被那个top100%的坑咬过还是一开始就老老实实用了主范围上界 项目已开源欢迎 Star你的 Star ⭐ 就是对我最大的支持仓库地址https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/widget-hawkeye-map/目录下核心就一个WidgetHawkeyeMapDemo.vue。搬去自己项目时盯住三件事①Scene.mapProjection默认是 GeographicProjection别被顶部MAX_LATITUDE85.05112878那个 Web Mercator 残留常量骗了它压根没参与小地图投影② 黄框top必须用mainExtent.maxY反算原 demo 写extent.maxY会恒为 100%把框顶到容器底被裁掉③ 三道守卫经度环绕westeast/ 纬度clampLatitude/ 地平线Rectangle.MAX_VALUE缺一不可否则跨日界线或看全地球时黄框会除以 0 乱飞。Cesium酱の百宝箱 · 第 74 篇复眼悬窗观全局一框锁定主图心2026 年的秋夜与你共探「角落那只小窗怎么把世界缩成一枚图钉」