ECharts GL实战:从GeoJSON到3D地图渲染的全流程解析
去年做数据可视化大屏客户要求把各区域的业务指标从平面地图上“立起来”——能旋转、能缩放、能看出高度差不能只是普通热力图那种贴片感。我第一反应是用Three.js手搓但算完工作量就冷静了地图边界解析、相机控制、光照阴影、数据对接、老旧内网机器的GPU兼容全自研至少两周起步。后来换用ECharts GL的geo3D和map3D系列一个下午就把带区域着色、柱体叠加、飞线动画的3D地图跑起来了。这篇就把我在实际项目中用echartsGL做3D地图渲染的完整过程拆开讲为什么选它而不是Three.js或Cesium、GeoJSON数据怎么处理和注册、map3D核心配置怎么调出理想效果、业务数据如何叠加到三维地图上以及几段能帮你省下大半天时间的排错经验。适合正要接手3D地图需求的前端、数据可视化工程师也适合被大屏项目逼到墙角但还没接触过WebGL的同学。1. 为什么是ECharts GL3D地图渲染方案选型众生相1.1 从Canvas 2D到WebGL3D地图的技术分野很多人第一次尝试做“3D地图”时还是在用Canvas 2D或者SVG硬画。方案通常是把地图多边形在平面坐标系里画出来然后做视差位移、加个伪阴影或者靠rotate做旋转。说实话这在静态大屏上能唬人但一旦用户要交互——按住拖拽旋转、缩放拉近、改变俯仰角度——2D渲染根本接不住因为它没有深度缓冲也没有透视投影的概念。WebGL的出现把这条路径彻底抬高了。它把大量几何计算交给GPU顶点着色器和片元着色器并行处理浏览器里跑上万个多边形依然能保持流畅的帧率。ECharts GL正是基于WebGL为ECharts全家桶做的3D扩展你不需要手写任何着色器代码只要声明配置项WebGL渲染层就帮你把网格、光照、材质、阴影全部处理好。1.2 主流技术方案对比不是所有3D地图都该用Three.js我踩过的选型弯路不止一次这里直接给出一份实战向的对比结论。方案上手成本地图数据适配与图表生态的联动适合场景Three.js 自绘地图极高需要自己处理几何体、相机、光照、拾取需额外做GeoJSON解析和网格生成几乎没有图表联动全靠自己写对视觉效果有极高定制的数字孪生/游戏化场景Cesium高偏GIS三维地球原生支持各种地理数据精度高弱不适合做BI图表大范围地球、地形、倾斜摄影测量Mapbox GL JS中高有自定义样式体系需要token部分服务有配额提供一些可视化图层但生态偏GIS高保真底图数据图层ECharts GL低会ECharts基本就会只需一个GeoJSON再registerMap天然同源一个option里2D和3D混排BI大屏、区域统计地图、叠加柱体飞线我的判断标准很简单如果核心诉求是“把一个统计指标按行政区域显示成3D形态”ECharts GL是效率最优解如果要做真实场景的三维地球、楼层内部漫游那才需要去碰Cesium或Three.js。最怕的情况是团队里只有一个人会一点Three.js结果所有人陪着他维护一堆自定义地图网格代码后续想加个tooltip都费劲。1.3 什么时候该果断放弃ECharts GL也说说反例。ECharts GL的定位是“图表”不是“GIS引擎”所以遇到这些需求时我建议换工具需要对地图做精确的经纬度测量、需要加载OSM路网瓦片和POI检索、需要处理地形起伏真实高度这些应该交给Cesium或Mapbox。另外如果你的地图数据量非常大——比如全国街道级别的边界、几十万个多边形——ECharts GL的拾取和渲染会有明显压力不如把数据抽稀到省级/市级粒度再上3D。2. 地图数据的地基GeoJSON获取、校验与瘦身2.1 GeoJSON结构回顾与坐标系常识ECharts GL不是把地图文件当作图片来用的它需要的是标准GeoJSON。一个典型的结构长这样{ type: FeatureCollection, features: [ { type: Feature, properties: { name: 北京市, adcode: 110000 }, geometry: { type: MultiPolygon, coordinates: [ [[[116.39, 39.92], [116.40, 39.93]]] ] } } ] }这里的关键是geometry.coordinates每一组经纬度[lng, lat]按顺时针或逆时针围成一个环多个环组合成Polygon或MultiPolygon。ECharts在绘制时只读取经纬度坐标properties里的字段可以随意扩展后续区域高亮、视觉映射、事件回调都靠它传数据。有一点要提醒如果数据是WGS84投影还是GCJ-02坐标系ECharts本身不区分它只认经纬度。国内有些公开数据源返回的是GCJ-02加偏坐标放到标准地图底图上会有几十到几百米的偏移。如果发现区域边界和实际底图对不齐优先怀疑坐标系而不是代码。2.2 常用公开数据源与下载方式做中国省市县边界我目前最常用的还是阿里云DataV.GeoAtlas的边界JSON服务它按adcode分省级、市级、区县级下载地址有固定规律浏览器直接访问就能拿到JSON。项目里我一般先把JSON下载到本地public/geojson目录而不是运行时跨域fetch避免部署后出现CORS问题也方便离线内网部署。除了它还有几个可以备选的路子OpenStreetMap的行政边界导出适合做国外数据但数据量大且边界精度参差不齐。Mapshaper自带的数据集适合拿来做简化测试。某些项目方提供的CAD/ArcGIS shp文件需要先用工具转成GeoJSON。无论数据来自哪里落地前都要做同一件事验证坐标范围。合法经纬度范围是[-180, 180]和[-90, 90]如果你拿到的数据里出现[116, 39, 0]这种莫名多了一维的数组或者NaN那基本是转换过程出了问题渲染时会出现碎片飞线或整个地图变形。2.3 数据清洗与抽稀处理很多GeoJSON边界文件特别“重”省级文件几MB区县级甚至十几MB。问题是ECharts画地图根本不需要这么高的坐标精度——屏幕上整个省份可能只有几百个像素宽坐标点密集到亚像素级是没有意义的。我常用的抽稀工具是simplify-js它基于Douglas-Peucker算法可以在误差允许范围内把多边形顶点数量大幅削减。Node脚本大概长这样const simplify require(simplify-js); const geoJson require(./province.json); const tolerance 0.005; // 根据实际显示尺寸调整 for (const feature of geoJson.features) { const rings feature.geometry.coordinates; feature.geometry.coordinates rings.map(ring { const points ring.map(([x, y]) ({ x, y })); return simplify(points, tolerance, true).map(p [p.x, p.y]); }); } console.log(JSON.stringify(geoJson));tolerance的取值需要反复试太小文件还是大太大边界会出现明显的锯齿和自相交。我的经验是先设置为0.001画一遍看效果再逐步放大直到“视觉上没变化但文件体积明显下降”为止。省级边界抽稀到200KB左右、市级到50KB左右是比较健康的状态。2.4 注册地图的机制名字对不上就会静默白屏ECharts里地图不是自动加载的你必须先把自己准备好的GeoJSON注册到图表实例的全局环境中echarts.registerMap(china, geoJson);这个china不是内置的魔法名称完全是你自己起的任意字符串。但问题也出在这——后续所有配置项里的map字段必须和它完全一致包括大小写和空格。我见过不少白屏现场排查到最后就是geo配置写了map: China注册时却用的china错一个字母地图就消失。后面第6章会专门展开排错链路。3. 跑通第一个map3D最小实现与核心代码拆解3.1 引入依赖的正确方式ECharts GL不是ECharts内置功能需要额外引入扩展。最简单的方式是引两个CDN文件script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts-gl2.0.9/dist/echarts-gl.min.js/script注意顺序不能反echarts-gl依赖echarts全局对象。版本上尽量用echarts 5.x搭配echarts-gl 2.x如果还在用echarts 4.x需要换成echarts-gl 1.x配置项有细微差异。实际项目中我不建议把CDN写死在index.html里而是用npm安装并在构建时打包npm install echarts echarts-gl然后在入口文件里直接import * as echarts from echarts后import echarts-gl这种方式对脚手架项目最省心。3.2 最小的完整示例下面这个例子是我第一次跑通map3D时保留的精简版可以直接复制成HTML文件在浏览器打开!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleECharts GL 3D 地图示例/title style html, body { margin: 0; background: #0a1930; } #map { width: 100vw; height: 100vh; } /style /head body div idmap/div script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts-gl2.0.9/dist/echarts-gl.min.js/script script fetch(./china.json) .then(res res.json()) .then(geo { echarts.registerMap(china, geo); const chart echarts.init(document.getElementById(map)); chart.setOption({ tooltip: { show: true }, series: [{ type: map3D, map: china, regionHeight: 6, itemStyle: { color: #1e6fba, opacity: 0.9, borderWidth: 1, borderColor: #ffffff }, label: { show: true, color: #fff, size: 10 }, emphasis: { itemStyle: { color: #ffd700 }, label: { show: true } }, viewControl: { alpha: 45, beta: 0, distance: 100, autoRotate: true, autoRotateSpeed: 8 } }] }); }); /script /body /html如果这段代码能正常显示出一个微微倾斜、可以自动旋转、鼠标拖拽不费力的3D省份色块图那恭喜你基础链路已经通了。接下来遇到的所有问题都只是在这个骨架上做优化和扩展。3.3 map3D系列配置逐项解读map3D的输出效果本质上是“一张地图多边形网格 光照 相机视角”的组合。我这里把最影响观感的几项配置拉出来解释全部基于我实测过的表现配置项作用我的常用取值regionHeight每个区域多边形向上挤出多少高度的厚度4~8太小没立体感太大像乐高积木itemStyle.color区域基础颜色根据大屏主题定蓝紫科技系最稳itemStyle.borderColor区域边界线的颜色清晰度利器白色或浅青色shading着色模型color是纯色lambert带漫反射明暗lambertlabel.show是否显示区域名称大屏建议开但要控制字号避免遮挡viewControl.alpha相机俯仰角40~50之间最有体积感viewControl.autoRotate是否自动旋转大屏轮播场景开交互分析场景关light.main.shadow是否开启方向光阴影低配机慎开掉帧严重这里特别解释一下regionHeight。它不是真实地理高度而是视觉上的挤出厚度。把每个行政区域想象成一块压扁的巧克力regionHeight就是巧克力的厚度。厚度适中时旋转起来能看到边界有清晰的高度层次感厚度过大时相邻区域之间会像积木一样叠出明显落差反而丢失了地图的“区域感”。3.4 到底用map3D还是geo3D先想清楚要叠加什么很多人一开始分不清这两个系列我把它们的定位说清楚map3D负责把区域本身渲染成三维体适合直接用visualMap给每个区域按数据值着色是“一张被数据染色的立体地图”。geo3D更像一个三维坐标系底层组件本身也画地图几何体但它存在的意义是给其他3D系列——bar3D柱体、scatter3D散点、lines3D飞线——提供悬挂的坐标系。换句话说你只想让省份块按GDP数值变颜色用map3D你想在地图上方竖一根根柱子、画一条条从北京飞往上海的曲线就必须用geo3D作为底座再叠加bar3D或lines3D系列。第5章的场景就是在geo3D上完成的。4. 让画面高级起来灯光、相机、材质与配色实战4.1 光照体系环境光、平行光与半球光的搭配3D地图好不好看七成功力在光照。刚上手时我直接不写light配置结果地图黑黢黢一片完全没有层次。ECharts GL的光照体系分两类ambient环境光负责给所有表面一个基础亮度避免纯背光的死黑main主方向光负责产生明暗面让地形起伏变得立体。我常用的室内大屏光照配方light: { main: { color: #ffffff, intensity: 1.2, alpha: 50, beta: 30, shadow: true, shadowQuality: high }, ambient: { intensity: 0.3 } }alpha和beta控制方向光从哪个角度打过来相当于太阳的位置。调光的技巧是让光从地图的左上后方照过来这样右前方会保留阴影立体感最强。如果发现地图某些区域一片纯黑优先把ambient.intensity调高到0.4~0.5而不是去动主光源强度。4.2 相机视角perspective与orthographic的视觉差异map3D的viewControl里有个容易被忽略的projection字段取值是perspective或orthographic。简单类比perspective像人眼近大远小有强烈的空间纵深感orthographic像工程制图物体不随距离缩小所有区域宽度恒定。我的使用建议是要“沉浸感”比如带飞线轨迹、要模拟俯瞰效果用perspective要“数据仪表感”比如行政区域统计图、需要对不同区域面积做客观比较用orthographic更合适。它不会因为某个区域离相机近就放大失真数据可视化场景下往往更严谨。viewControl: { projection: orthographic, alpha: 40, distance: 100, minDistance: 60, maxDistance: 150, autoRotate: false, rotateSensitivity: 1, zoomSensitivity: 1 }4.3 从“贴片感”到“质感”材质与后处理默认的map3D材质其实是平扁的跟平面地图贴了层皮没区别。想提升质感先把shading从默认的color改成lambert它会基于光源方向计算每个面的漫反射地图表面立刻有了明暗转折。如果机器配置和场景允许还可以打开后处理特效postEffect: { enable: true, bloom: { enable: true, bloomIntensity: 0.2 }, SSAO: { enable: true, radius: 5, intensity: 1 } }bloom是泛光效果让高亮区域和边界线产生柔和的发光感很适合搭配深色科技风大屏SSAO全称屏幕空间环境光遮蔽能让相邻区域之间的缝隙产生自然的暗角过渡面积块不再像纸片拼接而是像真正堆叠的实体。但这俩都是性能杀手低端显卡机器上一开帧率直接打对折我会在部署前先拿最低配的电脑测一遍。个人经验是SSAO对地图观感提升一般很多场景只开bloom就够了。4.4 用visualMap把数据变成颜色语言如果区域颜色是固定的那它只是一张漂亮的底图要让数据容量、金额、评分在图上形成一目了然的高低对比需要引入visualMap。这步配置其实和2D地图一模一样option { visualMap: { min: 0, max: 100, calculable: true, inRange: { color: [#1b2a52, #2d5f8b, #3aa8a8, #7cd6a4, #f0e68c] }, textStyle: { color: #fff } }, series: [{ type: map3D, map: china, data: [ { name: 北京市, value: 95 }, { name: 广东省, value: 62 }, { name: 上海市, value: 88 } ], ... }] };需要注意的是data里的name必须和GeoJSON里properties.name完全一致否则匹配不上会显示默认灰色。我踩过的一个坑是GeoJSON里省份名带“省”“市”后缀而业务数据里是“广东”“北京”所有区域全部落空只能一遍遍比对。处理办法是在注册GeoJSON前统一对properties.name做归一化或者在写data时严格对齐原始名称。5. 把业务数据“钉”到三维地图上叠加柱体、飞线与散点5.1 为什么叠加业务数据要用geo3D而不是map3D上一章说的map3D适合区域着色但如果你要在某个城市坐标上长出一根数据柱、画一条连接两个城市的轨迹曲线map3D是做不到的——它不是一个真正的“坐标系”只是一个几何体容器。这时候必须把地图换成geo3D组件它仍然会渲染3D地图区域但底层提供了一套经纬度到三维空间的定位规则bar3D、scatter3D、lines3D都能以coordinateSystem: geo3D的方式挂到它上面。5.2 用bar3D让每个城市立起数据柱先看geo3D加bar3D的核心代码const option { tooltip: {}, geo3D: { map: china, regionHeight: 4, itemStyle: { color: #123456, borderColor: #6ab5ff, borderWidth: 1 }, emphasis: { itemStyle: { color: #3a6ea5 } }, viewControl: { alpha: 35, distance: 120, autoRotate: false }, light: { main: { intensity: 1.2, alpha: 50, beta: 30 }, ambient: { intensity: 0.4 } } }, series: [{ type: bar3D, coordinateSystem: geo3D, data: [ [116.405, 39.905, 85], // [经度, 纬度, 数值] [121.473, 31.230, 120], [113.264, 23.129, 66] ], barSize: 1.5, minHeight: 0.2, itemStyle: { color: #f4d03f }, shading: lambert }] };barSize控制的是柱子的粗细单位不是像素而是相对于地图Width的比例1.5代表柱子比较粗。数值小的城市柱子太矮贴在地图上不明显所以我会用minHeight给一个最低高度保证视觉上都能看见。一个最常见的问题柱子的3D位置是经纬度转过来的但高度直接对应数值如果某个城市数值是别人的100倍柱子会冲破地图容器需要配合visualMap把数值压缩到合理范围或者对数据做开方/对数变换。5.3 scatter3D散点与lines3DF飞线大屏的视觉主角散点适合标记事件发生地、点位权重飞线适合展示城市间的流向、覆盖范围。两者都可以和柱体放在同一个option里因为都挂在同一个geo3D坐标系上。// 散点 { type: scatter3D, coordinateSystem: geo3D, symbolSize: 6, itemStyle: { color: #ff6b6b }, data: [ [116.405, 39.905, 50], [121.473, 31.230, 80] ] } // 飞线 { type: lines3D, coordinateSystem: geo3D, effect: { show: true, trailWidth: 2, trailLength: 0.2 }, lineStyle: { color: #22d3ee, width: 1, opacity: 0.8 }, data: [{ coords: [ [116.405, 39.905], [121.473, 31.230] ] }] }飞线的effect.trailLength是尾迹长度0.2已经比较克制太大会变成拖着长尾巴的彗星。lineStyle.color我习惯用高饱和青蓝色和深蓝底图对比强烈但注意不要同时叠加太多条飞线否则画面会变成一坨发光的毛线团。我通常在数据筛选阶段把Top N条线抽出来N控制在30以内。5.4 事件交互点击高亮与下钻的坑3D地图的事件和2D地图类似图表实例直接on(click)即可拿到参数chart.on(click, function(params) { console.log(params.name, params.value); // 拿到区域名称后可以触发下钻、弹窗等业务逻辑 });但有个不小的坑在3D场景里emphasis高亮是实时Hover触发的如果你试图用dispatchAction({ type: highlight })控制某个区域高亮在部分GL图表版本里是不响应的。我的替代方案是直接通过setOption的动态数据更新把目标区域的itemStyle.color改掉再配合一个定时器恢复。虽然粗暴但可控性最好。6. 项目落地避坑实录渲染异常、性能退化和框架集成6.1 地图白屏的排查链路我大概有八成的答疑时间花在“地图为什么白屏”上这里直接给出一套排查顺序打开控制台看有没有JS报错。最常见的是Map china not exists这说明registerMap没生效或顺序错了。检查network面板里GeoJSON请求是否成功。如果你直接用fetch请求远端服务一旦对方没开CORS请求会成功但数据无法读取控制台报跨域错。本地放一份JSON是根治手段。容器Div是否真的占了空间。height: 0会让图表初始化拿不到可用尺寸渲染一片空白这个错误在CSS里非常隐蔽。确认option里确实有GL系列。有人把series配置写成了type: map而不是type: map3D地图只会用2D模式渲染整体是平面的第一眼看确实也是“白屏没图”。检查echarts-gl脚本是否在echarts之后加载。反了的话扩展注册不上map3D系列直接被当成未知类型处理。真正“找不到原因”的白屏九成落在第3条父容器用了flex布局但没给最小高度div实际高度是0。我现在的习惯是一律给容器元素写死min-height: 500px。6.2 帧率与内存低配机器上的优化顺序3D地图最终要跑在客户的内网电脑上那配置你根本想象不到。我踩过最狠的一次是在一台没有独立显卡的Windows办公机上旋转地图卡到2帧。后来总结出优化优先级按投入产出比排序第一把postEffect.enable关掉效果立竿见影能恢复60帧左右的流畅度。第二把light.main.shadow关掉计算量直线下降代价是立体感弱一些。第三GeoJSON做抽稀把顶点数压到十分之一CPU侧网格更新和拾取会轻松很多。第四chart.init(dom, null, { devicePixelRatio: 1 })虽然会牺牲一些文字清晰度但3D场景下渲染压力能降好几倍低分屏根本看不出区别。第五切到projection: orthographic它的矩阵计算比perspective轻量交互也更平滑。如果优化完还是卡最直接的办法是降低初始distance可交互范围限制用户把地图放得过大避免每帧绘制超大量的三角形。6.3 Vue 3和React集成挂载、清场和事件绑定的正确姿势在框架里用ECharts GL核心只有三件事初始化时机、resize监听、组件卸载时销毁实例。以Vue 3组合式API为例import { ref, onMounted, onBeforeUnmount } from vue; import * as echarts from echarts; import echarts-gl; import chinaGeo from ./geojson/china.json; export function useMap3D() { const chartRef ref(null); let chart null; const handleResize () chart chart.resize(); onMounted(() { echarts.registerMap(china, chinaGeo); chart echarts.init(chartRef.value); chart.setOption({ /* 配置略 */ }); window.addEventListener(resize, handleResize); }); onBeforeUnmount(() { window.removeEventListener(resize, handleResize); if (chart) { chart.dispose(); chart null; } }); return { chartRef }; }这里最容易被忽视的是chart.dispose()。组件卸载后如果不销毁实例WebGL的渲染上下文不会自动释放多来几次页面切换浏览器会直接报“Too many active WebGL contexts”然后整个应用无法再创建任何3D图表。另外如果你采用按需加载GeoJSON注意异步时序问题。setOption必须在registerMap完成之后再调用所以fetch之后再用chart.setOption不要在onMounted里同步执行否则大概率白屏。6.4 包体积控制和一个复盘按需引入这块echarts-gl的官方支持一直没有2D图表那么丝滑。我的经验是如果整个项目就为了一个3D地图页面直接全量引入最稳妥如果项目本身有很多2D图表可以先用echarts/core手动注册2D组件再单独引echarts-gl扩展整体体积会比“两个全量包”小一截。但对包体积特别敏感的场景优先考虑把GeoJSON放到CDN这个文件瘦身带来的收益往往比抠图表模块更明显。复盘我自己的耗时选型对比花了小半天数据抽稀和清洗一下午第一个map3D跑通两小时叠加bar3D和飞线半天最后性能调优和低配机验证又占了一天。整体下来一个具备区域着色、柱体叠加、飞线动效和交互的3D地图页面大约一个完整工作日可以交付。作为参考当时用Three.js自研的话这个时间至少翻三倍。最后分享一个小经验验收时一定要在暗色、亮色两种环境都跑一遍因为3D光照效果对环境亮度极其敏感。同一套配置在暗色会议室里很有质感放到阳光充足的客户现场就会显得发灰、对比度不足。我在交付前的最后一轮调整通常是带着一台普通笔记本直接去客户现场跑一版现场调light和bloom比在办公室里对着显示器调小参数高效得多。