ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ECharts自定义地图实战:从GeoJSON到32区域可视化方案

ECharts自定义地图实战:从GeoJSON到32区域可视化方案

简介:ECharts地图自定义资源包内含8个文件,由JS、JSON和HTML构成,整体约1.02MB,轻量易用。JS文件用于地图渲染、组件扩展与交互配置,JSON文件保存GeoJSON格式的区域边界数据,HTML文件提供可直接运行的演示页面,便于快速预览和二次开发。资源面向前端开发人员、数据分析师及大屏可视化项目使用者,能够在ECharts中加载自定义区域地图,辅助展示销售数据、人口分布、交通流量等地域维度信息。包内展现了自定义地图从GeoJSON数据接入到页面渲染的完整思路,并提供了清晰的项目结构,方便对照理解与改造复用。当前已有121位学习者下载参考,适合希望快速落地自定义地图效果或深入理解ECharts地图机制的开发者。

1. ECharts 地图自定义解决的是区域边界问题,从一份 32 区资源说起

拿到标题里这个「ECharts地图-自定义32.rar」,先说结论:它大概率不是什么黑科技插件,而是把「按自定义区域做数据可视化」这件事的前半段——区域边界数据——提前准备好了。ECharts 官方内置的是标准中国地图、省份地图,但实际业务里你经常遇到「我们自己划的片区、园区、网格、战区」这种统计口径,官方地图根本对不上,这时候只能走echarts.registerMap注册一份自定义 GeoJSON。这套资源常见理解就是一份切成 32 个自定义区域的矢量边界,再配套一套可复用的 ECharts 地图配置。这篇就按这个场景,把数据来源、注册流程、样式定制、踩坑排错一条龙讲透,适合做大屏、后台报表、BI 看板的前端开发者。核心反直觉结论:ECharts 里的地图本质是一份 JSON 数据,不是图片,任何能画出闭合边界的形状都能变成地图。

2. 拿对底图数据:GeoJSON 的来源、合并与坐标系检查

要做自定义地图,第一步不是写 ECharts 配置,而是先拿到一份跑得通的 GeoJSON。很多人一上来就写series.map,图出不来,问题八成出在注册数据上。所以数据源和格式校验必须放在最前面,这一步决定后面省不省心。

2.1 先弄清 registerMap 注册的是什么:矢量边界不是图片

ECharts 渲染地图的原理,是先把地理边界解析成屏幕上的矢量面,再填充颜色、挂上 label 和事件。echarts.registerMap(mapName, geoJson)的作用,就是把这份边界数据按名字存进 ECharts 内部,之后series.map通过map: mapName去引用它。这里的 GeoJSON 是标准地理数据格式,结构长这样:外层是FeatureCollection,里面有一个features数组,每个 feature 包含properties和geometry,geometry的类型通常是Polygon或MultiPolygon,坐标是经纬度数组。ECharts 只关心几何形状和properties.name这个字段,因为name是用来匹配数据、显示 tooltip 的键。

理解了这一点,所谓的「自定义 32 个区域」,本质上就是准备一份包含 32 个 feature 的 GeoJSON。echarts 社区里流传的很多自定义地图案例,其实配置代码几乎一模一样,区别只在 GeoJSON 这份数据。所以不要怕自定义地图,它没有想象中难,难点全在数据准备。建议你在动手前先想办法搞到一份目标区域的边界数据,用浏览器直接打开看一眼,确认有features、有geometry.coordinates、有properties.name,再往下走。

2.2 行政区划边界从哪拿:几个来源与取舍

获取行政区划矢量边界,常见的路子有这么几个,我按实用程度排一下。最常用的是阿里云 DataV 的地图选择器,浏览器里选区域、直接下载 GeoJSON,数据粒度到区县级,输出就是标准的 FeatureCollection,几乎不用改就能喂给registerMap。这个来源在 echarts 数据可视化项目里出镜率最高,因为太省事了。天地图提供的是 WMTS 瓦片服务,适合做底图叠加,但它给的是栅格瓦片而不是矢量数据,不能直接用来做区域 hover 和点击。高德地图、百度地图也都有行政区划边界能力,但导出格式要自己转,授权范围要自己确认,个人演示项目没必要在这上面耗时间。

如果是内网离线部署环境,我的做法是提前把需要的 GeoJSON 下载好,放到静态资源目录里,再用fetch加载。这里有个常被忽略的问题:地图选择器下载的 GeoJSON 是加密坐标(GCJ-02),和 GPS 设备的 WGS-84 坐标存在几百米偏差。单独用 ECharts 展示统计数值完全没影响,但如果要在地图上叠加 GPS 轨迹、或者叠加天地图卫星瓦片,错位会很明显,后面第 5 章会专门讲。下表是我对几个数据源的快速评估。

数据来源矢量/瓦片适合场景注意事项
DataV 地图选择器矢量 GeoJSONECharts 注册地图、统计展示坐标是 GCJ-02,方便快捷
天地图 WMTS栅格瓦片底图、离线地图无法做区域交互,需申请 token
高德/百度行政区划接口返回边界业务系统集成格式需转换,授权自行确认

2.3 合并多个区域成一张自定义地图:node 脚本

「自定义 32 个区域」不一定是 32 个单独的区划,很多时候是把多个已有的市、区合并成一个统计片区,比如把三个区合成一个「业务大区」。地图选择器一次只能导出一个区域,所以要写个小脚本把多个 GeoJSON 合成一份。下面是我常用的 node 合并脚本,逻辑很简单,但能省掉大量手工拼接时间。

// merge-geo.js // 把地图选择器下载的 a.json、b.json 合并成一份自定义区域数据 const fs = require('fs'); const files = ['a.json', 'b.json']; const targetName = '联合演示区'; // 合并后区域对外显示的名字 function mergeGeoJSON(fileList) { let merged = { type: 'FeatureCollection', features: [], }; for (const f of fileList) { const raw = JSON.parse(fs.readFileSync(f, 'utf-8')); // 地图选择器导出的文件本身是 FeatureCollection,取其中的 features const features = raw.type === 'FeatureCollection' ? raw.features : [raw]; merged.features = merged.features.concat(features); } // 统一覆盖 properties.name,ECharts 靠它匹配 series.data 里的 name merged.features.forEach((feat) => { feat.properties = { name: targetName }; }); return merged; } const output = mergeGeoJSON(files); fs.writeFileSync('custom-zone.json', JSON.stringify(output)); console.log('合并完成,features 数量:', output.features.length);

这个脚本的核心就两件事:把多个文件的features数组合并到一个 FeatureCollection 里,然后把每个 feature 的properties.name改成同一个名字。这样在 ECharts 里,多个不连续的面会被当成同一个区域渲染,鼠标滑过时一整片一起高亮,数值也汇总到同一个name下。注意geometry本身不用改,只要坐标都在合理范围内,ECharts 会自己拼合渲染。

合并还有个视觉细节:如果两个区域原本相邻,合并后边界仍然存在,渲染时会看到一条内部细线。多数统计场景不碍事,如果你希望视觉上彻底融为一体,可以在itemStyle.borderWidth设为 0,或者接受这条线作为区域划分提示。不要试图用脚本去消除共边,那个工程量大且容易把多边形弄坏。

2.4 合并前必须做的坐标系与 bbox 检查

合并之前,我建议先对每份源数据做一次坐标检查,避免把一份损坏的数据混进去。检查分两件事:bbox 范围和坐标系。bbox 检查最简单,遍历所有坐标,找到经度最小最大值、纬度最小最大值。以中国范围为例,经度大致在 73 到 136 之间,纬度大致在 3 到 54 之间。如果算出来的 bbox 接近 0,说明坐标丢失或写错了;如果跑到上千,说明坐标被当成了投影坐标处理。下面这段可以直接在 node 里跑。

// check-bbox.js // 检查 GeoJSON 的经纬度范围是否合理 const fs = require('fs'); const geo = JSON.parse(fs.readFileSync(process.argv[2], 'utf-8')); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const feat of geo.features) { for (const poly of feat.geometry.coordinates) { for (const ring of poly) { for (const [x, y] of ring) { minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); } } } } console.log(`bbox: 经度 ${minX.toFixed(3)} ~ ${maxX.toFixed(3)},纬度 ${minY.toFixed(3)} ~ ${maxY.toFixed(3)}`);

注意:area_selector 输出的坐标是 GCJ-02,如果你后续要叠加 GPS 轨迹或 WGS-84 来源的瓦片,必须自己做坐标转换,或者干脆只用纯矢量展示,不要混合使用。

3. 用 registerMap 把自定义轮廓挂进 ECharts:最小可跑通代码与必调参数

数据准备好之后,就到了核心环节:注册地图并渲染。这一章给一套能直接拷贝的最小实现,把registerMap和series.map的关联讲清楚,顺带把 map 系列里最常用的一批参数逐个说明白。这套代码跑通了,你的「自定义 32 区域」地图就已经完成八成。

3.1 一套能直接拷贝的最小 HTML

下面的 HTML 文件假设你已经把上一章的custom-zone.json放在了同目录下。流程是:fetch拉取 GeoJSON →JSON.parse解析成对象 →registerMap注册 →setOption渲染。注意fetch返回的 response 要调用res.json(),这一步拿到的才是对象,不要对它再JSON.stringify。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>自定义 32 区地图</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> <style> #map { width: 900px; height: 620px; margin: 24px auto; } </style> </head> <body> <div id="map"></div> <script> // 1) 拉取自定义 GeoJSON,res.json() 返回的是已经解析好的对象 fetch('./custom-zone.json') .then((res) => res.json()) .then((geoJson) => { // 2) 注册地图:第一个参数是地图名,series.map 必须和它一致 echarts.registerMap('custom32', geoJson); const chart = echarts.init(document.getElementById('map')); // 3) 32 个区域的模拟数据,name 必须和 GeoJSON 的 properties.name 对应 const data = [ { name: '联合演示区', value: 132 }, { name: '北部片区', value: 88 }, // 其余区域的 data 项在此补充 ]; chart.setOption({ series: [{ type: 'map', map: 'custom32', roam: true, label: { show: true }, itemStyle: { areaColor: '#eef2f7', borderColor: '#888' }, emphasis: { label: { show: true }, itemStyle: { areaColor: '#ffcf81' } }, data: data, }], }); }); </script> </body> </html>

这段代码的逻辑顺序很重要:先注册、后初始化、最后用setOption绑定数据。如果把setOption写在fetch之前,或者map: 'custom32'写成了别的字符串,图表就一片空白。registerMap的第一个参数是自定义地图名,你可以随便起,但series.map的值必须和它完全一致,这个对应关系是新手最容易翻车的地方。

3.2 参数解读:map、roam、label、itemStyle、emphasis 怎么配

map 系列本身是个普通 series,但它有一套专属参数。下面表格是我日常最常用的几个,按必调程度排了序。

参数取值示例作用
map'custom32'指定要渲染的地图名,必须与 registerMap 第一参一致
roamtrue/'scale'/'move'是否允许用户缩放和平移,true两者都开
label.showtrue是否显示区域名称
itemStyle.areaColor'#eef2f7'区域默认填充色
itemStyle.borderColor'#888'区域边界线颜色
emphasis{ itemStyle: {...}, label: {...} }鼠标滑过时的高亮样式
selectedMode'single'/'multiple'是否允许选中区域,常用于联动下钻

roam是最影响体验的参数。true表示缩放和平移都开,适合区域多、边界细的图;如果只希望用户缩放不想平移,写roam: 'scale'即可。注意scale默认以鼠标位置为中心,这个行为没法改,所以地图边缘区域缩放时容易「跑出视野」,配合layoutCenter手动固定地图位置会舒服很多,这个在第 4 章讲。

label和itemStyle是视觉层的基础。地图刚渲染出来看着「丑」,通常不是参数不够,而是itemStyle没区分默认态和高亮态。我会把默认areaColor设成低饱和度的浅色,emphasis.itemStyle.areaColor设成高亮色,滑过时才有反馈感。borderWidth默认是 1,如果你的区域很小,建议降到 0.5,避免边界糊成一团。

3.3 series-map 与 geo 组件的分工,别两套一起上

ECharts 里能渲染地图的除了series.map,还有一个独立的geo组件。两者都可以注册同一份 GeoJSON,但定位不同。series.map是数据系列,直接和data绑定,适合做「区域染色、数值展示」;geo组件是坐标系,本身不生产数据系列,适合给散点图、飞线图、效果地图当底图,比如在地图上画一条轨迹线,线的坐标落在geo坐标系里。

我见过不少人在一个图表里同时配了geo和series.map,结果区域要么画了两遍,要么 hover 错乱。如果你只是做区域统计图,series.map一套就够了;只有当你要在底图上叠加其他图形时,才考虑geo组件。另外,如果项目里还接了 OpenLayers 这类 GIS 引擎来管瓦片底图,和 ECharts 的分工要明确:GIS 引擎负责瓦片、投影、图层控制,ECharts 负责矢量区域和数值渲染,两者不要抢同一块画布。

4. 让自定义地图可读性拉满:visualMap、标签与手动摆位

地图能画出来只是第一步,数据可视化要的是「一眼看出高低」。这一章做两件事:把数值映射到颜色上,再把标签、位置调整到适合阅读的状态。做完这一步,这张图才真正能放到大屏或报告里。

4.1 用 visualMap 把数值映射到区域颜色

区域染色最常用的方式是连续型visualMap,它根据series.data里的value自动分配颜色。下面这段是在上一章的基础上追加的配置,把 value 从 0 到 200 映射到浅蓝到深蓝。

chart.setOption({ visualMap: { min: 0, max: 200, inRange: { color: ['#e8f0ff', '#3b7cff'] }, text: ['高', '低'], calculable: true, left: 16, bottom: 16, }, });

visualMap的关键参数是min、max和inRange.color。min和max决定颜色映射的数值范围,超出范围的按两端颜色处理;inRange.color是一个颜色数组,数组第一个是低值颜色、最后一个是高值颜色,中间会自动插值。calculable: true会显示一个可拖动的滑块,方便看的人手动过滤区间。注意max不要拍脑袋填,最好根据数据最大值往上取整,否则大部分区域颜色都会偏深,失去区分度。

如果你的业务场景是「达标/不达标」这种离散判断,可以把visualMap换成piecewise分段型,按阈值切成几段,每段指定一个颜色。比如 0~100 红色、100~200 黄色、200 以上绿色,这在 echarts 数据可视化项目里很常见,配置上只是把visualMap.type改为'piecewise',再写pieces数组。我一般优先用连续型,因为视觉上更平滑,只有产品明确要求分档时才用分段。

4.2 label 与 tooltip 的自定义格式化

地图上的名称标签默认显示properties.name,但很多场景你想显示「名称 + 数值」,或对长名称做截断。label.formatter和tooltip.formatter都可以接收一个函数,参数里有当前区域的name和value。下面是一个实际可用的写法。

series: [{ type: 'map', map: 'custom32', label: { show: true, fontSize: 10, formatter: (params) => params.name, }, tooltip: { formatter: (params) => `${params.name}<br/>数值:${params.value ?? 0}`, }, }]

formatter函数里的params对象,常用的字段是params.name和params.value。value来自series.data里对应 name 的那一项,如果某项没写数据,params.value是NaN,所以要加?? 0兜底。echarts 实战案例里那个视觉引导线和富文本提示框的写法,本质也是在这个 formatter 里拼 HTML,你可以把提示框做得复杂些,比如加一个颜色小方块、列多行指标。地图区域密集时,label.show建议只在emphasis里开,默认态关掉,否则标签会叠成一片。

4.3 layoutCenter、layoutSize、aspectScale 三个摆位参数

地图在容器里的位置和比例,是很多人调半天也调不明白的部分。常见误区是去改容器的width/height,但 ECharts 地图默认按容器宽高自适应,真正该调的是layoutCenter和layoutSize这两个参数。layoutCenter控制地图中心点在容器的位置,layoutSize控制地图整体尺寸,可以写百分比,也可以写像素。下面这段把地图固定到容器偏上的位置,并让尺寸撑满容器短边。

series: [{ type: 'map', map: 'custom32', layoutCenter: ['50%', '54%'], layoutSize: '100%', aspectScale: 0.75, }]

layoutSize: '100%'表示地图的宽度撑满容器,高度按比例自动算;如果希望地图占高度而不是宽度,可以把layoutSize改成具体像素值。layoutCenter的偏移很微妙,地图区域形状不规则时,几何中心并不等于视觉中心,我一般先用默认值渲染,再根据效果微调['50%', '54%']里的两个数。aspectScale控制的是地图的宽高比系数,默认是 0.75,调大调小会让地图整体变高或变扁。这个参数说玄学也不过分,因为不同区域的形状差异很大,没有统一标准,只能每次渲染后肉眼看,觉得「扁了」就调大,「瘦了」就调小。

这三个参数配合roam,能解决九成的地图摆位问题。地图区域多时,我还会在roam开启的前提下,把初始zoom设成一个小值,让用户第一眼看到的是全局轮廓而不是某一角。zoom写在series里就行,默认是 1,按需调整。

5. 自定义地图避坑:5 个高频翻车点与排查顺序

自定义地图的报错通常不炸控制台,而是「白屏」「没反应」「错位」这类看起来像玄学的问题。这些坑我基本都踩过,下面按排查优先级列 5 个最常见的,每条都按「现象 → 原因 → 解决」写,照着这个顺序查能少走弯路。

5.1 图出不来:白屏或只显示标题

现象:页面正常渲染,标题和坐标轴(如果有)都在,但地图区域一片空白。原因分两类:一是registerMap的第二参不是对象,比如你fetch后手贱又做了一次JSON.stringify,或者把 GeoJSON 文件路径写错导致加载了个 HTML 字符串;二是series.map的名字和registerMap第一参不一致。

解决:打开控制台,先console.log(geoJson)确认它是对象且features长度大于 0,再检查map: 'custom32'是否和echarts.registerMap('custom32', geoJson)完全相同。字符串大小写、前后空格都会被 ECharts 严格匹配,这是最典型的翻车点。经验:所有从外部文件读进来的数据,先打印、再注册,不要凭感觉相信它。

5.2 区域不响应:hover 无反应、点击拿不到 name

现象:地图画出来了,颜色也对,但鼠标滑过区域不高亮,chart.on('click')里拿到的params.name是undefined。原因多半是 GeoJSON 里properties.name缺失,或者series.data里的name和 GeoJSON 里的名字对不上。

解决:检查custom-zone.json里每个 feature 的properties,至少要有name字段。用上一章的合并脚本时,我特意覆盖了properties.name,就是为了保证这一项不出问题。series.data里写不写name都行,不写的话该区域就没有数值,但是properties.name必须存在。做个自检:把 GeoJSON 的features.map(f => f.properties.name)打印出来,和数据源里的 name 列表逐项对比,任何不一致都会导致对应区域「沉默」。

5.3 地图漂移出容器:只显示一半或位置偏到角落

现象:地图能渲染,但只有一小块在容器内,或者整体偏到左上角/右下角,怎么拖都拖不回来。原因通常有两个:一是容器在echarts.init时是隐藏的,或者宽度为 0,导致图表拿到的初始尺寸不对;二是 GeoJSON 的 bbox 异常,坐标范围超出正常经纬度区间,ECharts 的自动缩放被带偏。

解决:如果容器一开始隐藏(比如在 tab 页里),页面切换后要手动调用chart.resize(),最好在显示后的下一帧执行。bbox 异常就回到第 2.4 节的检查脚本,把经纬度范围打印出来看看。临时救急方案是用layoutCenter: ['50%', '50%']和layoutSize: '80%'手动把地图拉回视野,这两个参数能覆盖大部分定位问题,但不替代真因排查。

5.4 瓦片底图和矢量错位:GCJ-02 与 WGS-84 混用

现象:在 ECharts 上叠加了天地图或高德瓦片,地图背景是准的,但区域的边界明显偏移,有的区域漂到海上。原因就是数据源坐标系不一致。DataV 地图选择器下载的是 GCJ-02(火星坐标,已加偏移),而天地图 WMTS 使用的是国测局加密经纬度,GPS 原始数据是 WGS-84,三者互相对齐时都会错位。

解决:这是我在做离线地图叠加时踩得最深的一个坑。我的习惯是:如果只做区域统计图,完全不用管坐标系,纯矢量展示最安全;如果一定要叠加瓦片底图,先确认瓦片服务和 GeoJSON 用的是同一套坐标,或者对 GeoJSON 做一次坐标纠偏。不要指望 ECharts 帮你做坐标转换,它本身不提供转换能力,转换要自己在数据处理阶段完成。搞不清楚时,果断放弃叠加,用纯色背景加边框,不会错。

5.5 切换地图时旧图残留:clear、同名覆盖与 geo 冲突

现象:在同一个图表实例里从「全国地图」切到「自定义区域图」,新图出来了,但旧图的某几个区域还留在画布上,或者 hover 行为混乱。原因有两个可能:一是setOption默认是合并模式,旧 series 没被替换干净;二是你同时配了geo组件和series.map,两个图层在抢同一个容器。解决:切换前主动执行chart.clear(),清空所有组件和 series,再重新registerMap和setOption。如果只是更新数据而地图轮廓不变,则不需要 clear,直接setOption({ series: [{ data: newData }] })合并更新即可。另外,geo和series.map用一个就好,除非你清楚知道要用 geo 坐标系去画额外图形,否则别两套一起上。

6. 进阶:区域下钻与 GeoJSON 边界校验

把自定义地图跑通之后,最常用的进阶玩法是区域下钻:点击 32 个区域里的某一个,跳到该区域内部更细的划分。这里给出一个最简实现思路,以及我每次上线前必做的数据校验动作。

下钻的核心就是「点击 → 重新注册地图 → 重新 setOption」。给chart挂一个click事件,根据params.name去加载对应子区域的 GeoJSON,再走一遍registerMap和setOption。注意注册用的地图名每次要换一个,比如'custom32-child',避免覆盖主图的数据源。还要维护一个返回栈,记录上级地图名,方便做「返回上一级」。

chart.on('click', (params) => { // params.name 是点击区域的 name,用它去加载对应子地图数据 fetch(`./geo/${params.name}.json`) .then((res) => res.json()) .then((childGeo) => { // 用新名字注册,避免覆盖 custom32 echarts.registerMap('custom32-child', childGeo); chart.setOption({ series: [{ type: 'map', map: 'custom32-child', roam: true }], }); }); });

下钻的坑在于数据文件命名必须和区域 name 严格一致,名字里有空格或特殊字符时,fetch容易 404。我一般会在生成 GeoJSON 时就统一把 name 里的空格去掉,或者用路由映射表,避免前端去猜文件名。

最后说一个我养成的验证习惯:每次拿到新的自定义 GeoJSON,先别急着写页面,把它丢回地图选择器或任意 GeoJSON 预览工具里,肉眼看一遍边界是否闭合、有没有飞地、有没有明显缺角。再用第 2.4 节的脚本算一次 bbox,确认坐标量级正常。最后在代码里把features.map(f => f.properties.name)打印出来,和数据源逐项核对。这三步做完,地图基本不会出幺蛾子。

自定义地图这件事,上手之后会发现真正的价值在前面的数据处理,ECharts 配置反而是最省心的部分。把「拿数据、查坐标系、测 bbox、对 name」这四步固化成本能,比记住任何参数都管用。希望这篇能帮你把「自定义 32 区域」这套方案真正落地,少走我走过的弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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