前端做 GIS 空间分析不是新鲜事,但把坡向计算搬到浏览器里跑、还要能交互式选范围,中间藏了多少细节?这篇文章把完整实现拆开给你看。
开篇
CesiumJS 提供了sampleTerrainAPI 可以采样地形高度,但没有内置"坡向分析"这个功能。你得自己把整个计算流水线搭起来:从 DEM 采样到梯度计算,从色带映射到 Canvas 叠图,最后再盖一层方向箭头。每一步都有坑。
这篇文章就是我的实测笔记——我会逐段拆解aspect_analysis.html的核心实现,环境搭建和底图加载一笔带过,重点放在 @@计算链路@@ 和 @@可视化策略@@ 上。
核心思路:从地形到坡向的四步流水线
整个工具的逻辑其实不复杂——就四步:
- ++地形采样++ —— 在用户绘制的矩形范围内,按网格密度采样地形高度
- ++梯度计算++ —— 用中心差分法算出每个格网的东西向和南北向坡度
- ++坡向转换++ —— 把梯度分量转为 0°~360° 的方位角(0°=北,顺时针)
- ++可视化呈现++ —— 用 Canvas 生成色带叠加层 + 箭头矢量表示方向
这四步串起来就是一条流水线,每一步的输出是下一步的输入。贴个简化版流程:
矩形范围→ sampleTerrain → 高度矩阵 → 梯度计算 → 坡度+坡向 → Canvas色带叠图 → 箭头渲染
看代码的时候你会注意到,整条流水线全部塞在analyzeCurrentRectangle()这一个方法里。这么做不是偷懒——是为了保证每一步的数据都是最新的,避免参数改了但结果没刷新这种前端常见的状态不同步问题。
下面我一步步拆。
地形采样:sampleTerrain的几个坑
坑一:采样点数组不能随便构造
采样是整个链条的第一步,也是最容易翻车的一步。Cesium 的sampleTerrain接受一个Cartographic数组和一个采样层级,返回带高度的Cartographic数组。
关键点在构造这个数组:
letpositions=[];for(letj=0;j<rows;j++){letlat=south+(latSpan*j)/(rows-1);for(leti=0;i<cols;i++){letlon=west+(lonSpan*i)/(cols-1);positions.push(newCesium.Cartographic(lon,lat,0));}}letsampled=awaitCesium.sampleTerrain(this.viewer.terrainProvider,this.params.terrainLevel,positions,);注意这里terrainLevel是可调的(8-15),并且这里精度其实没设置。两个采样点的间距直接由矩形范围的经纬度跨度和网格数决定。这点和桌面 GIS 不太一样——桌面软件通常让你指定像元大小,而这里是纯数学网格,每个网格点就是一个采样点。
坑二:采样层级是个性能杠杆
terrainLevel的值越大,采样精度越高,但耗时也越长。我默认设了 12——经过实测,100×100 的网格在 level 12 时大概 2-3 秒,level 15 直接上 6-8 秒。
这就是为什么我在面板里加了autoAnalyze开关——关掉它,你可以先调好所有参数再点"开始分析",省得每改一个参数就触发一次 3 秒的重新计算。
坑三:地形返回的高度可能 undefined
DEM 数据不是全地球都有。采样点落在 DEM 覆盖范围之外时,sampleTerrain返回的height就是undefined。代码里必须兜底:
letheights=newFloat64Array(cols*rows);letvalid=newUint8Array(cols*rows);for(letk=0;k<sampled.length;k++){letheight=sampled[k].height;if(height===undefined||Number.isNaN(height))continue;heights[k]=height;valid[k]=1;}用valid数组标记有效点,后续梯度计算时遇到无效点就直接跳过。这个细节不处理,后面梯度计算会给你各种 NaN 污染。
梯度和坡向:这部分纯数学,但很容易算错方向
拿到高度矩阵之后,就可以计算每个格网的坡度和坡向了。
梯度:中心差分法
代码用的是最简单的中心差分法——每个格网的东西向梯度用左右两个邻点的高度差除以两倍格网间距,南北向同理。这是标准做法,不稀奇。
但这里有两个细节值得说一下:
leteastMeterPerRad=earthRadius*Math.cos(lat);第一,经纬度换算成米。东西向 1 度的实际距离随纬度变化——赤道最大,极点趋近于零。所以每次迭代都要乘以Math.cos(lat)。你直接用(nextLon - prevLon) * earthRadius算东西向梯度的话,高纬度地区会严重失真。
第二,坡向角的定义。GIS 里坡向是坡面面向的方向(坡面法向量在水平面上的投影),不是坡面下降的方向。所以代码里传到getAspectAzimuthDeg时梯度分量都取了反号:
letaspectDeg=this.getAspectAzimuthDeg(-dhdEast,-dhdNorth);dhdEast和dhdNorth是高度沿东西、南北方向的变化率。坡面上升最快的方向是(dhdEast, dhdNorth),但坡向要输出的是坡面朝哪个方向,所以取反——下降最快的方向和坡面朝向一致。
坡向角到色带
算出来的 0°~360° 方位角,要映射到色带颜色上。代码支持 4 种内置色带:
| 色带 | 视觉风格 | 适合场景 |
|---|---|---|
| ++八方向++ | 8 色离散,区分度高 | 快速识别 N/NE/E/SE/S/SW/W/NW |
| ++冷暖方位++ | 蓝→白→红渐变 | 南北坡对比明显 |
| ++彩虹方位++ | 多色渐变 | 精细方位辨识 |
| ++灰度方位++ | 黑白渐变 | 叠加在影像上不抢视觉 |
色带切换是onChange→ 直接触发reanalyzeIfReady(),全程实时重算,不用等、不用手动点刷新。
可视化:Canvas 叠图 + 箭头,两个图层各有门道
Canvas 叠图:给每个格网"画"一个像素
这部分是整个工具最"手工"的地方。计算完所有格网的坡向后,不是用 Cesium 的 Entity 或 Primitive 来渲染——而是直接画到 Canvas 上,再把 Canvas 转成SingleTileImageryProvider贴到地球上。
letcanvas=document.createElement("canvas");canvas.width=cols;canvas.height=rows;letcontext=canvas.getContext("2d");letimageData=context.createImageData(cols,rows);// ... 逐像素 fillcontext.putImageData(imageData,0,0);letprovider=newCesium.SingleTileImageryProvider({url:canvas.toDataURL("image/png"),rectangle,});this.resultLayer=this.viewer.imageryLayers.addImageryProvider(provider);这个方案的好处是:
- 性能好——无论多少格网点,最终都是一张图
- 渲染可控——每个像素的颜色、透明度完全自己掌控
- 不需 Entity——不用创建成千上万个 Entity 来渲染
但它有个限制:Canvas 的cols × rows不能太大。我试过 1000×1000,浏览器直接卡死。代码里用Math.min(1000, ...)做了上限保护——这个保护不是可有可无,是生产环境必须加的。
箭头:坡向的方向可视化
色带用颜色表示方向,但人眼对颜色的方向感知其实不直观——东是红的、西是蓝的,这需要看色卡才能读懂。所以代码里又加了一层箭头层——在格网范围内按固定间隔绘制箭头,箭头从低处指向高处(坡向的反方向…不对,应该是指向坡面朝向)。
等一下,让我再确认一下箭头的方向:
getArrow(lon,lat,lonLen,latLen,aspectDeg){letrad=Cesium.Math.toRadians(aspectDeg);letdx=Math.sin(rad)*lonLen;letdy=Math.cos(rad)*latLen;return{startLon:lon-dx*0.5,startLat:lat-dy*0.5,endLon:lon+dx*0.5,endLat:lat+dy*0.5,};}aspectDeg是坡向的方位角——坡面面向的方向。箭头从中心向(dx, dy)方向延伸,这就是箭头指向的方向。所以箭头指向的就是坡面朝的方向——(地理上北是 0°,箭头往北就是朝北坡)。
箭头用的是 Cesium 的PolylineArrowMaterialProperty,以线条形式 clamp 在地形上,方向感非常直截了当。arrowStep控制箭头间距——值越小箭头越密,但性能也越差。默认 10 格一跳,100×100 的网格大概产生 100 个箭头,渲染压力不大。
交互:矩形绘制也得自己搞
Cesium 没有内置的"画矩形选择范围"功能,得自己实现。代码用的是ScreenSpaceEventHandler:
- 第一次左键点击 → 记录第一个角点
- 鼠标移动 → 实时预览半透明青色矩形
- 第二次左键点击 → 确定范围,触发分析
这里有 一个容易忽略的坑:矩形绘制用LEFT_CLICK,但 Cesium 默认的LEFT_CLICK还会触发选中 Entity。如果你的场景里有很多 Entity(比如之前分析留下的箭头),点下去可能选中的是箭头而不是角点。代码里先clearAll()再开始绘制,解决了这个问题。
另外,预览矩形的heightReference: CLAMP_TO_GROUND让它贴在地形表面,classificationType: TERRAIN确保它跟随高低起伏——而不是悬在空中。
写在最后
回头看这个工具,最有价值的不是"坡向分析"本身——桌面 GIS 早就有这个功能了——而是它证明了前端可以做空间分析这件事,而且能做到可交互、可调参、即时出结果。
有几个经验以后做类似项目时能复用:
把采样计算和 Canvas 渲染拆成独立步骤。采样参数改了 → 重新跑计算 → 重新画 Canvas。每次改渲染参数(透明度、色带)→ 只重建 Canvas 叠图,不重新采样。这样参数切换的响应速度从几秒降到几十毫秒。
能用一个图层解决就别用 Entity。SingleTileImageryProvider是 Cesium 里被低估的功能——可以当成一个"万能贴图工具"用。
地形精度和性能是跷跷板。terrainLevel+gridCols/gridRows这两个参数决定了计算耗时。100×100 + level 12 大概 2-3 秒,是一个比较甜的性能点。
说说不足——这个工具目前最明显的短板是不支持不规则范围。只能画矩形,多边形不行、流域边界更不行。这是下一步要考虑迭代的方向。如果读者有需求,我后面补一篇多边形的实现。
这篇文章的完整代码可以在aspect_analysis.html里找到,配图用的色带效果见正文。有问题或更好的实现方案,欢迎留言交流。
完整代码
见原文:https://mp.weixin.qq.com/s/iewp3NNTWps0EhcoX7QwNQ
关注公众号 “GIS 开发手记”,及时获取首发内容!
往期精选
- Cesium 空间分析:坡度分析
- Cesium 通视分析:3D 城市模型上"划线"看可见区
- Cesium 3D 热力图:从 Canvas 热力到 GPU 顶点着色器
- Cesium 点聚合:海量点地图可视化方案
- 自定义虚线箭头材质:把两个内置 Material 焊在一起
- Cesium 中文字体:贴地 / 贴墙 / 动态文字方案
- 网络地图坐标系完全指南:WGS84 / GCJ02 / BD09 / CGCS2000 与坐标转换实战