第65篇:地图卷帘控件——点「关闭卷帘」,消失的却是右边那半张地图
上篇结尾我留了这么一句话“下一篇我们接着往回收——这次不是遮罩而是把两块影像拼在同一屏上左右对比。地图卷帘控件我们下一篇见。”上一篇 #64 我们做了一件没有 Cesium 参与的事一层黑布、五个光点、一个 2.2 秒的定时器。它不碰 WebGL也不问地球加载到哪了。今天这个控件正好反过来——它几乎是纯 Cesium 的卷帘的分割不是靠裁剪 DOM而是靠逐片元丢弃。屏幕上那条 5 px 的线只是个把手真正干活的是着色器。大家好我是 Cesium 酱也可以叫我本猿一名在 WebGIS 领域摸爬滚打多年的前端开发者。写这篇前我又干了一件傻事点了关闭卷帘满心以为会回到正常的 Bing 底图结果屏幕一整片变成了高德。那一刻我才意识到关闭这个词在这个控件里关掉的其实是另一侧。本文目标把卷帘的三件套splitDirection/splitPosition/ 逐片元 discard说清楚再顺着那条能拖的分割线看看它的坐标是怎么换算的、滑块偏了多少像素以及关闭卷帘到底关掉了什么。 本文涉及的全部代码均已开源完整的仓库地址见文末可自行取用、随意魔改。一、痛点与场景为什么要让两块底图同屏 背景现状影像对比不是炫技是刚需——时态变化、多源校核、规划前后全靠左边一张、右边一张摆在一起看。而如果做成两个窗口用户就得靠记忆对齐视角误差全在脑子里。典型场景里它要对付的是这些时态对比灾前 / 灾后、去年 / 今年同一块地的变迁️多源校核卫星影像 vs 街道图看道路与建筑对不对得上️规划前后现状影像 vs 规划总平一屏说服甲方️矢量与影像叠加一边看地名路网一边看真实地貌变化检测人工复核算法标出的图斑靠卷帘快速抽查 传统痛点做法不少各有各的别扭。方案问题开两个窗口对比视角、缩放各自独立靠人脑对齐误差说不清用透明度叠两层两层彼此洗掉细节颜色互相污染看不出变化自己写 DOM 裁剪只能裁 DOM 层裁不了 WebGL 画布里的地球反复切换图层显隐切换之间有时间差变化靠闪来感知最容易看漏 本文方案Cesium 原生给的卷帘三件套1️⃣两侧各挂一个方向imageryLayer.splitDirection SplitDirection.LEFT / RIGHT2️⃣一个分割位置scene.splitPosition0~1 的相对值3️⃣着色器逐片元丢弃不属于自己那一侧的像素直接alpha 0✨ 一句话总结真正干活的是着色器里的一次 discard屏幕上那条线和那个圆把手只是给鼠标一个能抓的地方。二、核心技术原理三件套与那条 5 px 的线2.1 三件套两个方向 一个位置先把枚举值钉死——它和直觉是反的我把源码翻出来确认过Scene/SplitDirection.js常量数值含义SplitDirection.LEFT−1.0只显示在分割线左侧SplitDirection.NONE0.0永远显示默认SplitDirection.RIGHT1.0只显示在分割线右侧注意LEFT是负数。这条后面会用到。demo 里的挂法也很直白// 源码级剧透WidgetMapSplitDemo.vueloadBingImagery(viewer,sceneCallbacks,(layer){layer.splitDirectionSplitDirection.RIGHT// 右侧 Bing 影像})// ...compareLayerviewer.imageryLayers.addImageryProvider(provider)compareLayer.splitDirectionSplitDirection.LEFT// 左侧 高德街道 / 影像viewer.scene.splitPositionviewer.scene.splitPosition||0.52.2 分割靠的是逐片元丢弃不是裁剪这是本篇最该记住的一点。我把GlobeFS.glsl里那几行挖了出来L245-253#ifdef APPLY_SPLIT float splitPosition czm_splitPosition; // Split to the left if (split 0.0 gl_FragCoord.x splitPosition) { alpha 0.0; } // Split to the right else if (split 0.0 gl_FragCoord.x splitPosition) { alpha 0.0; } #endif读法split 0.0也就是LEFT -1→ 当像素的 x大于分割位置就把 alpha 打成 0 →只在左边活着split 0.0RIGHT 1→ 当 x小于分割位置就打 0 →只在右边活着而czm_splitPosition是相对值换算出来的绝对像素Renderer/UniformState.js里是splitPosition × drawingBufferWidth。所以scene.splitPosition是一个 0~1 的比例和屏幕分辨率无关。 顺带一个默认值上的坑FrameState.js里this.splitPosition 0.0——默认是 0不是 0.5。也就是说如果你只给图层设了LEFT/RIGHT却忘了设splitPosition右侧图层会占满、左侧一个像素都看不见。#12 那篇已经提醒过一次这里再说一遍因为它实在太容易踩。2.3 拖动链路坐标是相对把手的拖动手感全在这几行functionmoveHandler(movement:{endPosition:{x:number}}):void{constrelativeOffsetmovement.endPosition.xconstsplitPosition(splitterWrap.value.offsetLeftrelativeOffset)/splitterWrap.value.parentElement!.offsetWidth splitterWrap.value.style.left${100*splitPosition}%viewer.scene.splitPositionsplitPosition}关键在于movement.endPosition.x到底是什么坐标。我去翻了Core/ScreenSpaceEventHandler.js的getPositionL11-20functiongetPosition(screenSpaceEventHandler,event,result){constelementscreenSpaceEventHandler._element;if(elementscreenSpaceEventHandler._canvas){result.xevent.clientX;// 画布直接用客户端坐标}else{constrectelement.getBoundingClientRect();result.xevent.clientX-rect.left;// 其它元素减去元素左边得到「相对元素」的坐标}} 把手.cesium-slider不是 canvas所以拿到的是相对把手自身的 x——鼠标在它左边就是负数右边可以远大于 5 于是offsetLeft relativeOffset正好把相对把手换算回相对父容器再除以父容器宽度就是比例——这行是对的⚠️ 但它没有夹取clamp鼠标拖出容器splitPosition会变成负数或大于 1把手也会跟着left: -30%之类跑出屏幕另外demo 里把把手元素强转成了HTMLCanvasElementhandlernewScreenSpaceEventHandler(splitterWrap.valueasunknownasHTMLCanvasElement)从上面的源码看ScreenSpaceEventHandler接受任意 element这个as unknown as HTMLCanvasElement纯属为了满足类型运行时没有任何作用。2.4 滑块几何圆心其实偏了 2.5 px把手和线的尺寸都在 CSS 里.cesium-slider{position:absolute;top:0;bottom:0;left:50%;z-index:20;width:5px;background-color:#d3d3d3;}.splitter{position:absolute;left:-21px;top:calc(50% - 22px);width:42px;height:42px;border-radius:50%;...}把数字代进去算一下水平线宽 5 px把手宽 42 px。要让把手居中压在线上偏移量应是−(42 − 5) / 2 −18.5 px代码写的是−21 px→ 把手中心落在线的左边缘比线的中心偏左 2.5 px垂直把手高 42 px居中应是50% − 21px代码写的是50% − 22px→偏上 1 px这两个偏移肉眼几乎看不出来但一旦你要让线的位置和鼠标抓的位置严格对齐比如做吸附这 2.5 px 就会跳出来咬人。2.5 「关闭卷帘」关掉的是哪一侧这是本篇最反直觉的一处。看onEnableChangeif(value){splitterWrap.value.style.displayblockviewer.scene.splitPosition0.5}else{splitterWrap.value.style.displaynoneviewer.scene.splitPosition1// ⚠️ 注意是 1}把splitPosition 1代回 2.2 那两行着色器split 0左侧图层→ 只在x ≤ 分割位置时存活分割位置在最右边 →整屏都是它split 0右侧图层→ 只在x ≥ 分割位置时存活分割位置在最右边 →整屏都不满足全被丢掉所以关闭卷帘不是回到 Bing 底图而是左侧对比底图点关闭卷帘之后高德街道图 / 影像图整屏变成高德Bing 影像彻底消失选「无」没有左侧图层所有影像都被丢弃只剩地球底色 真想要关闭后回到底图应该是splitPosition 0右侧图层占满或者干脆把图层的splitDirection改回SplitDirection.NONE。2.6 那个|| 0.5让 0 永远设不进去viewer.scene.splitPositionviewer.scene.splitPosition||0.5splitPosition为 0 时是falsy所以这行会把 0 换成 0.5。后果你没法通过这行把分割位置设成 0虽然手动拖到最左还是可以的。判断该写成??或者直接判undefined。2.7 顺带一句图层顺序addImageryProvider是往上叠的所以后加的高德在 Bing 之上。本例里两层分处左右、互不重叠顺序无所谓但如果你哪天把两层都设成NONE盖在上面的就是后加的那一层。2.8 卷帘不只属于影像我顺着czm_splitPosition这个 uniform 搜了一遍着色器目录发现它出现在不止一处着色器管的是GlobeFS.glslL245地球表面——影像与地形BillboardCollectionFS.glslL129-133billboard——#62 那些文字标签Shaders/Model/ModelSplitterStageFS.glsl模型——3D Tiles 也会走这一步而Cesium3DTileset自己就带着splitDirection属性Cesium3DTileset.jsL927默认SplitDirection.NONE。 也就是说同一个scene.splitPosition可以同时切开影像、文字标签和 3D Tiles——不是只有底图能卷 判断条件也一模一样split 0管左边、split 0管右边都拿gl_FragCoord.x比⚠️ 但每个对象要自己设splitDirection影像设了、模型没设模型就不会跟着卷——它默认是NONE永远显示三、代码实战关键实现拆解⚠️ 温馨提示demo 用的是 Bing 影像 高德瓦片UrlTemplateImageryProvider。换成你自己的服务时URL 模板、subdomains、maximumLevel都要跟着改但两侧各挂一个方向 一个分割位置 一条可拖的线这条主线一字不差。3.1 给两侧各挂一个方向constprovidernewUrlTemplateImageryProvider({url,// 高德 style7 街道 / style6 影像subdomains:[1,2,3,4],maximumLevel:18})compareLayerviewer.imageryLayers.addImageryProvider(provider)compareLayer.splitDirectionSplitDirection.LEFTsplitDirection是逐图层属性想换边改图层的这个字段即可不用重建⚠️maximumLevel: 18是硬上限拉到比这更近就没有瓦片了那一侧会糊成一片⚠️ 第三方瓦片服务有坐标系差异高德是 GCJ-02与 WGS84 底图叠加会有百米级偏移。做严格校核时这点必须先处理3.2 把鼠标接上去handlernewScreenSpaceEventHandler(splitterWrap.valueasunknownasHTMLCanvasElement)handler.setInputAction((){moveActivetrue},ScreenSpaceEventType.LEFT_DOWN)handler.setInputAction((){moveActivetrue},ScreenSpaceEventType.PINCH_START)handler.setInputAction(moveHandler,ScreenSpaceEventType.MOUSE_MOVE)handler.setInputAction(moveHandler,ScreenSpaceEventType.PINCH_MOVE)handler.setInputAction((){moveActivefalse},ScreenSpaceEventType.LEFT_UP)handler.setInputAction((){moveActivefalse},ScreenSpaceEventType.PINCH_END)moveActive这道闸只有按下之后才响应移动避免鼠标随便路过就把分割线推走同时接了 PINCH触屏双指也能拖⚠️onBeforeUnmount里要handler.destroy()忘了的话事件残留在已销毁的元素上3.3 开关别忘了它设的是 1functiononEnableChange(value:boolean):void{if(value){splitterWrap.value.style.displayblockviewer.scene.splitPosition0.5}else{splitterWrap.value.style.displaynoneviewer.scene.splitPosition1// 左层占满、右层全丢}syncSplitter()viewer.scene.requestRender()}requestRender()不能省demo 用的是按需渲染不主动请求画面不会重画⚠️ 想要关闭 回到右侧底图把这里的1改成03.4 记得把线同步回来functionsyncSplitter():void{constpositionviewer.scene.splitPosition splitterWrap.value.style.left${position*100}%}DOM 只是引擎状态的镜像splitPosition才是真身线的位置由它推出来 所以任何改了splitPosition的地方初始化、开关、拖动都要调一次syncSplitter()⚠️ 反过来不成立只改线的style.left而没改splitPosition地球不会跟着变——看着动了其实没动3.5 补上缺失的 clamp 与开关语义两个改动都很小但能消掉 2.5 与 2.7 里的两个坑// 1) 拖动时夹取避免把手跑出容器constraw(splitterWrap.value.offsetLeftrelativeOffset)/parentWidthconstsplitPositionMath.min(1,Math.max(0,raw))splitterWrap.value.style.left${100*splitPosition}%viewer.scene.splitPositionsplitPosition// 2) 让「关闭」回到右侧底图而不是把右层全丢掉}else{splitterWrap.value.style.displaynoneviewer.scene.splitPosition0// 原本写的是 1}Math.min(1, Math.max(0, raw))把 splitPosition 锁在 [0, 1]把手就不会飘出容器关闭时给 0右侧图层占满、左侧图层被丢光——这才是回到 Bing 底图⚠️ 顺带把splitPosition || 0.5改成?? 0.5否则 0 永远设不进去四、功能亮点与性能优势4.1 面板启用卷帘开关同时改splitPosition左侧对比底图高德街道图 / 高德影像图 / 无提示按住中央圆形滑块左右拖动4.2 实测数字全部来自源码字面值与 Cesium 源码不是估算项实测值SplitDirection.LEFT−1.0SplitDirection.NONE0.0默认SplitDirection.RIGHT1.0scene.splitPosition默认0.0FrameState.jsL371启用时设为0.5关闭时设为1左层占满、右层全丢分割线宽度5 pxz-index: 20把手尺寸42 × 42 px圆形把手水平偏移−21 px居中应为 −18.5 px偏左 2.5 px把手垂直偏移50% − 22px居中应为 −21px偏上 1 px相机118.8°E / 32.05°N / 260 km4.3 核心优势零几何开销只是片元着色器里两次比较不建缓冲、不增绘制与图层系统解耦任意图层都能挂方向加几层都行与分辨率无关splitPosition是比例缩放窗口不用重算交互全在 DOM拖动的把手是普通元素样式随便改五、小贴士避坑指南关于「关闭卷帘」⚠️ 重要提示demo 的关闭是把splitPosition设成1结果是左侧图层占满、右侧图层全部丢掉——不是回到 Bing 底图左侧选「无」时再点关闭所有影像都会被丢弃只剩地球底色想要关闭后显示右侧底图改成splitPosition 0或把图层的splitDirection设回NONE关于那个默认值 0scene.splitPosition默认0不是 0.5只设LEFT/RIGHT却忘了设位置 →左侧一个像素都看不见splitPosition || 0.5会把 0 吃掉想精确控制请用??或显式判空关于拖动没有夹取splitPosition是算出来就直接用没有 clamp鼠标拖出容器值会跑到 [0, 1] 之外把手left也会跟着变成负百分比而跑出屏幕补一行即可Math.min(1, Math.max(0, splitPosition))关于把手与线的对齐42 px 的把手压在 5 px 的线上居中偏移应是−18.5 px代码是−21 px偏左 2.5 px垂直方向50% − 22px也比居中值偏上1 px平时看不出来一旦要做吸附到某比例就会暴露关于换底图UrlTemplateImageryProvider的 URL 模板、subdomains、maximumLevel都要跟着换⚠️高德是 GCJ-02 坐标系与 WGS84 底图叠加有百米级偏移做严格校核必须先做坐标转换第三方瓦片随时可能变更接口生产环境要有降级方案关于清理ScreenSpaceEventHandler要在onBeforeUnmount里destroy()切换对比底图时先imageryLayers.remove(compareLayer)否则会越叠越多改了splitPosition就调syncSplitter()改了线而没改splitPosition是看着动了其实没动坦诚局限这个 demo 只做了两块底图左右分屏没有做分割线吸附、键盘操作、触屏单指拖动、卷帘方向切换横向/纵向也没有处理坐标系偏移。这些都不改变三个值 一次 discard的主线但每一条都是上生产前要补的。六、写在最后Cesium 是头优雅的大象。我们曾经以为卷帘是把两张图各裁一半再拼起来今天才发现它一片几何也没裁——只是让每个片元自己回答一句我该在这一侧吗不该就不画。#64 那层遮罩是完全不碰引擎的极端#65 这条卷帘正好是另一个极端几乎没有一行代码是自己的全靠引擎给的三个值。两个极端摆在一起倒把控件这件事的边界画清楚了——有的控件是补丁有的控件只是把引擎的能力搬到一个能抓的地方。而最该记住的还是那句关闭卷帘关掉的是右侧那一层。一个布尔值写错方向用户看到的就是另一张地图。这个案例还不完美开关语义反直觉、拖动没夹取、把手偏了 2.5 px、坐标系没校正。但它把卷帘到底是怎么分屏的、那条线是怎么接上去的这件事讲清楚了这就够了。下一篇我们接着在控件这块走——这次不是左右分屏而是把这一屏有多少米标在角落里。距离比例尺控件我们下一篇见。如果你也做过影像对比的功能欢迎留言说说你是怎么解决关掉之后该显示哪一侧的——是老老实实改splitDirection还是干脆重建图层 项目已开源欢迎 Star你的 Star ⭐ 就是对我最大的支持仓库地址https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/widget-map-split/目录下核心就一个 172 行的WidgetMapSplitDemo.vue把高德的 URL 模板换成你自己的瓦片服务就能跑。Cesium酱の百宝箱 · 第 65 篇一条五像素的线不问缘由只让每个片元自己站队2026 年的秋夜与你共探两块底图如何在同一屏上各守半边