上周又被同事拉去看一块大屏,问题还是那个老问题:echarts 中国地图加自定义图标,圆点一换就消失。地图底图铺得好好的,scatter也渲染出来了,数据点全在,唯独把symbol换成自家业务图标之后,屏幕上干干净净,控制台连个红字都没有。这类问题我在过去几年里被问过太多次,几乎每做一块带地域分布的可视化大屏,都会重新踩一遍。图标不显示、显示出来糊成一片、放大之后图标缩成芝麻、图片导出变空白,桩桩件件都能让人在凌晨两点怀疑人生。
echarts 中国地图加自定义图标这件事,本质上不复杂:一张按经纬度铺开的中国地图,叠一层用图片或矢量路径代替默认圆点的散点系列。但它横跨了地图数据准备、坐标系绑定、图片资源加载、尺寸与缩放联动四个环节,任何一个环节掉链子,结果就是白屏或者一排看不清的小点。这篇内容适合三类人:刚接手大屏可视化、需要在地图上打业务标记的前端;已经把圆点做出来、想换成图标但卡住的同学;以及图标能显示、但缩放和性能体验一塌糊涂、想彻底理顺的老手。下面我按实际做项目的顺序,从需求拆到参数,从代码拆到坑,把能直接抄的部分全给你。
1. 需求拆解:地图上为什么非要贴图标
1.1 从"打点"到"贴标"的三类真实诉求
默认的scatter圆点能用,但它只回答"这里有数据",回答不了"这里是什么"。真正让业务方坚持要自定义图标的,通常是三类场景。第一类是身份区分,比如一张全国分布图上同时有仓库、门店、维修站三种实体,圆点全是一个颜色,图例再清楚也没人看得懂,必须靠图标形状做第一眼识别。第二类是状态表达,设备监控大屏上,正常、告警、离线三种状态需要三种不同的图形语言,甚至告警点要带脉冲动画,圆点加颜色在这种高密度场景下辨识度太低。第三类是品牌与视觉统一,很多大屏的视觉稿直接给了设计图标,业务方不接受"技术限制"这个解释,这时候就必须上图片符号。
这三类诉求对技术的要求并不一样。身份区分只要求 symbol 能按数据切换,状态表达往往还要叠加动画和层级,品牌统一则对清晰度、缩放表现、导出质量最挑剔。我在动手前一定会先问清楚属于哪一类,因为它直接决定后面是走图片image://路线,还是走path://矢量路线,两条路在缩放和高清屏上的表现完全不同,选错了返工成本很高。
1.2 动手之前必须先落定的两件事
第一件事是地图数据从哪来。echarts 5 之后,官方包体里已经不再内置任何地图 JSON,echarts.registerMap必须由你喂数据进去。这份数据可以是 GeoJSON,也可以是带encodeOffsets压缩过的 TopoJSON 变体。数据源的选取、边界表达的准确性、是否满足业务所在地的合规要求,这些必须由业务方确认,技术侧只负责"拿到合规数据后正确渲染"。我一般会把地图文件放在项目静态目录下,用fetch拉取,而不是写死成一大坨 JSON 塞进 JS 里,后者会让打包体积直接膨胀几 MB,首屏白屏时间肉眼可见地变长。
第二件事是渲染器的选择。Canvas 和 SVG 在普通图表里差异不明显,但在地图加图标这个场景下区别很大。Canvas 下每个图片符号是一次绘制指令,几百个图标时性能稳,但导出图片时容易被跨域污染卡住;SVG 下图标是真正的<image>节点,缩放不失真、DOM 可调试,但节点数一多,手机端直接卡成幻灯片。我的经验是:图标数量在 300 以内、需要高保真排版和导出的,选 SVG;超过 300 或者要跑在低端安卓设备上的,老老实实上 Canvas。这个判断标准后面还会再提一次。
1.3 一个容易被忽略的前提:坐标系概念
很多人第一次做地图打点,会以为传给scatter的value是"屏幕坐标"或者"省份名"。不是。scatter配coordinateSystem: 'geo'时,value的前两位是经度和纬度,echarts 内部会拿 geo 组件的投影结果做换算。这意味着两件事:其一,你的数据必须带真实经纬度,光有城市名是不够的(除非你只画map系列的区域填充);其二,经度在前、纬度在后,写反了不会报错,但点会跑到地图外面去,屏幕上什么都看不到,这是新手最常见的"数据明明有就是不显示"的原因之一。
经纬度的精度也不需要太纠结,小数点后两位在城市级别已经足够,四位能精确到街区。我见过有人在数据表里存了六位小数,然后手工截断成两位再入库,结果两个相邻站点重叠成一个点,白排查半天。数据链路里要么全精度传递,要么明确约定精度,别在中途偷偷截断。
2. 方案选型:四条路子里选一条最省心的
2.1 scatter 配 image 符号:最通用的主力方案
这条路是绝大多数项目的正解,配置量最少,可控性最好。核心就三行:type: 'scatter'、coordinateSystem: 'geo'、symbol: 'image://你的图片地址'。图片地址可以是在线 URL、相对路径、Base64 data URI,甚至可以用symbol: 'image://' + base64动态拼。它最大的优势是数据和样式完全解耦:数据里只关心经纬度和业务字段,图标由symbolSize、symbol控制,甚至可以把symbol写成函数,按数据里的type字段返回不同图片,一个系列搞定多种图标。
但它有一个必须提前知道的限制:symbolSize的数值单位是屏幕像素,不是地理单位。这一点决定了后面第 4 节的整个缩放联动方案。很多人以为把地图放大,图标会跟着放大,实际上不会——位置永远正确,尺寸永远不变。这个行为本身不算 bug,但在视觉上会造成"放大地图后图标显得越来越小"的观感,业务方第一眼就会提意见。
2.2 path 矢量符号:放弃图片换来的是全场景清晰
symbol: 'path://M0,10 L10,0 ...'这种写法用的是 SVG Path 字符串。相比图片,它有三个实打实的好处:任何分辨率下都不糊、颜色可以直接用itemStyle.color控制、体积可以忽略不计。如果你的图标是简单的几何形状(圆形徽章、三角形、水滴形图钉、六边形),我强烈建议走这条路,不需要设计师出图,不需要处理跨域,也不需要预加载。
代价是复杂图形不好画。一个带渐变和描边的品牌 logo,用 Path 表达基本是自虐,而且颜色固定后没法再做渐变。我的判断标准很粗暴:图标能不能用不超过 30 个锚点的手绘路径表达,能就上 path,不能就老老实实用图片。另外path://生成的符号默认会按symbolSize等比缩放,如果路径的宽高比和symbolSize给的数组比例不一致,图形会被拉伸,需要自己调整路径的viewBox。
2.3 effectScatter 与 markPoint:好看但各有短板
effectScatter是带涟漪动画的散点,视觉冲击力强,很多人第一眼就爱上它。它确实支持自定义symbol,但那个涟漪效果是固定画在符号底下的圆,尺寸由rippleEffect的scale和brushType控制,跟你换上去的图标并不完美咬合。图标是个瘦高的图钉时,底下一圈圆涟漪会显得很突兀。我的用法是:涟漪只留给"告警"这类需要强提示的点,正常点位一律用普通 scatter,混着用反而更清楚。
markPoint属于另一条道上的工具,它挂在某个系列上做标注,确实能贴图,但它本质是"在某个数据点上打标记",位置依赖宿主系列的数据,不适合做几十上百个独立点位。我早年试过用markPoint批量渲染城市标记,数据量一大,配置对象膨胀得离谱,切换时卡顿明显,后来全部推翻换成 scatter。至于graphic组件配合convertToPixel手动定位,更不推荐——地图一拖动一缩放,你得手动重算每一个 graphic 的坐标,稍不留神就跟地图脱节,维护成本极高。
| 方案 | 图标自由度 | 缩放表现 | 性能 | 适用场景 |
|---|---|---|---|---|
| scatter + image | 高,任意图片 | 尺寸需手动联动 | 中,几百个可控 | 品牌图标、复杂图形 |
| scatter + path | 中,需手绘路径 | 尺寸需手动联动 | 高 | 几何图形、状态标记 |
| effectScatter | 同上,但涟漪不贴合 | 同上 | 较低,动画开销大 | 告警点、重点强调 |
| markPoint / graphic | 高 | 差,需手动同步 | 低 | 单点标注、极少量 |
2.4 一个容易被踩的组合禁区
选型时还有个坑必须提:不要在同一个图表里既配geo组件、又配type: 'map'的系列。这两者都会把地图画出来,结果就是两遍底图叠在一起,边境线的描边颜色变深、hover 高亮错乱、点击区域对不上。很多人是因为看了一份旧教程,教程里用series.map画底图、又用 scatter 打点,他就照着抄,结果 scatter 的coordinateSystem: 'geo'找不到 geo 组件,直接不渲染。
正确的分工只有两种。要么全用geo:geo负责底图和漫游,所有系列(scatter、effectScatter、lines)都用coordinateSystem: 'geo'并指定geoIndex。要么全用series.map:底图由 map 系列画,打点用 map 系列的子组件或另行计算像素坐标。前者灵活、扩展性好,是我现在唯一推荐的写法;后者适合你只有一个系列、图省事的简单场景。
3. 核心实现:从注册地图到图标落到经纬度上
3.1 地图数据的准备与 registerMap 的三个坑
准备工作就一步,但这一步能出错的地方不少。用 npm 装好依赖后,把合规的地图 JSON 放到静态目录,运行时拉取并注册:
npm i echarts@5import * as echarts from 'echarts'; async function bootstrap() { const geoJson = await fetch('/static/geo/china.json').then(r => r.json()); // 第一个参数是地图名,后续 geo.map 必须与它完全一致 echarts.registerMap('china-map', geoJson); const chart = echarts.init(document.getElementById('map'), null, { renderer: 'canvas', // 图标多时用 canvas devicePixelRatio: window.devicePixelRatio }); // ...setOption }坑一:注册名和geo.map不一致。注册成'china-map',配置里写map: 'china',控制台会抛 "Map china not exists",但如果你全局还注册过别的名字,可能静默失败或者显示成另一张图。我习惯把名字定义成常量,注册和配置共用同一个变量,从根上杜绝。
坑二:GeoJSON 里的properties.name和你的数据name对不上。只有series.map的data是按名称匹配的,必须一字不差,"北京市"和"北京"是两个东西。scatter用经纬度,不受这个影响,但如果你同时要做区域填充色,这个坑躲不开。
坑三:坐标精度和地图投影。国内常见的地图数据有几种坐标系标准,业务数据如果来自某个采集设备,坐标系可能和地图数据不是同一套,直接叠上去会有几十到几百米的偏移。城市级别看不出来,园区级别就很明显。这种问题没法在 echarts 层面解决,只能在数据入库前统一转换,我一般会要求数据方明确标注坐标系来源。
3.2 geo 与 series 的绑定关系,一张图讲清楚
配置的地图部分,我固定拆成两块。geo组件管底图:map指定注册名、roam开启缩放拖动、scaleLimit限制缩放范围、itemStyle管区域填充和边框。系列管数据:coordinateSystem: 'geo'、geoIndex: 0(有多个 geo 时才需要明确指定)。两者之间唯一的纽带就是geoIndex,没有别的隐式关联。
const option = { geo: { map: 'china-map', roam: true, zoom: 1.1, scaleLimit: { min: 0.8, max: 6 }, itemStyle: { areaColor: '#0d2340', borderColor: '#2f6da8', borderWidth: 0.8 }, emphasis: { itemStyle: { areaColor: '#17385f' }, label: { show: false } // 底图 hover 不弹省名,避免遮挡图标 } }, series: [{ id: 'site-pins', type: 'scatter', coordinateSystem: 'geo', geoIndex: 0, symbol: 'image:///static/icon/pin.png', symbolSize: 36, data: [] }] };这里有个细节值得说:emphasis.label.show我基本都关掉。底图默认在鼠标移上去时会显示区域名称,当图标密集分布时,这个大标签会盖住图标和 tooltip,视觉上非常脏。关掉省名、把信息交给tooltip来表达,信息密度反而更高。如果业务确实需要知道区域名,用geo.regions给重点区域单独开标签,比全局开要干净得多。
3.3 图标尺寸、偏移与高清适配的计算过程
symbolSize到底给多少,不是拍脑袋。设设计稿宽度是 1920,视觉稿上图标占 36px,那么在大屏实际宽度 3840 的环境中,如果直接写 36,图标看起来只有设计稿的一半大,会显得又小又碎。换算公式是:
const DESIGN_WIDTH = 1920; const BASE_ICON_SIZE = 36; // 设计稿上的图标尺寸 function getSymbolSize(containerWidth) { return BASE_ICON_SIZE * (containerWidth / DESIGN_WIDTH); }容器 3840 时算出 72px,视觉比例才对。这个函数要绑在resize上重算,否则窗口一变,比例就失真了。
第二个要算的是锚点偏移。图片符号默认以图片的几何中心对齐到经纬度点。如果图标是圆形徽章,中心对齐没问题;但如果图标是"图钉",针尖才应该落在地理点上,中心对齐的结果是图钉整体往右下偏了半个高度,视觉上像指错了地方。解决办法是symbolOffset,它支持百分比,百分比是相对符号自身尺寸计算的:
symbolSize: [40, 56], // 宽 40 高 56 的图钉 symbolOffset: [0, '-50%'] // 向上移半个高度,让底部针尖对准坐标点推一下就知道为什么是 -50%:中心点在高度的一半处,要让底部贴住坐标点,整体需要上移的高度正好是高度的一半,也就是 -50%。如果是水平居中但视觉重心偏左的图标,同理用[10%, 0]微调。这个偏移量我一般在设计切图阶段就定好,写在配置常量里,不靠试。
第三个是清晰度。canvas 渲染时,浏览器会把图片缩放到symbolSize指定的尺寸,如果原图只有 36×36,而你要显示 72px,必然是拉伸模糊。所以切图规范应该是按最大可能显示尺寸的 2 倍出图,也就是图标最大显示 72px 时,切 144×144 的 PNG。多花的流量换来的是任何缩放下都不糊。图标如果带透明通道和圆角,PNG 或 WebP 都行;如果图标有纯色描边,可以试试 SVG 文件(image://xxx.svg也可以),清晰度天花板更高。
3.4 数据组织与 tooltip、点击事件的写法
scatter的data数组里,每一项我都用对象形式,而不是裸数组:
data: [ { name: '站点A', value: [121.47, 31.23, 128], type: 'hub', status: 'normal' }, { name: '站点B', value: [113.26, 23.13, 64], type: 'shop', status: 'warn' }, { name: '站点C', value: [104.06, 30.67, 32], type: 'shop', status: 'offline'} ]value的前两位是经纬度,第三位以后随便塞业务数值,会原样带到 tooltip 和事件回调里。name不是必需的,但强烈建议带上,tooltip 的默认标题、无障碍文案、导出时的数据对照都靠它。
有了type字段,图标切换就变成一行函数:
symbol: (val, params) => { const map = { hub: 'image:///static/icon/hub.png', shop: 'image:///static/icon/shop.png' }; return map[params.data.type] || 'circle'; }tooltip的写法没什么花头,关键是要trigger: 'item',否则 hover 到地图区域时也会弹,体验很乱。formatter里拿params.data自己拼 HTML,比默认模板灵活得多:
tooltip: { trigger: 'item', confine: true, // 限制在容器内,避免被屏幕边缘截断 backgroundColor: 'rgba(8,20,36,.92)', borderColor: '#2f6da8', textStyle: { color: '#cfe3ff', fontSize: 12 }, formatter: (p) => { const d = p.data || {}; const v = d.value || []; return `<b>${p.name || d.name}</b><br/> 经度:${v[0]} 纬度:${v[1]}<br/> 业务值:${v[2] ?? '-'}`; } }点击事件的坑在于必须过滤componentType。因为底图区域点击也会触发click,如果你不加判断,点空白区域也会执行你的业务跳转逻辑:
chart.on('click', (params) => { if (params.componentType !== 'series') return; if (params.seriesId !== 'site-pins') return; openDetail(params.data); });小细节:seriesId比seriesName可靠,因为seriesName可能重复,id是唯一的。这也是我前面坚持给系列写id的原因。
4. 缩放联动:让图标跟着地图一起变大
4.1 为什么地图放大了,图标反而显得更小
这个现象值得单独拆一节,因为它是"自定义图标"方案里最容易被业务方挑刺的地方。原因很直白:geo的漫游缩放,改的是投影到屏幕的映射关系,地图上的省界、区域面积会按比例放大;而scatter的symbolSize是绝对像素值,跟缩放倍数没有任何关系。于是你把地图放大两倍,省变大了,点还是那么大,相对比例就缩了一半。
注意,位置始终是准的。很多人误以为是"错位",其实不是,经纬度投影每一帧都在重新计算,点永远钉在正确的坐标上,变的只是视觉尺寸。搞混这两件事会导致排查方向完全跑偏——去查坐标转换、去查geoIndex,最后发现问题根本不在那儿。
4.2 监听 georoam 动态改 symbolSize 的完整实现
思路很直接:监听georoam事件,读出当前的 zoom,用symbolSize = baseSize * zoom / baseZoom重算,再触发一次更新。先把 zoom 的变化抓到:
const DEFAULT_ZOOM = 1.1; let currentZoom = DEFAULT_ZOOM; let rafId = null; chart.on('georoam', (params) => { const opt = chart.getOption(); const geoOpt = (opt.geo && opt.geo[0]) || {}; // 事件参数里的 zoom 是当前缩放值,getOption 读到的更稳妥,二选一兜底 currentZoom = params.zoom != null ? params.zoom : (geoOpt.zoom != null ? geoOpt.zoom : currentZoom); if (rafId) return; rafId = requestAnimationFrame(() => { rafId = null; refreshPinSize(); }); });requestAnimationFrame这层节流是必须的。拖动和滚轮缩放时,georoam一秒钟能触发几十次,每次都setOption的话,主线程会被配置合并和重绘塞满,手感立刻变黏。用 rAF 把一帧内的多次事件合并成一次,实测下来拖动顺滑度有质的差别。
然后是刷新尺寸的函数。这里我把symbolSize写成函数而不是数字,理由是函数能读到最新的currentZoom,而 setOption 传数字需要每次都算出具体值并触发完整合并:
function makeSize(baseSize) { return () => { const ratio = currentZoom / DEFAULT_ZOOM; // 缩放倍数做一次软限幅,避免极端缩放下图标大到遮住整个省 const capped = Math.min(Math.max(ratio, 0.6), 2.2); return baseSize * capped; }; } function refreshPinSize() { chart.setOption({ series: [{ id: 'site-pins', symbolSize: makeSize(getSymbolSize(chart.getWidth())) }] }); }注意makeSize每次返回一个新函数。这是让 echarts 感知变化的关键——传同一个函数引用,合并时会被判定为无变化而不重绘。这个细节我踩过一次,改了半天以为逻辑写错了,实际上是引用没变。
4.3 限幅、边界与几种不该做联动的场景
Math.min(Math.max(ratio, 0.6), 2.2)这行限幅不是可有可无的。scaleLimit允许放大到 6 倍,如果图标严格按 6 倍放大,一个 36px 的图标会变成 216px,直接盖住半个省,tooltip 都点不出来。限幅后的视觉效果是:放大到一定程度后图标不再变大,保持"够看清"的尺寸即可。这个上限值我一般取 2 到 2.5 之间,具体看图标密度。
还有几种情况我会主动放弃联动。一是点位超过 200 个时,缩放联动带来的重绘成本明显上升,我会改成"只在缩放停止后 200ms 重算一次",用一个setTimeout做防抖替代 rAF 的逐帧更新,牺牲一点点跟随感换流畅度。二是移动端,双指缩放本身就会触发页面滚动和手势冲突,再加上连续的 setOption,低端机会直接卡住。移动端的做法是把roam设为'scale'只允许缩放,同时关掉尺寸联动,图标固定大小,用户体验反而更稳。
还有个容易忽略的边界:地图平移(不缩放)时georoam也会触发,此时 zoom 没变,refreshPinSize算出来的尺寸和之前一样,属于无效重绘。稳妥的做法是在函数里比对一下上次的 zoom 值,相同就直接 return,省掉一次无谓的 setOption。
5. 常见问题与排查实录
5.1 图标不显示:按这个顺序自查,五分钟定位
这张表是我几年下来固定的排查顺序,从最常见到最罕见排列。80% 的问题在前三行就能解决。
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 地图正常,图标完全不出现 | 图片路径 404 / 拼写错误 | 浏览器直接访问图片 URL,看是否 200 |
图标不出现,但换成'circle'就正常 | 图片被跨域或格式不支持 | 检查图片是否同源,是否用了 WebP 兼容性差的格式 |
| 图标出现但跑到国外去了 | 经纬度写反 | 检查value[0]是否经度、value[1]是否纬度 |
| 图标全挤在左上角 | 数据是省份名不是坐标 | 确认coordinateSystem: 'geo'且数据为数值坐标 |
| 地图本身白屏 | registerMap未执行或名不符 | 控制台看地图名报错,核对注册名 |
| 图标出现但黑方块 | 透明 PNG 被当 JPG 处理 / 预乘 alpha | 换 PNG 重试,或改用path:// |
| 图标尺寸巨大盖满屏幕 | symbolSize传了数组但图片宽高比不符 | 检查数组值,或先固定 36 试 |
我特别想说"图片 404 不报错"这一条。echarts 加载图片失败是静默的,不会在控制台打印任何东西,符号就默默消失了。这也解释了为什么"换成 circle 就好了"——圆不需要加载资源。所以遇到图标消失,第一步永远是打开新标签页粘贴图片地址,确认它能被浏览器正常渲染。这个动作三十秒,能省你半小时。
5.2 跨域与导出图片:大屏交付时的固定环节
toolbox.saveAsImage是大多数大屏的标配需求,而它恰恰是自定义图标方案最脆弱的环节。原理是:canvas 一旦绘制了来自其他域的图片,这块画布就被标记为"污染",调用toDataURL会直接抛异常或返回空白。表现就是导出的图里地图和文字都在,唯独图标那块全空。
三种解法,按推荐程度排。首选 Base64 内联:把图标转成 data URI 直接写进symbol,symbol: 'image://data:image/png;base64,...',同源了,不会污染画布,代价是 JS 体积变大,图标不能太多太大。次选服务端放行:图片服务器配置允许跨域的响应头,让浏览器认可这次资源请求;这条路需要后端配合,我一般在大屏项目启动时就提出来,别等交付前一天才发现。最后是改渲染器:切到 SVG 渲染器后,导出走的是序列化而不是 canvas 截取,跨域限制天然消失,代价是图标数量多时性能下降。
顺带提一句 canvas 的另一个隐性坑:图片加crossOrigin属性后,如果服务器没配好头,图片会直接加载失败、退回静默消失。也就是说,为了导出而加跨域属性,有可能把原本能显示(只是不能导出)的图标搞成完全不显示。这个因果关系很多人绕不过来。
5.3 卡顿与重叠:图标数量上来之后的处理
先说重叠。点位密集时图标会互相压盖,视觉上变成一坨。echarts 5 提供了labelLayout: { hideOverlap: true },它能处理标签的重叠,但对符号本身无效。符号层面的重叠没有内置解法,我的做法是在数据侧做聚合:按当前缩放级别把邻近的点合并成一个"聚合点",缩放放大后自动拆分。聚合的判定可以用chart.convertToPixel({ geoIndex: 0 }, [lng, lat])拿到每个点的屏幕坐标,按网格做归并,再把聚合数量塞进symbolSize或 label 里。这套逻辑不难写,但必须做节流,否则每次缩放都全量算一遍,比不聚合还慢。
再说页面级卡顿。几百个图标时,如果页面同时还有别的图表在跑动画,或者有人在用低配机器看大屏,帧率会掉得很难看。几个能立刻见效的调整:关掉所有非必要动画(animation: false或者把animationDuration压到 200ms 以内)、hoverAnimation关掉、emphasis.scale设为 1.1 而不是默认的大幅放大、tooltip.showDelay设为 0 避免 hover 时的延迟计算。另外,large: true这个开关对 scatter 有效,但它会关闭符号自定义和标签,用图片图标时就别开了,开了等于白开。
还有个低频但很值得防的场景:图表实例泄漏。SPA 里路由切换时如果不chart.dispose(),旧实例持有的图片资源、事件监听都不会释放,来回切几次页面,内存就上去了,表现是"越用越卡"。我习惯在组件卸载钩子里统一dispose,顺手把georoam的监听也解掉——chart.off('georoam')别忘了。
6. 可以直接抄的工程化封装
6.1 一份配置模板,改数据就能跑
把前面所有要素拼起来,下面这份配置是我现在项目里的起点,复制过去换个地图名和图片路径就能跑:
const BASE_ICON_SIZE = 36; const DESIGN_WIDTH = 1920; const DEFAULT_ZOOM = 1.1; let currentZoom = DEFAULT_ZOOM; function buildOption(geoJsonName, pins) { return { backgroundColor: 'transparent', geo: { map: geoJsonName, roam: true, zoom: DEFAULT_ZOOM, scaleLimit: { min: 0.8, max: 6 }, itemStyle: { areaColor: '#0d2340', borderColor: '#2f6da8', borderWidth: 0.8 }, emphasis: { itemStyle: { areaColor: '#17385f' }, label: { show: false } } }, tooltip: { trigger: 'item', confine: true }, series: [{ id: 'site-pins', type: 'scatter', coordinateSystem: 'geo', geoIndex: 0, zlevel: 2, symbol: (val, p) => `image:///static/icon/${p.data.type || 'default'}.png`, symbolSize: () => { const ratio = currentZoom / DEFAULT_ZOOM; const capped = Math.min(Math.max(ratio, 0.6), 2.2); return BASE_ICON_SIZE * (ratio === ratio ? capped : 1); }, symbolOffset: [0, '-50%'], emphasis: { scale: 1.15 }, data: pins }] }; }zlevel: 2这一项单独说一下。它把散点放进独立的 canvas 层,好处是地图平移缩放时,图标层可以单独重绘,减少整图重绘的开销。代价是zlevel不同的元素之间无法做层级穿插,tooltip 的显示顺序可能受影响。图标数量过百时我会打开它,几十个的时候关掉,避免不必要的图层开销。
6.2 资源和数据的约定,比代码更值钱
最后这部分是流程上的经验,比任何一段代码都省时间。图标资源统一放一个目录,命名跟type字段一一对应,比如hub.png、shop.png、offline.png,前端只维护一个后缀映射,新增类型只需丢一张图进去,不改代码。所有图标统一画布尺寸,比如都是 144×144 的透明画布,图形居中,这样symbolSize可以全局统一,需要不同视觉大小时只调symbolSize不调图。图钉类图标在画布上预留底部空间,让针尖落在画布底边中点,配合固定的symbolOffset: [0, '-50%'],所有图钉的锚点行为就完全一致。
数据侧的约定是:value前两位永远是经纬度,业务字段一律走对象属性,不要往value第三位以后塞超过两个值。我见过有人把七八个业务字段全塞进value数组,后期想加字段就得改所有消费方,非常痛苦。对象属性扩展性最好,而且 tooltip 里读起来也直观。
还有一个我个人的小习惯:给每个系列留一个id,所有的 setOption 更新都用id定位而不是索引。地图类图表往往会有多个系列(底图打点、连线、热力),用索引更新一旦顺序调整就全乱,用id永远不会错位。这个小约束在项目做大的时候能救很多次。
我做过的几个地图项目里,真正让人返工的从来不是 echarts 的 API 难用,而是那些不成文的默认行为:图标加载失败静默、symbolSize不跟随缩放、跨域图片导出空白、getOption读不到你以为有的字段。把这些默认行为当成已知条件写进文档和注释,下次接手的人(包括三个月后的自己)就不用再熬一遍。