恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
三维散点可视化实战:ArcGIS JSAPI + Three.js SimplePoint图层加载与交互
首页
资讯中心
/
三维散点可视化实战:ArcGIS JSAPI + Three.js SimplePoint图层加载与交互
三维散点可视化实战:ArcGIS JSAPI + Three.js SimplePoint图层加载与交互
发布时间:2026/10/2 9:35:03
最近做城市 POI 的散点可视化手头几千个经纬度坐标要在三维地图上撒开、着色、点选来回折腾了几天最后是 JSAPIThree 的 SimplePoint 图层把活干利索的。这套东西本质上是把 Three.js 的渲染能力和 ArcGIS 的场景管理接起来SimplePoint 就是其中专门为点要素设计的轻量图层。这篇学习笔记围绕“加载简单点图层”展开记录数据组装、样式配置、图层挂载和点击交互的完整流程。适合谁看如果你已经在用 ArcGIS API for JavaScript想在三维场景里渲染一批坐标点又不想被原生符号系统绑住手脚那这篇可以直接抄。如果你手里只有一张表——字段是经纬度和数值也就是 ArcGIS 圈里最常说的“已知坐标的点加入图层”那正好是老路。1. 为什么用 JSAPIThree 做散点可视化1.1 散点可视化的几个常见痛点做地理可视化的朋友应该都有体会在 ArcGIS 体系里撒点看起来很简单点多了就麻烦。用 Graphics 一层层 add几千个点已经能明显感觉到卡顿用到两万点以上交互基本靠运气。FeatureLayer 相对好一点但它是服务端渲染的思路样式描述能力受符号系统限制想做“点的大小随属性变化 颜色渐变 三维空间旋转”这类效果写起来很别扭。另外一个痛点在于渲染管线的割裂。ArcGIS 的 2D 图层和 3D 场景视图是两套渲染逻辑PointCloudLayer 这类针对激光点云的方案又不适合普通散点。说白了常规手段解决不了“大量点 灵活样式 三维场景”的组合需求。我最初试过先把点导出成 GeoJSON再用 FeatureLayer 的 Graphics 图层加载结果三万点直接卡成 PPT。后来又试了原生 Three.js 自己写点云渲染确实流畅但要自己处理图层生命周期、坐标投影、避让代码量上去了维护成本也高了。JSAPIThree 恰好在这中间给了个省事方案。1.2 SimplePoint 到底解决什么问题JSAPIThree 选择了一条非常务实的路线保留 ArcGIS 的地图服务与场景管理把具体渲染交给 Three.js。SimplePoint 是这套扩展里专门为点要素设计的图层内部用 Three.js 的几何体批量生成点一次性送入 GPU因此在万级点数下依然能保持流畅。它的优势在于“数据即配即用”。你不需要按 ArcGIS 的标准构造 FeatureSet也不需要纠结渲染管线的分层逻辑只要提供一个数组里面是常见的字段longitude、latitude、value、name 等图层就能自行投影、构建 Buffer、生成网格。对于“已知坐标的点加入图层”这种需求几乎是零门槛。简洁是这套设计的核心价值。我后来翻了一下它的源码发现内部处理了 Web Mercator 投影换算、相机变化同步、场景销毁清理这些脏活。你给一坨坐标数组它给你一个可交互的三维散点图层中间省掉的时间非常可观。1.3 哪些场景适合用它根据我这次实践SimplePoint 适合这么几类场景城市 POI 分布门店、地铁站、共享单车点位点数几千到几万。属性分布表达按 value 映射点的大小、颜色形成“热力散点”。动态数据展示轮询接口新增点配合 Three.js 动画做“生长”或“呼吸”效果。三维场景叠加与模型、地形、路径等其他 Three.js 内容混合展示。反过来如果点数少于几百用 Graphics 叠加就够如果要完整符号体系比如井盖符号、复杂 marker 图片还是老老实实 FeatureLayer。选择的一个重要判断点你需要 Three.js 的自由度吗如果所有需求在 ArcGIS 原生图层里都能完成引入额外扩展就得慎重。毕竟多一个依赖就是多一份风险版本升级、API 变动都可能踩坑。2. 环境准备与核心概念拆解2.1 环境搭建与依赖版本匹配JSAPIThree 不是一个官方大包更像是一个封装层所以搭建时要注意版本匹配。我的环境是这样的arcgis/core4.27 版本three0.152 版本jsapithree最新发布版引入方式我用的是 npm Vite。核心模块的写法npm install arcgis/core three jsapithree代码里import { Map, SceneView } from arcgis/core; import SimplePointLayer from jsapithree/layers/SimplePointLayer;如果你不太想走打包工具也可以直接用 CDN 引入注意 jsapithree 的全局变量名通常是 window.JSAPIThree不同版本可能不一样建议先打印一下 window 对象确认。提示模块路径在不同版本里可能叫做 SimplePointLayer、JsapiThreePointLayer 之类的具体以你安装的包实际导出为准。我踩过这个坑——网上找的文章代码跑不通进 node_modules 一翻才发现类名已经改名了。2.2 从“已知坐标”到图层的核心数据链路为什么这个图层能做得这么轻因为它内部帮你完成了一条重要的数据链路地理坐标 - Web Mercator 投影坐标 - Three.js 世界坐标。ArcGIS 的 SceneView 在三维模式下使用的是 Web MercatorEPSG:3857作为基础坐标系单位是米。GPS 拿到的经纬度是角度没法直接塞进 Three.js 场景。JSAPIThree 在 SimplePoint 图层内部做了一次投影换算因此你只要给它{ longitude: 116.397428, latitude: 39.90923, value: 96, name: 故宫 }它就能自动完成经纬度到场景坐标的换算。理解了这条链路遇到“点没显示”“点位置不对”时你就知道往哪查要么是数据字段名不对要么是投影环节出了问题。如果图层本身没有提供投影处理或者你想在自定义的 Three.js 物体里复用这批坐标那就需要手动换算。我自己写过一个简化版本的经纬度转 Web Mercator 函数// WGS84 经纬度 - Web Mercator 平面坐标近似 function lngLatToWebMercator(lng, lat) { const earthHalfCircumference 20037508.34; const x (lng 180) / 360 * earthHalfCircumference * 2; const rad lat * Math.PI / 180; const y Math.log(Math.tan((90 lat) * Math.PI / 360)) / (Math.PI / 180); const mercatorY earthHalfCircumference * 2 * y / 360; return [x, mercatorY]; }不过这个公式在纬度接近 85 度以上会失真实际使用推荐用 ArcGIS 自带的 webMercatorUtils 工具import { webMercatorUtils } from arcgis/core/geometry; const point webMercatorUtils.geographicToWebMercator({ longitude: 116.397428, latitude: 39.90923 });2.3 样式参数的三个关键SimplePoint 对外暴露的样式参数并不复杂我实测最关键的是四个size、color、opacity、highlightColor。size点的半径。这里有个非常关键的问题——单位是“场景米”还是“屏幕像素”。我的实测版本按场景单位换算后最终表现为屏幕大小也就是说点的大小基本不随相机距离变化。如果你想要那种“拉近看更大”的效果可能需要自己扩一个比例因子或者在图层能力范围内改 size 的动态值。color点的底色可以传数组 [r, g, b] 或 [r, g, b, a]。建议用深色底图配亮色点对比度更高。opacity整体透明度。这个参数影响点选命中率后面会细说。highlightColor鼠标悬停或选中时的高亮色。配合点选交互非常有用。参数类型作用注意事项sizeNumber点的大小不同版本单位含义不同先做一小批数据实测colorArray点的基础颜色[r, g, b] 或 [r, g, b, a]opacityNumber整体透明度太透明会导致拾取困难建议不低于 0.3highlightColorArray高亮颜色点选或悬停时的反馈色如果要做“按属性映射大小”我看过的一些实现里会有 sizeFactor 或 radiusScale 之类的配置项字段名各不相同。做法是先看一下 API 文档或源码里支持哪些字段映射不支持就写个循环自己算 sizeconst maxValue Math.max(...rawPoints.map(p p.value)); const points rawPoints.map(item ({ longitude: item.lng, latitude: item.lat, value: item.value, name: item.name, size: 3 (item.value / maxValue) * 12 }));这比依赖库内置映射更稳因为你永远知道自己写的逻辑是什么。3. 实操从零加载 SimplePoint 散点图层3.1 第一步初始化三维场景一个干净的三维场景是跑通后续代码的前提。我这边用深色底图散点的对比度更高。import { Map, SceneView } from arcgis/core; const map new Map({ basemap: dark-gray-vector }); const view new SceneView({ container: viewDiv, map: map, center: [116.397428, 39.90923], zoom: 11, viewingMode: global });这里两个小细节center 传的是经纬度数组顺序是 [经度, 纬度]viewingMode 用 global 才有三维球体效果。如果数据范围集中在某个区域可以保留 local 模式沙盒坐标范围好控制一些。我把容器 div 的尺寸也顺手设置好避免一上来画布高度为 0那样子连报错都看不到只会白白黑屏。建议加一句 CSS#viewDiv { width: 100%; height: 100%; margin: 0; padding: 0; }3.2 第二步把接口数据组装成散点数组假设我们的数据来源是一份静态 JSON 或接口返回常见的结构是[ { name: 鼓楼, lng: 116.3936, lat: 39.94, value: 120 }, { name: 故宫, lng: 116.397, lat: 39.918, value: 300 } ]注意这里字段名写的是 lng 和 lat不是 longitude / latitude。不同版本的 SimplePoint 对字段名有默认约定我直接写个适配函数把对象批量映射成标准字段const rawPoints [...]; // 假设是接口返回的数据 const points rawPoints.map(item ({ longitude: item.lng, latitude: item.lat, value: item.value, name: item.name }));如果你数据里直接就是 GeoJSON那更省事把 features 数组提取出来每个 feature 的 geometry.coordinates 里拆出经纬度即可。注意 GeoJSON 的坐标顺序也是 [经度, 纬度]别写反。这一步我额外做了一层数据校验过滤掉经纬度为 NaN、超出合法范围的点。有些接口返回的数据里有异常值不滤掉会导致投影计算出 Infinity渲染时整个图层直接崩掉或者丢一批点。简单写个过滤const validPoints points.filter(p { return isFinite(p.longitude) isFinite(p.latitude) p.longitude -180 p.longitude 180 p.latitude -90 p.latitude 90; });3.3 第三步创建图层并加入地图接下来是关键代码。创建一个 SimplePoint 图层把数据丢进去配置好样式import SimplePointLayer from jsapithree/layers/SimplePointLayer; const simpleLayer new SimplePointLayer({ data: points, size: 7, color: [58, 168, 255], opacity: 0.85, highlightColor: [255, 200, 50], // 属性映射按 value 字段调整大小如果你的版本支持 sizeFactor: { field: value, minSize: 3, maxSize: 14, minValue: 0, maxValue: 500 } }); map.add(simpleLayer);这里 sizeFactor 不是每个版本都有如果报错或者不生效就用常量 size所有点一样大视觉上朴素一点但问题不大。我实际使用中发现更可控的做法是自己算好 size 再放进去也就是前面 2.3 里的手动映射方式。把图层加进 map 后默认会立即渲染。如果发现数据没显示先检查以下几个点data 数组是否为空字段名是否对得上大小写是否一致。view 是否已经加载完成。JSAPI 的图层在 view 未初始化时加入有时拿不到投影参数导致点不在预期位置。浏览器控制台有没有 WebGL 警告。Three.js 需要 WebGL1/WebGL2 上下文如果你的显卡不支持或驱动太旧图层会静默失败。我的调试习惯是加一层 console.log打印出图层实例的属性和内部对象比如图层是否有有效的 geometry 数量、是否在场景中注册成功console.log(simpleLayer);展开对象一看比看一万字文档都管用。3.4 第四步点击点弹出图片标签散点可视化做出来只是第一步能点、能有信息反馈才真正实用。在 ArcGIS 原生体系里点选通常靠 PopupTemplate在 JSAPIThree 里SimplePoint 走的是 Three.js 的射线拾取逻辑JSAPIThree 把这套逻辑包装成了一个事件。我实践中最顺手的做法是simpleLayer.on(pointer-click, (event) { const feature event.feature; if (!feature) { popupDiv.style.display none; return; } showPopup({ name: feature.name, value: feature.value, position: { x: event.screenX, y: event.screenY } }); });showPopup 是我自定义的函数在页面上生成一个绝对定位的 HTML 标签卡片。结合如今最常用的“图片 文字”信息卡卡片里可以塞一张缩略图和几行属性点击后出现、点击空白处消失这就是标题里提到的“img 标签 点击跳出图层”。示例的简单实现function showPopup(info) { const popup document.getElementById(point-popup); popup.style.left info.position.x px; popup.style.top info.position.y px; popup.style.display block; popup.innerHTML div classpopup-card img src${info.image || default.png} alt点位图 / div classpopup-info h3${info.name}/h3 p数值${info.value}/p /div /div ; }对应 HTML 结构大概是div idpoint-popup styledisplay: none; position: absolute; z-index: 1000;/div建议给 popup-card 加内边距、圆角和阴影否则弹出的卡片横在深色地图上会显得很生硬。点击地图空白处时把 popup 隐藏可以在 view 上监听 click 事件如果点击不落在任何点上就隐藏标签。有一个我差点翻车的点SimplePoint 的点击事件是图层层面的而你点击的是 Three.js 里的点几何体。如果你的 point 设置了较透明度和较小的 size射线拾取极容易落空。我的调参建议是size 不要小于 4透明度不要低于 0.3否则即便返回 feature 也容易出现“离目标点一段距离”的视觉误差。另外拾取事件返回的 feature 里字段名严格对应你传入 data 的属性而不是源数据里的 lng、lat。4. 常见问题与性能调优实录4.1 点全乱飞坐标序与坐标系排查第一个最容易踩的坑点跑到了地球另一边。这几乎 99% 是经纬度顺序问题。ArcGIS 的约定是 [经度, 纬度]GeoJSON 也是 [经度, 纬度]但很多国内接口、Excel 表格习惯写“纬度, 经度”。我这次数据是从数据库导出来的第一列是 lat第二列是 lng映射时没注意就全反了。排查方法很简单随便取一个点用已知的行政中心坐标比对一下就知道了。第二个坑点位置对但整体偏移几百米到几公里。这种情况通常是坐标系不一致。GPS 采集的坐标是 WGS84如果底图用的是 GCJ-02很多国内地图 SDK 的默认值两者差异在城市范围会明显可见。解决办法是把原始坐标先做完坐标转换再交给图层。JSAPIThree 默认按 WGS84 处理不做国内坐标偏移纠偏。这块我建议单独封装一个转换工具函数把“数据库坐标 - 标准经纬度 - 图层可识别数据”分两步走调试的时候能明确知道是数据问题还是渲染问题。4.2 上万点卡顿渲染优化与分批加载几万个点一次性渲染帧数会掉。我实测三万点纯 Points 方式下独立显卡能保持 60 帧但如果开太高精度、每个点又加了阴影帧数会暴跌。几个有效的优化手段尽量用轻量几何每个点就是一个顶点不要给每个点单独创建 Mesh 对象。SimplePoint 内部如果是用 Points 或 InstancedMesh 实现性能差别很大选型时优先关注这一点。关闭不必要的渲染效果Three.js 的阴影、后处理全关。像素比控制view 的 devicePixelRatio 在性能吃紧时可以定为 1而不是默认自适应。分帧加载数据量大时可以先渲染前 5000 点剩余数据按 requestAnimationFrame 分批加入避免首帧卡死。分帧加载的一段思路const chunkSize 5000; let index 0; function appendChunk() { if (index points.length) return; const chunk points.slice(index, index chunkSize); simpleLayer.appendData(chunk); // 具体方法名以当前版本为准 index chunkSize; requestAnimationFrame(appendChunk); } appendChunk();注意如果图层本身没有 appendData 这类方法可以一次性传入全部数据然后在生成点的大小的计算上做取舍毕竟卡顿的主要来源往往不是顶点数量而是每个点的独立材质。点材质统一的话GPU 压力会小很多。4.3 点选总不中射线拾取的坑与解法点击一个点却选中了旁边的点或者点击无反应。我遇到过两种情况一是层里还有其他透明点射线命中到了不可见但仍在场景中的点二是点的 size 太小射线找不到任何命中。处理方法给上层覆盖一个不可见点云时把它剔除出 raycast 列表或直接利用图层提供的事件选择。把拾取时的阈值增大比如在判断命中时只保留离射线最近的 feature而不是第一个。如果实在不行用一个 hitPoint 标记替换点集进行拾取保证拾取几何和显示几何分离。这块在文档里没有现成说明我花了不少时间去翻源码最后的教训是优先用图层自带的事件别自己手动 new THREE.Raycaster。图层自带事件通常已经处理好投影矩阵和相机矩阵的同步你手动做还要处理一堆矩阵变换很容易算偏。4.4 弹层不同步标签跟随地图的两种方案点击弹出的 img标签卡片有个常见问题地图旋转、平移或者缩放后标签还留在原地。因为 popupDiv 是挂载在页面上的绝对定位元素没有跟随地图坐标更新。解决思路有两条简单方案在 view 的 pointer-move 和 camera-update 事件里隐藏 popup或者重新计算一次屏幕坐标。需求不严格的时候隐藏最省事。跟手方案把世界坐标转屏幕坐标监听每次 camera 变化时更新 left/top。核心代码如下view.on(camera-changed, () { if (currentFeature) { const screenPoint view.toScreen(currentFeature.position); popup.style.left screenPoint.x px; popup.style.top screenPoint.y px; } });需要注意这里的 currentFeature.position 是图层内部返回的 Three.js 场景坐标传给 view.toScreen 前要先转成 MapPoint也就是地图坐标。这一步的 API 细节各版本不一致但不妨给个思路JSAPIThree 提供了把场景坐标转回地图坐标的方法搜一下几何转换相关的方法即可。结合我实际体验大多数情况下选简单方案就够了——隐藏标签不会打断用户的制图思路反而是最不容易出 bug 的交互。如果产品经理明确要求标签必须钉在地图上那再上跟手方案。4.5 关于版本与文档的几句心里话最后必须吐槽也是必须提醒的一点JSAPIThree 这类封装库的官方文档通常比较薄版本之间 API 变动也很大。我按网上某篇文章写出来的代码跑不起来查了源码才发现它用的图层类名和当前版本不一样。所以实操时我的经验是先在 node_modules 里翻一下包的结构找到实际的导出文件确认类名和方法名。跑测试时用 console.dir 把实例对象展开最容易发现属性名差异。遇到问题去仓库的 issues 或源码的 dist 文件里搜关键词比查文档快。这次做 SimplePoint 散点可视化最让我感慨的是“轻”。一个几千点的图从数据到画面就是配置一个图层的事。但轻的前提是你对坐标、投影、拾取这些底层机制有基本认知否则一出问题就抓瞎。我的体会是拿来主义没问题但至少要懂它的内部假设——数据坐标系、字段映射、渲染方式。后续我打算在这个图层上加一些时间序列变化的效果让 value 字段动起来按帧更新点的颜色和大小到时候再写第二篇笔记。