上一篇我们把气泡弹窗拆了,结尾我留过一句话:"下一个是 ECharts 图表——把一张图表焊在地球上的另一种浮层思路。"今天就来填这个坑。
你想象中"在地球上贴一张图表",大概是 Cesium 有个什么Chart控件,喂进去一份数据,它就在球面上给你画个柱状图。现实是:Cesium 的公开 API 里同样没有"图表"这种东西——和气泡、罗盘一样,它得靠你自己在"屏幕"和"地球"之间搭一座桥。只不过这次桥的另一头,站着一个 ECharts。
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
上次顺着"地图控件"这条线,罗盘(#68)管"朝哪、翻不翻",气泡(#69)管"点哪、咬哪"。结尾我留过原话:
下一个是 ECharts 图表——把一张图表焊在地球上的另一种浮层思路,同样是"屏幕 ↔ 地球"这座桥的又一遭,而且它和气泡一样,也得先回答"图表跟着地图转、还是钉死不动"这个老问题。
罗盘是缩在角落跟相机自转的小盘,气泡是焊死在某个经纬度的浮层,而图表要做的,是把一整张 ECharts 统计图"钉"在地球表面的某个点上,并且让它像路牌一样跟着你转、跟着你拉近。这恰恰是把上两座桥"反过来走"了一遭:气泡是"地球坐标 → 屏幕像素"(div 去追点),图表是"屏幕像素(图本身)→ 地球坐标"(图被锚回去)。桥还是那座,方向掉了个头。
一、先拆穿:Cesium 里没有"图表"这个东西
很多人第一次想在 Cesium 上挂图表,会下意识去找一个原生图表控件,或者指望Entity上能塞一张图进去自动渲染。本猿当初也这么想,结论是:Cesium 不画图表,它连"图表"这个概念都没有。你翻遍Viewer、Scene、Entity的文档,找不到任何负责"在三维地球上画一个柱状图/饼图"的对象。
那 demo 里地球上那些柱状图、玫瑰饼图、折线图是怎么来的?答案分两步,拆穿了特别朴素:
- 先用ECharts在浏览器里把图表画成一张普通的图片(PNG);
- 再把这张图片当成billboard(广告牌)的贴图,用
Cartesian3.fromDegrees钉在某个经纬度上。
也就是说,"地球贴图表"的真身是——ECharts 离屏渲染成图 + Cesium 的 billboard 当相框。Cesium 这边从头到尾只干了一件事:把那张图,贴在一个三维坐标上,再让它永远正对着你。
这种"图表焊上地球"的玩法,落地场景其实很具体:园区能耗的实时仪表盘钉在楼顶、各省 GDP 的柱状图铺在省界上、传感器网点的折线趋势挂在监测点旁——凡是"一个地理位置 + 一张统计图"的组合,都能用这座桥搭出来。它不是 Cesium 的官方能力,却是数据可视化类项目最高频的需求之一。
这里要顺手澄清两个容易上头的期待。第一,你得到的不是"活的 ECharts",而是一张"图表照片"。ECharts 的那些交互——鼠标悬停看 tooltip、点图例隐藏某一组、框选刷数据——在贴到地球上之后全都没了,因为 ECharts 的实时实例在出图那一刻就被销毁了,球上留下的只是一张静态位图。第二,也不能简单说"给 Entity 设个 billboard 把图表当贴图"就完事——Entity 的 billboard 只认一张"现成的图片",它根本不负责"把 ECharts 画成图片"这一步。所以这篇真正的功夫,全在"怎么把一份动态的 EChartsoption稳稳变成一张能喂给 billboard 的 PNG"上。后面第四节会展开,它是整篇最反直觉、也最容易让人踩空的一点。
二、离屏渲染:先把图表画成一张图
第一步的核心,是一个藏在屏幕之外的 ECharts 渲染容器。demo 的模板里它长这样:
<divref="chartContainer"class="chart-renderer"></div>配套的样式是这一行:
.chart-renderer{position:absolute;left:-9999px;top:0;z-index:-1;width:200px;height:350px;pointer-events:none;}注意这几个字:left: -9999px,但不是display: none。容器被推到屏幕外边很远的地方,但它仍然在渲染树里、有真实的宽高(200×350)。为什么不能干脆display: none?因为 ECharts 初始化时要量容器的尺寸来排版,一个display: none的容器量出来是 0×0,画出来的图就是一片空白——这和上一篇气泡弹窗里"v-show首帧display:none测到全零、弹出瞬间歪一帧"是同一类坑:任何"先量尺寸再定位"的活儿,碰到容器不可见就当场翻车。demo 用"推到 -9999px"而不是"藏起来",正是为了既看不见、又能正常量到 200×350。pointer-events: none则保证这块看不见的区域不会挡住底下地球的鼠标事件。
真正的出图逻辑在renderChartToImage里,浓缩成六步:
asyncfunctionrenderChartToImage(option,size){chartContainer.value.style.width=`${size[0]}px`// 先按目标尺寸撑开容器chartContainer.value.style.height=`${size[1]}px`constmyChart=echarts.init(chartContainer.value)// 在离屏容器上起一个 ECharts 实例awaitnewPromise((resolve)=>{letsettled=falseconstdone=()=>{if(!settled){settled=true;resolve()}}myChart.on('finished',done)// 等渲染"画完"事件myChart.setOption({...option,animation:false})// 关掉动画,避免半成品被截走setTimeout(done,600)// 兜底:finished 不触发也别卡死})constcanvas=chartContainer.value.querySelector('canvas')// 拿到 ECharts 画出的 <canvas>constimg=canvas.toDataURL('image/png')// 把画布导出成 PNG 的 dataURLmyChart.dispose()// 关键:实时实例用完即焚returnimg}三步值得停下来看:
setOption({ ...option, animation: false }):demo 把animation强制关掉。离屏出图要的是一张定稿的图,而不是一段正在生长的动画;不关的话,finished可能在动画中途就触发,截到半截柱子。'finished'事件 + 600ms 兜底:finished表示这一帧真正画完了。但 ECharts 在某些配置下finished不一定准时来(或者根本不冒泡),所以再加一个setTimeout(done, 600)当保险——最多等 600 毫秒,宁可截一张稍早的图,也不能让添加图表的操作卡死在这里。canvas.toDataURL('image/png'):这是整座桥的"焊点"。ECharts 最终把图渲染到一个<canvas>上,浏览器把这个画布编码成一段 PNG 的 Base64 字符串(data:image/png;base64,...)。Cesium 的 billboard 认的就是这种图片地址——于是"图表"和"地球"在此刻焊在了一起。
顺便提一句实测:本猿在 Node 里用echarts@6.1.0验证过,ECharts 也支持完全无 DOM的服务端渲染(echarts.init(null, null, { renderer: 'svg', ssr: true, width, height })直接拿 SVG 字符串,三种模板——柱状、折线、玫瑰饼——都能正常出图)。但 demo 没走这条路——它跑在浏览器里的 Vue 应用,要的就是一个能直接喂给 billboard 的image地址,所以选了"真实 canvas →toDataURL"这条更顺手的路线。两者殊途同归,区别只在"出图的环境"。
demo 还顺手内置了三套模板——柱状图(5 组并排bar)、南丁格尔玫瑰饼图(roseType: 'area'的pie)、折线图(3 条smooth曲线)——它们本质就是三份写死的 EChartsoptionJSON。UI 上那个文本框直接绑定optionText,你改 JSON 再点"添加图表"就能换成自己的图;想换类型(来个漏斗图、雷达图),把series.type改掉、按 ECharts 文档补字段即可。换句话说,出图这半截链路对"什么图"完全开放,它只认"一份能setOption的 JSON"这一个接口。也正因为"只认 JSON",这套出图链路可以彻底和"地球"解耦:你把option换成任何 ECharts 支持的类型、数据换成接口实时拉取的,出图这一步都不用改——它本质上是一个"ECharts → PNG"的纯前端转换器,Cesium 只负责最后钉那一下。
三、焊上地球:把那张图交给一个 billboard
第二步,是把上一步拿到的 PNG,钉到地球表面的某个点。addChartEntity干的就是这件事:
functionaddChartEntity(item,image){chartDataSource.entities.add({position:Cartesian3.fromDegrees(item.coord[0],item.coord[1]),// 锚点:经纬度,高度默认 0billboard:{image,// 上一步导出的 PNGhorizontalOrigin:HorizontalOrigin.CENTER,verticalOrigin:VerticalOrigin.BOTTOM,// 底部对齐 —— 图表"站"在坐标点上alignedAxis:Cartesian3.ZERO,// 永远正对相机,不随地形歪头width:item.size[0],height:item.size[1],scaleByDistance:buildScale()// 近大远小}})}width/height直接等于size(默认 200×350),也就是说 billboard 的像素占位就是图本身的像素尺寸,后续scaleByDistance只是在这个基础上整体放大缩小。两个字段决定了图表"长什么样、立在哪":
verticalOrigin: BOTTOM+horizontalOrigin: CENTER:billboard 是个永远正对你脸的"屏幕对齐四边形"。把它的底部中心对齐到锚点,图表就像一块路牌——底边钉在经纬度上,整块图朝上立着,你绕地球转一圈,它始终正面朝你。如果不设BOTTOM、用默认的CENTER,图表就会以"中心点"贴在坐标上、半截扎进地里。alignedAxis: Cartesian3.ZERO:billboard 默认就正对相机,这个字段控制它绕视线轴旋转。设成零向量就是"不转"——图表永远横平竖直地对着你,不会被地形坡度带歪。
而chartDataSource是一个CustomDataSource('echarts-charts'),所有图表都归到这一个数据源下,开关、清空、按参数重渲染都只对它下手,不污染球上的其它实体。
还有个性能层面的彩蛋:billboard走的是BillboardCollection的 GPU 实例化路线(第 62 篇讲过,每个 billboard 在顶点缓冲里只占 1 个顶点,绘制时count = 6, instanceCount = N)。所以几十上百张图表一起贴也不怕——它和海量文字、海量点位标签是一家人,瓶颈在"图本身的分辨率与数量",不在"再贴一张"这件事上。
四、近大远小:scaleByDistance 让图表跟着相机距离缩放
如果只是把图钉死在固定像素大小,远看一颗星、近看一块砖,整片图表就失去了"长在地球上"的真实感。demo 用一个NearFarScalar解决这个问题:
constSCALE_DEFAULTS={near:1000,far:100000,nearScale:1.3,farScale:0.08}functionbuildScale(){returnnewNearFarScalar(scaleParams.near,scaleParams.nearScale,scaleParams.far,scaleParams.farScale)}含义本猿直接翻了 Cesium 的未压缩源码(Scene/BillboardCollection里那段 GLSLinterpolateByDistance)确认:给定一个距离d,billboard 的缩放是在near和far之间线性插值出来的——
t = clamp((d - near) / (far - near), 0, 1) scale = mix(nearScale, farScale, t) // 即 nearScale + t * (farScale - nearScale)套进 demo 的默认值就是:near = 1000米时放大到1.3 倍,far = 100000米(100 公里)时缩到0.08 倍,中间线性过渡;距离比 1 公里还近就封顶在 1.3 倍、比 100 公里还远就触底在 0.08 倍,不再变化。
本猿用 demo 真实的相机位置(flyTo落点(-2744676, 5118433, 2843129))和三个示例图表的经纬度算了一遍,结果很说明问题:
| 图表 | 距相机距离 | 默认视角下的实际缩放 |
|---|---|---|
| 柱状图 @ (118, 26) | 98182 m | 0.102× |
| 玫瑰饼图 @ (118.3, 26.3) | 93291 m | 0.163× |
| 折线图 @ (118.15, 26.12) | 93469 m | 0.160× |
这同时也解释了为什么 demo 一上来你几乎看不见图表:默认相机离图表几十公里,0.1 倍的缩放把它们压成了角落里的小点。想看清,得滚轮拉近,而一拉近它们又随scaleByDistance长大——这个"越近越大"的手感,正是它有别于"贴一张固定大小的图片"的关键,也是"图表长在地球上"而不是"飘在屏幕上方"的体感来源。
也就是说,demo 一打开、相机停在默认高度时,这三张 200×350 的图实际只被显示成20~32 像素宽——小得像几个标点。这正是"近大远小"的本来面目:图表是按"它站在地球上某个点、离你几十公里"来缩放的,你拉近到 1 公里内它才长到 1.3 倍(约 260 像素宽),拉到 50 公里处约 0.7 倍,拉到 100 公里外就只剩 0.08 倍。一拉一推之间,图表就有了"立在大地上、随你远近而变"的体积感。
但这套机制藏着两个反直觉的代价,正经做产品时一定会撞上:
代价一:它是张静态照片,不是活的 ECharts。第二节说过,renderChartToImage末尾myChart.dispose()把实时实例烧掉了。球上那张图是toDataURL那一刻的定稿位图,ECharts 的全部交互(tooltip、legend 点选、dataZoom)都随实例一起没了。如果你需要"鼠标悬停地球上看图表数值",这条路走不通——正确的解法是另叠一个活的 ECharts div,用上一篇气泡弹窗的办法(worldToWindowCoordinates)投影到屏幕坐标、跟着点跑。举个具体的例子:你想做"点击省份、地球上弹出该省 GDP 的柱状图并能悬停看每年数值",走 billboard 的话,悬停只能拿到"你点在了某张图上"这个粗糙信息,tooltip 永远出不来;正确做法是叠活 div,让省份屏幕坐标算出来、图表本身保持可交互。所以选型时先问自己一句:我要的是"一张钉在地球上的图",还是"一个能戳的图表"?前者 billboard,后者活 div,两条路别搞混。
代价二:栅格分辨率封了顶。那张 PNG 是离屏画布在200×350 像素下栅格化出来的,缩放只是把它放大缩小,不会凭空多出像素。近一点(1.3 倍)时 200 像素宽被拉到 260 像素,文字和柱体边缘会轻微发糊;想让近处清清楚楚,得把离屏画布的分辨率调高(比如 600×1050 再缩),或者走前面提的 SVG 路线先出矢量再栅格化。demo 故意按 200×350 出图,是图省显存、图默认视角下本来也就显示几十像素;真要贴大字报表,这个默认值得改。好在 demo 把这组联动范围做成了可调面板——scaleParams是个reactive对象,UI 上near/far/nearScale/farScale四个输入框直接绑定它,想让图表整体大一点,把nearScale从 1.3 提到 3、或把far从 100km 拉到 300km,远处的图就不会缩得那么狠。但记住:调大nearScale等于把那张 200×350 的图放得更大,糊得更明显——分辨率这关绕不过去。
五、点哪放哪:从一次点击到地表坐标,再丢给图表
图表不能只靠代码里写死的坐标,demo 给了"在地图上点一下就放一张"的交互。togglePicking挂了一个LEFT_CLICK:
pickHandler.setInputAction((movement)=>{constray=viewer.camera.getPickRay(movement.position)// 屏幕点 → 射线(CSS 像素)if(!ray)returnconstpicked=viewer.scene.globe.pick(ray,viewer.scene)// 射线与地形求交if(!picked)returnconstcarto=Cartographic.fromCartesian(picked)// ECEF → 弧度经纬高constlon=CesiumMath.toDegrees(carto.longitude)constlat=CesiumMath.toDegrees(carto.latitude)lonInput.value=Number(lon.toFixed(6))latInput.value=Number(lat.toFixed(6))addChart([lon,lat],option,[params.width,params.height])// 直接按这个点加图},ScreenSpaceEventType.LEFT_CLICK)这条拾取链和上一篇气泡弹窗一字不差地同源:getPickRay吃的是 CSS 像素(movement.position),globe.pick把射线打到地形上,fromCartesian翻回经纬高——整条链路都是 CSS 像素、Retina 默认安全,和 #66 比例尺用canvas.width偏小的坑正好相反,也和 #69 气泡的拾取链完全一致。你甚至能在 UI 里手填经纬度,或开"拾取模式"点地图自动回填,再点"添加图表"把当前模板贴上去。
还有一个"按参数重新渲染全部"的按钮,对应rerenderAll:它先removeAll()把 dataSource 里的实体清空,再遍历缓存的chartItems逐个renderChartToImage+addChartEntity重新出图。注意这是手动刷新——你改了宽高、改了缩放联动范围、或者换了模板内容,要点一下它才重画,图表本身不会自己跟着数据变。这再次印证了第四节的结论:球上的图是快照,不是实时绑定。
chartItems是个模块级数组,每加一张图就 push 一份{ coord, size, option };rerenderAll正是拿着这份缓存,把球上实体removeAll之后再逐个重画。deleteLastChart和clearBillboards也是操作它——删最后一张就是pop掉末项并remove对应实体,清全部就是removeAll然后把数组截零。换句话说,球上"长什么样"完全由这份缓存说了算,它也是"按参数重新渲染全部"的底气所在。拾取回填时toFixed(6)把经纬度锁到小数点后六位(约 0.1 米精度),同一份option直接复用到点击点,保证你点哪、图就长哪。
六、写在最后
把这篇和前两篇摆在一起,"地图控件"这条线其实讲透了一种能力:在"屏幕"和"地球"之间来回翻译。
- 罗盘(#68)翻译的是"相机姿态 → 控件自转";
- 气泡(#69)翻译的是"地球坐标 → 屏幕像素"(div 去追那个点);
- 图表(#70)翻译的是"屏幕像素(图本身)→ 地球坐标"(图被 billboard 锚回去)。
为了把"图表"和"气泡"这两座反向的桥放一起看,本猿列了张对照表:
| 维度 | 气泡弹窗(#69) | ECharts 图表(#70) |
|---|---|---|
| 本体 | 矢量 DOM<div> | 栅格 PNG 贴图 |
| 跟随方式 | postRender每帧重投影 | billboard 锚定,GPU 自动 |
| 能否交互 | 活 DOM,可绑事件 | 静态图,无交互 |
| 最适合 | 文字 / 数值面板 | 统计图快照 |
拆穿了,所谓"Cesium 上贴 ECharts 图表",就是一个离屏 ECharts 容器 + 一句canvas.toDataURL+ 一个billboard贴图。Cesium 没画图表,它只是把那张图"递"到了地球表面的某个点上,再用scaleByDistance让它近大远小地陪着你转。
落到工程上,三个红线本猿替你标好了:
- 离屏容器别
display: none——要量到真实尺寸,推到屏幕外(-9999px)比藏起来靠谱,和气泡的v-show测零是同一类坑; - 图是静态快照,不是活的 ECharts——要交互就另叠活 div,别指望球上的位图能悬停出 tooltip;
- 锚点在椭球面 h=0,不是地形——
fromDegrees(lon, lat)高度默认 0,遇到高山图表会扎进山体或半空飘着;想贴着地形走,得自己采样地形高或上HeightReference/clampToGround,另外默认开着深度测试,山挡在前面时图表会被遮掉。
地图控件这条线还远没走完。罗盘管"朝哪翻"、气泡管"点哪咬哪"、图表管"把图焊哪"——回到这次的副标题,"把一张图表牢牢焊进地球表面"那个"焊"字,说的就是 billboard 的锚定:图表不是飘在屏幕上方的浮层,而是被钉进了地球表面的那个点;它跟着地球转、跟着相机远近、却始终不脱离坐标。这正是和气泡(#69)一脉相承的"屏幕 ↔ 地球"翻译能力,只不过气泡是图去追点,图表是点去接图。控件家族的老熟人还没走完——下一个登场的是缩放控制器控件:地图放大缩小看着最简单,可按钮每按一下,背后都是一整套"相机高度 ↔ 距地比例 ↔ 缩放档位"的换算,还要面对"拉近到贴地就穿模、拉远到失焦就失控"这类边界。下一篇我们就去拆它。咱们下篇见。
如果你也在 Cesium 上贴过图表,欢迎留言说说你是选了"billboard 静态图"这条路,还是硬上了"活 div 实时交互"——这两个选择,基本决定了你后面要填多少坑。
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在src/cases/echarts-map/目录下,核心就一个EChartsMapDemo.vue。搬去自己项目时,盯住三处就够了:renderChartToImage里的出图六步(容器撑开 → init → setOption 关动画 → 等 finished/600ms → toDataURL → dispose)、addChartEntity里的verticalOrigin: BOTTOM让图表立在点上、还有buildScale里的NearFarScalar近大远小。
Cesium酱の百宝箱 · 第 70 篇
图表本是屏上图,离屏一烘便成贴
2026 年的秋夜,与你共探「一张统计图怎样焊进地球表面」