第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 / RIGHT

2️⃣ 一个分割位置 :scene.splitPosition(0~1 的相对值)

3️⃣ 着色器逐片元丢弃 :不属于自己那一侧的像素直接 alpha = 0

✨ 一句话总结:真正干活的是着色器里的一次 discard,屏幕上那条线和那个圆把手,只是给鼠标一个能抓的地方。


二、核心技术原理:三件套与那条 5 px 的线

2.1 三件套:两个方向 + 一个位置

先把枚举值钉死------它和直觉是反的 (我把源码翻出来确认过,Scene/SplitDirection.js):

常量 数值 含义
SplitDirection.LEFT −1.0 只显示在分割线左侧
SplitDirection.NONE 0.0 永远显示(默认)
SplitDirection.RIGHT 1.0 只显示在分割线右侧

注意 LEFT 是负数。这条后面会用到。

demo 里的挂法也很直白:

ts 复制代码
// 源码级剧透:WidgetMapSplitDemo.vue
loadBingImagery(viewer, sceneCallbacks, (layer) => {
  layer.splitDirection = SplitDirection.RIGHT      // 右侧 = Bing 影像
})
// ...
compareLayer = viewer.imageryLayers.addImageryProvider(provider)
compareLayer.splitDirection = SplitDirection.LEFT  // 左侧 = 高德街道 / 影像
viewer.scene.splitPosition = viewer.scene.splitPosition || 0.5

2.2 分割靠的是逐片元丢弃,不是裁剪

这是本篇最该记住的一点。我把 GlobeFS.glsl 里那几行挖了出来(L245-253):

glsl 复制代码
#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.0(RIGHT = +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 拖动链路:坐标是"相对把手"的

拖动手感全在这几行:

ts 复制代码
function moveHandler(movement: { endPosition: { x: number } }): void {
  const relativeOffset = movement.endPosition.x
  const splitPosition =
    (splitterWrap.value.offsetLeft + relativeOffset) / splitterWrap.value.parentElement!.offsetWidth
  splitterWrap.value.style.left = `${100 * splitPosition}%`
  viewer.scene.splitPosition = splitPosition
}

关键在于 movement.endPosition.x 到底是什么坐标。我去翻了 Core/ScreenSpaceEventHandler.js 的 getPosition(L11-20):

js 复制代码
function getPosition(screenSpaceEventHandler, event, result) {
  const element = screenSpaceEventHandler._element;
  if (element === screenSpaceEventHandler._canvas) {
    result.x = event.clientX;                      // 画布:直接用客户端坐标
  } else {
    const rect = element.getBoundingClientRect();
    result.x = event.clientX - rect.left;          // 其它元素:减去元素左边,得到「相对元素」的坐标
  }
}
  • 🔑 把手(.cesium-slider)不是 canvas ,所以拿到的是相对把手自身的 x------鼠标在它左边就是负数,右边可以远大于 5
  • 🔑 于是 offsetLeft + relativeOffset 正好把"相对把手"换算回"相对父容器",再除以父容器宽度就是比例------这行是对的
  • ⚠️ 但它没有夹取(clamp) :鼠标拖出容器,splitPosition 会变成负数或大于 1,把手也会跟着 left: -30% 之类跑出屏幕

另外,demo 里把把手元素强转成了 HTMLCanvasElement:

ts 复制代码
handler = new ScreenSpaceEventHandler(splitterWrap.value as unknown as HTMLCanvasElement)

从上面的源码看,ScreenSpaceEventHandler 接受任意 element,这个 as unknown as HTMLCanvasElement 纯属为了满足类型,运行时没有任何作用。

2.4 滑块几何:圆心其实偏了 2.5 px

把手和线的尺寸都在 CSS 里:

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 「关闭卷帘」关掉的是哪一侧

这是本篇最反直觉的一处。看 onEnableChange:

ts 复制代码
if (value) {
  splitterWrap.value.style.display = 'block'
  viewer.scene.splitPosition = 0.5
} else {
  splitterWrap.value.style.display = 'none'
  viewer.scene.splitPosition = 1        // ⚠️ 注意是 1
}

把 splitPosition = 1 代回 2.2 那两行着色器:

  • split < 0(左侧图层 )→ 只在 x ≤ 分割位置 时存活;分割位置在最右边 → 整屏都是它
  • split > 0(右侧图层 )→ 只在 x ≥ 分割位置 时存活;分割位置在最右边 → 整屏都不满足,全被丢掉

所以"关闭卷帘"不是回到 Bing 底图,而是:

左侧对比底图 点"关闭卷帘"之后
高德街道图 / 影像图 整屏变成高德,Bing 影像彻底消失
选「无」(没有左侧图层) 所有影像都被丢弃,只剩地球底色

🔑 真想要"关闭后回到底图",应该是 splitPosition = 0(右侧图层占满),或者干脆把图层的 splitDirection 改回 SplitDirection.NONE。

2.6 那个 || 0.5 让 0 永远设不进去

ts 复制代码
viewer.scene.splitPosition = viewer.scene.splitPosition || 0.5

splitPosition 为 0 时是 falsy ,所以这行会把 0 换成 0.5。后果:你没法通过这行把分割位置设成 0 (虽然手动拖到最左还是可以的)。判断该写成 ?? 或者直接判 undefined。

2.7 顺带一句:图层顺序

addImageryProvider 是往上叠 的,所以后加的高德在 Bing 之上。本例里两层分处左右、互不重叠,顺序无所谓;但如果你哪天把两层都设成 NONE,盖在上面的就是后加的那一层。

2.8 卷帘不只属于影像

我顺着 czm_splitPosition 这个 uniform 搜了一遍着色器目录,发现它出现在不止一处:

着色器 管的是
GlobeFS.glsl(L245) 地球表面------影像与地形
BillboardCollectionFS.glsl(L129-133) billboard------#62 那些文字标签
Shaders/Model/ModelSplitterStageFS.glsl 模型------3D Tiles 也会走这一步

而 Cesium3DTileset 自己就带着 splitDirection 属性(Cesium3DTileset.js L927,默认 SplitDirection.NONE)。

  • 🔑 也就是说:同一个 scene.splitPosition 可以同时切开影像、文字标签和 3D Tiles------不是只有底图能卷
  • 🔑 判断条件也一模一样:split < 0 管左边、split > 0 管右边,都拿 gl_FragCoord.x 比
  • ⚠️ 但每个对象要自己设 splitDirection :影像设了、模型没设,模型就不会跟着卷------它默认是 NONE(永远显示)

三、代码实战:关键实现拆解

⚠️ 温馨提示 :demo 用的是 Bing 影像 + 高德瓦片(UrlTemplateImageryProvider)。换成你自己的服务时,URL 模板、subdomains、maximumLevel 都要跟着改,但"两侧各挂一个方向 + 一个分割位置 + 一条可拖的线"这条主线一字不差。

3.1 给两侧各挂一个方向

ts 复制代码
const provider = new UrlTemplateImageryProvider({
  url,                              // 高德 style=7 街道 / style=6 影像
  subdomains: ['1', '2', '3', '4'],
  maximumLevel: 18
})
compareLayer = viewer.imageryLayers.addImageryProvider(provider)
compareLayer.splitDirection = SplitDirection.LEFT
  • 🔑 splitDirection 是逐图层属性:想换边,改图层的这个字段即可,不用重建
  • ⚠️ maximumLevel: 18 是硬上限:拉到比这更近就没有瓦片了,那一侧会糊成一片
  • ⚠️ 第三方瓦片服务有坐标系差异:高德是 GCJ-02,与 WGS84 底图叠加会有百米级偏移。做严格校核时这点必须先处理

3.2 把鼠标接上去

ts 复制代码
handler = new ScreenSpaceEventHandler(splitterWrap.value as unknown as HTMLCanvasElement)
handler.setInputAction(() => { moveActive = true },  ScreenSpaceEventType.LEFT_DOWN)
handler.setInputAction(() => { moveActive = true },  ScreenSpaceEventType.PINCH_START)
handler.setInputAction(moveHandler,                  ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction(moveHandler,                  ScreenSpaceEventType.PINCH_MOVE)
handler.setInputAction(() => { moveActive = false }, ScreenSpaceEventType.LEFT_UP)
handler.setInputAction(() => { moveActive = false }, ScreenSpaceEventType.PINCH_END)
  • 🔑 moveActive 这道闸:只有按下之后才响应移动,避免鼠标随便路过就把分割线推走
  • 🔑 同时接了 PINCH:触屏双指也能拖
  • ⚠️ onBeforeUnmount 里要 handler.destroy():忘了的话,事件残留在已销毁的元素上

3.3 开关:别忘了它设的是 1

ts 复制代码
function onEnableChange(value: boolean): void {
  if (value) {
    splitterWrap.value.style.display = 'block'
    viewer.scene.splitPosition = 0.5
  } else {
    splitterWrap.value.style.display = 'none'
    viewer.scene.splitPosition = 1      // 左层占满、右层全丢
  }
  syncSplitter()
  viewer.scene.requestRender()
}
  • 🔑 requestRender() 不能省:demo 用的是按需渲染,不主动请求画面不会重画
  • ⚠️ 想要"关闭 = 回到右侧底图",把这里的 1 改成 0

3.4 记得把线同步回来

ts 复制代码
function syncSplitter(): void {
  const position = viewer.scene.splitPosition
  splitterWrap.value.style.left = `${position * 100}%`
}
  • 🔑 DOM 只是引擎状态的镜像 :splitPosition 才是真身,线的位置由它推出来
  • 🔑 所以任何改了 splitPosition 的地方(初始化、开关、拖动)都要调一次 syncSplitter()
  • ⚠️ 反过来不成立:只改线的 style.left 而没改 splitPosition,地球不会跟着变------看着动了,其实没动

3.5 补上缺失的 clamp 与开关语义

两个改动都很小,但能消掉 2.5 与 2.7 里的两个坑:

ts 复制代码
// 1) 拖动时夹取,避免把手跑出容器
const raw = (splitterWrap.value.offsetLeft + relativeOffset) / parentWidth
const splitPosition = Math.min(1, Math.max(0, raw))
splitterWrap.value.style.left = `${100 * splitPosition}%`
viewer.scene.splitPosition = splitPosition

// 2) 让「关闭」回到右侧底图,而不是把右层全丢掉
} else {
  splitterWrap.value.style.display = 'none'
  viewer.scene.splitPosition = 0        // 原本写的是 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.0
SplitDirection.NONE 0.0(默认)
SplitDirection.RIGHT +1.0
scene.splitPosition 默认 0.0 (FrameState.js L371)
启用时设为 0.5
关闭时设为 1(左层占满、右层全丢)
分割线宽度 5 px (z-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 km

4.3 核心优势

  • 🚀 零几何开销:只是片元着色器里两次比较,不建缓冲、不增绘制
  • 🧩 与图层系统解耦:任意图层都能挂方向,加几层都行
  • 📐 与分辨率无关 :splitPosition 是比例,缩放窗口不用重算
  • 👆 交互全在 DOM:拖动的把手是普通元素,样式随便改

五、小贴士:避坑指南

🚫 关于「关闭卷帘」

  • ⚠️ 重要提示 :demo 的关闭是把 splitPosition 设成 1 ,结果是左侧图层占满、右侧图层全部丢掉------不是"回到 Bing 底图"
  • 左侧选「无」时再点关闭,所有影像都会被丢弃,只剩地球底色
  • 想要"关闭后显示右侧底图":改成 splitPosition = 0,或把图层的 splitDirection 设回 NONE

🎯 关于那个默认值 0

  • scene.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 年的秋夜,与你共探"两块底图如何在同一屏上各守半边"

相关推荐
程序员清风20 小时前
Python 数据可视化入门:Matplotlib 核心用法
python·信息可视化·matplotlib
总要学点什么21 小时前
第63篇:3DTiles-大雁塔模型——190 块瓦片不是八叉树,抬高的却是球心那条法线
3d·数据挖掘·数据分析·gis·webgl
科创致远1 天前
科创致远 ESD 静电监控全景落地指南
人工智能·信息可视化·制造·精益工程
2603_965148111 天前
电商选品调研:通过关键词搜索API获取市场热销商品,结合商品详情API分析竞品卖点与定价
开发语言·数据库·人工智能·python·信息可视化
梅如你2 天前
全国逐年 1KM 分辨率 NDVI 植被指数数据集(2000-2024)
arcgis·gis·遥感
迪康Defender2 天前
终端安全运维:浅谈迪康端点安全一体化管理系统工具箱实用能力
运维·安全·信息可视化·自动化·运维开发·学习方法·远程工作
数字新视界2 天前
空调双机切换器AIRC1000红外控制原理解析
大数据·物联网·信息可视化·信息与通信·一体化机柜
中科致研zkzhiyan4 天前
大学教授职称晋升汇报 PPT 设计指南
信息可视化·powerpoint·ppt
迪康Defender4 天前
终端安全行为分析:审计日志如何转化为业务报表?落地实践与避坑指南
运维·经验分享·安全·信息可视化·自动化·安全威胁分析·运维开发