上一次我们拆了位置信息栏,结尾我埋了个话头:这家人还有一张"用图报状态"的脸没出场。位置信息栏是钉在角落写一行字,鹰眼小地图则是一句话都不写——它在角落放一个缩小的全局图,再叠个矩形框标出你当前视口覆盖了哪块地。今天就把这只"上帝视角的复眼"拆开。
顺带把进度摆一下:地图控件这条线一路走来,罗盘读相机朝向(#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 模式,把所有交互控件关干净:
miniViewer=newViewer(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.display='none'miniViewer.cesiumWidget.screenSpaceEventHandler.removeInputAction(ScreenSpaceEventType.LEFT_DOUBLE_CLICK)miniViewer.scene.backgroundColor=Color.TRANSPARENT// 把小地图自身的相机交互全部锁死constc=miniViewer.scene.screenSpaceCameraController c.enableRotate=c.enableTranslate=c.enableZoom=falsec.enableTilt=c.enableLook=false注意这里的关键词是"锁死":小地图不是让你操作的,它只是个被动展示窗口。容器 CSS 上又加了一道pointer-events: none加border-radius: 50%——所以它在视觉上是个圆窗,在交互上是块"玻璃",你点它等于点穿到下面的主图。
第二步,在这个小 Viewer 的上层,盖一个绝对定位的遮罩 divrangeOverlay:
<divref="miniContainer"v-show="enabled"class="cesium-hawkeye-map"><divref="rangeOverlay"class="hawkeye-range"></div></div>cesium-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=(mainWest+mainEast)/2constmainCenterY=(mainSouth+mainNorth)/2constmainSpanX=mainEast-mainWestconstmainSpanY=mainNorth-mainSouthconstextWest=wrapLongitude(mainCenterX-mainSpanX)constextEast=wrapLongitude(mainCenterX+mainSpanX)constextSouth=clampLatitude(mainCenterY-mainSpanY)constextNorth=clampLatitude(mainCenterY+mainSpanY)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 源码(L250006:new 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 box(minX/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越靠北(越接近extMaxY),top越接近 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(比如 west=179、east=-179,实际跨度 2° 但数值上 east<west)。demo 的两手准备:一是用wrapLongitude把所有经度归一化回[-180, 180),二是用if (viewRect.west >= viewRect.east) return直接放弃本次绘制。探针实测正常视角(west=117.18、east=120.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, north=90。注意这里west == east == -180,刚好命中洞一那条west >= east守卫(-180 >= -180为真),于是syncMap直接return:黄框定格不动,不去画一个退化矩形。这层守卫是鹰眼的"安全带"——若没有它,spanX会变成 0,后面(mainMaxX - mainMinX)/spanX直接除以 0 →NaN→ 黄框要么消失要么乱飞。探针在"看全地球"的极端 case 下也验证了:ext spanX = 0,demo 在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 是上一节那个top=100%,那才要修)。
设计账(第二个 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 上做过鹰眼,欢迎留言说说:你有没有被那个top=100%的坑咬过,还是一开始就老老实实用了主范围上界?
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在src/cases/widget-hawkeye-map/目录下,核心就一个WidgetHawkeyeMapDemo.vue。搬去自己项目时,盯住三件事:①Scene.mapProjection默认是 GeographicProjection(别被顶部MAX_LATITUDE=85.05112878那个 Web Mercator 残留常量骗了,它压根没参与小地图投影);② 黄框top必须用mainExtent.maxY反算(原 demo 写extent.maxY会恒为 100%,把框顶到容器底被裁掉);③ 三道守卫(经度环绕west>=east/ 纬度clampLatitude/ 地平线Rectangle.MAX_VALUE)缺一不可,否则跨日界线或看全地球时黄框会除以 0 乱飞。
Cesium酱の百宝箱 · 第 74 篇
复眼悬窗观全局,一框锁定主图心
2026 年的秋夜,与你共探「角落那只小窗怎么把世界缩成一枚图钉」