做这个 Google Earth Engine APP 面板的起因很简单我需要在一个地图界面上点一下某个位置就能立刻看到离这个点最近的发电站是哪个、距离多远、装机容量多大、燃料类型是什么。说白了就是把“最近点距离点”这个空间分析变成一个普通人都能用的可视化面板而不是让每个使用者跑到代码编辑器里去改脚本。这篇文章把整个面板从零到一拆开讲清楚包括界面怎么搭、最近距离怎么算、点击地图之后数据怎么回填以及最后部署成 Earth Engine App 时最容易踩的坑。如果你正好在做选址评估、电力基础设施调研或者想在 GEE 里做类似“点一下查最近对象”的功能这篇可以直接照着改。1. 面板到底解决什么问题从“最近点距离点”说起1.1 一个选址评估的典型场景我印象很深的一次经历是某项目组拿着一张规划草图过来上面有十几个备选位置他们想快速比对每个位置周边有没有现成的发电资源。当时他们的做法是打开传统的桌面 GIS把发电站图层放进去再用“邻域分析”里的距离工具一个个去算。流程倒是不复杂但问题在于每天决策一变就要重新算一遍而且不是每个人都会用 GIS 软件。后来我建议把这件事做成一个 GEE 里的 App 面板地图上点一个位置自动把最近的发电站标出来同时把它的基本信息列在旁边的面板上。这样一来使用者不用懂任何空间分析概念只需要“点一下”剩下的事情交给脚本。1.2 “最近点距离点”不是简单的两点距离标题里这句话“最近点距离点所获取的发电站信息”很多人第一反应是“计算两个坐标之间的距离不就行了吗”。实际做的时候会发现事情没那么简单。第一发电站不是只有一个点。有些数据里是点要素有些是面要素尤其是大型水电站、风电场会画出一个范围。面要素和点的最近距离不是简单的经纬度勾股定理而是要计算点到面边界的实际距离。第二你要找的是“最近的那个”不是“所有距离小于某个阈值的”。大多数 GIS 分析工具默认给你一个距离字段或者给你一个缓冲区筛选结果不会直接告诉你怎么优雅地拿到“最小距离的那个对象”。这需要你自己设计查询逻辑。第三GEE 里所有计算都是分布式的数据量越大越要小心。如果傻乎乎地把所有发电站下载到本地再遍历比较数据量一上来就等着超时报错吧。1.3 用 GEE 做这件事的收益和代价用 GEE 而不是传统 GIS最直接的收益是零部署成本。你不需要一台安装专业软件的电脑不需要维护空间数据库只需要一个浏览器打开代码编辑器把脚本保存成 App就能分享给别人使用。代价就是学习曲线有点陡。GEE 的“客户端脚本 服务器端对象”模型和传统编程很不一样很多人第一次写的时候会跟各种ee.*对象较劲为什么print()不出来为什么getInfo()卡住了这些我在第 4 节会专门讲。核心结论先放在这里这个项目本质上不是一个“算法项目”而是一个“交互界面项目”。算法部分最近距离、排序取第一个在 GEE 里大概是三五行代码的事真正花时间的是面板布局、点击回调、图层管理、容错处理。2. 先把界面搭出来ui.Panel 布局与组件选型2.1 面板拆成哪几个功能区我习惯在设计任何 GEE 界面之前先在纸上画一个大概的布局草图不画的话后面很容易改来改去。这个查询面板我拆成了三块左上角是控制区标题、燃料类型筛选下拉框、结果展示标签、清空按钮。中间主体是地图区用户在这里点击取点同时显示底图、发电站分布、最近距离连线。底部或右侧是结果区显示最近发电站的名称、类型、装机容量、距离、运行状态。我最终采用的是左右布局左侧一个固定宽度的面板右侧是地图。这样在宽屏显示器上很舒服如果放到小屏幕上会有些挤但大多数业务场景都是办公室电脑访问问题不大。2.2 ui 组件选型对照GEE 的 UI 组件不算多但够用。我这里列一个实际选型表功能需求使用的组件为什么这么选承载整个界面的根容器ui.Panel可以设置布局方式、宽度、边距显示提示文字ui.Label轻量支持动态更新燃料类型筛选ui.Select下拉选项比复选框节省空间业务上通常是单项筛选点击查询按钮ui.Button语义清晰触达成本低地图交互ui.Map自带点击事件、图层管理不用额外引地图库结果数据呈现ui.Label拼接数据量小不需要表格组件用多个 Label 更直观这里有一个容易被忽视的点ui.Select的选项值最好是英文代码显示的文本再单独做映射。比如源码里存的是coal、hydroUI 上显示的却是“火电”“水电”。这样做的好处是后续用筛选条件去ee.Filter.eq的时候不需要做中英文转换也不会因为显示语言改动影响查询逻辑。2.3 基础框架代码可运行的最小面板我先给一个最简版本这个版本已经可以运行出界面了但还没有业务逻辑你先跑通它再去加查询功能。// 初始化地图 var map ui.Map(); map.setControlVisibility(false); map.setCenter(110, 35, 5); // 左侧控制面板 var controlPanel ui.Panel({ layout: ui.Panel.Layout.flow(vertical), style: { width: 320px, padding: 12px } }); var titleLabel ui.Label(最近发电站查询, { fontSize: 18px, fontWeight: bold, margin: 8px 0 }); var fuelSelect ui.Select({ items: [ { label: 全部类型, value: all }, { label: 火电, value: coal }, { label: 水电, value: hydro }, { label: 风电, value: wind }, { label: 光伏, value: solar } ], value: all, placeholder: 选择燃料类型 }); var resultLabel ui.Label(点击地图开始查询, { whiteSpace: pre-wrap, fontSize: 14px, margin: 12px 0 }); var clearButton ui.Button({ label: 清空结果, onClick: function() { resultLabel.setValue(点击地图开始查询); // 这里后续会放图层清理逻辑 } }); controlPanel.add(titleLabel); controlPanel.add(ui.Label(燃料类型)); controlPanel.add(fuelSelect); controlPanel.add(resultLabel); controlPanel.add(clearButton); // 把面板和地图挂到根节点 ui.root.widgets().reset([controlPanel, map]);这段代码跑出来左边是一个窄面板右边是地球。界面看起来非常简单但你已经把 GEE App 的骨架搭好了。后续所有逻辑都往这个骨架里填。2.4 布局里容易忽略的三个细节第一个细节是ui.Panel.Layout.flow(vertical)和默认布局的区别。默认的ui.Panel布局是流动布局但如果你不加参数它有时候会按照Widget的顺序横着排。做这种“标题在上、按钮在下”的控制面板必须显式指定 vertical。第二个细节是样式whiteSpace: pre-wrap。结果标签里的内容往往是多行的比如“电站名称XX”“装机容量XX”“距离XX”如果不设置pre-wrap换行符很可能显示不出来或者显示为空格最后挤成一段很难读。加上它就相当于 CSS 里保留了换行和空白多行文本的排版立刻正常。第三个细节是setControlVisibility(false)。默认的地图会带缩放控件、投影信息有时候还有很多 Logo 之类的叠加会让界面显得很乱。在面向业务用户的 App 里我一般先关掉所有默认控件再按需打开。这个函数接受一个布尔值也可以传一个对象来单独控制某几个按钮的显示。3. 最近发电站怎么算三种思路与最终方案3.1 方法一对每个点算距离再排序取第一个这个思路是最直接的遍历发电站图层里的每个要素计算它与目标点的距离把结果作为一个新属性写进要素然后用sort()按距离升序排列取第一个。在 GEE 里代码可以写成这样// targetPoint 是用户点击产生的 Geometry var targetPoint ee.Geometry.Point([lon, lat]); // plants 是发电站 FeatureCollection var withDistance plants.map(function(f) { var dist f.geometry().distance(targetPoint, 1); return f.set(distance_km, dist.divide(1000)); }); var nearest withDistance.sort(distance_km).first();这里有两处需要解释一下。第一.distance()方法的第二个参数是最大误差容忍度单位是米。GEE 的几何运算不是数学上精确的而是在某个容差范围内逼近如果你只传一个参数默认容差可能偏大对于一些微型要素可能会出现距离偏差。我传的是 1 米这个精度对公里级别的选址分析完全够用也不会让计算变慢。第二sort(distance_km)默认是升序所以.first()拿到的就是距离最小的那个要素。如果你不想用.first()也可以.sort(distance_km, true).first()第二个参数true表示升序。很多人想当然地认为 sort 是降序结果取到的反而是最远的这个坑我见得太多了。3.2 方法二缓冲区 withinDistance 过滤另一种常见的做法是先给目标点画一个缓冲区比如 50 公里然后用ee.Filter.withinDistance过滤掉缓冲区外的要素再在过滤结果里比较距离。var bufferDistance 50000; // 50公里 var nearby plants.filter( ee.Filter.withinDistance({ geometry: targetPoint, distance: bufferDistance, maxError: 10 }) );这个方法有一个很现实的问题缓冲区的半径你得先给定。如果最近发电站在 60 公里外你设了个 50 公里结果就是空的。有人可能会说“那我把半径设大一点100 公里总够了吧”但是缓冲区半径越大参与运算的要素就越多速度会变慢而且仍然存在“超出边界”的可能。所以缓冲区的思路适合什么场景呢适合你明确知道“只要看周边 XXX 公里范围内有没有资源”的场景比如你就是要找 20 公里内的电源点那 even 没必要找最远的。但如果你要的是“不管多远给我最近的”缓冲区就不是最优雅的方案。3.3 方法三客户端遍历全部点位千万别这么做还有一种方案看起来很“程序员思维”把发电站 FeatureCollection 用getInfo()拉回本地然后在 JavaScript 里用循环遍历所有点手动比较距离。// 反面教材别这么写 var allPlants plants.getInfo(); var minDist Infinity; var nearestPlant null; for (var i 0; i allPlants.features.length; i) { var coords allPlants.features[i].geometry.coordinates; var dx coords[0] - lon; var dy coords[1] - lat; var d Math.sqrt(dx*dx dy*dy); if (d minDist) { minDist d; nearestPlant allPlants.features[i]; } }这个方案在小数据量的时候完全能跑而且代码看起来很好理解。但问题有三个。第一getInfo()是同步阻塞的如果数据量大一点整个页面会卡住浏览器甚至会弹“无响应”提示。第二它把所有数据传输到了客户端。发电站数据如果全球范围有几万条传输量不大但也不少更痛苦的是它忽略了 GEE 的分布式计算优势。第三也是最重要的面要素的几何距离你不能用经纬度直接算。上面代码里的dx、dy是角度不是米。地球不同纬度上经纬度对应的实际距离差异很大算出来的“最近”严格来说是不正确的。哪怕你对dy乘个纬度校正系数也只是近似不如服务器端的geometry.distance()精确。我一开始其实也写过一版客户端遍历的代码因为思路简单后来被大数据量狠狠教育了。有那个功夫不如把逻辑放在服务器端。3.4 为什么我最终选方法一最终我选的是“map distance sort first”这条链路原因可以列成三点语义上完全匹配需求。我要的就是“距离最短的那个”它最直接。服务器端运算不阻塞界面。所有比较发生在 GEE 的分布式后端客户端只需要等待一个结果。对点要素和面要素都适用。f.geometry().distance()内部会正确处理边界距离不需要我额外区分数据类型。这个方法唯一要注意的是性能关于这一点我在第 6 节里有更详细的经验数据。4. 把点击变成结果事件回调、图层清理与信息回填4.1 地图点击事件怎么接GEE 的ui.Map有onClick事件你可以给它挂一个回调函数。点击地图后回调会拿到点击位置的经纬度坐标。map.onClick(function(coords) { var lon, lat; // 老版回调返回 {lon, lat}新版可能返回 GeoJSON Point if (coords.lon ! undefined coords.lat ! undefined) { lon coords.lon; lat coords.lat; } else if (coords.coordinates) { lon coords.coordinates[0]; lat coords.coordinates[1]; } if (lon undefined || lat undefined) { resultLabel.setValue(本次点击坐标解析失败请重试); return; } runQuery(lon, lat); });这里写一个兼容判断是有原因的。GEE 的 UI API 在迭代过程中点击事件的返回结构有过调整我在不同时间段写的脚本里见过的格式不完全一样。为了不让面板在某个版本下面直接报错我会加一层防御性判断。你如果在自己环境里跑通了可以不需要这个兼容分支但留着也影响不大。4.2 服务器端对象与前端数值的“翻译”这是整个面板开发里最核心的一个概念我单独拎出来讲。在 GEE 的 JavaScript 环境里ee.FeatureCollection、ee.Geometry、ee.Number这些对象并不是普通的 JavaScript 对象。你写的nearest.get(name)并不会立刻返回一个字符串它返回的是一个“引用”一个等待服务器端计算的表达式。如果你直接resultLabel.setValue(nearest.get(name))大概率会看到输出变成ee.Text对象的内部表示而不是电站名称。正确的做法是把服务器端对象“取回”客户端。GEE 提供了两种方式getInfo()同步取回会阻塞当前脚本。在 Code Editor 里写代码测试没问题但放进按钮事件里时要小心页面卡顿。evaluate(callback)异步取回回调函数里拿到的是普通 JavaScript 对象适合放在交互事件里。我在查询逻辑里用的是.evaluate()。比如nearest.evaluate(function(result) { if (!result) { resultLabel.setValue(没有找到符合条件的发电站); return; } // result 是一个 GeoJSON Feature 对象 var props result.properties; resultLabel.setValue( 电站名称 (props.name || 未知) \n 燃料类型 (props.fuel_type || 未知) \n 装机容量 (props.capacity_mw || 0) MW\n 距离 Number(props.distance_km).toFixed(2) km\n 运行状态 (props.status || 未知) ); });这里你注意distance_km在服务器端是一个ee.Number到了evaluate的回调里就会变成普通数字。所以后续能直接用Number(...).toFixed(2)来控制显示格式。如果你忘了这层转换直接拿ee.Number去.toFixed()界面会直接报错。4.3 结果图层和距离线怎么写查询出结果之后光改一行文字不够最好在地图上把三个东西画出来用户点击的点用一个明显的点标记。最近的发电站比如红色。两点之间的连线直观显示“最近距离”是连在哪条线段上的。连线我习惯用ee.Geometry.LineString来做var line ee.Geometry.LineString([ [lon, lat], [nearestGeometryLon, nearestGeometryLat] ]); map.addLayer(line, { color: orange, width: 3 }, 最近距离连线);不过这里有一个问题如果你每次都addLayer地图图层列表会越堆越多所有新旧结果混在一起很快分不清哪一次是最新的。我做了一个辅助函数专门清理同名图层function clearLayersByTag(map, tag) { var layers map.layers(); var count layers.length(); for (var i count - 1; i 0; i--) { var layer layers.get(i); if (layer.getName().indexOf(tag) ! -1) { layers.remove(i); } } } // 在每次查询前调用 clearLayersByTag(map, 最近); clearLayersByTag(map, 点击点);这里的思路很简单每次我加图层时都用固定的关键词作为名称的一部分比如“点击点-当前位置”“最近-发电站”“最近-距离线”。下次查询先删掉所有名称里带这个关键词的旧图层再加新的。你在自己的项目里可以调整关键词策略但原理是一样的用名称做标记按名称批量清理。一个容易踩的细节是从layer.getName()取值在某些版本里名称可能返回一个ui.Label而不是字符串。为了避免这种不确定性我在调用indexOf之前先String(layer.getName())强转一下。防御性编码在这种长期维护的脚本里很有价值。4.4 完整可运行的查询逻辑代码到这里把前面的片段串起来一个可以用的查询逻辑就出来了。我把整合后的核心代码贴出来方便你直接基于它改// 假设 plants 是已经加载好的发电站 FeatureCollection function runQuery(lon, lat) { // 清掉上一次的结果图层 clearLayersByTag(map, 最近); clearLayersByTag(map, 点击点); // 画用户点击的点 var clickPoint ee.Geometry.Point([lon, lat]); map.addLayer(clickPoint, { color: blue, pointRadius: 7 }, 点击点); // 根据下拉框筛选燃料类型 var selectedFuel fuelSelect.getValue(); var filteredPlants plants; if (selectedFuel ! all) { filteredPlants plants.filter(ee.Filter.eq(fuel_type, selectedFuel)); } // 计算距离并取最近 var targetPoint ee.Geometry.Point([lon, lat]); var withDistance filteredPlants.map(function(f) { var dist f.geometry().distance(targetPoint, 1); return f.set(distance_km, dist.divide(1000)); }); var nearest withDistance.sort(distance_km).first(); // 异步取回结果并更新界面 resultLabel.setValue(正在计算最近发电站...); nearest.evaluate(function(result) { if (!result || !result.geometry) { resultLabel.setValue(没有找到满足条件的发电站); return; } var props result.properties; var nearestLon result.geometry.coordinates[0]; var nearestLat result.geometry.coordinates[1]; var nearestPoint ee.Geometry.Point([nearestLon, nearestLat]); map.addLayer(nearestPoint, { color: red, pointRadius: 8 }, 最近-发电站); map.addLayer(ee.Geometry.LineString([[lon, lat], [nearestLon, nearestLat]]), { color: orange, width: 2 }, 最近-距离线); resultLabel.setValue( 电站名称 (props.name || 未知) \n 燃料类型 (props.fuel_type || 未知) \n 装机容量 (props.capacity_mw || 0) MW\n 距离 Number(props.distance_km).toFixed(2) km\n 运行状态 (props.status || 未知) ); map.centerObject(nearestPoint, 12); }); }这段代码跑通之后你已经有了一个完整的“点击查询最近发电站”面板。剩下的问题基本都集中在数据层面和部署层面。5. 部署成 GEE App 前必须确认的三件事5.1 数据资产授权与可见性很多人做完界面在 Code Editor 里点“运行”一切正常就急着去部署 App结果发布出去之后别人一访问地图上什么都没有。最常见的原因是数据资产权限没开。你的发电站数据如果是上传到 Assets 里的私有资产默认只有你自己的账号能访问。别人打开 App 时这个 App 是以他们的身份运行的访问你的私有资产就会被拒绝。解决办法是在资产权限设置里加上一个“读者”角色范围至少覆盖目标用户群体。GEE 的资产权限支持按邮箱或者域名授予读取权限你可以在资产属性里找到分享设置。我在实际配置的时候会把“所有能访问 App 的人”都加进读者列表而不是只放一两个测试账号。因为你永远不知道分享出去的链接会被哪些人转发。5.2 计算配额与超时控制GEE 的请求是有配额限制的App 场景的配额和 Code Editor 里个人调试的配额不完全一样。每一次点击查询背后都是一次服务器端计算如果多人同时使用、频繁点击配额可能很快耗尽。我的经验是在 App 端给按钮加一个“计算中”的禁用状态防止用户连续点击提交多个请求。虽然 GEE 的 UI 支持异步不会因为点击多次就崩掉但每一次点击都会消耗计算资源。如果你的数据量比较大比如全国范围的发电站几万个点每一次都要做全量距离计算这个开销会很高。我在下一节会讲一些裁剪思路。部署之前最好在控制台看一下性能表现别上线了两天才发现每天的钱哗哗地烧。5.3 Code Editor 调试与线上 App 的差别在 Code Editor 里代码本质上是在一个受控的工程环境里运行你可以直接打日志、直观看到图层叠加调试体验极好。但部署成 App 之后你看到的只是用户端的界面没有控制台没有图层列表窗口没有错误输出。这带来一个实际影响你写代码时必须假设“用户会犯错”“数据会有意外”把所有异常分支都处理掉而不是依赖自己在控制台里去观察。比如结果字段缺失、坐标解析失败、筛选后无结果这些分支我在前面代码里都写了兜底提示就是这个原因。还有一个小细节App 发布之后如果后续改了脚本需要重新“部署新版本”否则旧链接看到的还是老代码。这个流程不复杂但很容易忘记。我一般会在代码顶部加一个简单的版本号字符串界面标题栏也显示版本这样用户截图反馈问题时我能立刻知道他们用的是哪个版本。6. 我这版实现里踩过的坑数据与性能相关6.1 经纬度坐标别写反这个听着像废话但我在这个项目里真的遇到过一次。上传 CSV 的时候表头我写了一行lon,lat但数据里某几行其实是先纬度后经度。这导致那几条记录被画到了完全相反的位置距离计算当然全错。建议在任何一步都统一“经度在前纬度在后”的约定。GEE 的ee.Geometry.Point和ee.FeatureCollection的坐标顺序都是 [经度, 纬度]但 CSV 上传界面会让你指定经纬度列如果你手动输入数据很容易按 x,y 或者“纬度、经度”的顺序写。最简单的方法上传后先随机挑几个点跟地图上的真实位置比对一下别等到查询出错再回头看数据。6.2 字段名与空值发电站数据不像纯人工整理的表格那么规整。字段名可能是name、Name、plant_name燃料类型可能是coal、Coal、coal-fired容量字段在一些行里可能是 null甚至整行缺数据。在filter之前建议先看一下字段分布。尤其是fuel_type这种字段如果大小写不统一ee.Filter.eq(fuel_type, coal)会漏掉Coal。稳妥的做法是在上传前先做一次数据清洗把枚举字段统一成小写。还有一个比较容易忽略的坑.distance()计算在个别要素几何异常时可能返回 null一旦混进distance_kmsort()的排序结果会不稳定。我在实际生产脚本里会加一层过滤把几何无效的要素先剔除var validPlants plants.filter(ee.Filter.notNull([name, fuel_type]));6.3 大数据量的 distance 计算如何拆我前面说“map sort first”很优雅但它并不是没有上限的。实测下来几千个点的发电站数据整个查询链路能在几秒内返回体验可以接受。但如果数据量到了十万级别全量map一次要遍历所有要素再排序耗时会明显上升。这时候有两个优化方向。第一个方向是分区域索引。先判断用户点击点所在的大区域只取该区域内的发电站参与计算比如先用ee.Filter.withinDistance做一个粗筛再用排序精确找最近。这个“先粗筛再细排序”的组合兼顾了准确性和性能。第二个方向是减少几何精度。distance()时我传的容差是 1 米但如果你的业务不需要那么精确把容差调大到 10 米甚至 100 米计算速度能得到可观的提升。实际距离误差几十米对选址评估来说通常无伤大雅。6.4 后续可以扩展的方向这个面板做出来之后往后面向实际业务扩展的方向还挺多的。一个很自然的扩展是批量点查询把用户手动点击改成一次上传多个备选点坐标输出一张“每个点对应的最近电站和距离”表格。这在 GEE 里实现并不难本质上是把同样的逻辑套到一个FeatureCollection的map上。另一个方向是加入时间维度。发电站数据中如果有投产年份或退役年份你可以加一个时间筛选比如只看已投运的电站。这样查询结果更贴合实际需求。还有一个很实用的扩展结合 GEE 的影像资源把道路网、输电线路、夜间灯光影像叠加到同一个面板里。选址评估往往不是只看距离还要看接入条件夜间灯光影像可以粗略反映区域基础设施活跃程度。叠加图层在 GEE 里只需要多addLayer一段代码但业务价值会明显提升。我在给某项目组交付这个面板之后他们后续给我提的最多的需求就是“能不能把结果导出成表格”。其实 GEE 里可以用Export.table.toDrive把查询结果导出到云盘但由于面板是交互式的我最终是用一个小函数把当前结果显示到控制台再由使用者复制到表格工具里。这一步看起来笨但实际使用中反而最顺手因为决策的人往往只需要当次结果而不是一整份历史记录。