第70篇:数据可视化-ECharts图表——把一张图表牢牢焊进地球表面
上一篇我们把气泡弹窗拆了结尾我留过一句话下一个是 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 的模板里它长这样divrefchartContainerclasschart-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]}pxconstmyChartecharts.init(chartContainer.value)// 在离屏容器上起一个 ECharts 实例awaitnewPromise((resolve){letsettledfalseconstdone(){if(!settled){settledtrue;resolve()}}myChart.on(finished,done)// 等渲染画完事件myChart.setOption({...option,animation:false})// 关掉动画避免半成品被截走setTimeout(done,600)// 兜底finished 不触发也别卡死})constcanvaschartContainer.value.querySelector(canvas)// 拿到 ECharts 画出的 canvasconstimgcanvas.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 里用echarts6.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: BOTTOMhorizontalOrigin: CENTERbillboard 是个永远正对你脸的屏幕对齐四边形。把它的底部中心对齐到锚点图表就像一块路牌——底边钉在经纬度上整块图朝上立着你绕地球转一圈它始终正面朝你。如果不设BOTTOM、用默认的CENTER图表就会以中心点贴在坐标上、半截扎进地里。alignedAxis: Cartesian3.ZERObillboard 默认就正对相机这个字段控制它绕视线轴旋转。设成零向量就是不转——图表永远横平竖直地对着你不会被地形坡度带歪。而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确认给定一个距离dbillboard 的缩放是在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 m0.102×玫瑰饼图 (118.3, 26.3)93291 m0.163×折线图 (118.15, 26.12)93469 m0.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_CLICKpickHandler.setInputAction((movement){constrayviewer.camera.getPickRay(movement.position)// 屏幕点 → 射线CSS 像素if(!ray)returnconstpickedviewer.scene.globe.pick(ray,viewer.scene)// 射线与地形求交if(!picked)returnconstcartoCartographic.fromCartesian(picked)// ECEF → 弧度经纬高constlonCesiumMath.toDegrees(carto.longitude)constlatCesiumMath.toDegrees(carto.latitude)lonInput.valueNumber(lon.toFixed(6))latInput.valueNumber(lat.toFixed(6))addChart([lon,lat],option,[params.width,params.height])// 直接按这个点加图},ScreenSpaceEventType.LEFT_CLICK)这条拾取链和上一篇气泡弹窗一字不差地同源getPickRay吃的是 CSS 像素movement.positionglobe.pick把射线打到地形上fromCartesian翻回经纬高——整条链路都是 CSS 像素、Retina 默认安全和 #66 比例尺用canvas.width偏小的坑正好相反也和 #69 气泡的拾取链完全一致。你甚至能在 UI 里手填经纬度或开拾取模式点地图自动回填再点添加图表把当前模板贴上去。还有一个按参数重新渲染全部的按钮对应rerenderAll它先removeAll()把 dataSource 里的实体清空再遍历缓存的chartItems逐个renderChartToImageaddChartEntity重新出图。注意这是手动刷新——你改了宽高、改了缩放联动范围、或者换了模板内容要点一下它才重画图表本身不会自己跟着数据变。这再次印证了第四节的结论球上的图是快照不是实时绑定。chartItems是个模块级数组每加一张图就 push 一份{ coord, size, option }rerenderAll正是拿着这份缓存把球上实体removeAll之后再逐个重画。deleteLastChart和clearBillboards也是操作它——删最后一张就是pop掉末项并remove对应实体清全部就是removeAll然后把数组截零。换句话说球上长什么样完全由这份缓存说了算它也是按参数重新渲染全部的底气所在。拾取回填时toFixed(6)把经纬度锁到小数点后六位约 0.1 米精度同一份option直接复用到点击点保证你点哪、图就长哪。六、写在最后把这篇和前两篇摆在一起地图控件这条线其实讲透了一种能力在屏幕和地球之间来回翻译。罗盘#68翻译的是相机姿态 → 控件自转气泡#69翻译的是地球坐标 → 屏幕像素div 去追那个点图表#70翻译的是屏幕像素图本身→ 地球坐标图被 billboard 锚回去。为了把图表和气泡这两座反向的桥放一起看本猿列了张对照表维度气泡弹窗#69ECharts 图表#70本体矢量 DOMdiv栅格 PNG 贴图跟随方式postRender每帧重投影billboard 锚定GPU 自动能否交互活 DOM可绑事件静态图无交互最适合文字 / 数值面板统计图快照拆穿了所谓Cesium 上贴 ECharts 图表就是一个离屏 ECharts 容器 一句canvas.toDataURL 一个billboard贴图。Cesium 没画图表它只是把那张图递到了地球表面的某个点上再用scaleByDistance让它近大远小地陪着你转。落到工程上三个红线本猿替你标好了离屏容器别display: none——要量到真实尺寸推到屏幕外-9999px比藏起来靠谱和气泡的v-show测零是同一类坑图是静态快照不是活的 ECharts——要交互就另叠活 div别指望球上的位图能悬停出 tooltip锚点在椭球面 h0不是地形——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 年的秋夜与你共探「一张统计图怎样焊进地球表面」