☰
第55篇:空间分析-剖面分析——红线走大圆,剖面却按经纬直线采样
2026/10/13 7:04:23 网站建设 项目流程

上一篇结尾我撂下一句话:

这条空间分析线才刚开头。地形挖开了,下一步自然就是沿着一条线把它切开看看里面长什么样——剖面分析,我们下一篇见。

坑挖完了,现在该切了。

从 #53 的标记标绘,到 #54 的地形开挖,这条空间分析线走的其实是同一个逻辑:**先把球上看得见的东西,变成算得准的东西。**点位标记那篇,我们把"一个点"变成了清单里的一行;地形开挖那篇,我们把"一块地"变成了一个洞。今天轮到"一条线"——把球上那条剖面线,变成一张高程曲线图。

剖面分析是空间分析里最不花哨、但最常被要求交付的功能。选线、定管线、算输电通道、评估徒步路线,最后交出去的东西往往就是一张剖面图:横轴距离、纵轴高程,一眼看出一条线是爬升还是下降、哪里最陡、哪里有个垭口。它是那种做对了没人夸、画歪了立刻被指着鼻子问的功能。

但这条线比看起来难搞。球上那条红线有它自己走的路径,剖面图有它自己采的路径,中间还夹着一个类别轴——本猿一开始以为这三件事是一回事,量完之后发现是三回事。

大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。

写这篇的时候是凌晨,我把 demo 里的默认路径来回画了十几遍,就为了确认一个反直觉的现象:球上那条红线中间明明没拐弯,剖面曲线却在中间鼓了个包。后来发现那不是我画歪了——是红线和剖面采的,根本不是同一条线。

本文目标:把剖面分析这条链路切成四段——密化路径、挑采样点、取高程、画曲线,然后把每一段的误差都量出来:剖面曲线上的每个点到底在哪儿、横轴上那个"距离"是不是真的距离、那条 10° 的斜线上两条线能差出多远。💪

💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。


一、痛点与场景:为什么"切条线看剖面"这么容易翻车?

🎯 背景现状:剖面分析干的事很朴素——沿着一条线,每隔一段取一次地面高程,再把"距离—高程"画成二维曲线。它是把一个三维问题压成二维,而压的过程里每一步都在丢信息:路径被简化成折点、折点之间被插成采样点、采样点被取成高程、高程被画进一个横轴等距的画布里。四步走下来,图是圆的,数据是尖的,差在哪儿全靠底层实现。

典型场景里,它要同时满足这些需求:

  • 🛣️选线:一条拟建公路 / 管廊 / 输电线路,要先知道它翻几道梁、最深挖多少
  • 🚰管线纵断面:市政管线要按坡度走,剖面图是唯一的校核依据
  • 🥾徒步 / 越野路线评估:累计爬升、最大坡度、补给点落差
  • 📊填挖方粗算:沿线的地面线与设计线一对比,粗略知道哪段是挖、哪段是填
  • 🧪科研 / 教学:地形起伏的可视化表达,丘陵、河谷、阶地一眼可见

🤦 传统痛点:本猿把常见的几种做法列了一下,每一条都踩过:

方案问题
桌面 GIS 切剖面,再导出图表数据要出浏览器、出进程、出软件,来回三趟;改一次参数重跑一遍
直接sampleTerrain猛打点拿到的是一串高程,但它不知道"这条线的哪一段最陡"——因为没算累积距离
采完样随手用 Excel 连线横轴按"第几个点"排,不是按距离排;点密的地方被压扁、点疏的地方被撑开
拿球上那条红线当基准那条线走的是测地线(大圆),而采样走的是经纬度线性插值,长线上根本不在一条沟里
用globe.getHeight取高程它返回undefined的时候你未必看得见——demo 里一句?? 0就把它变成海平面了

💡 本文方案:这个案例的实现链路其实清清楚楚,就四段:

1️⃣密化路径:把用户给的折点(或内置的演示路径)按每段固定 500 个插值点铺成一条密集折线,同时给每个点算一个累积距离
2️⃣挑采样点:按"每段步数 / 固定间距 / 总点数"三种方式,从密化折线上挑出真正要取高程的位置
3️⃣取高程:对每个采样点调viewer.scene.globe.getHeight(),拿回三角网格上的高度
4️⃣画曲线:把(累积距离,高程)丢给 ECharts,画一条带面积填充的曲线

✨ 一句话总结:把一条剖面线当成一条带累积距离的密集折线,用地形三角网格的射线求交当高程源,最后把结果按采样序号等距摆进一张二维图里——每一步都对,可每一步都在丢东西。问题不在于哪一步算错了,在于没人告诉你丢的是什么。


二、核心技术原理:一条剖面,四段拼出来

2.1 路径与采样的数据结构

整个案例前后跑动的其实只有两个结构,一个比一个大:

// 密化折线上的一个点:位置 + 累积距离typeSamplePoint={lon:number;lat:number;cumDistM:number}// demo 里路径本身只用 [lon, lat] 两个数表示constDEFAULT_PATH:[number,number][]=[[90.5,30.5],[91,31],[91.5,31.5],[90.5,31.5]]// 剖面数组里每一行的含义:经度 / 纬度 / 高程 / 累积距离typeProfilePoint=[number,number,number,number]

// 源码级剧透:buildDenseSamples里那个DENSE_PER_SEGMENT = 500是每段插 500 个点,不是"整条线插 500 个点"。所以默认 3 段路径的密化数组长度是1 + 500 × 3 = 1501;而你手绘一条 20 个折点的路径,它会涨到1 + 500 × 19 = 9501。

这个 500 是写死的,跟段长一点关系都没有:一段 100 米的线和一段 1500 公里的线,都插 500 个点。这笔账第五节还要再算一次。

2.2 完整技术路线

我们设计的完整技术路线:

核心亮点:第 ④ 步"取高程"是本文的杀手锏 🔪——它的返回值只有三种可能:一个数字、undefined、或者抛异常。而 demo 里只接住了第一种。

2.3 三种采样方式的差别

面板上那个下拉框,看着只是"参数习惯不同",实际决定的是横轴上一格代表多少米。三种方式的算法(源码里就是一个if / else if / else):

采样方式原理默认路径点数一格代表
每段步数(默认)每段独立按stepCount等分,stepCount20~3003×100+1−2 =301每段不同(733.080 / 738.842 / 957.667 m)
固定间距n = floor(总长 / 间距),从 0 取到n×间距5000 m 档 →49点恒定(5000 m)
总采样点数n = 总点数,直接等分全长20~1000恒定(总长 ÷ (n−1))

前两种的差别很微妙但很要命:

  • 每段步数:每段都给同样多的点,所以段越长、每格越大。这条路径的段 3 就是活证据。
  • 固定间距:每格一样宽,但它是从起点往后数整格,最后一格走不满就被丢掉——totalLength报的是n × 间距,不是真实全长。

🎯 一句必须记住的话:**"固定间距"下报告的总长永远 ≤ 真实总长,缺口最大是一个间距。**默认路径实测:100 m 档丢 62.471 m,2000 m 档丢1262.471 m,5000 m 档丢 1262.471 m。而"总采样点数"和"每段步数"两个模式不截断,最后一点就压在终点上。


三、代码实战:三个关键实现

⚠️温馨提示:下面只贴关键片段,完整的单文件组件在仓库src/cases/profile-analysis/ProfileAnalysisDemo.vue。这个 demo 依赖 Bing 影像 + Cesium World Terrain,把 Bing 换成自己的底图就能跑。

3.1 密化 + 采样:两个数组,一次去重

constDENSE_PER_SEGMENT=500// 🔑 距离用球面大圆公式,半径取的是 IUGG 平均半径,不是 WGS84functionhaversineMeters(lon1,lat1,lon2,lat2){constR=6371008.8constdLat=CesiumMath.toRadians(lat2-lat1)constdLon=CesiumMath.toRadians(lon2-lon1)consta=Math.sin(dLat/2)**2+Math.cos(CesiumMath.toRadians(lat1))*Math.cos(CesiumMath.toRadians(lat2))*Math.sin(dLon/2)**2return2*R*Math.asin(Math.sqrt(a))}// 🌰 密化的核心:位置各自线性插值,距离按段长等比分配for(leti=1;i<=DENSE_PER_SEGMENT;i++){constt=i/DENSE_PER_SEGMENTdense.push({lon:CesiumMath.lerp(sx,ex,t),lat:CesiumMath.lerp(sy,ey,t),cumDistM:cum+segLen*t})}// 🔑 "每段步数"模式下,第 i 段的终点和第 i+1 段的起点是同一个累积距离for(leti=0;i<=stepCount;i++)targets.push(cum+(segLen*i)/stepCount)targets=targets.filter((v,i)=>i===0||v!==targets[i-1])

🔑 关键点:

  • 位置和距离用的是两套算法。位置是lon / lat各自线性插值,距离是起点到终点的 haversine 距离乘以t。两者在短段上贴合得很好,段一长就分家。
  • "每段步数"模式下targets天然有重复,靠上面那句filter去重。本猿把stepCount从 20 到 300 逐个试了一遍,重复对恒等于"段数 − 1",去重是真的生效的——因为segLen × stepCount / stepCount在这个量级上恰好浮点相等。
  • 但"重复的折点"去不掉。如果你在地图上手抖,把同一个位置点了两次,那一整段的targets全部相等,去重后只剩 1 个——少采一整段。实测 4 个折点里首两重合、stepCount = 100时,采样点从 301 掉到201。

3.2 取高程:一个?? 0掩盖了三种情况

for(constsampleofsamples){letheight=0try{constheightValue=viewer.scene.globe.getHeight(Cartographic.fromDegrees(sample.lon,sample.lat))height=heightValue??0// 🔑 未加载 / 出界 / 没渲染 → 一律 0 m}catch{height=0// 🔑 连异常也吃掉}profile.push([sample.lon,sample.lat,height,sample.cumDistM])}

🔑 关键点:

  • getHeight的返回值只有三种,demo 只接住了一种。Cesium 未压缩源码里Globe.prototype.getHeight有三处return undefined:零级瓦片还没建好(L866)、经纬度不在任何零级瓦片里(L874)、以及往下找到的最深那层瓦片没有renderedMesh(L912)。
  • 它是"逐级往下钻"的:从零级瓦片沿四叉树一路往下,只把"有renderedMesh"的瓦片记成tileWithMesh,最后在这块瓦片上取交点。也就是说——剖面精度取决于当前渲染到哪一层。你把相机拉近、瓦片加载得更细,同一条剖面线的高程会变。
  • 高程来自三角面,不是高程栅格。链路是GlobeSurfaceTile.pick→TerrainMesh.pick→terrainPicker.rayIntersect,本质是沿椭球面法线打一条射线、和三角网格求交,落在三角形内部就是线性插值。所以采样点比网格密的那部分,是白采的。

3.3 画曲线:三个"看起来无害"的选项

chart.setOption({grid:{left:40,right:16,top:24,bottom:28},xAxis:{type:'category',// 🔑 类别轴:等距,与距离数值无关boundaryGap:false,name:'距离 (km)',data:distances,// 🌰 距离字符串,只当刻度标签用axisLabel:{showMinLabel:true,showMaxLabel:true,interval:Math.max(1,Math.floor(distances.length/6))}},yAxis:{type:'value',name:'高程 (m)',scale:true},// 🔑 scale 不从 0 起series:[{data:heights,type:'line',smooth:true,showSymbol:false,lineStyle:{color:'#2f80ed',width:2},areaStyle:{color:'rgba(47, 128, 237, 0.35)'}}]})

🔑 关键点:

  • type: 'category'把 301 个采样点摆成 301 个等宽刻度。distances数组里的 “0.00 / 41.01 / 82.03 …” 只是标签,不参与定位。这一点是后面所有横轴失真的总源头。
  • interval是"跳过几个",不是"每几个显示一个"。写interval: 50,实测显示的是第 0、51、102、153、204、255 个刻度;再加上showMaxLabel: true补的终点,一共7 个标签。
  • scale: true让 y 轴不从 0 起。默认数据范围 2760~4210 m,实测轴范围被算成2700~4500 m——那块蓝色面积的基线是2700 m,不是海平面。
  • smooth: true不会制造假峰。本猿本来攒了一堆"平滑把尖峰冲过头"的素材,实测发现 ECharts 把控制点钳制在了相邻两点的包围盒里(源码注释写得很直白:Avoid exceeding extreme after smoothing),实测最大越界只有 0.74 m——那是像素取整的噪声(172 px 对应 2200 m,1 px = 12.79 m)。所以这一条不用避。

四、功能亮点与性能优势

4.1 三种采样方式的点数与代价

本猿把三种方式在默认路径上都跑了一遍(Node 里复刻了同一套算法,@cesium/engine26.2.0 + ECharts 6.1.0,不是估算):

采样方式档位采样点数密化数组报告总长与真实总长 241262.471 m 的缺口
每段步数20611501241262.471 m0
每段步数100(默认)3011501241262.471 m0
每段步数3009011501241262.471 m0
固定间距100 m24131501241200 m−62.471 m
固定间距500 m4831501241000 m−262.471 m
固定间距2000 m1211501240000 m−1262.471 m
固定间距5000 m491501240000 m−1262.471 m
总采样点数20201501241262.471 m0
总采样点数100010001501241262.471 m0

注意最后一列:"固定间距"永远差那么一点,而且差多少由间距决定,不由路径决定——缺口精确等于真实总长 mod 间距。

再看采样点数和绘制对象数的关系——折点数会让采样点数爆炸:

路径密化数组每段步数 20每段步数 100每段步数 300
默认 4 折点150161301901
手绘 20 折点950138119015701

4.2 横坐标:等距的画布,不等距的现实

这是本猿最想让大家看一眼的一张图:

实测三段的真实长度(haversine,R = 6371008.8):73308.018 / 73145.390 / 94809.063 m,占全长的 30.39 % / 30.32 % /39.30 %。

而图上它们占的宽度是 33.33 % / 33.00 % /33.00 %。于是:

段真实长度真实占比图上占比每格米数失真系数
段 173308.018 m30.39 %33.33 %733.080 m1.0970×
段 273145.390 m30.32 %33.00 %738.842 m1.0885×
段 394809.063 m39.30 %33.00 %957.667 m0.8398×

最疏的一格比最密的一格宽1.3064 倍。段 3 比段 1 长21501.045 m,在图上却一样宽——它的每一段爬升被横向压扁到 76.5 %,看着比实际平缓 23.5 %。

简单说:**类别轴画出来的是"采样序号的曲线",不是"距离的曲线"。**采样均匀(固定间距模式)时两者一致;采样不均匀时(每段步数模式、或折点分布不均时),图上的坡度就是假的。

顺带说一句 y 轴。scale: true把轴范围定在 2700~4500 m,面积图的基线落在这 2700 m 上——那块看着像"山体截面"的蓝色填充,底座其实是悬空的。真要表达"填挖方体积"这类量,scale必须关掉(关掉后轴范围实测是 0~5000 m)。

4.3 那条红线到底走的是哪条线

球上那条红线是polyline.clampToGround = true,没有指定arcType。查源码,GroundPolylineGeometry的默认值是这么写的:

// @cesium/engine · Source/Core/GroundPolylineGeometry.js// @param {ArcType} [options.arcType=ArcType.GEODESIC]// @param {number} [options.granularity=CesiumMath.RADIANS_PER_DEGREE]this.arcType=options.arcType??ArcType.GEODESIC

granularity的默认值是 1°,也就是111.195 km。实测PolylinePipeline.generateArc在不同跨度上输出的顶点数:

路径跨度generateArc 顶点数
0.5°2
1°2
2°3
5°6
10°10

默认路径三段全都不够 1°,红线中间一个点都没插——它就是两个端点之间的一条直线。而剖面的采样点是在lon / lat上线性插值的,画在球上是一条"经纬线"。两者中间差多少?

路径haversine 段长采样点偏离测地线最大距离
默认段 1(0.5° 斜线)73.308 km67.567 m
默认段 3(1.0° 纬线)94.809 km108.297 m
手绘 5° 纬线476.524 km2684.266 m
手绘 10° 纬线952.806 km10754.602 m
手绘 10° 斜线1435.335 km30229.783 m

默认那两段上,一百米上下的偏差在球上确实看不出来。但只要你画的线超过 1°——一条从北京到天津的剖面线就差不多 1.2°——偏差立刻进入"米"级;到了省级跨度(5°~10°),剖面采的点和球上红线的最远处能差 30 公里。那时候你指着球上那条红线说"我要看这条线的剖面",实际上切的是另一条山脊。

核心优势

  • 🚀纯前端闭环:影像、地形、采样、绘图全在浏览器里,没有任何服务端依赖
  • 📏三种采样口径:等步数 / 等间距 / 等点数,分别对应"每段都要看细"、“我要固定物理间距”、“我只想要 N 个点”
  • 🖐️手绘即算:ScreenSpaceEventHandler收LEFT_CLICK攒折点,RIGHT_CLICK或LEFT_DOUBLE_CLICK收尾,边画边看
  • 📈图随线动:剖面图和球上红线共用同一份路径数据,改折点两边一起变

五、小贴士:避坑指南

🎯 关于长度:haversine 用的是平均半径

haversineMeters里R = 6371008.8(IUGG 平均半径),而 Cesium 的Ellipsoid.WGS84是椭球。两者在同一段上的差不是常数符号,是会互相抵消的——默认路径三段实测:

  • 段 1:haversine 比测地线少 62.719 m(−0.0856 %)
  • 段 2:少 59.448 m(−0.0813 %)
  • 段 3:多 192.929 m(+0.2035 %)——因为它是纯纬线,纬圈半径在 lat 31.5° 上约为 95 km/°,平均半径算不出来这一段
  • 合计:haversine241262.471 m,测地线241333.234 m,短 70.763 m

⚠️ 重要提示:三项误差恰好抵消掉一大半,纯属这条路径运气好。别把"这次对了"当成"这个公式对"。要跟 Cesium 内部完全一致,直接用EllipsoidGeodesic.surfaceDistance:

constg=newEllipsoidGeodesic(Cartographic.fromDegrees(a[0],a[1]),Cartographic.fromDegrees(b[0],b[1]))constmeters=g.surfaceDistance// 🔑 Cesium 自己就是这么算的

📐 关于横轴:interval是"跳过几个"

axisLabel.interval的语义容易反着记:写n表示每 n+1 个刻度显示一个。demo 写的是Math.max(1, Math.floor(点数 / 6)),实测在 20 ~ 1000 个点上,显示出来的标签永远是 6~7 个(showMinLabel和showMaxLabel还会各补一个)。

⚠️ 重要提示:想让横轴真的是距离轴,把xAxis.type换成'value',data换成[[距离, 高程], ...]的点对,再把boundaryGap/interval一起删掉。收益是坡度立刻变真;代价是点密的地方会被挤在一起——而这恰恰是真实情况。

⛰️ 关于高程:先确认它真的取到了

剖面图最怕的不是"不准",是"静默地不准"。getHeight返回undefined时,?? 0会给你一条 0 米高的假平台,而try / catch连异常都吞了。

⚠️ 重要提示:把undefined和"真的 0 米"分开报出来。最小改动就是if (h === undefined) missed++,然后把?? 0留在后面,最后在面板上加一行"缺失点 N 个"。顺带提醒:分析前先把相机飞到路径上方、等它加载完。getHeight只认已经渲染出网格的瓦片,相机没看过的地方就是undefined。

⚡ 关于采样:密不等于准

Cesium World Terrain 的CesiumTerrainProvider默认用GeographicTilingScheme(源码 L186),level L 的瓦片经纬度跨度都是180 / 2^L度:

层级角度跨度纬向边长经向边长(lat 31°)
100.175781°19489.0 m16787.9 m
110.087891°9744.5 m8393.9 m
120.043945°4872.2 m4197.0 m

而采样点间距实测:每段步数 300 → 268.07 m;固定间距 100 m 档 → 100.03 m。这些点里绝大多数两两落在同一个三角形内部,取到的是同一张平面上的线性插值。

⚠️ 重要提示:与其把采样点加密,不如先把相机拉近、让瓦片加载到更细的一层——那才真的增加了信息。采样密度真正解决的是另一个问题:**别把山脊采漏。**本猿用一条构造的地形(山脊半宽约 2412 m)做了 40 次相位扫描,同一个山脊,不同采样间距能踩到的最坏情况是:

采样点数采样间距最坏漏掉的山脊高度
2012698.0 m725.08 m
1002437.0 m564.04 m
301804.2 m94.88 m
901268.1 m15.78 m

同一个山头,采样点少的时候能不能看见它,全看它是不是恰好落在某个采样点上。

📦 关于几个上限

  • Math.max(...heights)的实参上限:实测在本机 V8 上是124000 ~ 125000,超了直接RangeError: Maximum call stack size exceeded。这个 demo 的最坏情况是"每段步数 300 + 415 个折点",日常够用;但如果哪天把上限放开,记得改成reduce。
  • "固定间距"没有点数上限:间距滑到最小 100 m,一条 241 km 的路径就是 2413 个点、2413 次同步射线求交。Cartographic.fromDegrees本身的构造成本只有0.3729 ms(2413 次,已预热),但getHeight才是大头——而且它是同步的,全压在主线程上。
  • 密化数组只跟折点数有关:1 + 500 × (N−1)。手绘 20 个折点就是 9501 个对象,跟路径多长无关。

目前的局限与后续可以做的:

  1. 采样循环是同步for,采样点上千时会卡一下 UI——按 200 个一批切开,或挪进 Worker
  2. DENSE_PER_SEGMENT = 500写死——应该按段长自适应(ceil(段长 / 目标间距))
  3. 累积距离和位置用了两套算法——统一成EllipsoidGeodesic就干净了
  4. 横轴是类别轴——换type: 'value'才是真距离轴
  5. 缺采样点没有上报——undefined和 0 米现在长得一模一样
  6. 只能画一条剖面——没法两条线对比填挖方

六、写在最后

剖面分析这件事,做久了会有一种错觉:/ 以为自己切开的是地形,/ 其实切开的是这个引擎的一层层抽象——/ 你手里那张曲线,只是抽象漏下来的一点影子。

从 #53 把点变成清单,到 #54 把地挖出一个洞,再到今天顺着一条线把地形切开——空间分析这条线终于从"看"走到了"量"。🎉

现在,Cesium 不仅能告诉你"那里有多高",还能告诉你"这一路是怎么爬上来的"了。虽然它爬的可能是另一条山脊。

这个案例还不完美:横轴是类别轴、密化常数写死、缺采样点静默变 0、长线上红线和采样线各走各的。但它把"一条剖面是怎么拼出来的"这件事讲清楚了,这就够了。

这条线还能继续往下切。下一次我们不停在地表上了——站在一个点上往四周看:哪些地方看得见,哪些地方被挡着。通视分析,我们下一篇见。

如果你在自己的项目里也画过这种"看着对、量着歪"的剖面图,欢迎留言说说你最后是怎么校准的——是靠改用数值轴,还是干脆在服务端算完再丢给前端画。


🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)

仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX

代码在src/cases/profile-analysis/目录下,是一个独立的 Vue 单文件组件,复制进你的项目、把 Bing 影像换成自己的底图就能跑。


Cesium酱の百宝箱 · 第 55 篇
红线绕球走大圆,剖面按格子走直线
2026 年的秋夜,与你共探"一条线的高程"

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询