上一篇我们把飞线的方向掉了个头,结尾埋了个话头:
CASE_LIST 里的下一站是数据可视化-PM2.5分布(#81)——从"线"换成"场":不再是一条条飞线,而是一整片连续分布的数值表面。表达方式又换一档,咱们下回再拆。
回顾一下这条线走到哪儿了:#79 注册了GLMap坐标系,让 ECharts 的经纬度转去调 Cesium 的投影(背面剔除用dot(法线, 相机朝向) ≥ 0),再把camera.percentageChanged拧到 0.02、把animation关掉,图就跟着相机走了;#80 把拓扑从一对多反成多对一,桥接层一行没改。
这一篇,桥接层第三次零改动——ChartLayer、GLMapCoordSys、相机设置、覆盖层全部照搬,一个字没动。变的只有buildOption()和喂给它的数据。
但这次换掉的东西比上一篇更根本:series 类型从lines换成了scatter。前两篇画的是"关系"——A 到 B 有一条线,线本身是主角,点只是端点;这一篇画的是"场"——190 座城市各有一个数,谁也不连着谁,点自己成了主角,线彻底消失。
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、从"线"到"场":同一套坐标系,第三张完全不同的图
先把这三篇摆在一起看,你会发现一件挺有意思的事:
| 篇号 | series 类型 | 画的是什么 | 桥接层改动 |
|---|---|---|---|
| #79 迁徙 | lines×3 +effectScatter×3 +scatter×3 | 30 条发散的弧线 | 从零写 |
| #80 信息发送 | lines×1 +effectScatter×1 +scatter×1 | 30 条汇聚的弧线 | 0 |
| #81 PM2.5 | scatter×1 +effectScatter×1 | 190 个大小不一的圆点 | 0 |
同样是 30 条线,换的只是数据方向;同样是一个坐标系,换的连图表的种类都变了。到第三篇,那句"坐标系负责投影、series 负责画什么"才算真正被验证过——GLMapCoordSys里没有一行代码关心你画的是线还是点,它只回答一个问题:这个经纬度现在在屏幕的哪个像素上。
这一篇里,dataToPoint被调了190 次(190 个城市各一次),而且每次相机一动就全部重算一遍。前两篇只有几十个点,这次量级上去了,但桥接层不用为它做任何优化——因为投影本身很便宜,真正贵的是 ECharts 的重绘。
把视线从"画了什么"挪到"怎么投影"上,你才会意识到这套桥接层真正的价值:它是可复用的资产,不是一次性的胶水。#79写它、#80一字不改沿用、#81还是一字不改——三篇累计下来,你花在"让图表跟着地球转"上的代码量,被摊薄成了接近零。对比那些每接一张图就重写一遍投影逻辑的项目,差距在第三张图时才显出来。这也正是我反复在系列里强调"先搭坐标系、再填 series"的原因:坐标系是地基,series 是装修,地基别跟着装修一起拆。
"场"和"线"在数据组织上的差别也是根本性的:
// 线(#79/#80):一条记录 = 两个端点{coords:[[lng1,lat1],[lng2,lat2]]}// 场(#81):一条记录 = 一个位置 + 一个值{name:'武汉',value:[lng,lat,pm25]}注意convertData()那句geoCoord.concat(item.value)——把[经度, 纬度]和浓度拼成三元组,所以symbolSize回调里拿到的val[2]才是浓度。这个"第 2 项"没有任何语义保护:哪天你在前面插一个字段,图会照样画出来,只是大小全错,而且不报错。
functionconvertData(data:Pm25Item[]):{name:string;value:number[]}[]{constres:{name:string;value:number[]}[]=[]for(constitemofdata){constgeoCoord=pm25GeoCoordMap[item.name]if(geoCoord){res.push({name:item.name,value:geoCoord.concat(item.value)})}}returnres}另外,这里的"场"其实是离散采样:190 个城市是 190 个孤立观测点,中间没有任何插值。真正的连续场要上heatmap系列或栅格插值(克里金、IDW),那是另一个话题——散点气泡图的诚实边界是"我只有这 190 个数,中间的我自己也不知道"。
顺便说一句数据质量:190 条数据和 190 条坐标严丝合缝对上了,一条都没被if (geoCoord)过滤掉(对比 #80 那个"目标名写错 → 全部被 filter → 一条线都不画且零报错"的坑,这份数据是干净的)。不过坐标精度是混的:170 条只给了 2 位小数(约 1.1 km 量级),另外20 条给了 6 位小数(看着像从地理编码服务批量取的)。在全国视野下这点误差无所谓(2 位小数 ≈ 0.15 px),但你要是放大到一个城市看,混精度就会露馅。
二、先量一遍这 190 个数:中位数 54,最脏的 279
画图之前先把数据摸清楚,这是我写这类文章养成的习惯——不量一遍就配参数,等于闭着眼睛调色。探针跑了下面这一组:
| 统计量 | 值(µg/m³) |
|---|---|
| 最小值 | 9(海门) |
| 下四分位 p25 | 36 |
| 中位数 | 54 |
| 上四分位 p75 | 86 |
| 最大值 | 279(大庆) |
| 均值 | 67.39 |
| 标准差 | 46.58 |
| 极差 | 270 |
均值 67.39 明显大于中位数 54——右偏,说明有那么十几个城市把尾巴拉得很长。这个形状后面会直接影响"圆点大小"该怎么映射,先记住。
按国标的分级粗粗切一刀(35 / 75 / 115 / 150 / 250):
| 等级 | 城市数 | 占比 | 累计 |
|---|---|---|---|
| 优(< 35) | 45 | 23.7% | 23.7% |
| 良(35~75) | 85 | 44.7% | 68.4% |
| 轻度污染(75~115) | 32 | 16.8% | 85.3% |
| 中度污染(115~150) | 16 | 8.4% | 93.7% |
| 重度污染(150~250) | 10 | 5.3% | 98.9% |
| 严重污染(≥ 250) | 2 | 1.1% | 100.0% |
68.4% 的城市在"良"以上,真正严重的只有 2 座(大庆 279、武汉 273)。这个分布对可视化有个直接暗示:大部分点会挤在小尺寸那一端,少数几个点会非常大——也就是后文要说的"极端值主导画面"。
右偏还有第二个后果,提前说一句,到第四节末尾还会回来:它意味着如果你用一条"线性的"视觉通道(比如颜色从浅到深)去编码浓度,那么绝大多数城市会挤在色带最浅的那一格——因为中位数 54 和最大值 279 差了五倍多,线性色阶会把中间那一大片全压进同一个浅色调里。空气质量数据最该让人一眼看出的"分级",反而会被线性编码抹平。所以后文要补visualMap,一定得用分段(离散)色带,不能信手用连续渐变。
浓度最高的十二座城市(≥ 150):
大庆 279 | 武汉 273 | 合肥 229 | 菏泽 194 | 廊坊 193 | 衢州 177 长沙 175 | 岳阳 169 | 金华 157 | 湘潭 154 | 保定 153 | 常德 152记住最后这几个名字——它们在第四节和第五节会反复出现,因为它们既是数值最大的那批,也是地理位置最挤的那批。
三、两个 series:一个铺底,一个点睛
buildOption()只返回两个 series,非常简单:
functionbuildOption():Record<string,unknown>{constall=convertData(pm25Data)// 190 个consttop6=[...all].sort((a,b)=>b.value[2]-a.value[2]).slice(0,6)return{series:[{name:'pm2.5',type:'scatter',coordinateSystem:'GLMap',data:all,symbolSize:(val:number[])=>val[2]/10,label:{normal:{formatter:'{b}',position:'right',show:false},emphasis:{show:true}},itemStyle:{normal:{color:'#ddb926'}}},{name:'Top 5',type:'effectScatter',coordinateSystem:'GLMap',data:top6,symbolSize:(val:number[])=>val[2]/10,showEffectOn:'render',rippleEffect:{brushType:'stroke'},hoverAnimation:true,label:{normal:{formatter:'{b}',position:'right',show:true}},itemStyle:{normal:{color:'#f4e925',shadowBlur:10,shadowColor:'#333'}},zlevel:1}]}}第一个 series 是底:190 个普通scatter,颜色统一#ddb926(暗黄),不常显标签。
第二个 series 是眼睛:Top6 用effectScatter(带涟漪扩散动画),颜色提亮到#f4e925,加了shadowBlur投影,常显城市名,并且zlevel: 1。
zlevel: 1是个好设计,别删
ECharts 里zlevel不同的 series 会被画到不同的 canvas 图层上,zlevel大的浮在上面。Top6 设了 1,底下的 190 个点是 0,于是高亮的那六个点永远压在最上层——不会被人多势众的底图点盖住,它们的名字也不会被别的标签遮掉。
这是本篇里我最愿意抄走的一招:用zlevel给"重点要素"单独开一层,比在同一个图层里调z(绘制次序)更可靠,因为z只在同一层内排序。
回到两个 series 的分工本身:底图 190 个点用最朴素的scatter,不常显标签、不带动画,纯铺底;Top6 用effectScatter,常显名字、带涟漪、单独开层。这个"铺底 + 点睛"的两段式,是数据量大又想突出重点时的标准套路——让 184 个普通点安静地待在背景里,把读者的注意力用动画和图层"请"到那 6 个最关键的点上。代价你也看得到:底图点小到读不出数,所以"铺底"本质上是在牺牲个体可读性换全局可读性,和第四节讲的尺寸映射是同一个权衡的两面。
两个"名字对不上"的小毛病
其一:name: 'Top 5',但slice(0, 6)取了六个。系列名和实际条目数不一致,纯文字问题,但在排查"为什么图上有六个点却叫 Top5"的时候会浪费人五分钟。
其二:Top6 的门槛薄得像纸。第 6 名衢州 177,第 7 名长沙 175——只差 2 µg/m³。也就是说,这份数据里"进不进高亮名单"取决于一个几乎等于测量误差的差值。真要上生产,这里的取舍应该是"按阈值切"(比如 ≥ 150 的全部高亮,共 12 座)而不是"按名次切"——阈值切出来的分组有物理意义,名次切出来的没有。
把这两个小毛病放在一起看,它们暴露的是同一类问题:这份 demo 的代码是"能跑"优先,不是"好维护"优先。名字和实际数对不上、阈值切和名次切混用,单独看都不致命,可一旦你要改——比如"把高亮名单改成按阈值切"——你就得先猜清楚slice(0, 6)和name: 'Top 5'到底哪个是真相。我写这类文章一贯的做法是:凡是会影响读者理解或后续修改的命名,一律让它和代码行为严丝合缝,宁可多花五分钟,省下后来人五个小时。
三个写了等于没写的配置
showEffectOn: 'render'—— 这就是effectScatter的默认值;hoverAnimation: true—— 这也是默认值,而且在这个场景里根本触发不了(见第五节坑二);rippleEffect: { brushType: 'stroke' }—— 只有brushType改了(默认'fill'),period/scale/number全用默认4 / 2.5 / 3(这三个默认值我上一篇已经从源码里核对过了)。
顺带记一笔:rippleEffect这套默认值是effectScatter专属的——你要是手滑把它写在一个普通scatter上,ECharts 会安静地忽略,不报错、不画图、也不提醒你写错地方。这也是 ECharts 这类"配置即代码"框架的通病:写错位置的属性不会红,只会被默默吃掉。排错时最该怀疑的,往往是这些"看起来写了其实没生效"的配置。
四、symbolSize = value / 10:一条除式把差异放大了三十一倍
终于到本篇的核心了。整张图只有一条式子决定"每个点多大":
symbolSize:(val:number[])=>val[2]/10ECharts 的symbolSize是直径,所以直径D = v / 10。代入实测的两个端点:
- 最干净的海门 v = 9 →D = 0.90 px
- 最脏的大庆 v = 279 →D = 27.90 px
这里有个容易忽略的前提:symbolSize给出的是 ECharts 自己画布上的像素尺寸,而本篇的画布和 Cesium 的画布是 1:1 叠在一起的(覆盖层宽高都等于canvas.clientWidth/clientHeight),所以这些 px 直接就是屏幕 px——点多大,读者就看到多大,不受相机远近影响。这点和#70那种"贴 billboard、用scaleByDistance随距离缩放"的做法恰好相反:那篇的圆图会随你拉远而缩小,本篇的圆点永远是一个绝对尺寸。绝对尺寸的好处是读数稳定,坏处是密集时你没法靠拉远来"散开"团簇——这又绕回了坑一。
直径比 31.0 倍,半径比也是 31.0 倍。而眼睛看到的是面积——面积比 31.0² = 961.0 倍。
这就是 #76 讲比例符号面时专门对比过的两条路:
- 面积正比
r = √(v·F/π):面积比 = 数值比,是制图学的默认选择; - 半径正比
r = v·k:面积 ∝ v²,把差异按平方放大。
本篇选的是后者(k = 1/20,因为半径 = D/2 = v/20)。我把两条曲线在最大值处对齐(即令 v = 279 时两者直径都是 27.9 px),实测各档位:
| 浓度 v | 半径正比(本篇) | 面积正比 | 面积正比 / 半径正比 |
|---|---|---|---|
| 9 | 0.90 px | 5.01 px | 5.568× |
| 50 | 5.00 px | 11.81 px | 2.362× |
| 100 | 10.00 px | 16.70 px | 1.670× |
| 279 | 27.90 px | 27.90 px | 1.000× |
对齐到最大值之后,除了最大值那一点,半径正比画出的圆全都更小,而且数值越小被压得越狠。在 v = 9 那一端,面积正比会给出 5.01 px 的可见圆点,半径正比只给 0.90 px——不到一个像素。
把这条公式反过来,读者其实能在图上读出近似值:因为直径 = 浓度 / 10,所以看到一个点直径约 15 px,就能反推它的浓度约 150——这是个很友好的线性关系,比面积反推(要开平方)省事得多。但便利的另一面是陷阱:上面那个反推只在"你量得准直径"时成立,而 0.90 px 那种点你根本量不出直径,只能看到"有个东西在那儿"。换句话说,半径正比给了你一个好算的读数公式,却又悄悄让半数城市小到读不出数——便利和失真同源。
这不是理论推演,是画面上真的看不见。实测各尺寸档位的城市数:
| 直径 | 城市数 | 占比 |
|---|---|---|
| < 1 px | 1 | 0.5% |
| < 2 px | 10 | 5.3% |
| < 3 px | 31 | 16.3% |
| < 5 px | 83 | 43.7% |
| < 10 px | 153 | 80.5% |
| ≥ 10 px | 37 | 19.5% |
八成的城市直径不到 10 px,四成四不到 5 px。于是画面成了这个样子:绝大多数城市是勉强可见的小米粒,少数几个城市是硕大的圆饼,中间层次几乎空掉——而中位数 54(占 279 的 19.4%)对应的圆点只有 5.4 px,和最大值 27.9 px 之间隔着一条巨大的视觉鸿沟。
什么时候该选半径正比
不全是坏事。如果你的目的是"让极端值一眼跳出来",半径正比反而是对的:它把 279 和 54 的差距(5.2 倍)放大成面积上的 26.6 倍,读者第一眼一定先看到大庆和武汉。做预警、做排行榜、做"哪里最严重"的引导,这是有效的。
但如果读者需要比较数值,它就是错的——面积按平方放大之后,人眼对面积的感知又本来就偏弱,两个圆看起来"差不多大"时,真实数值可能差了一倍。
这也是为什么教科书上的"比例符号图"几乎一律用面积正比:它牺牲一点"极端值跳出来"的冲击力,换来了"任意两点都能公平比较"。本篇反过来选了半径正比,所以它在"预警"上加分、在"对比"上减分——没有绝对的对错,只有场景合不合适。
判断标准很简单:问一句"读者要排序还是要读数"。要排序(找最值)→ 半径正比可以;要读数(估大小)→ 必须面积正比,而且要配色阶(visualMap)和图例。
本篇还有一个更根本的缺失:颜色没编码任何信息
190 个底图点全是#ddb926一个颜色,Top6 全是#f4e925。整张图里,颜色不携带任何数据,只有尺寸携带。
这意味着:① 你没法从图上看出一个点的浓度是 60 还是 90(它们的直径只差 3 px);② 分级(优/良/轻度/…)在图上完全不可见,而分级恰恰是空气质量数据最该传达的信息。
补法很便宜:加一个visualMap,把 35 / 75 / 115 / 150 / 250 做成分段色带,再把symbolSize换成面积正比——同样是 190 个点,信息量立刻翻倍,代码只多十行。
顺带说,尺寸和颜色是两条正交的编码通道,加了visualMap不会和symbolSize打架——一个点可以同时"又大又红"。这正是专题地图的常规做法:大小表量级、颜色表等级,读者一眼既能排序(看大小)又能读数(看色阶)。这篇 demo 只用了大小一条通道,等于把一半的信息承载力闲置了。
五、会糊的三处,和点了也没用的三处
坑一:48 对城市在画面上真的叠在一起
把 190 个点按Rectangle.fromDegrees(73, 18, 135, 54)的视野铺开(900 px 宽),逐对算圆心距和半径和,实测:
圆心距小于半径和的城市对 = 48 对。重叠最严重的几对:
| 城市对 | 重叠量 | 实际距离 |
|---|---|---|
| 湘潭 – 长沙 | 11.3 px | 34.0 km |
| 株洲 – 湘潭 | 11.2 px | 24.5 km |
| 株洲 – 长沙 | 9.9 px | 39.8 km |
| 义乌 – 金华 | 7.7 px | 44.9 km |
| 北京 – 廊坊 | 7.0 px | 44.2 km |
长株潭(长沙 / 株洲 / 湘潭)三座城市两两相距 24~40 km,而它们的直径分别是 17.5 / 15.7 / 15.4 px——在这个视野下它们必然糊成一团。偏偏这三座的浓度都不低(175 / 157 / 154,全在"重度污染"档),也就是说图上最该被看清的三座城市,恰恰是糊得最厉害的三座。
有意思的是,把所有圆的面积加起来只有 10015 px²,占画面2.13%——整体一点都不挤,挤的是局部。这正是气泡图的典型病灶:稀疏地区浪费空间,密集地区糊成一片。
为什么"挤的局部"几乎都在同一片?我把 190 个点的经度拉出来看:81.1% 的城市(154 座)落在东经 110° 以东,经度小于 100°E 的只有 5 座。这正贴合胡焕庸线——中国的人口和城镇本来就压在东半壁,经纬度投影又是"度数等距"地铺开,于是城市物理上扎堆的地方,屏幕上必然也扎堆。长株潭、京津冀、长三角这三处团簇,本质上不是图的问题,是数据分布的问题;想靠调symbolSize把团簇解开,等于和地理较劲。
能缓解的办法,按代价从低到高:① 把symbolSize整体调小(治标,小点更看不见);② 给底图点加半透明 + 描边,重叠处能看出层次;③换heatmap系列, contiguous 的密度面天然不会出现"两个圆叠在一起"这种问题;④ 做聚合(按网格或按省归并)。真要上生产我倾向 ③。
坑二:覆盖层的pointer-events: none让 hover 全废
ChartLayer.createChartElement()里那句:
el.style.cssText=`position:absolute; top:0; left:0; ...; pointer-events:none;`pointer-events: none对所有后代元素生效(ECharts 自己创建的 canvas 不会覆盖这个属性)。后果是:鼠标事件一律穿透到 Cesium 的 canvas 上,ECharts 收不到任何事件。
于是下面两处配置成了死代码:
// 底图 series:hover 才显示城市名 —— 永远不会触发label:{normal:{...,show:false},emphasis:{show:true}}// Top6 series:鼠标悬停动画 —— 同样永远不会触发hoverAnimation:true更可惜的是 tooltip。气泡图最需要的交互就是"指一下这个圆,告诉我这座城市是多少",而这个桥接层下 tooltip 根本不可能出现。
要修也简单:把覆盖层改成pointer-events: none,但给 ECharts 的 canvas 单独设auto——代价是 Cesium 的相机手势会在图表区域被吃掉。这两件事目前是互斥的,只能按业务选一个:要交互就牺牲一部分地图操作,要地图流畅就放弃 hover。
为什么覆盖层默认要写pointer-events: none?因为 ECharts 那块 div 是严丝合缝盖在 Cesium canvas 上的,如果它吃事件,你拖地图时只要鼠标落在图表区就拖不动——所以这个none是为了保住地图操作、牺牲图表交互的取舍,不是疏漏。要交互的那条路,就是针对性地把图表区的pointer-events设回auto,代价是这一小块地面上相机手势失效。
(顺带一句:#80 那个"图例点不动"也是同一个根因,本篇没有图例,所以那里的问题在这里不存在。)
坑三:label: { normal: ... }是 ECharts 4 的写法
本篇两处都用了label: { normal: {...}, emphasis: {...} }这种嵌套。ECharts 从 4.0 起就取消了normal层级,源码里现在还留着一段兼容:
// echarts/dist/echarts.js L20493-20516if(opt[optType].normal){deprecateLog("'normal' hierarchy in "+optType+" has been removed since 4.0.");opt[optType]=normalOpt;// 把 normal 的内容提到上一层}所以它能用,但会在开发构建里打废弃日志,而且哪天兼容层被删掉就直接失效。新代码应该写成:
label:{show:true,formatter:'{b}',position:'right'},emphasis:{label:{show:true}}注意emphasis也从"嵌套在 label 里"挪到了顶层,并且要写成emphasis.label。本篇这种老写法里,label.emphasis会被兼容层搬到emphasis.label去——两边最终效果一样,但读代码的人会以为emphasis是 label 的属性。
剩下的小事
symbolSize回调只用于取val[2],没有下界保护。浓度 0 的城市会得到 0 px 的圆(ECharts 会画成一个看不见的点),建议写Math.max(2, val[2] / 10);本篇数据最小值是 9,所以这条保护没被触发,但作为通用组件它必须存在——否则你接任意一个含 0 值的数据源,都会凭空冒出一个看不见却占着坐标的点。- 没有
visualMap、没有legend、没有tooltip、没有title——四件套全缺,读者除了"大就是脏"之外得不到任何定量信息; - 相机视野是写死的
Rectangle.fromDegrees(73, 18, 135, 54),改数据范围时要记得同步改; percentageChanged = 0.02和animation: false照搬自 #79——这两条在 190 个点上比在 30 条线上更重要,因为重投影的量翻了好几倍。
六、写在最后
把这一篇收一下,其实就三句话。
第一,桥接层到第三篇还是零改动,这次连 series 类型都从lines换成了scatter。GLMapCoordSys从头到尾只做一件事——回答"这个经纬度现在在屏幕的第几个像素上",它不需要知道你拿这些像素去画线还是画点。这是这三篇最值钱的东西:把"投影"和"画什么"彻底分开之后,换一张图真的只是换buildOption()。
第二,symbolSize: val[2] / 10这十个字符,决定了整张图传达什么。它是半径正比,把 31 倍的浓度差画成了 961 倍的面积差,代价是八成的城市缩到 10 px 以下、最干净那座只有 0.90 px。这不是 bug,是一个没被说出来的取舍——它让极端值一眼跳出来(适合预警),同时让读者读不出中间那些城市的数值(不适合分析)。想两头都要,就把symbolSize换成面积正比,再补一条visualMap色带。
第三,这张图真正的短板不在代码,在"颜色什么都没编码"。190 个点一个颜色,分级信息全丢,而空气质量数据最有价值的恰恰是分级。补一个visualMap十行代码,信息量翻倍。
CASE_LIST 里的下一站是洪水淹没模拟(#82)——从"把观测值画成点"换到"把一个过程演成动画":这回不再是静态的场,而是随时间推进的水面。
如果你也在做这类"数据铺到地球上"的可视化,欢迎留言聊聊:你的气泡图是半径正比还是面积正比?有没有被"密集区糊成一团"难住过?
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在src/cases/chart-pm25/目录下,和 #79、#80 共用src/lib/cesium-chart.ts,核心只有ChartPm25Demo.vue和一份data.ts。搬去自己项目时,盯住四件事:①桥接层完全复用(ChartLayer+GLMap一个字都不用改,换图只改buildOption());②symbolSize决定的是直径,写的时候想清楚你要半径正比还是面积正比;③convertData()拼出来的三元组里,浓度在第 2 项,val[2]这个下标没有语义保护;④ 覆盖层是pointer-events: none,hover / tooltip 全部用不了,要交互就得改这一行并接受地图手势被吃掉。
Cesium酱の百宝箱 · 第 81 篇
圆点胖瘦藏浓淡,桥接三番未改行
2026 年的秋夜,与你共探「同一套坐标系,第三张完全不同的图」