资讯动态

第81篇:数据可视化-PM2.5分布——让圆点的胖瘦替每座城市报出浓度

发布时间:2026/10/29 22:38:09 来源:尧图企业网站定制
上一篇我们把飞线的方向掉了个头结尾埋了个话头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×330 条发散的弧线从零写#80 信息发送lines×1 effectScatter×1 scatter×130 条汇聚的弧线0#81 PM2.5scatter×1 effectScatter×1190 个大小不一的圆点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){constgeoCoordpm25GeoCoordMap[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海门下四分位 p2536中位数54上四分位 p7586最大值279大庆均值67.39标准差46.58极差270均值 67.39 明显大于中位数 54——右偏说明有那么十几个城市把尾巴拉得很长。这个形状后面会直接影响圆点大小该怎么映射先记住。按国标的分级粗粗切一刀35 / 75 / 115 / 150 / 250等级城市数占比累计优 354523.7%23.7%良35~758544.7%68.4%轻度污染75~1153216.8%85.3%中度污染115~150168.4%93.7%重度污染150~250105.3%98.9%严重污染≥ 25021.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():Recordstring,unknown{constallconvertData(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改了默认fillperiod/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半径正比本篇面积正比面积正比 / 半径正比90.90 px5.01 px5.568×505.00 px11.81 px2.362×10010.00 px16.70 px1.670×27927.90 px27.90 px1.000×对齐到最大值之后除了最大值那一点半径正比画出的圆全都更小而且数值越小被压得越狠。在 v 9 那一端面积正比会给出 5.01 px 的可见圆点半径正比只给 0.90 px——不到一个像素。把这条公式反过来读者其实能在图上读出近似值因为直径 浓度 / 10所以看到一个点直径约 15 px就能反推它的浓度约 150——这是个很友好的线性关系比面积反推要开平方省事得多。但便利的另一面是陷阱上面那个反推只在你量得准直径时成立而 0.90 px 那种点你根本量不出直径只能看到有个东西在那儿。换句话说半径正比给了你一个好算的读数公式却又悄悄让半数城市小到读不出数——便利和失真同源。这不是理论推演是画面上真的看不见。实测各尺寸档位的城市数直径城市数占比 1 px10.5% 2 px105.3% 3 px3116.3% 5 px8343.7% 10 px15380.5%≥ 10 px3719.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 px34.0 km株洲 – 湘潭11.2 px24.5 km株洲 – 长沙9.9 px39.8 km义乌 – 金华7.7 px44.9 km北京 – 廊坊7.0 px44.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.cssTextposition:absolute; top:0; left:0; ...; pointer-events:none;pointer-events: none对所有后代元素生效ECharts 自己创建的 canvas 不会覆盖这个属性。后果是鼠标事件一律穿透到 Cesium 的 canvas 上ECharts 收不到任何事件。于是下面两处配置成了死代码// 底图 serieshover 才显示城市名 —— 永远不会触发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。搬去自己项目时盯住四件事①桥接层完全复用ChartLayerGLMap一个字都不用改换图只改buildOption()②symbolSize决定的是直径写的时候想清楚你要半径正比还是面积正比③convertData()拼出来的三元组里浓度在第 2 项val[2]这个下标没有语义保护④ 覆盖层是pointer-events: nonehover / tooltip 全部用不了要交互就得改这一行并接受地图手势被吃掉。Cesium酱の百宝箱 · 第 81 篇圆点胖瘦藏浓淡桥接三番未改行2026 年的秋夜与你共探「同一套坐标系第三张完全不同的图」

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑