
做区域运营的朋友上周甩过来一张截图问我为什么他写的echarts 中国地图打开是一片空白控制台干干净净没有任何报错。我让他把配置发过来看到series: [{ type: map, map: china }]就明白了——这个写法在 ECharts 4 时代是标准答案到了 5.x 直接失效。更进一步的诉求是他不想要默认的圆点要在地图上打上自家门店的小图标最好是那种带尖角的气泡针鼠标悬停能出名字和数值。这类需求在业务大屏、区域运营看板、物流轨迹展示里出现频率极高但把中国地图自定义图标散点这三件事串起来中间埋的坑比想象的多地图数据要自己注册、图标挂错坐标系会全部堆在左上角、图片图标会被压扁、经纬度不做转换会整体偏移几百米、几十个点跑得好好的扩到两千个点就卡成幻灯片。下面这些内容就是我这些年反复踩、反复填出来的完整链路从地图数据注册一直讲到性能收尾每一段都可以直接拿去改配置用。适合已经能用 ECharts 画普通折线柱状图、但第一次把散点往地图上挂的同学也适合做过几版大屏、想在图标细节和性能上再抠一抠的老手。1. 拿不到地图是第一步ECharts 5 的地图数据链路很多人卡在这一步就放弃了转头去用别的库。其实问题不在能力而在于不清楚 ECharts 的地图渲染到底由哪几块拼起来。搞清楚这个结构后面挂图标才有地方挂。1.1 map: china 为什么变成了一张空白画布ECharts 在 4.x 及更早的版本里内置了一份中国地图的边界数据所以map: china直接能画。从 5.0 开始为了包体积和地图数据的维护方式内置的地图数据被整体移除了。这意味着registerMap之前任何以字符串去引用地图名的配置都拿不到图形图表容器里什么都不画而且不报错——这是最坑的地方因为按常规思路你会去查 series 配置、去查主题、去查容器高度唯独想不到是数据源没注册。判断方法很直接打开控制台执行echarts.getMap(china)返回undefined就说明没注册过。这个 API 名字看起来像获取地图实际上是查询已注册的地图数据对象返回 undefined 就是空的。我一般是先跑这一行再往下排查。第二个容易误判的现象是注册了地图但geo和series.map同时写了画面上出现两层边界线重叠缩放的时候一层动一层不动。这不是数据问题是组件重复后面 2.1 节会专门讲。1.2 registerMap 的两种接入姿势注册地图的数据源是一个 GeoJSON 对象结构上是标准的 FeatureCollection每个 Feature 里带properties.name省名或市名和对应的几何边界。ECharts 只认这个结构所以不管你从哪里拿到边界数据最终都要转成这个形状再注册。第一种姿势是静态引入适合数据量不大、构建时就能确定的场景import * as echarts from echarts; import chinaJson from ./geo/china.json; echarts.registerMap(china, chinaJson);这种写法打包工具会直接把 JSON 打进 bundle好处是首屏不闪坏处是体积上去了。全国省级边界数据压缩后大概几百 KB如果还要下钻到市级数据量会翻十倍以上这时候就要考虑第二种姿势。第二种是运行时拉取配合异步初始化async function initMap() { const res await fetch(/geo/china.json); const geoJson await res.json(); echarts.registerMap(china, geoJson); const chart echarts.init(document.getElementById(map)); chart.setOption(option); }这里有个顺序问题必须注意registerMap一定要在setOption之前完成否则第一次渲染仍然是空白等到数据回来再setOption一次才会出图。如果你的页面里option是响应式数据驱动的很容易出现先设 option 后注册地图的时序错误表现就是首屏空白、改一下窗口大小突然就好了——因为触发了重绘。1.3 地图数据到手先做三项自检拿到一份 GeoJSON 别急着往项目里塞先过一遍这三项能省掉一大半返工。检查项判断方法不合格的表现结构完整性是否有type: FeatureCollection和features数组注册成功但一片空白名称一致性properties.name是否与你代码里的区域名完全一致区域高亮、点击事件全部失效边界完整性目视核对完整轮廓不能只有主体区块周边区域缺失视觉效果异常第二项特别隐蔽。有些数据源里写的是内蒙古自治区有些写的是内蒙古你的regions配置里如果写的是简称高亮就是不上色而且不报错。我一般的做法是先console.log打出所有properties.name复制粘贴到配置里绝不手打。第三项是目视检查把图渲染出来后放大看一圈确认没有明显的大块空白。有些精简版数据为了压体积会裁掉部分区域业务上如果涉及这些区域的展示就会出问题。交付前花两分钟看一眼比上线后被业务方截图来问要体面得多。2. geo 与 scatter 的组合逻辑图标到底该挂在哪一层地图画出来了接下来是往上面打图标。这一步的选择决定了后面所有的能力边界——能不能联动高亮、能不能点击下钻、能不能做飞线都取决于你选的是哪种挂法。2.1 三种挂法的能力差异对比新手最容易踩的坑是把series-map和geo组件同时写进配置结果画面上出现两层边界、两层标签缩放时还互相打架。原因在于series-map内部会自动创建一份 geo 坐标系你又显式加了一个geo两者叠加绘制。分清这几种挂法才不会互相干扰。挂法写法图标能力适用场景map markPointseries.typemap内嵌markPoint只能用内置符号数量少只需要标三五个重点城市geo scattergeo组件 scatter.coordinateSystemgeo完整支持自定义图标、样式、事件绝大多数业务场景geo effectScatter同上类型换成特效散点带涟漪动画强调实时性的点位我现在的默认选择是第二种。geo组件只负责画底图scatter只负责打点职责清晰各改各的不互相影响。需要给某个省上色走geo.regions配置需要调整点位样式只动 scatter 的itemStyle和symbol两条线互不干扰。markPoint的局限在于它属于标记而非独立系列能配置的参数少symbol支持的类型有限而且数据量一大就容易糊在一起。三五个点的展示用它最省事上了几十个点就一定换 scatter。2.2 coordinateSystem: geo 下的数据格式陷阱选定 geo scatter 之后第一个报错通常来自数据格式。scatter 挂在地理坐标系上时data里每一项必须是[经度, 纬度, 数值]这样的结构顺序不能反也不能用对象形式直接传经纬度。series: [{ type: scatter, coordinateSystem: geo, geoIndex: 0, data: [ { name: 站点A, value: [116.40, 39.90, 128] }, { name: 站点B, value: [121.47, 31.23, 96] } ], symbol: circle, symbolSize: 12 }]经纬度写反是最常见的低级错误。反了之后不会报错点会飘到地球另一端——具体表现是全部点挤在图幅的某个角落里或者干脆跑到图外看不见。我一开始排查这类问题总是去翻坐标系配置后来养成习惯先把点按[经度, 纬度]顺序打印一遍看看经度是不是都在 73 到 135 之间、纬度是不是在 3 到 54 之间一眼就能识别。另一个细节是geoIndex。当你只配了一个geo时这个字段可以省一旦图上有多个地理组件比如主图加一个缩略图不给geoIndex就会默认挂到第 0 个。我在一个双图联动的项目里被这个坑过一次联动点位全部画到了缩略图上排查了半个小时才发现是索引没指定。2.3 图标跟着地图漫游、尺寸却不跟着缩放的机制geo开启roam: true之后用户可以拖拽、滚轮缩放。这时候你会发现一个奇怪的现象地图放大了点位图标的大小纹丝不动。很多人第一反应是 bug其实是设计如此——symbolSize的单位是屏幕像素它锚定的是画布不是地理坐标。点的位置跟着坐标系走点的尺寸跟着屏幕走。这个特性有它的好处缩小到全国视角时图标不会缩成看不见的小点放大到城市级别时图标也不会胀成一个大色块。大多数大屏场景要的就是这个效果。确实需要图标随缩放变化的场景比如地图放大后想强调局部就得自己接管chart.on(georoam, (params) { const zoom chart.getOption().geo[0].zoom; const size Math.max(8, Math.min(24, 12 / zoom)); chart.setOption({ series: [{ symbolSize: size }] }); });这段逻辑能跑但要注意georoam触发频率很高每次都全量setOption会引起明显掉帧。我后来的做法是加一层节流或者直接用dispatchAction局部更新。更稳的思路其实是在georoam里只记录 zoom 值用requestAnimationFrame合并到下一帧再统一更新。3. 自定义图标的四条落地路线与真实表现到这一节才是标题里自定义图标的正题。ECharts 的symbol字段接受好几种形式不同的形式在颜色控制、清晰度、性能上差别很大选错了后期改起来很痛苦。3.1 image:// 图片图标的拉伸失真与修正最直觉的做法是用一张 PNGsymbol: image:///assets/pin.png, symbolSize: [24, 30]这条路能快速出效果设计师给了什么图就用什么图。但它有两个硬伤。第一个是失真。ECharts 会把图片强行拉伸到symbolSize指定的宽高。如果你的图片是 24×30而你写了symbolSize: 24单值宽高会被同时设成 24图片就被压扁了。图片图标必须用数组形式给出宽高并且比例要和原图一致。我见过太多人在这里被图片变形坑到以为是图片本身有问题实际上是尺寸参数写成了单值。第二个硬伤是颜色不可控。itemStyle.color对image://完全无效图片是什么色就是什么色。想根据数据分级变色高值红色、低值蓝色只能准备多套图片按数据切换symbolsymbol: (value, params) { return params.data.value[2] 100 ? image:///assets/pin-red.png : image:///assets/pin-blue.png; }symbol支持函数是很多人不知道的这个技巧在分级展示场景里非常实用代价只是多准备几张图。第三个问题是高清屏。1 倍图在 2 倍屏上会发虚所以要准备 2 倍甚至 3 倍图用symbolSize缩小显示。这就回到了第一个问题的反面——尺寸要用数组且是显示尺寸而非原图尺寸。3.2 path:// 矢量图标颜色可控的首选如果图标是单色的绝大多数业务场景都是我强烈建议用 SVG 路径symbol: path://M512 64C301 64 128 237 128 448c0 288 384 512 384 512s384-224 384-512C896 237 723 64 512 64z, symbolSize: [26, 34], itemStyle: { color: #2f7cf6, borderColor: #fff, borderWidth: 1.5 }这条路线的优势是压倒性的矢量绘制任何分辨率都清晰、itemStyle.color直接生效、borderColor和borderWidth能描边、体积只有几百字节。上面这个路径就是一个标准的地图针形状可以直接拿去改。获取路径的方法很简单在图标库页面找到想要的图标导出 SVG用编辑器打开把path d...里面的字符串复制出来前面拼上path://就行。注意只支持单条路径多路径图标要合并或者用symbol的复合写法比较复杂一般合并成一条更省事。3.3 symbolSize 的函数化与分级展示不管是图片还是矢量路径symbolSize都可以接函数按数据动态调整。这在做业务量越大图标越大的效果时很关键symbolSize: (value, params) { const v value[2]; // 第三位是数值 return 14 Math.sqrt(v) * 1.8; }用平方根而不是直接乘系数是因为数值分布往往跨度很大——最大的一万、最小的一百线性映射会让大值图标胀到五十像素、小值只有十几像素视觉上完全失衡。平方根压缩能把这个差距拉回到可接受的区间。这是我在几个大屏项目里反复验证过的经验凡是涉及面积或尺寸的数据映射优先考虑开方或对数。三级分类的场景用阶梯更直观symbolSize: (value) { const v value[2]; if (v 500) return 32; if (v 100) return 22; return 14; }阶梯的好处是视觉上能一眼区分档位坏处是档位边界附近会突然跳变。业务方如果对平滑有要求还是用连续映射。3.4 symbolOffset 与尖角锚点对齐用气泡针形状的图标时有个细节默认情况下图标的几何中心对准坐标点。但气泡针的语义是尖角指着一个位置中心对上去就偏了半个图标高度。symbolOffset: [0, -50%]symbolOffset的百分比是相对symbolSize计算的-50%在纵向就是把图标整体往上挪半个高度让尖角正好落在坐标点上。用矢量路径时还要注意路径本身在包围盒内的分布——ECharts 会取路径的 bounding box 缩放到symbolSize如果路径在原始 SVG 里就偏下缩放后依然偏下需要额外补偿。我一般的做法是先在设计工具里把画布裁到刚好贴合图形让尖角恰好在底边中点这样symbolOffset的值最可预期。4. 坐标偏了几百米坐标转换这一步躲不掉图标位置整体偏移但形状没问题、大小没问题每一点都偏了几百米——这种症状几乎可以确诊为坐标系没转换。这是纯技术问题跟地图数据质量无关。4.1 三套坐标系的关系与偏差量级现实世界里的坐标有好几种表达方式。原始 GPS 采集出来的是 WGS84国内公开地图服务普遍用的是经过偏移处理的 GCJ-02部分服务商在 GCJ-02 基础上又加了一层形成 BD-09。三者的差异在不同地区不一样一般在几百米量级。坐标类型常见来源与 WGS84 的偏差WGS84GPS 设备、部分开放数据基准无偏差GCJ-02主流地图服务的公开接口、地理编码结果数百米地区不同有浮动BD-09某类地图服务的接口返回在 GCJ-02 基础上再偏移判断自己手里是什么坐标最实际的信号是数据来源从带地理编码能力的接口拿到的地址转经纬度结果基本都不是 WGS84从硬件设备直接读出来的原始 GPS 记录基本是 WGS84。两者混用图上就会出现两套偏移方向不一致的点看起来像是随机散落。4.2 偏移的直观表现与三步判断怎么确认是坐标问题而不是数据问题我总结了三个判断动作。第一步找一个你百分百确定位置的参照点比如某条主干道和某条河的交叉口把它的坐标打上去看图标落在哪。如果落在交叉口旁边几十米那是坐标精度问题可以接受如果落在隔壁街区甚至河对岸那就是坐标系问题。第二步把所有点关掉只留一个然后把地图放大到街道级别。坐标系偏差在小比例尺下不明显放大了之后偏移会变得非常刺眼一眼就能看出来。第三步对比偏差方向的一致性。如果所有点都朝同一个方向偏那就是系统性的坐标系问题如果偏移方向杂乱那是单点数据质量问题得回去查数据源。我遇到最多的情况是第一种从某个地理编码接口批量拿了一批地址转经纬度直接扔进图里结果整体往某个方向偏了几百米。换上转换函数之后立刻对齐。4.3 转换实现与验证转换本身有成熟的开源小工具不需要自己推导公式。项目里装一个坐标转换库把接口返回的数据过一遍再进图表import { gcj02ToWgs84, wgs84ToGcj02 } from coord-transform-lib; const rawList await fetchPoints(); const points rawList.map(item { // 接口返回 GCJ-02地图底图按 WGS84 绘制需要反向转换 const [lng, lat] gcj02ToWgs84(item.lng, item.lat); return { name: item.name, value: [lng, lat, item.count] }; });方向判断是关键底图用的是什么坐标点位就要转成什么坐标。国内很多 GeoJSON 边界数据是按 WGS84 绘制的而业务接口返回 GCJ-02这时候要做的是 GCJ-02 转 WGS84方向反了偏差会翻倍。判断底图坐标的办法是找一个已知地标比如某座跨江大桥的桥心用两套坐标分别打点看哪个落在正确位置。转换之后一定要做一次目视验证。我的习惯是在项目里临时加一个校验点数组放两三个绝对确定的地标坐标上线前打开看一眼对齐情况确认无误再删掉。这个成本只要五分钟能挡住大部分坐标事故。5. 点位堆叠之后层级、标签与点击联动几十个点的时候一切正常接到几百个点、并且每个点都要带标签问题就开始冒出来了标签互相叠、tooltip 命中的是下面那层、点击之后高亮不生效。5.1 层级控制与 tooltip 的命中顺序当图上有多个 scatter 系列比如已开业一层、筹备中一层后添加的系列默认绘制在上层也会优先响应鼠标事件。这本身符合直觉但如果上层系列的点位symbolSize很大、下层系列的点被完全盖住下层就永远点不到。第一种解法是控制 z 值series: [ { name: 已开业, type: scatter, z: 3, /* ... */ }, { name: 筹备中, type: scatter, z: 2, /* ... */ } ]z控制的是同层级内的绘制顺序数值大的在上。zlevel则是更粗粒度的分层用不同的 canvas 层次绘制一般只在需要隔离动画或大量图形时才用普通场景用z就够了。第二种解法是让点位在视觉上不重叠把重要系列的点做得更大、加描边。描边这个技巧很好用borderColor: #fff, borderWidth: 2能让重叠的点之间产生视觉分离即使小点被压在大点下面边缘也能透出来一部分。还有一点容易忽略tooltip默认trigger: item命中逻辑是按绘制顺序来的。如果同一个位置有两个点重叠tooltip 只会显示最上面那个。需要同时展示时可以考虑把数据合并成一个点、在tooltip.formatter里输出多行视觉上反而更清爽。5.2 标签重叠的三种降噪方案标签重叠是点位密集场景的头号视觉问题。三种方案按侵入性从小到大排。第一种开labelLayout的自动避让labelLayout: { hideOverlap: true }, label: { show: true, position: right, formatter: {b}, fontSize: 11 }hideOverlap: true会让 ECharts 自动计算标签包围盒重叠的标签隐藏掉。这个方案零成本缺点是隐藏哪些标签不可控重要点位可能被藏起来。第二种按阈值分级显示。缩到全国视角只显示大点位的标签放大后再显示全部。配合前面提过的georoam事件let showAllLabel false; chart.on(georoam, () { const zoom chart.getOption().geo[0].zoom || 1; const next zoom 2.5; if (next ! showAllLabel) { showAllLabel next; chart.setOption({ series: [{ label: { show: next } }] }); } });这里加了状态判断只有跨越阈值时才setOption避免频繁重绘。第三种标签外置。点位本身不显示名字鼠标悬停才通过 tooltip 展示。这个方案最干净但牺牲了一眼看全的能力适合点位极密、且用户主要靠悬停探索的场景。三种方案可以叠加使用默认开hideOverlap放大后放全部标签极密区段退回 tooltip。实际项目里我基本都是组合着上。5.3 点击选中与跨图表联动地图点位最有价值的交互是点击联动——点某个点右侧面板展示该点的详细数据。实现靠chart.on(click)chart.on(click, (params) { if (params.componentType ! series) return; // 过滤掉地图底图的点击 if (params.seriesType ! scatter) return; const point params.data; renderDetailPanel(point); });componentType这层过滤很重要。地图底图geo组件本身也会响应点击如果你不做过滤点空白区域也会触发回调传进来的params.data是undefined面板渲染直接报错。这个坑我在第一版里就踩了页面点了空白处就白屏。高亮联动用dispatchActionchart.dispatchAction({ type: highlight, seriesIndex: 0, dataIndex: params.dataIndex });配套要在 series 里开emphasis.focus: self让高亮时其他点自动淡化视觉聚焦效果会好很多。记得在切换选中项时把上一个downplay掉否则会越点越亮最后整张图都是高亮的。6. 从几十个点扩到两千个点性能与适配收尾本地跑五十个点丝滑真实数据一上来两千个点浏览器风扇开始转。这是大屏项目最常见的翻车现场也是最后一公里。6.1 缩放时图标尺寸不变的取舍前面 2.3 提过symbolSize锚定屏幕像素不随地图缩放。这个默认行为在大多数场景下是对的但它有个隐性代价当地图缩到全国视角、两千个点挤在一起时每个点还是 12 像素视觉上就是一坨糊。我的处理原则是默认不动只在缩放比例越过阈值时做一次批量调整。前面给的georoam加状态判断的写法就是干这个的。关键是要在setOption里用notMerge: false默认做局部更新而不是全量替换否则每次缩放都会重建整个系列性能直接崩掉。另外roam开启后建议顺手把scaleLimit加上geo: { roam: true, scaleLimit: { min: 0.8, max: 8 } }不限制的话用户可以无限放大放大到一定程度后 ECharts 仍在按原始精度绘制边界内存和渲染压力都会上去。设一个上限既保护性能也避免用户迷失在过度放大的视图里。6.2 大数据量下的三个简化策略两千个点以上的优化按投入产出比排序。第一把symbol换成最简单的形状。circle的绘制成本远低于path://因为矢量路径每一帧都要走一遍路径解析和填充描边。如果点位真的很多先用圆点撑住性能配合颜色分级做区分视觉上也能接受。优化手段实现方式收益量级简化 symbolcircle替代path://明显点越多越明显关闭无用动画animation: false明显首屏尤其开启 large 模式large: true, largeThreshold: 500中等依赖数据分布减少 label默认关闭悬停显示明显label 开销很大第二关掉动画。大屏上散点入场动画看着漂亮但两千个点同时做入场动画首帧能卡住两三秒。animation: false一加瞬间顺滑。第三开large模式。scatter 支持large: true开启后 ECharts 会走一套简化的渲染路径跳过大量逐点样式计算代价是部分精细样式比如每点不同的描边会失效。largeThreshold控制触发阈值我一般设 500 到 1000 之间低于这个量级没必要走简化路径。还有一个容易被忽略的开销源tooltip。两千个点全开 tooltip鼠标移动时命中检测本身就有成本。如果交互要求不高可以改成点击才出 tooltip或者用tooltip.appendToBody: false让浮层留在图表容器内减少 DOM 重排。6.3 容器尺寸变化与响应式补丁最后一块也是最容易被漏掉的一块自适应。图表初始化时按容器的实际宽高定尺寸容器尺寸一变侧边栏折叠、窗口缩放、大屏分辨率切换图不会自己跟着变表现是被拉伸或者留白。标准写法是监听 resizeconst chart echarts.init(container); const resize () chart.resize(); window.addEventListener(resize, resize); // 组件卸载时务必清理 onUnmounted(() { window.removeEventListener(resize, resize); chart.dispose(); });两个细节值得说说。一是resize事件在拖拽窗口时会高频触发图表 resize 本身有成本建议加debounce包一层100 到 200 毫秒足够。二是副作用清理chart.dispose()一定要在组件销毁时调用否则实例和 canvas 都挂在内存里路由来回切几次就能看到内存曲线一路向上。我在一个后台项目里就因为漏了dispose切了二十多次页面之后整个 tab 卡死。还有一种特殊情况容器初始是display: none比如藏在未激活的 tab 里此时echarts.init拿到的宽高是 0图渲染出来是空的。解法是在 tab 激活、容器可见之后再调一次chart.resize()或者用ResizeObserver监听容器本身的尺寸变化比监听 window 更精准const ro new ResizeObserver(() chart.resize()); ro.observe(container);ResizeObserver的优势是只关心容器侧边栏折叠这种不改变窗口尺寸但改变容器宽度的场景window 的 resize 是抓不到的。这两个细节我踩过的次数不少尤其是display: none那个——本地开发时 tab 默认展开怎么测都好一上线用户从别的页面切进来就是白屏排查起来方向全错。个人经验里这类地图加自定义图标的需求八成的时间不是花在怎么画出来而是花在为什么偏了为什么变形了为什么不跟手这些细节上。我现在的习惯是开一个新项目先把三个校验点钉死地图注册是否成功、经纬度是否是预期的坐标系、图标的宽高比是否和原图一致。这三点确认完剩下的样式调整都是五分钟的事。