做国内业务的地图可视化,绕不开一个很现实的组合:百度地图 + ECharts。我这边常年做物联网大屏、数据分析后台这类项目,几乎每个项目都要在真实路网上叠加业务数据,点分布、轨迹流向、区域热力、指标卡片,单靠地图自带的覆盖物能画,但样式和交互远不如 ECharts 顺手。标题里写的“bMap”,Web 端一般指的就是百度地图 JavaScript API(GL 版叫 BMapGL),移动端 uni-app 里也会沿用这个叫法指代百度地图模块。这篇我把实际项目里怎么把百度地图和 ECharts 组合起来用的思路、代码、踩坑记录都整理一遍,适合正在做地图数据可视化、或者打算在地图上叠图表但还没想清楚方案的开发者,内容偏实战,照着能搭出第一版。
1. 为什么是“bMap + ECharts”,而不是让地图全包
1.1 地图能力和图表能力的分工
很多人一上来就纠结:百度地图自己能加 Marker、能画 Polyline、能弹 InfoWindow,为什么还要多引入一个 ECharts?我的答案是:地图擅长表达“位置”,不擅长表达“数据”。
Marker 适合做点位标注,但你想让每个点根据业务指标显示出不同大小的气泡、不同颜色的柱子,甚至点击后联动其他图表,地图覆盖物的定制成本会迅速上升。更别说飞行迁徙线、轨迹流光、热力渐变这些动效,纯靠地图 API 不是不能做,而是动画和时间线控制会写得非常痛苦。ECharts 天生就是干这个的,series 类型丰富、动画内置、事件体系完整,把数据语义表达交给它,把空间定位交给百度地图,各干各擅长的部分。
翻了翻我最近接的几个需求,基本跑不出这三类:
- 散点分布:按区域展示设备、门店、订单的分布密度
- 飞线轨迹:显示不同节点之间的流向和流量
- 热力渲染:分析人群聚集、事件热度
这三个场景如果只靠百度地图的 Marker、Polyline、热力图图层来做,勉强能做,但一旦涉及颜色映射规则、图例联动、点击高亮、时间轴轮播,ECharts 的配置项体系就明显更省力。所以我的技术选型很简单:百度地图管底图和交互定位,ECharts 管数据图层和视觉呈现。
1.2 核心难点:两套坐标系“打架”
这个组合真正让人头疼的,不是 API 本身,而是坐标系。百度地图用的是 BD09LL 坐标(百度自己加过密的),ECharts 用来定位的经纬度数据,如果来自第三方通常分两种:GPS 采集的是 WGS84,高德/腾讯地图生态里常见的是 GCJ02。把 WGS84 或 GCJ02 的坐标直接扔到百度地图底图上,点位会偏移几百米甚至更远,大屏上看起来就像数据“飘”了。
这一点必须在一开始就定好规矩:地图统一用 BD09LL,业务数据入库时尽量统一转成百度坐标,如果接口数据拿不到转换后的坐标,前端就需要在渲染前做坐标纠偏。网上有很多公开的坐标转换算法,核心是一组近似计算参数,能做到亚米级到米级精度,地图可视化完全够用。我的做法通常是在数据请求层统一做一次coordConvert(point),转完再交给 ECharts,别在渲染组件里到处转。
另外还要明白一个容易混淆的点:ECharts 自带的map系列里的 geoJSON(比如中国地图、省市地图),很多开源数据源的坐标系是 GCJ02 甚至 WGS84,直接叠在百度地图上同样会错位。所以如果你要 ECharts 和百度地图叠加,最省事的思路不是让 ECharts 自己去注册地图坐标系,而是让百度地图作为唯一坐标系,ECharts 只负责在绝对定位图层里画点、线、面,坐标通过地图的投影方式换算。
2. 准备工作和依赖引入
2.1 申请 AK 和两种引入方式
百度地图 JavaScript API 从 v1.0 到现在的 GL 版本都需要一个 AK(访问密钥)。去百度地图开放平台创建浏览器端应用,应用类型选“浏览器端”,域名按实际项目填,本地调试可以用localhost,但要注意线上环境必须把正式域名加进去,不然地图会报APP RefererCheckError,这是个低级坑,我见过不少人在联调时才被抓回来。
引入方式有两种:
第一种是 HTML 里直接用<script>标签加载,适合传统多页应用或者想快速验证的场景:
<script src="https://api.map.baidu.com/api?v=1.0&type=webgl&ak=你的AK"></script>注意 GL 版必须带type=webgl,普通版则不带。加载成功后,全局会挂一个BMapGL命名空间(普通版是BMap)。如果你是移动端 uni-app 项目,流程略有不同,但也是去开放平台申请 key,然后在 manifest.json 里配置模块参数,思路完全一致。
第二种是 NPM 方式。百度官方没有把 GL 库完整发布成 npm 包,社区里的包要么是封装过的,要么需要自己维护加载逻辑。我的习惯是:写一个独立的mapLoader.js,用 Promise 封装动态 script 注入,保证同一个页面里多次调用不会重复加载。这样在 Vue 或 React 项目里用起来比较顺:
let mapPromise = null; export function loadBMap(ak) { if (mapPromise) return mapPromise; mapPromise = new Promise((resolve, reject) => { if (window.BMapGL) { resolve(window.BMapGL); return; } const script = document.createElement('script'); script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${ak}`; script.onload = () => resolve(window.BMapGL); script.onerror = reject; document.head.appendChild(script); }); return mapPromise; }ECharts 就简单多了,直接npm install echarts,按需引入系列:
import * as echarts from 'echarts';2.2 ECharts 挂到地图上的两种思路
一个是全屏独立图层。在地图容器上面叠一个透明的 ECharts 容器,ECharts 负责画散点、线、热力等所有数据图层,百度地图只当底图。地图拖动、缩放时,我们监听地图事件,重算像素坐标并更新图表。
另一个是自定义覆盖物。百度地图提供了Overlay基类,我们可以把 ECharts 实例包进去,让它作为一个“会跟随地图移动的图表卡片”挂在某个坐标点上。适合做点位详情弹窗、某个区域旁边的指标卡。
这两种思路我项目里都用过。全屏图层适合大屏热力、飞线这种需要整幅图联动的场景;自定义覆盖物适合局部交互。下面的实操我会把前者作为主路线展开,因为它最能体现“BMap + ECharts”组合的价值。
2.3 为什么我一直强调“先统一坐标系再动手”
引入依赖是体力活,真正决定一个地图项目会不会返工的是数据层的坐标系设计。我见过一个真实案例:A 同事把 GPS 设备上报的 WGS84 坐标直接存库,前端拿到后直接投到百度地图上,点位全偏了半条街,排查了一天才发现是坐标系问题。所以无论数据是从设备报上来、从第三方接口拉过来、还是后端已经处理过一遍,前端都必须建立一道“坐标清洗”的关卡。
我一般固定在工具函数里维护一个normalizeCoord(coord, fromType),统一转成 BD09LL:
const coordConvert = (lng, lat, fromType = 'WGS84') => { // 工程实践里常用公开的近似算法做纠偏 // 真实项目中建议在服务端统一转换,前端只做兜底 return { lng, lat }; };这个函数不值得过度设计,核心是让所有坐标到渲染组件之前都过一遍。后端如果能直接吐 BD09LL,前端这个函数就可以空转跳过,但它必须存在,因为不是所有数据源都听话。
3. 实操:在地图上叠加 ECharts 数据图层
3.1 初始化地图并铺好叠加层
先写地图初始化的基础代码。假设页面里有一个高度已定的#mapContainer,地图初始化如下:
const map = new BMapGL.Map('mapContainer'); const cityCenter = { lng: 113.265, lat: 23.129 }; // 实际换成你的业务中心坐标 map.centerAndZoom(new BMapGL.Point(cityCenter.lng, cityCenter.lat), 12); map.enableScrollWheelZoom(true);这里有几个细节。centerAndZoom是在地图加载完成后立即调用,如果在 Vue 的created周期里调用,可能因为容器还没渲染出来导致地图初始化为 0 高度,后续所有交互都异常。我一般放在mounted或者nextTick里。
然后是叠加 ECharts 的容器层:
const chartDom = document.createElement('div'); chartDom.id = 'mapEChartsLayer'; chartDom.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; `; document.getElementById('mapContainer').appendChild(chartDom); const myChart = echarts.init(chartDom);注意两个关键点:
position: absolute是必须的,不然无法覆盖在地图上pointer-events: none是必须的,不然这个透明图层会挡住地图的拖拽、缩放、点击
如果你需要地图和图表交互(比如点击 ECharts 里的点,同时定位到地图),后面再单独处理命中区域的pointer-events,不要一开始就全部放开。
3.2 散点数据的投影换算与渲染
E:Charts 的画布坐标系是像素,百度地图的坐标是经纬度,所以核心一步是:把业务点的经纬度通过地图投影换算成像素坐标,再喂给 ECharts。
BMapGL 提供了pointToOverlayPixel(point),可以把经纬度转成相对于地图容器的像素坐标。关键代码如下:
const points = [ { lng: 113.265, lat: 23.129, value: 30 }, { lng: 113.275, lat: 23.139, value: 58 }, { lng: 113.255, lat: 23.119, value: 72 }, ]; const mapWidth = map.getSize().width; const mapHeight = map.getSize().height; const scatterData = points.map(item => { const pixel = map.pointToOverlayPixel(new BMapGL.Point(item.lng, item.lat)); return { value: [pixel.x, mapHeight - pixel.y, item.value], // ECharts 的 y 轴方向向上 name: item.name || '', }; }); myChart.setOption({ grid: { left: 0, top: 0, right: 0, bottom: 0 }, xAxis: { type: 'value', min: 0, max: mapWidth, show: false }, yAxis: { type: 'value', min: 0, max: mapHeight, show: false }, series: [{ type: 'scatter', data: scatterData, symbolSize: (val) => Math.max(8, Math.sqrt(val[2]) * 3), itemStyle: { color: '#ff8a00' }, }], });这里有个非常容易踩的细节:ECharts 的 y 轴默认自下而上,而屏幕像素坐标是自上而下的,所以要用mapHeight - pixel.y做一次翻转。少了这一步,你会发现所有点的上下位置反了。
还有,pointToOverlayPixel得到的是相对于容器左上角的坐标,ECharts 的 grid 需要设成left:0, top:0, right:0, bottom:0,否则默认坐标轴区域会留白,点就整体偏移了。
3.3 地图拖拽和缩放后如何保持同步
全屏图层方案最大的问题在于:地图一拖动、缩放,之前算好的像素坐标就全过期了。所以必须监听地图的视角变化事件,重新计算所有像素点,然后setOption更新。
BMapGL 在 webgl 模式下,拖拽过程可以监听mapmove,结束后监听moveend;缩放也有zoomend。比较稳的写法是:
const updateEChartsFromMap = () => { const newData = points.map(item => { const pixel = map.pointToOverlayPixel(new BMapGL.Point(item.lng, item.lat)); return { value: [pixel.x, map.getSize().height - pixel.y, item.value], name: item.name || '', }; }); myChart.setOption({ series: [{ data: newData }], }); }; map.addEventListener('moveend', updateEChartsFromMap); map.addEventListener('zoomend', updateEChartsFromMap);函数节流还是要做的。我把updateEChartsFromMap用requestAnimationFrame包一层,避免地图拖动过程中高频触发 setOption 导致卡顿。实测下来非常明显,不包的话大屏缩放掉帧严重,包了之后平滑很多。
如果点位数量很大,比如几千个以上,每次全量重算并 setOption 可能产生毫秒级的阻塞。我一般会配合 ECharts 的animation: false和progressive选项,把动画关闭,用渐进渲染让图表分段绘制;或者在地图不频繁操作时做一次数据抽稀。抽稀的策略按业务定:根据当前缩放级别调整格网大小,简单说就是“缩得越远看得越粗”,保证地图交互不掉帧。
3.4 飞线轨迹的实现
飞线是地图大屏里很受欢迎的效果。有了上面的投影思路,飞线也是同一套逻辑:把两个城市的经纬度分别转成像素坐标,然后让 ECharts 的lines系列在像素坐标系里画曲线。
const linesData = [ { from: { lng: 113.265, lat: 23.129 }, to: { lng: 113.365, lat: 23.229 }, }, ]; const lineCoords = linesData.map(item => { const fromPixel = map.pointToOverlayPixel( new BMapGL.Point(item.from.lng, item.from.lat) ); const toPixel = map.pointToOverlayPixel( new BMapGL.Point(item.to.lng, item.to.lat) ); return [ [fromPixel.x, map.getSize().height - fromPixel.y], [toPixel.x, map.getSize().height - toPixel.y], ]; }); myChart.setOption({ series: [{ type: 'lines', coordinateSystem: 'cartesian2d', data: lineCoords.map(coords => ({ coords })), effect: { show: true, period: 6, trailLength: 0.4, symbol: 'arrow', symbolSize: 6, }, lineStyle: { color: '#00d4ff', width: 1, opacity: 0.8, curveness: 0.3, }, }], });这里要特别提醒:ECharts 里lines系列如果没有指定coordinateSystem,默认可能是 geo 或 cartesian2d。我们用的是像素坐标轴,所以必须显式写coordinateSystem: 'cartesian2d',否则数据会画不出来或者画到地图外面去。这是我当时从“在地图 API 里画线”切换到 ECharts 时卡得最久的一个点。
飞线的动画会让大屏看起来很有“流动感”,但注意控制线数量。一屏几十条很爽,几百条同时开 effect 动画,帧率会明显下降。我的习惯是把 effect 动画只对最核心的 TopN 数据开启,其余用静态线表示。
3.5 点击交互:地图和图表双向联动
地图与 ECharts 图层叠加后,交互要分成两种走:
查地图上的点击,用 BMapGL 的点击事件。查 ECharts 图层的点击,用 ECharts 的事件。由于 ECharts 图层设置的是pointer-events: none,默认它收不到鼠标事件。如果只是需要“点击某个数据点后地图定位到对应位置”,可以在初始化 ECharts 时把这个容器单独一个 series 开放命中,然后在图表的 click 事件里获取像素坐标再转回经纬度:
myChart.on('click', (params) => { const pixel = new BMapGL.Pixel(params.value[0], map.getSize().height - params.value[1]); // 像素坐标转回经纬度 const point = map.pixelToOverlayPixel(pixel); map.centerAndZoom(point, 14); });还有一种更简单的方案:给chartDom单独保留一条“有值区域”的路径。但说实话,像素转经纬度已经够用了。实际项目里,我的经验是尽量把“点击”这个语义放在数据点上,而不是让地图容器去接收所有点击事件。因为透明层的点击事件会把地图的拖拽手感搞得很奇怪,与其让用户一边拖一边误触,不如只开放数据点所在的命中区域。
如果你实在需要地图空白区域也能点,可以给 ECharts 容器加pointer-events: none之外再单独给某个存在数据的 series 开启emphasis联动,用 ECharts 的dispatchAction控制高亮,不阻塞地图手势。这个方案我在移动端试过,触屏场景下反馈比较不错。
4. 常见问题与排查技巧实录
4.1 数据点整体偏移几百米
这个问题我在前面铺垫过,真踩坑的时候比说的更隐蔽。现象是:别的图层(比如 Marker)位置是对的,但 ECharts 画的点整体偏了半条街。这通常不是代码 bug,而是坐标系没有统一。
排查顺序我一般是:
- 先看数据源的坐标类型,问后端是 BD09LL、GCJ02 还是 WGS84
- 用地图自带的 marker 放同一个坐标点,看位置是否正常
- 如果 Marker 正常而 ECharts 偏移,检查是否是
pointToOverlayPixel换算时忘了 y 轴翻转
坐标系问题可以在服务端解决,也可以在前端做一个“BD09LL / GCJ02 / WGS84”互相转换的库。网上有现成算法,拿过来封装好,注意精度即可。我自己遇到过最怪异的情况是后端返给接口的坐标是成都的,但数据注释里写的是“火星坐标”,前端不知道,按百度坐标投上去,点位直接飞到了都江堰方向。从那之后我在项目里养成了一个习惯:接口联调第一件事就是拿一个已知真实位置的坐标点去验证,不验完不往下做。
4.2 透明图层挡住了地图手势
表现:地图无法拖拽、缩放、点选,滚动页面时地图区域一直卡住。
原因是chartDom默认是 div,pointer-events默认为 auto,盖在地图上就把事件“吃掉”了。解决办法就是前面反复强调的:在把图表容器 append 到地图父容器时,务必设置pointer-events: none。
但设置 none 之后,ECharts 自己的 hover、click 事件也失效了。这时需要按需开放:
#mapEChartsLayer { pointer-events: none; } #mapEChartsLayer .echart-hit-area { pointer-events: auto; }可以给需要交互的 series 设置一个单独emphasis或通过 tooltip 触发。或者更直接一点:在需要点击的区域用chartDom.addEventListener手动绑定,而 ECharts 内部是否响应进一步的事件,用一个标志位控制。坑点在于移动端:touch 事件和地图手势的冲突比 PC 更明显,开放命中后手指滑动时容易误触发图表事件,这种我建议直接tooltip: { triggerOn: 'click' },并在地图dragstart时把图表交互禁用,dragend再恢复。
4.3 地图拖动后图表与底图错位
很多情况下,第一次渲染是对的,但一拖地图,ECharts 图层里的点就“留在原地”,和底图脱节。
这是因为我们只在初始化时计算了一次像素坐标,没有同步监听地图的视角变化。解决代码就是 3.3 里的map.addEventListener('moveend', updateEChartsFromMap)。
但要注意 BMapGL 是 WebGL 渲染,地图移动过程中如果监听的是movestart到moveend的区间,可能因为高频触发而性能下降。我的做法是用moveend+zoomend触发重算,再加一层requestAnimationFrame节流。如果业务上必须在地图移动过程中保持同步,比如路径回放、车辆追踪,那就别用 ECharts 全屏叠加了,换 BMapGL 的自定义覆盖物或者原生 Polyline 更合适。这个选型要在方案阶段想清楚,别等做一半再切。
4.4 图表容器尺寸为 0 导致内容不显示
ECharts 初始化时如果容器没有高度或宽度,图表画不出来,地图容器也可能会因为外层布局没稳定而变成 0 高度。最常见的是在 Vue 的mounted里直接初始化,但此时外层v-if或懒加载还没完成。
我一般解决办法是:初始化地图前先确认容器offsetHeight > 0,如果为 0 就等到下一帧,或者用setTimeout延迟到页面渲染完成后执行。这不是什么优雅的方案,但确实最有效。更好的做法是封装一个initMapWhenReady(container, callback),用requestAnimationFrame循环判断,容器尺寸就绪后再跑地图初始化。
另外,页面里如果有折叠面板、切换 tab、弹窗等操作,显示/隐藏地图容器后需要手动调用map.resize()和myChart.resize(),否则会出现一半空白或比例不对。我的统一方案是给页面装一个ResizeObserver:
const observer = new ResizeObserver(() => { map.resize(); myChart.resize(); }); observer.observe(document.getElementById('mapContainer'));实测下来,这比在各种业务回调里到处调用 resize 可靠得多,尤其是大屏经常有面板展开收起的情况。以后你换个主题或者全屏切换,也基本不用管 resize 的事。
4.5 数据量大导致交互卡顿
地图本身渲染几千个 Marker 就会吃力,ECharts 叠加层如果一次塞几千个点,刷新时也会卡。逐层优化:
- 点位层面:优先用 ECharts 的
scatterGL或者开启渐进渲染,控制单次绘制的数据量 - 视觉效果:大数据量时把
animation: false打开,关闭过渡动画 - 交互层面:鼠标 hover 时只在事件回调里取当前数据,不要全图重绘
我自己的压测经验是:普通 scatter 系列在 webgl 地图叠加层里,3000 个点左右还算流畅;超过 5000 个就有肉眼可感的掉帧。超过这个量级,建议做聚合,比如按网格、按行政区合并,或者直接用百度地图自带的聚合 Marker 功能展示分布,再用 ECharts 展示聚合后的统计图表,而不是把每一个点都画在地图上。
4.6 常见问题速查表
| 现象 | 主要原因 | 处理建议 |
|---|---|---|
| 点位置偏移几百米 | 坐标系混用(WGS84/GCJ02 与 BD09LL) | 统一坐标转换,接口层保证 BD09LL |
| ECharts 点上下颠倒 | 像素坐标系 y 轴方向未翻转 | value[1] = mapHeight - pixel.y |
| ECharts 图层挡住地图拖拽 | 透明容器pointer-events未设为 none | 设置 pointer-events:none,按需开放热区 |
| 地图拖动后图表脱节 | 未监听地图视角变化事件 | 监听 moveend/zoomend,重算像素坐标 |
| 图表空白不动 | 容器尺寸为 0 或未随页面变化 | 初始化前确认尺寸,用 ResizeObserver 监听 |
| 地图加载报 Referer 错误 | AK 域名白名单未配置 | 开放平台配置正确域名 |
| 加载多个脚本时全局变量未定义 | BMapGL 脚本加载顺序 | 用 Promise 封装动态加载 |
4.7 省后台流量的几个小技巧
大屏项目一般有个特点:地图数据会定时轮询。每 30 秒或 1 分钟拉一次接口,如果每次返回全量点数据,前端要做两次 diff:一次是给地图的,一次是给 ECharts 的。其实没必要,ECharts 的 setOption 支持notMerge参数,数据变化时可以直接替换 series 数据,同时保留动画和交互配置。
myChart.setOption( { series: [{ data: newScatterData }], }, { notMerge: false } // 默认浅合并,避免整图闪烁 );另外,地图底图的瓦片请求挺耗流量的。可视化大屏一般不要求每帧刷新底图,把 CSS3D 或 canvas 的地图模式打开,并关闭一些不必要的高清标注,能省很多请求。这个要看具体版本和渲染模式,我一般在项目配置里关掉 POI 文字显示,只保留路网和行政边界,视觉上也更干净。
5. 移动端 bMap 模块与 Web 端的差异
标题里带着“bMap”,很多读者其实是做 uni-app 的。移动端的 bMap 模块和 Web 端 BMapGL 的 API 完全是两套东西,但场景思路是可以平移的。
uni-app 的 bMap 模块依赖原生的百度地图 SDK,提供MapContext一类的方法,Marker、MoveAlong、Translate 这些能力都比较完整。但原生的地图组件本质上是一个原生 view,ECharts 不能像 Web 端那样直接用一个 div 盖上去。我通常的做法是:原生地图负责底图和定位,用<canvas>组件叠加一个透明画布,再把地图经纬度换算成屏幕坐标,在 canvas 上绘制散点、飞线。换算原理和 Web 端完全一致:先用MapContext.getCenterLocation拿中心点,用单位像素对应的经纬度步长推出所有点的像素位置,只是没有了pointToOverlayPixel这么顺手的 API,需要自己维护一个简单的投影函数。
如果是小数据量的场景,更稳妥的是不用 ECharts,直接用 bMap 模块自带的 Marker 和 polyline,把飞线用 polyline 加沿线图片动画模拟。虽然效果打折,但性能和原生手势都更稳。说白了就是:移动端上,能用原生能力就用原生,ECharts 只用在“数值表达”很强的地方,别硬把它套成 Web 端的用法。
6. 一些项目里沉淀下来的体会
最后说点我实际的感受。这个组合用到现在,最大的一个认知转变是:不要为了让“看起来厉害”而把所有东西都塞到地图图层里。BMap + ECharts 能做很炫的效果,但炫不是目的,业务信息清楚、数据更新跟得上、交互不打折才是核心。地图上叠图表这件事,真正的难点从来不在 API,而在坐标系、事件和性能这三座“看不见的山”,把这三样处理干净,后面就是纯粹的数据可视化功底。
我个人在项目里的一个固定习惯是:凡是涉及地图和 ECharts 叠加的接口,需求评审时我都会追着后端确认坐标类型,并规定接口直接返回 BD09LL。一次沟通确认,能省掉前端一整个坐标转换模块的开发和维护成本。数据接口没到位之前,前端先用模拟数据跑通渲染链路,也是最实际的项目推进方式。
如果你正准备在项目里上这套组合,我的建议是:先做一个不带任何业务逻辑的“最小联动 Demo”,也就是地图初始化 + ECharts 透明图层 + 一组写死的坐标点 + 拖拽缩放联动,把这个链路跑通,再往上堆业务复杂度。这个 Demo 跑顺了,项目里真正的数据量撑起来其实是水到渠成的事。