ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ECharts全国地图自定义图标:选型、定位与性能优化实践

ECharts全国地图自定义图标:选型、定位与性能优化实践

ECharts 全国地图加自定义图标,这个组合我在最开始做业务分布大屏的时候就反复折腾过。当时的需求特别朴素:底图上把每个站点的位置标出来,但领导明确要求不能用系统默认的小圆点,每个站点要有自己的身份感——仓配中心是一个箱子图标,门店是一个房子图标,临时点位是一个小旗子图标,鼠标悬停还得能弹出详情卡片。看着只是把symbol从圆点换成图片,真正动起手来才发现问题一个接一个:图标不显示、位置往下飘了半个身位、放大之后糊成一片、点位一多就开始掉帧。

这篇就围绕“ECharts 全国地图 + 自定义图标”这一件事,把整套方案从选型、配置、参数换算到性能与排错完整讲一遍。适合三类人看:正在做大屏的、后台需要一张区域分布分析的、以及移动端塞了一个轻量地图组件的。哪怕你之前只画过柱状图,照着文里的配置块改一改也能跑起来;做过几张地图的同学,可以重点看参数换算和性能那两节,那里面的坑基本上人人都要踩一遍。

1. 先把需求拆开:这张图到底难在哪

1.1 地图和图标其实是两件互不干扰的事

很多人一上手就想找“带图标的地图组件”,思路一开始就跑偏了。在 ECharts 的模型里,地图和标记从来是分离的两层:下层是geo组件或map系列,负责把地理轮廓画出来,提供一个经纬度到画布坐标的投影变换;上层是scatter、effectScatter这类散点系列,负责拿到经纬度之后,在自己的位置画一个符号。符号长什么样,完全由symbol决定,跟地图没有半毛钱关系。

理解了这个分层,后面所有配置就有归属了:底图不好看,调geo.itemStyle;图标画不出来,调series.scatter.symbol;图标位置不对,调symbolOffset;图标太大太小,调symbolSize。我见过不少项目把这三件事混在一个配置对象里瞎试,改一处动全身,最后配置成了玄学。分层思维是这张图能不能维护的前提。

另一个容易忽略的点是:图标的坐标必须和地图用同一套坐标系。ECharts 内部对geo上的散点做投影时,走的是和地图完全一致的变换管线,所以你只要给对经纬度,位置就是准的。真正会偏的,通常是你自己在地图上叠了 DOM 覆盖层、或者用了一张设计师画的位图当底图——那种情况下 ECharts 根本不知道你的底图用了什么投影,自然对不上。

1.2 三类典型场景,决定三套不同做法

同样是“地图 + 图标”,不同场景的最优解差别很大,硬套一套配置会很难受。

第一类是运营大屏。特点是点位少(十几个到几十个)、视觉要求高、有动画和交互、通常投在 4K 大屏上。这种做法可以用高精度位图图标,甚至每个点位配不同图片,配合effectScatter做呼吸效果,视觉冲击力最强。代价是图片资源多、首屏加载慢,得做预加载。

第二类是后台分析页。点位可能上百,需要筛选、联动、点击下钻。这种场景我更推荐path://矢量图标,颜色能跟着itemStyle.color走,方便用不同颜色表达不同状态,而且没有额外请求,缩放不失真。

第三类是移动端轻量图。屏幕小、点位密、点击热区要求大。这种就得控制图标数量和尺寸,必要时做聚合,或者在缩小时用圆点、放大时切图片,别一上来就怼几十张 PNG。

先想清楚自己属于哪一类,再往下看具体实现,能省掉一半返工。

2. 底座选型:geo 组件还是 map 系列

2.1 两者的分工与选择标准

ECharts 里画地图有两条路:series.type: 'map'和geo组件。它们长相相似,能力边界却不一样。

series-map本质是一个“数据驱动的图表系列”,它能直接把数据映射成省份填色,配visualMap很方便,但它自带的坐标系是封闭的,你在它上面叠散点会比较别扭。geo组件则是一个纯粹的“坐标底图”,它不消费数据、不参与图例、只负责画轮廓和提供投影,天生适合当背景板。

我的判断口径很简单:只要你要在地图上叠自定义图标,就用geo打底,散点系列用coordinateSystem: 'geo'挂上去。反过来,如果你的需求是“各省按指标填色,不要标记点”,那series-map更顺手。两者也可以共存:geo画底图,series-map用geoIndex关联过去,但配置会绕,非必要不这么干。

用geo打底还有一个隐性好处:roam缩放拖拽是geo自身的能力,缩放后散点会跟着地图一起移动,坐标不会脱节。这一点在series-map上做叠加时经常出岔子。

2.2 aspectScale 这个参数,决定了地图“胖瘦”

geo.aspectScale默认值是0.75,很多人压根没注意过它。它的作用是调整地图的纵横比,用来抵消常见投影方式带来的形变。默认0.75意味着地图在垂直方向被压缩了四分之一,看起来更“扁”一些。

这个参数本身不会导致图标错位,因为图标和地图共用同一套变换。但它会影响视觉观感:如果你把aspectScale设成1,地图会变“高”,图标之间的相对疏密关系也会跟着变。做视觉对齐的时候,如果设计稿是按1出的,代码里用了默认0.75,你会觉得“怎么图标全都挤在一起了”。这种问题不是坐标错了,是比例不对。

我一般会在项目初期就把aspectScale定死并写进注释,避免后面有人调了半天以为是数据问题。实测下来,做全国范围的展示,0.75到0.85之间观感比较自然;如果只展示单个区域,可以适当往1靠。

2.3 注册地图数据的完整流程

ECharts 本身不带地理数据,得先把 GeoJSON 注册进去。流程就三步:拿到 GeoJSON、调用echarts.registerMap()、在配置里引用注册名。

import * as echarts from 'echarts'; import geoJson from './data/my-region.json'; // 注册名随便起,但要和后面 geo.map 里的名字严格一致 echarts.registerMap('myMap', geoJson); const option = { geo: { map: 'myMap', roam: true, zoom: 1.1, aspectScale: 0.8, itemStyle: { areaColor: '#0f2544', borderColor: '#2c5f9e', borderWidth: 1 }, emphasis: { itemStyle: { areaColor: '#1b3b66' }, label: { show: false } }, label: { show: false } } }; echarts.init(document.getElementById('chart')).setOption(option);

几点要留心。注册名必须唯一且一致,同一个页面注册两次同名地图,后一次会覆盖前一次,如果两个图用了不同粒度的数据,就会出现“这个图的地图变了那个图也变了”的诡异现象。另外registerMap是全局的,在单页应用里如果组件销毁时不清理,反复挂载会一直往内存里堆。

GeoJSON 的体积也要关注。全国省级的 GeoJSON 精简后通常几百 KB,市级或更细粒度的动辄几 MB。我的做法是在构建阶段把它转成 JSON 模块并做简化(比如用工具降低坐标点精度),或者干脆在服务端按需裁剪后返回,别把一份全量数据塞进首屏包里。

3. 自定义图标的四条路,怎么选

3.1 image:// 位图方案:视觉最自由,坑也最多

最直观的做法就是symbol: 'image://路径'。设计给了什么图就用什么图,渐变、阴影、立体感全都能保留,这是大屏场景的标配。

{ type: 'scatter', coordinateSystem: 'geo', geoIndex: 0, symbol: 'image:///static/icons/warehouse.png', symbolSize: [36, 44], data: [ { name: '点位A', value: [116.40, 39.90, 128] }, { name: '点位B', value: [121.47, 31.23, 96] } ] }

这条路的问题集中在三点。第一是尺寸失真:symbolSize对图片是直接给定宽高的,图片会按这个尺寸拉伸,比例不对就变形,所以宽高比必须和原图一致。第二是清晰度:ECharts 会按浏览器的devicePixelRatio渲染画布,symbolSize写的是逻辑像素,在 2 倍屏上等于被放大了两倍,1 倍图必糊。第三是跨域:图片来自其他域且没做跨域许可时,画布会被污染,导出图片(getDataURL)会直接失败。

3.2 path:// 矢量方案:颜色可控、体积为零

path://后面接一段 SVG 的路径数据,ECharts 会把它当路径填充渲染。最大的好处是能被itemStyle.color染色,同一套图标可以用不同颜色表达不同状态,还不用额外请求。

{ type: 'scatter', coordinateSystem: 'geo', geoIndex: 0, symbol: 'path://M512 64C264.6 64 64 264.6 64 512c0 247.4 200.6 448 448 448s448-200.6 448-448S759.4 64 512 64z', symbolSize: 22, itemStyle: { color: '#ff7a45', borderColor: '#ffffff', borderWidth: 1.5 } }

注意这里用的是填充色,所以 SVG 路径最好是从设计软件里导出“合并后的实心轮廓”,而不是一堆带描边的线条。路径里如果有多个子路径,会按同一个填充规则统一上色。另外path://的坐标系是 1024×1024 的视口,路径数据超出这个范围会被裁掉,拿到的图标如果显示不全,八成是这个原因。

和它配套的还有symbolKeepAspect,设成true时图标会保持原始宽高比不拉伸,在symbolSize写成单值时特别有用。

3.3 字体图标与雪碧图:省请求的两种打法

如果项目里本来就有图标字体(比如各种 iconfont),可以直接用文字符号当symbol。这种方式的好处是颜色、大小全靠itemStyle和fontSize控制,跟着主题走。缺点是字体文件本身就有一两百 KB,而且图标的视觉风格受限于字体库。

雪碧图方案适合“图标种类固定但数量多”的场景:把十几张小图拼成一张大图,用 CSS 式的偏移量去取。ECharts 的symbol不支持背景定位,所以这条路要配合graphic组件或者自己写一个自定义系列,实现成本偏高。我自己的项目里用得不多,除非点位规模真的到了几千级别且图片资源很多,否则不划算。

3.4 选型对照表

方案视觉表现是否可染色清晰度额外请求适用场景
image://位图最强,支持渐变阴影否需 2 倍图每类图标一个请求大屏、图标种类少
path://矢量中等,纯色填充是无损无后台分析、状态着色
字体图标取决于字库是无损字体文件已有图标库的项目
雪碧图强否需 2 倍图一个请求图标种类极多的场景

选的时候问自己两个问题:图标需不需要按数据变颜色?点位规模是不是超过两百?答案确定了,方案基本也就定了。

4. 完整实现:从底图到可点击的图标

4.1 容器与初始化

容器必须给明确高度,这是新手最常犯的错。div没有高度时 ECharts 初始化出来的画布是 0 高,现象就是“图没出来”,控制台却干干净净。

<div id="chart" style="width: 100%; height: 640px;"></div>
const chart = echarts.init(document.getElementById('chart'), null, { renderer: 'canvas', devicePixelRatio: window.devicePixelRatio || 1 });

renderer我基本只选canvas。svg渲染器在图标数量少、需要矢量导出时有用,但遇到大量图片符号时性能反而不如 canvas,而且部分滤镜效果不支持。

4.2 散点系列的核心配置

下面这段是我项目里比较通用的一份骨架,把常用参数都摆出来了:

const iconMap = { warehouse: 'image:///static/icons/warehouse.png', store: 'image:///static/icons/store.png', site: 'image:///static/icons/site.png' }; const option = { tooltip: { trigger: 'item', confine: true, backgroundColor: 'rgba(8, 22, 40, 0.92)', borderColor: '#2c5f9e', textStyle: { color: '#dbe9ff', fontSize: 12 }, formatter: (p) => { const [lng, lat, val] = p.value; return ` <div style="display:flex;align-items:center;gap:8px;"> <img src="${p.data.icon}" style="width:20px;height:24px;" /> <b>${p.name}</b> </div> <div style="margin-top:6px;">指标值:${val}</div> <div>经纬度:${lng.toFixed(2)}, ${lat.toFixed(2)}</div> `; } }, geo: { map: 'myMap', roam: true, zoom: 1.2, aspectScale: 0.8, itemStyle: { areaColor: '#0f2544', borderColor: '#2c5f9e', borderWidth: 1 }, emphasis: { itemStyle: { areaColor: '#1b3b66' } } }, series: [ { name: '业务点位', type: 'scatter', coordinateSystem: 'geo', geoIndex: 0, zlevel: 2, symbol: (value, params) => iconMap[params.data.type] || iconMap.site, symbolSize: (value, params) => (params.data.level === 'core' ? [40, 48] : [28, 34]), symbolOffset: [0, '-50%'], itemStyle: { opacity: 1 }, emphasis: { scale: 1.15 }, selectedMode: 'single', select: { itemStyle: { borderColor: '#ffd666', borderWidth: 2 } }, data: [ { name: '点位A', value: [116.40, 39.90, 128], type: 'warehouse', level: 'core', icon: '/static/icons/warehouse.png' }, { name: '点位B', value: [121.47, 31.23, 96], type: 'store', level: 'normal', icon: '/static/icons/store.png' } ] } ] };

这里有几个关键点值得单独说。symbol和symbolSize都支持函数回调,第一个参数是数据项的值,第二个参数是带data的对象,所以可以直接按业务类型选不同的图标和尺寸,一套系列搞定多种点位,不用拆成五个系列。zlevel: 2让图标的画布层压在底图之上,避免某些环境下被地图区域盖住。emphasis.scale是悬停放大,注意它只在鼠标上有效,触摸设备上要点一下才有反馈。

4.3 symbolOffset 的换算:图标为什么会“飘”

这是最高频的问题,没有之一。ECharts 的符号默认以坐标点为中心绘制,也就是说,如果你放一个 40×48 的图钉图片,它的中心会落在经纬度对应的位置上,视觉上整个图标就往上飘了半个高度,图钉的尖端并没有指向目标点。

解决办法是symbolOffset: [0, '-50%']。这里的百分比是相对于symbolSize的,'-50%'就是把图标整体上移自身高度的一半,这样图标底部就正好压住坐标点。如果你用的是圆形图标、想让它居中,就别加这个偏移;用的是小旗子、路锥这类“底部为锚点”的图标,就必须加。

横向偏移同样道理。有些图标在设计时右侧有多余留白,视觉重心偏左,就得用[2, '-50%']这种组合微调。我的经验是:先按图标实际视觉锚点算,再用肉眼在校验点上校准一次,别指望公式一次到位。

还有一点容易被忽略:symbolOffset的百分比基准是符号尺寸,当你用函数让不同点位有不同大小时,同一个百分比会产生不同的像素位移——这恰好是我们想要的,因为大图标的偏移量本来就该更大。

4.4 交互:点击、选中与联动

点击事件的params里带componentType、seriesIndex、data,拿数据很方便:

chart.on('click', (params) => { if (params.componentType !== 'series') return; const { name, value, type } = params.data; openDetailPanel({ name, lng: value[0], lat: value[1], type }); }); chart.on('georoam', () => { // 缩放拖拽后做尺寸自适应,见 5.2 });

需要注意的是,geo本身也会触发点击(点在空白区域时componentType是geo),如果不做判断,用户点地图空白处也可能弹出面板。加一行判断就能避免。

选中态用的是selectedMode+select,注意select样式对图片符号的效果有限,borderColor只会作用在符号外框上,对image://的图片是画不出来的。想让选中效果明显,我的做法是额外叠一个effectScatter系列,选中时只把这一条数据喂进去,用涟漪动画做高亮反馈,视觉上比改边框强得多。

5. 视觉打磨:不糊、不飘、不打架

5.1 高清屏上的位图清晰度

ECharts 初始化时会把devicePixelRatio传给画布,2 倍屏上画布实际像素是 CSS 尺寸的两倍。这意味着你写symbolSize: [36, 44]时,图片在画布上占了 72×88 的物理像素,1 倍图被插值放大,边缘必然发虚。

做法很直接:图标源文件按最大展示尺寸的 2 倍准备,也就是显示 36×44 就切 72×88 的图。这样在 2 倍屏上一比一映射,3 倍屏会轻微放大但肉眼可接受。如果再往上堆 3 倍图,体积会失控,收益不成正比。

还有一种更省的做法:用 SVG 图片文件当symbol。现代浏览器支持 SVG 作为图片源,ECharts 通过image://加载时不做特殊处理,但浏览器本身会做矢量渲染,缩放不糊,体积还小。这条路我实测在主流浏览器上没问题,唯一要注意的是 SVG 里别引用外部字体或图片,否则会加载失败。

5.2 缩放拖拽时图标尺寸的处理

geo.roam打开之后,地图可以缩放。这里有个反直觉的现象:地图放大了,图标尺寸却不变。原因是symbolSize的单位是逻辑像素,与地图的缩放级别无关。视觉上的结果就是地图细节越来越大,图标相对越来越小,最后变成一堆小点。

如果产品要求“图标随地图等比缩放”,就得自己监听georoam事件,读当前zoom值,动态改写symbolSize:

let baseSize = 36; let rafId = null; chart.on('georoam', () => { if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { const opt = chart.getOption(); const zoom = opt.geo[0].zoom || 1; const nextSize = Math.round(baseSize * Math.min(zoom, 2.2)); chart.setOption({ series: [{ symbolSize: [nextSize, Math.round(nextSize * 1.2)] }] }); }); });

注意三点。用requestAnimationFrame做节流,不然拖拽一次会触发几十次setOption,直接卡死。setOption时只传要改的字段,别把整个option重新塞一遍,那样会触发全量重绘。给缩放上限设个阈值(上面写的2.2),避免放大到极限时图标大得像贴纸。

反过来,如果产品希望图标始终固定大小(大多数分析型页面的选择),那就什么都不用做,反而要保持symbolSize是常量。

5.3 重叠、遮挡与图例配合

点位密集时图标会互相压盖,尤其在经济活跃、点位扎堆的区域。三个应对思路:

第一,按重要度控制绘制顺序。ECharts 的散点按数据顺序绘制,后画的压在先画的上面,所以把核心点位排在数组末尾就能浮在最上层。

第二,用大小和透明度做层次。核心点位大且不透明,次要点位小而半透明,视觉上自然分层。

第三,真的挤不下就聚合。可以按经纬度网格做一次分桶,同一格子里的点位合并成一个,显示数量气泡,点击后再展开。这一步需要自己写预处理逻辑,官方没有现成的聚合能力。

图例这块也有个坑:legend绑定的是系列名,不是数据项名。你想通过图例控制“仓库/门店/施工点”的显隐,用单个系列是做不到的,必须拆成多个scatter系列,每个类型一个,name分别对应图例项。这是设计上必须提前决策的点——想省事就用单系列 + 函数式symbol,想灵活筛选用多系列,两者不可兼得。

6. 性能:点位从 20 个涨到 2000 个

6.1 卡顿的真实来源

第一次遇到大数据量时我以为是数据太多,后来逐项测下来,真正的瓶颈往往不是数据量本身,而是这几处:

大量图片解码。每个image://图标都要走一次图片加载,即使浏览器有缓存,几百个不同的 URL 也会产生明显开销。解决办法是把图标数量压到个位数,或者干脆用path://。

zlevel 分层过多。每多一个zlevel,ECharts 就多创建一块画布,内存和合成开销都涨。分层控制在两层以内,geo一层、标记一层就够了。

tooltip 里的 HTML 太重。formatter返回一大段带<img>的 HTML,鼠标快速划过时会频繁重建 DOM,掉帧非常明显。可以在 tooltip 里只放文字,图片交给别的地方展示;或者给img加上固定的宽高避免布局抖动。

频繁 setOption。任何在mousemove、georoam里直接调setOption而不做节流的代码,都是性能杀手。

6.2 降级策略与实测数据

我在一个中等规模的项目上做过一轮压测,配置是geo底图 + 单个scatter系列 + 2 倍图 PNG 图标,容器 1440×800,在普通办公本上得到大致这样的体感:

点位数量首屏渲染拖拽缩放处理方式
50 以内无感知流畅直接用位图图标
200 左右约 200ms轻微掉帧图标改path://,减少 tooltip 复杂度
500 左右约 500ms明显卡顿分级显示,缩小视图只留核心点
1000 以上超过 1s不可用必须聚合或分区域按需加载

这组数字会随设备和图标复杂度浮动,但量级关系是可靠的。我的降级顺序是:先换矢量图标,再减少同时可见的点位,最后才考虑聚合。聚合虽然效果最好,但实现成本和交互复杂度都高,能不做就不做。

另外一个实用技巧是:初始化时先只渲染核心点位,等chart.on('finished')之后再追加次要点位。用户的感知是“图先出来了,细节慢慢补上”,体验比白屏等两秒好得多。

7. 常见问题排查实录

7.1 图标不显示,或者显示成方块

按发生概率排,原因依次是:路径写错(相对路径的基准是页面 URL 而不是 JS 文件位置)、跨域被拦、图片尺寸为 0、symbolSize被设成了0。

排查顺序我一般这么走:先把symbol换成'circle',如果圆点能出来,说明坐标系和系列配置没问题,问题在图标本身;再把图片 URL 直接贴到浏览器地址栏,能打开说明路径没问题,打不开就是路径或权限问题;最后看控制台的网络面板,有没有 404 或 CORS 报错。

symbol写成函数时还有一种隐蔽的坑:回调返回了undefined。比如用iconMap[params.data.type]取值,而某个数据项的type拼错了,就会拿到undefined,ECharts 不报错,直接画个默认方块或者什么都不画。建议在回调里加个兜底:iconMap[type] || iconMap.default。

7.2 图标位置整体偏移

先分清是“整体偏了一个方向”还是“某个区域偏”。

整体偏移最常见的原因是symbolOffset没配。如前所述,符号默认以中心对齐,底部锚点的图标会整体上浮半个身位,看起来就像“所有点都往北移了”。加[0, '-50%']立刻正常。

如果偏移量很小但普遍存在,怀疑坐标基准不一致。不同来源的地理数据可能用了不同的坐标系,混用之后会产生几百米级别的系统性偏移,在城市级别的地图上一眼就能看出来。解决办法是统一数据源,或者在入库前做一次坐标转换,别在渲染层做偏移修正——那是治标不治本。

如果只有部分区域偏,那基本可以断定是那份 GeoJSON 的特殊区域处理没做对。ECharts 支持specialAreas配置,用来手动指定某些区域的位置,跨区域展示时经常需要它。

7.3 缩放后图标糊了 / 错位了

糊了是因为位图分辨率不够,参考 5.1,换 2 倍图或 SVG。错位了先检查有没有在georoam里做了setOption但没做节流,导致中途某次更新用了旧的zoom值。

还有一种更隐晦的错位:在roam过程中,如果同时存在多个geo组件(比如画中画小地图),getOption()拿到的zoom是全部geo的数组,你得按下标取对那个。用opt.geo[0]还是opt.geo[1]取错了,图标就会按错误的比例缩放,看起来像位置飘了。

7.4 问题速查表

现象最可能的原因快速验证处理方式
整张图空白容器无高度检查div的 computed height给容器显式高度
只有底图没有图标coordinateSystem未设或geoIndex错把 symbol 改成 circle 看能否出现补全coordinateSystem: 'geo'
图标是方块图片加载失败地址栏直接访问 URL修路径或加兜底
图标全部上浮未设symbolOffset注释掉偏移观察变化加[0, '-50%']
图标拉伸变形symbolSize比例与图不符对比原图宽高比按比例改尺寸
高清屏发虚1 倍图在 2 倍屏渲染放大浏览器检查换 2 倍图或 SVG
缩放后图标相对变小symbolSize不随 zoom 变缩放前后对比监听georoam动态调整
点位密集处互相遮挡数据顺序与层级问题按重要度排序后重绘调数据顺序 + 分层尺寸
导出图片失败图片跨域污染画布看getDataURL是否报错同域部署或加跨域头
拖拽时卡顿严重georoam里无节流看事件触发频率加requestAnimationFrame

一些在实际使用中总结出的体会

做这类图时间长了会发现,真正难的不是把图标画出来,而是把它画得在每台设备上都对。我自己踩得最狠的一次是上线后才发现图表在导出的截图里图标全丢了,原因就是图片跨域污染了画布,而本地开发环境是同域所以从来没暴露过。后来我养成了一个习惯:凡是涉及外部图片的图表,上线前一定要在真实域名下走一遍导出流程,这个检查只要两分钟,能省掉一次深夜回滚。

还有一个小技巧可以分享:把图标资源集中在一个常量对象里管理,并且给每个类型都留一个default兜底。数据是后端给的,字段值拼错、新增了没登记的类型,这些都是家常便饭,一个兜底图标能让页面在数据出问题时依然可读,而不是一片空白。图表的健壮性往往不体现在正常流程,而体现在脏数据到来时它还能不能站住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进