前面八篇我们把控件这条线从头写到尾——罗盘读朝向(#68)、气泡追地表点(#69)、图表焊地球(#70)、缩放砸焦点(#71)、浮层跟光标(#72)、状态条钉角落(#73)、鹰眼分身(#74)、右键菜单承操作(#75)。八位常客做的事其实都一样:在屏幕上摆个东西,让地球的信息流到你眼前。这次换个方向——不再只是"读地球",而是要把数据画进地球。
先看一个最朴素的需求。我手上有八个城市的数值:北京 2189、上海 2487、广州 1868、成都 2094、武汉 1374、西安 1296、南京 942、重庆 3205(demo 里预置的,源码没标单位,本文一律称"数值",你换成自己的字段就行)。现在要把它们标到地图上,并且让人一眼看出谁大谁小。
最偷懒的做法是把数字直接写在标签上——但人脑不擅长比较一串阿拉伯数字,你得像读表格一样挨个扫。专题制图里有个更老练的办法:让符号本身的大小去承载这个数值。数值大,圆就大;数值翻倍,圆看着就该"大一圈"。这就是比例符号(proportional symbol),把圆做成面而不是点,就成了本篇的比例符号面。
为什么非得是"面"?用点符号(point的pixelSize)也能调大小,而且省事得多。差别在两点:面符号的半径是地理尺度(多少公里),能真实表达"这个数值覆盖多大范围",点的大小是屏幕像素,一缩放就失了意义;面符号有填充、描边、居中标签三层可以玩,能同时承载数值(大小)、类别(颜色)、名称(标签)三个维度。代价是顶点多、重建贵——第五节的坑三就是这个代价。
顺带区分一对容易混的概念。分级符号(graduated symbol)先把数值切成几档,每档一个固定大小(0–1000 小圆、1000–2000 中圆、2000+ 大圆);比例符号(proportional symbol)是连续的,每个值都有唯一对应的大小——2189 和 2190 会得到两个略微不同的半径。分级符号图例好做、读得快,代价是档内的差异被抹平;比例符号忠实,代价是必须配图例,否则读者根本不知道某个大小的圆代表多少。demo 走了个折中:直接把数值当标签写在圆心,用标签顶替了图例——小数据集够用,几十个点以上就该换回正规图例了。
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
这件事拆开只有三步,但每一步都藏着一个"你以为的和实际的不一样":
- 算半径——数值怎么换算成米?是让面积正比,还是让半径正比?
- 画圆——地球上没有
drawCircle,这个"圆"是怎么变成一圈经纬度的? - 贴上去——一堆多边形 entity 怎么管理、参数改了怎么重建?
三步走完你会发现,真正难的不是第三步,而是前两步里那些"看起来对、实测不对"的细节。
一、先说清楚:比例符号面到底在解决什么
先给它一个正经定义。比例符号面:以面符号的面积或半径表示数值属性,符号中心锚在要素位置上,大小连续变化(区别于"分级符号"那种分档跳变)。
它的价值全在"一眼"两个字上。给读者八个数字,他得读完、排序、比较,两三秒过去;给他八个大小不同的圆,扫一眼就知道重庆最大、南京最小——这是前注意加工(preattentive processing),不需要你主动去想。
但这里有个陷阱,也是本篇第一个真正的技术分岔:“数值翻倍,圆该怎么变大?”
直觉有两种答案,而它们差着一个根号:
- 半径正比:数值 ×2 → 半径 ×2(圆看着"大了一倍")
- 面积正比:数值 ×2 →面积×2 → 半径 ×√2 ≈ ×1.414
选哪个?制图学的选择是面积正比。因为人眼对符号的感知更接近面积而非半径;用半径正比,数值 ×2 会让面积变成 ×4,大值被夸张得离谱——看图的人会以为重庆是南京的三四倍大(实际数值比是 3205 : 942 ≈ 3.4 倍,半径正比下面积比是 11.6 倍,把差异放大了三倍多)。
demo 把两种都做了成一个下拉框,正好可以把差别量出来。
二、半径怎么算:一个根号造成的分歧
核心就是这几行:
functioncomputeRadius(value:number):number{letradius:numberif(params.mode==='area'){radius=Math.sqrt((value*params.areaFactor)/Math.PI)}else{radius=value*params.radiusPerUnit}constmin=params.minRadiusKm*1000constmax=params.maxRadiusKm*1000returnMath.max(min,Math.min(max,radius))}areaFactor的单位是"㎡/单位数值"(默认 2e7,即每单位数值给两千万平方米的面积),radiusPerUnit是"m/单位数值"(默认 80)。
面积正比为什么是sqrt(v·F/π)?因为目标是让面积A = v · F,而圆面积A = πr²,反解就得到r = √(v·F/π)。半径正比则直接r = v·k。
把八个城市代进去实测(_tmp/_probe76.cjs):
| 城市 | 数值 | 面积正比 r | 半径正比 r |
|---|---|---|---|
| 北京 | 2189 | 118.05 km | 175.12 km |
| 上海 | 2487 | 125.83 km | 198.96 km |
| 广州 | 1868 | 109.05 km | 149.44 km |
| 成都 | 2094 | 115.46 km | 167.52 km |
| 武汉 | 1374 | 93.53 km | 109.92 km |
| 西安 | 1296 | 90.83 km | 103.68 km |
| 南京 | 942 | 77.44 km | 75.36 km |
| 重庆 | 3205 | 142.84 km | 256.40 km |
盯着南京那一行看——两种模式几乎一样(77.44 对 75.36)。再看重庆——142.84 对 256.40,差了 1.8 倍。这不是巧合,两条曲线必然相交。
交点可以解出来:令√(v·F/π) = v·k,两边平方得v·F/π = v²k²,于是
v = F / (π · k²) = 2e7 / (π × 80²) = 994.72探针实测正是994.72,此时两种模式都给出79.577 km。数值小于 995 时面积模式更大,大于 995 时半径模式更大。南京 942 就落在这个交点附近,所以两个模式长得几乎一样;重庆 3205 远在右侧,半径模式立刻放飞到 256 km。
再做一个交叉验证,把这个差别钉死。拿最大的重庆(3205)和最小的南京(942)比,数值比是 3205 / 942 =3.402:
- 面积正比:半径比 142.84 / 77.44 = 1.8445,面积比 = 1.8445² = 3.402—— 与数值比完全相等;
- 半径正比:半径比 256.40 / 75.36 = 3.402,与数值比相等(这就是它的定义),但面积比 = 3.402² = 11.58。
一句话概括:面积正比让"看到的面积比"等于"真实数值比";半径正比让"看到的半径比"等于数值比,而把面积放大到了数值比的平方。你挑的不是哪个公式,而是"让读者的哪一种感知去对齐真实数据"。
顺带量一件 demo 没说的事:这几个圆会互相压住吗?用EllipsoidGeodesic把城市两两距离算一遍,看半径和有没有超过中心距:
| 城市对 | 中心距 | 面积模式半径和 | 半径模式半径和 |
|---|---|---|---|
| 成都–重庆 | 264.0 km | 258.3 km(不相交) | 423.9 km相交 |
| 上海–南京 | 269.5 km | 203.3 km(不相交) | 274.3 km相交 |
| 武汉–南京 | 457.3 km | 171.0 km | 185.3 km |
面积模式下八个圆全部不相交,而且最接近的一对(成都–重庆)中心距 264.0 km 对半径和 258.3 km,余量只剩 5.7 km——默认系数 2e7 显然是被"调"过的。切到半径模式,成都–重庆与上海–南京立刻各自糊成一团。这也侧面说明了为什么面积正比才是默认值:它不只会让比例说实话,还顺带让图面不打架。
最后核对一遍"面积正比"到底兑现了没有。用算出的半径反算面积,和定义值v·F对一对:
| 城市 | 数值 v | 定义面积 v·F | 反算面积 πr² | 相对差 |
|---|---|---|---|---|
| 重庆 | 3205 | 64100.0 km² | 64098.7 km² | 0.0020% |
| 南京 | 942 | 18840.0 km² | 18840.0 km² | 0.0001% |
(那 0.002% 只是我把半径四舍五入到两位小数带来的,不是公式误差。)面积正比没有骗人。
顺带补一个严谨性的尾巴:πr²是平面圆面积,而符号画在球面上,真实的球冠面积是2πR²(1−cos(r/R)),比πr²小约(r/R)²/12。实测:r=77.44 km 小0.00123%、r=142.84 km 小0.00419%、r=500 km 小0.05132%,与级数估计(r/R)²/12分毫不差。这个量级日常完全不用管,但万一有人追问"你这个面积到底准不准",这就是答案。
这条结论很实用:换系数时先算一下交点,你就知道自己的数据落在哪一侧、半径正比会把差异放大多少。别等图糊成一团才发现。
三、圆是怎么画出来的:投影 → 缓冲 → 反投影
算出半径(米)之后,第二步才是真功夫:在地球上画一个半径 118 公里的圆。
地球上没有drawCircle(lng, lat, r)。你当然可以自己围着中心点按方位角撒一圈点,但那要处理测地线、方位角、收敛……麻烦且容易错。demo 走的是一条更聪明的路,全在src/cases/measure-lib/buffer.ts里。
第一步,建一个"以这个点为中心"的方位等距投影。
constproj=geoAzimuthalEquidistant().rotate([-centerPoint[0],-centerPoint[1]]).scale(earthRadius)用的是d3-geo的geoAzimuthalEquidistant(方位等距投影),rotate把投影中心搬到目标点,scale(earthRadius)把尺度设成地球半径(@turf/helpers的earthRadius = 6371008.8 m)。
为什么必须是方位等距?因为这个投影有一条独一无二的性质:从中心出发到任意点的距离,投影后保持不变(这正是 “equidistant” 的含义)。有了它,“平面上以原点为圆心、半径 R 的圆”,反投影回球面就是"以该点为中心、测地半径 R 的圆"。别的投影都没这个保证——你换成墨卡托,算出的是个畸变的东西。
第二步,在投影平面里做缓冲。用JSTS(JTS 的 JS 移植,也就是 PostGIS 背后那个几何引擎):
constbp=newBufferParameters()bp.setQuadrantSegments(Math.max(1,params.steps))bp.setJoinStyle(BufferParameters.JOIN_ROUND)bp.setEndCapStyle(BufferParameters.CAP_ROUND)constbuffered=newBufferOp(geom,bp).getResultGeometry(params.radius)对一个点做缓冲,结果就是一个近似圆的多边形。
为什么不用更直白的办法?最直觉的路子是"自己按方位角撒一圈点":从中心出发,每隔 360/n 度取一个方位角,沿测地线走 r 米得一个顶点。这条路在 Cesium 里完全走得通(EllipsoidGeodesic就能算),但有两个坑:一是你得自己处理跨日界线、跨极点时方位角的连续性;二是测地线本身是曲线而非直线,n 取小了圆会明显走形,取大了又要自己算一堆点。
投影法的聪明在于把球面问题降维成平面问题:在方位等距投影里,"到中心等距"这件事被精确保持,于是平面上的一个圆(JSTS 一瞬间算完,还自带 round join / round cap)反投影回去就是球面上的等距圈。降维 + 复用成熟几何库,比手写测地线稳当得多——这是这个 demo 最值得抄走的一招。
第三步,反投影回经纬度,取外环,转成 Cesium 的位置数组:
constbufferPoly=pointBuffer(item.lng,item.lat,bufferParams)constring=bufferOuterRing(bufferPoly)constpositions=ring.map(([x,y])=>Cartesian3.fromDegrees(x,y))然后就是普通的PolygonHierarchy+ polygon entity,可选再加一条闭合 polyline 当边框([...positions, positions[0]])和一个居中标签。
标签那一段值得单独拎出来,因为它藏着一个从 #62 借来的老熟人:
label:{text:item.label?`${item.label}${item.value}`:String(item.value),style:LabelStyle.FILL_AND_OUTLINE,showBackground:true,backgroundColor:Color.fromCssColorString('#0e2340').withAlpha(0.6),disableDepthTestDistance:Number.POSITIVE_INFINITY}showBackground+ 半透明深蓝底 +FILL_AND_OUTLINE(白字配 4px 深蓝描边)这一套,是为了让标签压在橙色圆面上仍然读得清——深色底图上的标签,靠"白字 + 描边 + 背景板"三件套最稳。而disableDepthTestDistance: Number.POSITIVE_INFINITY正是 #62 拆过的那个默认值:它把深度测试永久关掉,标签永远画在最前面,连地球背面的标签也会穿透显示。demo 只有八个城市且全在正面,看不出问题;你一旦铺到全国甚至全球,背面城市的标签会"穿"过来糊在正面——生产里要么换成有限的距离值,要么改用heightReference让它随地形走。
半径的理论上限:别超过对跖点
顺手把这个"圆"能画多大说清楚。球面上一点到自身的最远距离就是到对跖点的距离,等于半个大圆周长:
r_max = π · R = π × 6371008.8 m ≈ 20,015 km半径超过这个值,球面根本不存在到中心那么远的点,几何上直接无解;而即使接近它,反投影出的环也会缩成一个点、绕向开始不稳定,画出来的"面"可能变成整个地球减去那个圆的补集。所以生产里给它留一道上限是对的——demo 的maxRadiusKm = 500距这个天花板还差着四十倍,属于绝对安全区。真要画几千公里级的符号,先想想是不是该换个表达(比如 choropleth 分级填色),别硬撑比例符号。
那个"圆滑度 48",实际是 192 边形
这里有个参数名骗人的地方。UI 上写着"圆滑度",值 48,你大概以为是 48 边形。但setQuadrantSegments的语义是每个象限的线段数——一个圆有四个象限,所以实际边数是4 倍。
探针实测(steps→ 外环点数,含闭合重复点):
| steps | 环点数 | 去重后边数 | 倍数 |
|---|---|---|---|
| 8 | 33 | 32 | 4.00 |
| 16 | 65 | 64 | 4.00 |
| 32 | 129 | 128 | 4.00 |
| 48 | 193 | 192 | 4.00 |
| 64 | 257 | 256 | 4.00 |
| 128 | 513 | 512 | 4.00 |
恒为 4 倍,一次没偏。所以默认那档给的是192 边形。好在 192 边形内接于圆时,面积已达真圆的99.9822%(只少 0.0178%),视觉上就是个圆;就算退到 steps=8(32 边形)也有 99.36%,肉眼基本看不出棱角。真正影响的是顶点数量——steps=48 × 8 个城市 ×(面 + 边框)≈ 三千多个顶点,缩放时全要重算。
顺带说一个细节:外环是首尾重合的(193 个点里第 1 个和最后 1 个是同一个坐标)。demo 直接把整条环喂给PolygonHierarchy——重复点无害,Cesium 认;画边框时又[...positions, positions[0]]再补一次,于是首尾各有一段重复。这不会出错,但你要是自己去重,记得别把环弄断(少一个点就是断开的多边形)。
四、三个参数背后的取舍:系数、钳制、圆滑度
面板上一堆滑块,真正需要想清楚的只有三个。
**系数(areaFactor/radiusPerUnit)**决定"一个单位数值画多大"。它是纯线性缩放,改大改小只影响整体观感,不影响比例关系——这是最安全的一个参数。默认值(2e7 ㎡/单位)配 942~3205 这组数,得到 77~143 km 的半径,在 16,500 km 高的全国视角下大小合适。
系数怎么定?别拍脑袋,反解就行。想让最大值的符号不超过某个半径r_max,令F = π·r_max² / v_max(面积模式)或k = r_max / v_max(半径模式)。比如想让重庆(3205)的圆半径是 150 km:
- 面积模式:
F = π × (1.5e5)² / 3205 =2.205e7 ㎡/单位(demo 默认 2e7,正是同一量级); - 半径模式:
k = 1.5e5 / 3205 =46.8 m/单位(demo 默认 80,明显偏大)。
两个默认值差开的那一截,就是"半径正比在交点右侧被放大"的现场证据。换数据时先按最大值的目标半径反解一次系数,比反复拖滑块快得多。
钳制(minRadiusKm/maxRadiusKm)看着是保底,实际会破坏比例。这是本篇最需要警惕的一条。
钳制的语义是"半径不许小于 5 km、不许大于 500 km"。听起来无害——不就是防止小值看不见、大值糊满屏吗?但被钳住的那部分,比例关系就没了。
阈值可以精确解出来。面积模式下令√(v·F/π) = 5 km和= 500 km:
v_min = (5e3)² · π / 2e7 = 3.93 v_max = (5e5)² · π / 2e7 = 39269.91半径模式下更直接:v_min = 5000/80 = 62.5,v_max = 500000/80 = 6250。
实测验证:面积模式下v = 1,公式算出来是2.523 km,但被Math.max抬成5 km——面积不再正比,小值之间的差异被整个抹平。半径模式下v = 50同理,4.0 km 被抬成 5 km。
所以钳制是一笔交易:用小值区间的"比例失真",换"每个符号都看得见"。demo 默认参数下,八个城市一个都没被钳住(最小的南京 942 也有 77 km),所以这笔交易暂时没被触发——但你换一组跨几个数量级的数据(比如 1 到 10000),小值那头会齐刷刷变成一样大的小圆点,那时候就得回来调minRadiusKm。
**圆滑度(steps)**是纯粹的精度-性能旋钮,见上一节的边长对照。8 就够看,48 是默认,128 属于浪费。
五、五个坑:球面椭球、NaN、重建代价、打空、尺度
坑一:这个"圆"在 WGS84 上其实略微不圆。buffer 是在半径 6371008.8 m 的球上算的,而 Cesium 把它画在WGS84 椭球上。这不是同一个东西。
我用 Cesium 的EllipsoidGeodesic把反投影后的每个顶点到中心的真实大地线距离量了一遍(请求 100 km,steps=48):
| 位置 | 纬度 | 实测均值 | 相对偏差 | 东西向 | 南北向 |
|---|---|---|---|---|---|
| 广州 | 23.13° | 99.880 km | −0.120% | 100.164 km | 99.602 km |
| 南京 | 32.06° | 99.965 km | −0.035% | 100.206 km | 99.731 km |
| 北京 | 39.90° | 100.052 km | +0.052% | 100.250 km | 99.862 km |
两个结论:平均误差随纬度从 −0.12% 走到 +0.05%(约在北纬 36° 附近过零);东西向恒比南北向长 0.4%~0.6%。
也就是说,它其实是个被压扁了半厘的椭圆。0.5% 肉眼绝对看不出来,日常做专题图完全够用——但别拿它去做需要精度的量算(比如"这个符号覆盖了哪些乡镇"),那 0.5% 在 100 km 上就是 500 米。要精确就得自己按测地线撒点,别走这条投影捷径。
坑二:钳制救不了 NaN。addItem入口挡了value <= 0:
if(value<=0){resultMessage.value='数值必须大于 0'return}这道校验不能省——但要注意,后面的钳制是拦不住 NaN 的。Math.sqrt(负数)得NaN,而Math.min(max, NaN)返回NaN、Math.max(min, NaN)也返回NaN,NaN 会一路穿过去。所以校验必须做在入口,别指望Math.max/min兜底。
坑二点五:这个面贴在 h=0 的椭球面上,加载地形就会被埋。demo 的 polygon 只给了hierarchy和material,没设height/heightReference,所以它画在椭球面而不是地形面上——和 #70 那张 billboard 是同一个坑。开着地形看,山会把它盖住,平原则正常。要贴地形就加heightReference: HeightReference.CLAMP_TO_GROUND;要整体抬高就用height。这一条 demo 没做,搬去生产前记得补。
坑三:拖一下透明度,全部几何重建一遍。watch监听了 13 个参数:
watch(()=>[params.mode,params.areaFactor,params.radiusPerUnit,params.minRadiusKm,params.maxRadiusKm,params.steps,params.fillColor,params.fillAlpha,params.showBorder,params.borderColor,params.borderWidth,params.showLabel,params.labelSize],()=>{if(items.length>0)rebuild()})而rebuild()是先全删后全建:
for(constentityofitemEntities)viewer.entities.remove(entity)itemEntities.length=0// ... 再逐个 add于是你拖"透明度"滑块,每一帧都要销毁并重建 8 个城市 × 3 个 entity = 24 个 entity、三千多个顶点。几何其实一个字节都没变。
粗算一下代价:8 个城市 ×(1 个面 + 1 条边框 + 1 个标签)=24 个 entity;面与边框各 193 个点,单城市 386 个顶点,合计约3000 个顶点。这些在拖动滑块时每帧推倒重来。生产里应该按参数分类:改颜色/透明度只更新material,改半径/圆滑度才重建几何。这个 demo 图省事,别照抄。
坑四:拾取打空是静默的。点击添加走pickPosition:
constpos=pickPosition(viewer.scene,event.position)if(!pos)return打空(点到太空)直接 return,界面上没有任何反馈——用户会以为按钮坏了。生产里至少给个提示。
坑五:比例符号面是"给固定尺度看的"。demo 相机固定在 16,500 km 高看全中国,符号半径 77~143 km,比例协调。但你一旦把镜头推近,这些"面"会大得离谱——因为它们不会随缩放变小。对比 #70 那张 ECharts 图:它是 billboard,靠scaleByDistance随距离缩放。面符号没有这回事,它是钉在地球表面的一块地。要做跨尺度就得自己上scaleByDistance那套,或者按视高切换符号尺度档位。
坑六:默认不相交,不代表你的数据不相交。前面量过,默认参数下面积模式八个圆全部不相交(成都–重庆余量只剩 5.7 km)。但这只是这组数据的运气——一旦某个城市的数值跳一个量级,或者两个点挨得更近,圆就会叠上去。demo 的fillAlpha = 0.45让重叠处颜色加深,这在只有两三处重叠时反而是有用的(能看出"这里密度高"),但重叠一多就成了一团糊。真遇到密集数据,建议只有两条路:要么把大圆画在下层、小圆画在上层(按数值降序插入 entity),要么干脆换成分级填色(choropleth),别指望半透明能救。
六、写在最后
把这一篇收一下,比例符号面可以压成三句话:先定比例关系(面积正比r = √(v·F/π)是制图学的正确默认,半径正比r = v·k会把差异按平方放大;两曲线在v = F/(π·k²) = 994.72处相交,换系数前先算一下你的数据落在哪一侧)、再把半径变成一圈经纬度(建一个以该点为中心的方位等距投影→ JSTS 在平面里缓冲 → 反投影;steps是每象限线段数,实际边数是4 倍,默认 48 给的是 192 边形)、最后盯住两个"其实不是"(钳制会抹平小值区间的比例关系,面积模式在 v<3.93 时全部被抬成 5 km;这个圆在 WGS84 上东西向比南北向长约 0.5%,是球面假设留下的痕迹)。
从 #68 到 #75 那八篇,我们做的事可以概括成一句:把地球的信息搬到屏幕上。这一篇开始反过来了——把数据的信息画进地球里。方向一换,问题的性质也变了:前面关心的是"坐标别漂、别反、别在边界翻车",这一篇关心的是"符号的大小有没有说实话"。半径差一个根号、钳制吃掉一截、球面替椭球做了主——三个都会让图好看但说谎。专题制图最难的地方从来不是画出来,而是画出来的东西仍然忠实于数据。
这条线刚开头。CASE_LIST 里的下一站是三维地层-钻孔建模(#77)——从"在地表画符号"往下走,进到"把地下的东西建出来"。从二维的符号表达,到三维的地层结构,换个维度,咱们下回再拆。
如果你也做过比例符号图,欢迎留言聊聊:你默认用面积正比还是半径正比?有没有被"最小值那一档全变成一样大的小圆点"坑过?
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在src/cases/proportional-area/目录下,核心是ProportionalAreaDemo.vue+../measure-lib/buffer.ts。搬去自己项目时,盯住四件事:①默认走面积正比(r = √(v·F/π)),半径正比会把差异按平方放大,两模式交点在v = F/(π·k²);②steps是setQuadrantSegments(每象限线段数),实际边数是4 倍,48 给的是 192 边形;③ buffer 走的是R=6371008.8 m 的球,在 WGS84 上东西向比南北向长约 0.5%,别拿它做精确量算;④ 钳制(min/maxRadiusKm)会抹平小值区间的比例关系(面积模式 v<3.93 全被抬成 5 km),Math.max/min拦不住 NaN,入口必须自己判value > 0。
Cesium酱の百宝箱 · 第 76 篇
半径藏根号,圆里见多少
2026 年的秋夜,与你共探「一个数字,怎么长成一个圆」