第79篇:数据可视化-模拟迁徙效果——把三十条航线画成会飞的弧

前面两篇我们一直在跟"地下的东西"较劲------把钻孔插值成地层(#77)、再换成体素自带属性(#78)。这次换回地表,做点会动的。

你一定在各类大屏上见过它:一张地图上,几条弧线从某个城市"飞"向四面八方,弧线上有个小亮点或一架小飞机在跑,终点还画着一圈圈扩散的涟漪。这叫迁徙图(也叫飞线、OD 线),是数据可视化里最经典也最好看的一种。

真要在 Cesium 里做出来,第一反应大概是去找 Polyline / PolylineGlow 之类的 API,自己算弧线、自己做动画。但本篇走的是另一条路:让 ECharts 直接画在 Cesium 上面 。ECharts 天生就有 lines + effect 这一套流动特效,我们只要解决一个问题------怎么让 ECharts 的经纬度,跟着 Cesium 的相机实时投影到屏幕上。

💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。


先说结论:本篇做的事,本质是给 ECharts 写一个自定义的"坐标系"。

ECharts 默认认的是直角坐标系、极坐标系、地理坐标系(geo)。我们注册一个叫 GLMap 的坐标系,让它的"数据 → 屏幕点"这一步,转去调 Cesium 的投影。剩下的画线、特效、图例、涟漪,全交给 ECharts。

本项目用的是 ECharts 6.1.0 (echarts.version 实测),registerCoordinateSystem 从 ECharts 5 起就是公开 API,自定义坐标系这条路是官方支持的,不是钻空子。

一件事想清楚就能省很多事:这不是 Cesium 在画,是 ECharts 在画,Cesium 只负责回答"这个经纬度现在在屏幕的哪个像素上"。


一、先说清楚:图是 ECharts 画的,而且它盖在 Cesium 上面

src/lib/cesium-chart.ts 里有个 ChartLayer,做的事情非常简单:

ts 复制代码
private createChartElement(): HTMLDivElement {
  const canvas = this._viewer.scene.canvas
  layerSeq += 1
  const el = document.createElement('div')
  el.setAttribute('id', `${this.id}-${layerSeq}`)
  el.style.cssText = `position:absolute; top:0; left:0; width:${canvas.clientWidth}px; height:${canvas.clientHeight}px; pointer-events:none;`
  this._viewer.container.appendChild(el)
  return el
}

一块 position: absolute 的透明 div,严丝合缝地盖在 Cesium 画布上 ,echarts.init 直接在它上面渲染。

这里必须和 #70 那篇对照着看------那篇也是"把 ECharts 弄到地球上",但做法完全相反:

#70 图表焊地球 #79 迁徙图
ECharts 在哪渲染 离屏容器(left:-9999px) 盖在 Cesium 画布上的透明 div
产物 一张 PNG,当 billboard 贴到球面 矢量图形,每帧重投影
图属于谁 属于地球(跟着地球转、会被地形遮挡) 属于屏幕(永远正对镜头、浮在最上层)
交互 无(静态快照,tooltip 全丢) 保留 (图例、tooltip 都在,只要不被 pointer-events 挡掉)
成本 一次渲染,之后几乎不花钱 相机一动就要全量重算
适合 图表要"钉在某块地上" 图表要"罩住整个视野"

一句话概括这个取舍:#70 把图贴进三维世界,#79 把三维世界压进平面图。迁徙图要罩住全国、要跟着相机走、要保留交互,所以走 #79 这条。

顺带一提 pointer-events: none------这是必须的,否则这块 div 会把 Cesium 的鼠标操作全吞掉(#72 讲过的同款问题)。但它也带来了本篇第一个坑,第五节细说。


二、GLMap 坐标系:一个自定义坐标系,把经纬度实时投到屏幕

核心就是这个类。先看注册:

ts 复制代码
echarts.registerCoordinateSystem(
  'GLMap',
  GLMapCoordSys as unknown as Parameters<typeof echarts.registerCoordinateSystem>[1]
)

ECharts 的自定义坐标系只要满足几个方法就行,其中最关键的是 dataToPoint(数据 → 屏幕点):

ts 复制代码
dataToPoint(data: [number, number]): number[] {
  const cartesian3 = Cartesian3.fromDegrees(data[0], data[1])
  if (!cartesian3) return []
  const up = Ellipsoid.WGS84.geodeticSurfaceNormal(cartesian3, new Cartesian3())
  const cameraDirection = this._viewer.camera.direction
  if (Cartesian3.dot(up, cameraDirection) >= 0) return []          // ← 背面剔除
  const coords = this._viewer.scene.cartesianToCanvasCoordinates(cartesian3)
  if (!coords) return []
  return [coords.x - this._mapOffset[0], coords.y - this._mapOffset[1]]
}

三步,每一步都有讲究。

第一步,经纬度转世界坐标。 Cartesian3.fromDegrees(lng, lat),高度默认 0,落在椭球面上。

第二步,背面剔除------这是整个方案里最见功力的一行。

ts 复制代码
if (Cartesian3.dot(up, cameraDirection) >= 0) return []

up 是该点处的椭球法线 (朝外),cameraDirection 是相机的视线方向。点积 ≥ 0 说明法线与视线同向------这个点在地球背面,直接返回空数组(ECharts 收到空数组就不画)。

这一行看着简单,但它解决的是球面可视化最头疼的问题:地球背面的点会被"翻"到正面来。如果不剔除,你在中国上空看地图,阿根廷的城市会诡异地出现在画面上。

我把这个判据化简并逐点验证了一遍。相机垂直俯视时 direction = −up(天底),于是:

复制代码
dot(up_P, −up_center) = −cos(角距) ≥ 0  ⟺  cos(角距) ≤ 0  ⟺  角距 ≥ 90°

也就是说,判据等价于"该点距相机天底点的角距 ≥ 90°",而且与相机高度无关。 实测(天底取 104°E / 35°N):

城市 角距 dot 结果
北京 11.16° −0.9811 保留
广州 14.40° −0.9686 保留
乌鲁木齐 15.08° −0.9656 保留
东京 28.96° −0.8749 保留
悉尼 81.83° −0.1420 保留(勉强)
纽约 104.28° +0.2466 剔除
圣保罗 152.03° +0.8832 剔除
布宜诺斯艾利斯 165.56° +0.9684 剔除

逐一吻合,一处不差。

第三步,世界坐标转屏幕像素。 scene.cartesianToCanvasCoordinates------查源码会发现它就是 SceneTransforms.worldToWindowCoordinates 的一层马甲,而后者用的是canvas 的 clientWidth/clientHeight(CSS 像素) (#66、#69 都核对过这一点)。这正好和覆盖层 div 用 clientWidth/clientHeight 设的尺寸对齐------两边单位一致,不会错位。

最后说一个实现上的"野路子": GLMapCoordSys 需要拿到 viewer,但 registerCoordinateSystem 的 create(ecModel, api) 只给了 api。于是 lib 里在创建图表后顺手把 viewer 挂到了 ZRender 实例上:

ts 复制代码
const zr = this._chart.getZr() as unknown as { viewer?: unknown }
zr.viewer = viewer

然后坐标系那边 toViewer(api.getZr().viewer) 取回来。这是个 hack,但很实用------它让"自定义坐标系"这条只认 ECharts 的路,能摸到 Cesium 的相机。


三、三层 series 叠出一条迁徙线

坐标系搭好,剩下的就是纯 ECharts 活。本篇给三个枢纽(北京、上海、广州)各配三层 series ,3 × 3 = 9 个 series:

第一层:尾迹层。 关键是 lineStyle.width: 0------线本身完全不可见 ,只有 effect 在跑:

ts 复制代码
{
  type: 'lines', coordinateSystem: 'GLMap', zlevel: 1,
  effect: { show: true, period: 6, trailLength: 0.7, color: '#fff', symbolSize: 3 },
  lineStyle: { normal: { color: color[i], width: 0, curveness: 0.2 } },
  data
}

trailLength: 0.7 表示拖尾占线长的 70%,symbolSize: 3 是个小圆点,period: 6 是跑完一条线要 6 秒。线宽设 0、只留特效,这是 ECharts 迁徙图的标准套路------好看且不抢主线的戏。

第二层:主线 + 飞机。 线这回可见了,还挂了个飞机符号在飞:

ts 复制代码
{
  type: 'lines', coordinateSystem: 'GLMap', zlevel: 2,
  symbol: ['none', 'arrow'], symbolSize: 10,
  effect: { show: true, period: 6, trailLength: 0, symbol: PLANE_PATH, symbolSize: 15 },
  lineStyle: { normal: { color: color[i], width: 1, opacity: 0.6, curveness: 0.2 } },
  data
}

symbol: ['none','arrow'] 表示起点不要符号、终点画箭头;effect.trailLength: 0 表示这一层不要拖尾(拖尾已经由第一层负责了);effect.symbol: PLANE_PATH 是个飞机形状。

那个 PLANE_PATH 值得单说------它是一整条 SVG path 字符串 (335 个字符),以 path:// 开头:

ts 复制代码
export const PLANE_PATH =
  'path://M1705.06,1318.313v-89.254l-319.9-221.799l0.073-208.063...'

ECharts 支持用 path:// 把任意 SVG 路径当符号。这是把"图标"塞进图表最省事的办法------不用注册图片、不用担心跨域,一段字符串就完事。

第三层:终点的涟漪散点。

ts 复制代码
{
  type: 'effectScatter', coordinateSystem: 'GLMap', zlevel: 2,
  rippleEffect: { brushType: 'stroke' },
  label: { normal: { show: true, position: 'right', formatter: '{b}' } },
  symbolSize: (val: number[]) => val[2] / 8,
  itemStyle: { normal: { color: color[i] } },
  data: item[1].map((pair) => ({
    name: pair.to.name,
    value: geoCoordMap[pair.to.name].concat([pair.to.value])
  }))
}

effectScatter + rippleEffect 就是那圈扩散的涟漪;symbolSize 用 val[2] / 8 把数值映射到半径------实测 value 从 10 到 95,对应 1.25 px 到 11.875 px。

还有一个由参数推导出来的现象值得知道。 period: 6 是"跑完一整条线要 6 秒"------它是按线 设的,不是按长度。于是每条线的实际飞行速度与其长度成正比:北京→常州那种短线上,小飞机是慢慢爬;北京→拉萨那种长线上,它呼啸而过。

这其实是符合直觉的(像"航程固定 6 小时"),大屏上也好看。但如果你要的是恒定速度 (比如表达"同样的车速"),就得按线长反算出各自的 period:

ts 复制代码
// 想要恒定速度:period 与线长成正比
effect: { period: 6 * (该线的屏幕长度 / 参考长度) }

(屏幕长度会随相机变化,所以恒定速度在动态地图里其实做不到严格恒定------这也是很多人干脆用固定 period 的原因。)

数据长这样 :三个枢纽各 10 条,共 30 条 迁徙记录,涉及 21 个不同的目的地 (有重叠,比如"长春"被三个枢纽都连了)。每组的 value 是 95、90、80......10 递减,geoCoordMap 里存了 114 个城市的经纬度。

顺带说一个容易被忽略的参数:zlevel。 本篇尾迹层是 zlevel: 1,主线层和涟漪层是 zlevel: 2。在 ECharts 里,不同的 zlevel 会被画到不同的 canvas 图层上 ------这不是单纯的"叠放顺序",而是分层渲染:动画只发生在某一层时,其它层不必重绘。

这个设计在这里非常关键:跳动最厉害的是 effect(拖尾、飞机),它们集中在高层;而相对静态的元素留在低层。把"动的东西"和"不太动的东西"分到不同 zlevel ,是 ECharts 做动画时最有效的一招。相比之下 z(同层内的叠放顺序)只管前后,不触发分层。

curveness: 0.2 是弧线的弯曲程度------它是二次贝塞尔控制点相对弦长的偏移比例。同一个值对不同长度的线,视觉弯度是一致的(因为它是相对量),这点比用绝对像素好用。


四、让图表跟着相机走:两个必须改的默认值

桥接层建好之后,还有一件事:相机一动,图得跟着动 。ChartLayer 的做法是监听 camera.changed:

ts 复制代码
this._onCameraChanged = () => this.resize()
viewer.camera.changed.addEventListener(this._onCameraChanged)

就这一句,但必须配两个改动,否则效果会很糟。

其一,percentageChanged 必须调小。 demo 里写了:

ts 复制代码
viewer.camera.percentageChanged = 0.02

camera.changed 是自带死区 的事件------默认 percentageChanged = 0.5,意思是相机变化超过 50% 才触发一次(#73 详细拆过)。用默认值的话,你拖动地图半天,图表才懒洋洋地更新一下,线会明显"粘"在旧位置上。调到 0.02 才跟手。 这是本篇和 #73 最直接的一处呼应。

其二,动画必须关掉。 ChartLayer.setOption 里强制塞了一个 animation: false:

ts 复制代码
setOption(option: Record<string, unknown>): this {
  this._chart.setOption({ ...option, animation: false } as never)
  return this
}

原因很实在:相机一动就重投影一次,如果开启动画,ECharts 会把每个点从旧屏幕位置补间 到新位置------于是线会拖影、会迟滞、会看着像在"追"地图。关掉动画,让位置直接跳到位,观感才是对的。

顺带两个写得讲究的细节:

  • show 用的是 visibility: hidden,不是 display: none 。这避开了 #69 那个坑------display:none 会让元素量得全零,再显示时布局全错。
  • destroy() 里老老实实摘掉了 camera.changed 监听并 dispose() 了图表。覆盖层这种东西最容易泄漏,卸载时这几行不能省。

五、五个坑:图例点不动、反投影是错的、每帧重算、弧线不贴地、注册顺序

插一句:如果不用 ECharts,直接用 Cesium 原生做,要补哪些活? 这个问题值得想一遍,因为它决定了"桥接"到底省了多少事:

  • 弧线 :Polyline 走的是大圆/测地线吗?默认不是------它是逐点直线连接 (arcType 相关),你得自己插值出大圆上的中间点(沿 EllipsoidGeodesic 取样若干段);
  • "抬起来"的弧:迁徙图那种离开地面向上拱的弧,Cesium 里没有现成的,得自己给中间点加高度(按弦长比例做个抛物线/正弦抬升);
  • 流动动画 :得靠 CallbackProperty 每帧返回新的 positions,或自己写 CustomShader 用时间偏移纹理坐标;
  • 发光 :PolylineGlowMaterialProperty 能凑合,但和 ECharts 的拖尾不是一回事;
  • 终点的涟漪 :得自己做一圈圈放大的 EllipseGeometry 并逐帧改半径和透明度;
  • 图例 / tooltip / 交互:全部自己写。

对比下来就很清楚了:ECharts 那套特效是"免费"的,桥接层只花了一个 dataToPoint。 代价前面也说了------相机一动全量重算、弧线不参与球面判定、且图表属于屏幕不属于地球。特效复杂但数据不大 → 桥接划算;数据量巨大或要严格贴地 → 原生划算。

坑一:pointer-events: none 让图例点不动------demo 的提示文案是错的。 覆盖层整体设了 pointer-events: none(为了不吞 Cesium 的操作),于是盖在它上面的 ECharts 图例也收不到任何鼠标事件。

但 demo 的提示写着"右下角图例可开关各枢纽线路",option 里也确实设了:

ts 复制代码
legend: { orient: 'vertical', top: 'bottom', left: 'right',
          data: ['北京 Top10', '上海 Top10', '广州 Top10'],
          textStyle: { color: '#fff' }, selectedMode: 'multiple' }

selectedMode: 'multiple' 是"可多选开关"的意思------配置和实现打起来了。实际跑起来图例是点不动的。

修法不难:CSS 里子元素的 pointer-events 可以覆盖父级的 none。给图例那一块单独设 pointer-events: auto 就行(ECharts 的图例是普通 DOM,可以通过 legend 的容器或在 setOption 后用 DOM 操作挂上去,也可以改 lib,让覆盖层默认 none、但暴露一个开关)。写这类桥接层时,"要不要透传鼠标"必须单独设计,不能一刀切。

坑二:pointToData(反投影)是错的,只是暂时没暴露。 看实现:

ts 复制代码
pointToData(point: number[]): number[] {
  const cartesian3 = new Cartesian3(point[0] + this._mapOffset[0], point[1] + this._mapOffset[1], 0)
  const cartographic = ellipsoid.cartesianToCartographic(cartesian3)
  ...
}

它把屏幕像素 (x, y, 0) 直接当成 ECEF 地心坐标去反算经纬度。实测:屏幕点 (400, 300) 被算成 36.8699°E、0.0000°N,高度 −6377637 米 ------因为这个"坐标"距地心只有 500 米,压根不在地球表面上,结果毫无意义。

为什么现在没炸? 因为 getRoamTransform() 返回单位矩阵、GLMap 不支持漫游,pointToData 实际不会被调用。这是个睡着的 bug ------哪天你想加"点击图表反查经纬度"或者支持漫游,它就会立刻发作。正确做法是用 scene.pickEllipsoid / pickPosition 走真正的拾取(#72 拆过那三条路径)。

坑三:相机一动就全量重算。 camera.changed → resize() → 重新布局 → 每个点重投影一次。本篇 30 条线、几十个点,配合 percentageChanged = 0.02 和 animation: false 才跟手。数据量一上去(几百条线)就要考虑节流 ,或者改成只在 postRender 里更新、并降低更新频率。

坑四:弧线只投影了两个端点,中间不参与球面判定。 这是上面那条背面剔除的副作用。ECharts 的 lines 只拿 coords: [起点, 终点] 两个点各调一次 dataToPoint,中间的弧线是在屏幕空间用贝塞尔插值的。

后果有两个:一是只要两端都在正面,中间就会直直地画过去 ------哪怕这条线的中间段其实应该绕过地球背面。二是长距离线看着会"飞得太直",没有贴着球面的弧度感。中国境内(跨几十度)看不出问题;跨洲的飞线就要注意了,必要时得自己把线拆成多段、逐段投影。

坑六:只监听了 camera.changed,没有监听窗口 / 容器尺寸变化。 翻一遍 cesium-chart.ts,addEventListener 只出现了一次:

ts 复制代码
this._onCameraChanged = () => this.resize()
viewer.camera.changed.addEventListener(this._onCameraChanged)   // 就这么一处

而 resize() 干的事是------把覆盖层的宽高同步成 canvas.clientWidth/clientHeight,再调 chart.resize()。

问题在于:窗口或者父容器尺寸变了,相机并没有"变化",于是 camera.changed 不会触发。 结果就是覆盖层一直停留在创建时的像素尺寸------图表要么留白、要么被裁掉一块,直到你下一次拖动地图才恢复。

修法很直接:再挂一个 ResizeObserver(或 window.resize),让容器尺寸变化时也走一遍 resize():

ts 复制代码
this._ro = new ResizeObserver(() => this.resize())
this._ro.observe(this._viewer.container)
// destroy() 里记得 this._ro.disconnect()

这类"盖在画布上的覆盖层",尺寸同步一定要同时盯住两个来源:相机 和 容器。

坑五:注册顺序。 coordinateSystem: 'GLMap' 不是 ECharts 的内置值,echarts.registerCoordinateSystem('GLMap', ...) 必须在 setOption 之前 跑过。本 lib 把它写在模块顶层,所以 import 之后自动生效------但如果你把 GLMapCoordSys 挪到函数里惰性注册,忘了先注册就会报 "Unknown coordinate system"。


六、写在最后

把这一篇收一下,Cesium 上做迁徙图可以压成三句话:先架桥 (一块盖住画布的透明 div + echarts.init,pointer-events: none 让 Cesium 的操作照常)、再写坐标系 (注册 GLMap,让 dataToPoint 转去调 Cesium 的投影,其中"法线点积 ≥ 0 就剔除"那一行解决了背面翻转,实测等价于"角距 ≥ 90°" )、最后调两个默认值 (camera.percentageChanged 从 0.5 调到 0.02 才跟手,animation: false 才不拖影)。剩下的弧线、飞机、涟漪,全是 ECharts 的现成能力。

它和 #70 放在一起看特别有意思:同样是"ECharts 上球",一个把图贴进三维世界,一个把三维世界压进平面图。 前者图属于地球(会转、会被挡、但一劳永逸),后者图属于屏幕(永远正对、保留交互、但每帧重算)。选哪个,取决于你希望读者把这张图当成"地上的东西"还是"眼前的仪表"。

再补两条复用建议。 一是 getViewRect() 和 getRoamTransform() 这两个方法看着不起眼,却是自定义坐标系的"契约"------前者告诉 ECharts 视口矩形(本篇直接返回 0,0,宽,高),后者返回漫游变换矩阵(本篇返回单位矩阵,等于声明"不支持漫游")。想加漫游,就得让这两个方法说真话 ,否则缩放平移会算错。二是本篇这套桥接不止能画迁徙图:heatmap、scatter、graph(力导向关系图)只要改用 coordinateSystem: 'GLMap',同样能漂到 Cesium 上------坐标系一次写好,图表类型随便换。

从 #76 到 #79,我们把"数据怎么落到地球上"这件事换了好几种形态:地表画符号(#76)→ 地下插值建模(#77)→ 体素自带属性(#78)→ 屏幕空间的流动特效(#79)。形态一直在换,但每一篇的教训其实指向同一处:你选的渲染形态,决定了哪些东西会撒谎------符号大小会撒谎、深度读数会撒谎、而这一篇撒谎的是"这条线贴着地球飞"。

CASE_LIST 里的下一站是数据可视化-信息发送效果(#80)------同样是飞线这一族,但语义从"人从一个城市迁到另一个城市"变成"信息从一个节点发到另一个节点"。表达对象换了,动画节奏、符号语言、数据组织都得跟着变。同一条线往下走,咱们下回再拆。

如果你也在 Cesium 上做过迁徙图,欢迎留言聊聊:你是走 ECharts 桥接,还是直接用 Cesium 的 Polyline 自己算弧?有没有被"图例点不动"这类小问题卡过?


🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)

仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX

代码在 src/cases/chart-migrate/ 目录下,搭配 src/lib/cesium-chart.ts 一起看。搬去自己项目时,盯住四件事:① camera.percentageChanged 必须调小 (默认 0.5 的死区会让图表严重滞后,本篇用 0.02)且 animation: false (否则相机一动线就拖影);② 背面剔除那行 dot(法线, 相机朝向) >= 0 → return [] 不能省,实测等价于"角距 ≥ 90°" ,去掉它地球背面的点会翻到正面;③ 覆盖层 pointer-events: none 会连带让 ECharts 图例点不动 (demo 提示里"图例可开关"与实现不符),要图例可交互得单独给那一块设 pointer-events: auto;④ pointToData 把屏幕像素当 ECEF 坐标反算是错的 (实测 (400,300) → 36.87°E / 0°N / 高 −6377637 m),只因 GLMap 不漫游才没暴露,要反查经纬度请用 scene.pickEllipsoid / pickPosition。


Cesium酱の百宝箱 · 第 79 篇

一屏两笔分经纬,九线齐飞向四方

2026 年的秋夜,与你共探「一块盖住地球的画布,怎么让线自己飞起来」

相关推荐
Apok'2 小时前
零基础学Python量化(1):研究大纲与流程
python·机器学习·金融·数据挖掘·数据分析
benchmark_cc3 小时前
如何为 QuantDash Python SDK 编写一个最小化的连接验证脚本?
python·数据分析·pandas·量化交易·股票数据·quantdash
Elastic 中国社区官方博客4 小时前
5 分钟内将 56 页扫描文档转化为带引用的答案:面向搜索无法读取的文档的检索增强生成流水线
大数据·运维·人工智能·elasticsearch·搜索引擎·信息可视化·全文检索
STLearner4 小时前
KDD 2026 | (2月轮)时空数据(Spatial-Temporal)论文总结时空【下】(人群移动,城市科学(资源调度,)
大数据·人工智能·python·深度学习·机器学习·语言模型·数据挖掘
limit00755 小时前
三维模型悬空与切片偏移的排查顺序
低代码·3d·arcgis·web3·安卓·webgl
LiuPig刘皮哥6 小时前
小可爱xiaokeai3D陪玩视觉设计方案
人工智能·3d·虚拟陪伴·虚拟偶像·xiaokeai
效率工作实验室7 小时前
商业智能报告趋势解析:标准化报告为何正被个性化分析取代
数据分析·agent·商业智能报告·标准化报告·个性化分析
Wanda12147 小时前
机器人与机电一体化3D数字孪生机器-Day7
人工智能·3d·机器人·数字孪生
钱工的门窗技术笔记8 小时前
铝木复合门窗中木材的角色分类与界面可分解性:结构判定、失效路径与维护设计
网络·物联网·数据分析