☰
ECharts两级地图下钻:GeoJSON注册与状态管理实战
2026/11/5 6:03:52 网站建设 项目流程

简介:面向数据可视化开发者与前端学习者的中国行政区划地图数据包,围绕 ECharts 地图图表场景整理全国省级地图及各省下辖城市地图,可用于区域统计、经济指标、人口分布等数据的交互式展示,也适合课程案例和项目原型快速搭建。压缩包共 863 个文件,以 610 个 JSON 地图数据和 250 个 JavaScript 文件为主,另有少量说明与配置文件,整体约 12.62MB;其中 JSON 主要承载行政区域边界坐标,JS 可辅助地图加载或示例逻辑,便于按省、市层级检索并接入 series 配置。已有 14521 人学习下载,具备较高的实践参考价值。使用者可结合颜色映射、tooltip、缩放和平移等交互配置,较快完成中国地图可视化页面,并对照不同地区数据差异进行分析与展示。

1. 从全国到市级:这套 echarts 两级地图方案真正难在哪

不少人第一次做地图可视化,是在 ECharts 里写上一行map: 'china'就以为大功告成,打开页面却是一片空白——内置地图数据早就从主包里被移了出去,得自己找 GeoJSON 再registerMap。真正让「echarts 全国的省地图 + 每个省对应的市地图」这套方案变复杂的,从来不是画图本身,而是两级数据的组织方式与下钻状态的管理:全国图要按省着色,点进某个省要换成该省的市级边界,点返回还得把上一级的数据、缩放、图例原样还原。这套做法面向的是做大屏区域看板、做大数据可视化课程设计、需要按行政区划逐级查看指标的人,下面从数据准备讲到下钻实现,再落到怎么验收。

2. 地图数据准备:GeoJSON 的分级目录与 registerMap 注册机制

两级地图的第一道坎不在代码,在数据。全国一张图、每个省各一张图,加起来几百个 GeoJSON 文件,怎么放、怎么命名、什么时候加载,直接决定后面写代码是顺手还是天天救火。下面用 A 省、B 市、C 市做代称,adcode 用100000、100100、100101这类示意编号,实际落地时换成自己区域的名称与编码即可,机制完全一样。

2.1 文件命名与目录组织:用 adcode 而不是中文名

用中文名做文件名和路径,在打包工具、静态服务器、URL 请求这三处都容易翻车。开发机上跑得好好的geo/A省.json,部署到另一台机器上可能因为编码差异变成%E6%9F%90%E7%9C%81.json,甚至直接 404;带空格、全角括号的名称更麻烦。adcode 是纯数字,天然没有编码问题,而且它自带层级关系——前两位对应省级,完整六位对应市、县级,用代码一截就能判断属于哪一级,不用再维护一张对照表。

目录建议扁平化,一层放全国和省,一层放市:

路径层级内容加载时机
geo/100000.json全国省级边界首屏必加载
geo/100100.jsonA 省该省市级边界点击该省时懒加载
geo/100101.jsonB 市该市下辖区县边界需要三级下钻时才加载
geo/name-index.json索引名称 → adcode 映射首屏加载,体积很小

不把市级文件塞进省文件的子目录里,是为了让构建工具的静态资源扫描简单一些,路径拼起来也只是一句`geo/${adcode}.json`。如果市级文件多到几百个,再按省分文件夹;在大多数课程设计和中小型看板里,扁平结构已经够用,还省得写递归查找。

2.2 registerMap 的注册时机与缓存

echarts.registerMap是全局注册,注册进去的地图名在整个页面生命周期里都能被任意图表实例引用。这意味着两件事:第一,重复注册同名地图会直接覆盖前一份,正在显示的图表不会自动刷新,得重新setOption;第二,注册过的地图会一直留在内存里,来回切十几次如果每次都注册,内存就一点点涨上去。

比较稳妥的写法是包一层带缓存的懒加载函数,用 adcode 字符串作为地图名,避免中文名在不同数据源里写法不一致(「A省」和「A 省」在注册表里是两个不同的 key):

import * as echarts from 'echarts'; // 已注册地图的缓存:key 是 adcode 字符串 const mapRegistry = new Map(); export async function ensureMap(adcode) { const key = String(adcode); if (mapRegistry.has(key)) return mapRegistry.get(key); // 动态 import,交给打包工具做代码分割 const mod = await import(`../geo/${key}.json`); const geoJson = mod.default ?? mod; echarts.registerMap(key, geoJson); // 地图名 = adcode,纯数字不歧义 mapRegistry.set(key, geoJson); return geoJson; }

这个函数返回 Promise,调用方await之后就能直接setOption,不用关心是首次加载还是命中缓存。把注册和加载绑在一起,可以避免「忘了注册就画图」这种最常见白屏原因。mapRegistry用Map而不是普通对象,是因为 adcode 都是数字字符串,用对象会遇到__proto__这类奇怪的键,虽然实际不会撞上,但换成Map更省心。

提示:如果项目用的是按需引入,记得把MapChart也注册进echarts.use([...]),只引echarts/core而不引地图系列,同样会得到一片空白。

2.3 数据裁剪:精度与体积的取舍

从公开渠道拿到的全国 GeoJSON,动辄几 MB,边界点密到肉眼看不出区别,却要让用户在首屏等上好几秒。地图可视化的精度需求其实很低——大屏上一条省界也就一两个像素宽,坐标保留到小数点后两位完全够。通常的做法是先用 mapshaper 做一次离线裁剪,把体积压下来再进代码仓库:

# 简化边界到原顶点数的 8%,保留小面积区域,只留必要属性 npx mapshaper raw/100000.json \ -simplify 8% keep-shapes \ -filter-fields name,adcode \ -o format=geojson precision=0.01 geo/100000.json

-simplify 8%是经验值,压到 5% 以下时小岛屿、飞地容易变形,8%~15% 之间通常肉眼看不出差别;keep-shapes保证小面积区域不会被简化算法直接删掉,缺一块地图比模糊更难看;-filter-fields只保留name和adcode,那些childrenNum、parent之类的冗余字段对渲染没用,白白占体积;precision=0.01把坐标小数位砍到两位,全国图能再省一截。裁剪完可以顺手看一眼文件大小,全国图控制在两百 KB 以内、单个省几十 KB,是首屏体验比较舒服的量级。

3. 总体架构设计:渲染层、数据层与下钻状态机的职责划分

两级地图最容易写乱的地方,是把「换地图」「换数据」「改面包屑」三件事全塞进一个点击回调里。一开始能跑,等到要加「从 URL 参数直接进入某省」「定时刷新当前层级数据」「导出当前视图」这些需求,就得把那段逻辑复制一遍再改,改到第三遍必然漏掉某处。

3.1 三层拆分:谁该干什么

按职责切成三层,每层只对外暴露一两个函数:

层职责明确不做的事
渲染层生成 option、setOption、resize、销毁不发请求、不判断业务层级
数据层加载 GeoJSON、拉指标数据、建名称索引、算色阶区间不碰 chart 实例
交互层监听点击、维护状态、驱动下钻与回退、更新面包屑不直接改数据源

拆分之后,drillTo函数读起来就是四行:取地图、取数据、换视图、改状态。想加「URL 直进某省」,只是换个入口调用同一个drillTo;想加「定时刷新」,只需要重跑数据层再调一次渲染层,地图不用重新注册。这套结构在课程设计里可能显得有点重,但只要地图层级超过两级,收益立刻体现出来。

3.2 下钻状态机:三个层级、一个历史栈

状态机要管的东西很少,就是当前位置和来时的路:

const state = { level: 'country', // country | province | city adcode: '100000', // 当前显示的地图编码 stack: [], // 历史栈,元素形如 { adcode, level } };

转移规则也很简单:country只能往province走,province只能往city走,city不再往下;回退只能反向弹栈,栈空就不响应。把这套规则写成判断,比在点击回调里散落一堆if (name === '全国')可靠得多。

状态更新有个细节值得注意:先完成地图加载和数据请求,成功之后再改state,失败就保持原状。反过来写的话,一次网络抖动就会让「当前位置」和「实际显示」对不上,用户点返回时看到的是上一级而不是上上级,这种问题排查起来很费劲,属于典型的血泪经验。

3.3 数据对齐:用 adcode,不要用名称做唯一键

ECharts 的地图系列靠名称匹配:series.data里某一项的name,必须和 GeoJSON 里features[].properties.name完全一致,才会被着色、才响应高亮。而实际拿到的两份数据,名称几乎不可能天然对齐——指标表里写「A省」,GeoJSON 里写「A 省」;一边带「市」后缀一边不带;还有全角空格混在里面。名称对不上,地图照常画出来,但所有区域都是底色,看起来像「数据没生效」。

可靠的做法是全程用 adcode 做唯一键,名称只用于展示。加载 GeoJSON 后先建一张名称到 adcode 的索引,匹配时对名称做归一化:

// 用于匹配的归一化:去空白、去行政后缀,只用于查找,不修改原始 name const SUFFIX = /(省|市|自治区|特别行政区|地区|盟|自治州|县)$/; export function normalizeName(name = '') { return String(name).replace(/\s/g, '').replace(SUFFIX, ''); } export function buildNameIndex(geoJson) { const idx = new Map(); geoJson.features.forEach((f) => { const p = f.properties || {}; idx.set(normalizeName(p.name), String(p.adcode)); }); return idx; }

注意归一化只用于「找」,不改 GeoJSON 里的name。原因很直接:改了name就必须同步改所有数据源的name,任何一处漏改都会重现上色失败。保留原始名称、索引里存 adcode,再把业务数据按 adcode 关联回来,这条路最稳。业务侧的数据表也建议直接用 adcode 做主键,名称只作为展示字段,从源头避开这类对齐问题。

4. 核心实现:option 配置、下钻切换与散点飞线的完整代码

到这一层,前面准备的地图和状态都要落到一份 option 上。三级地图共用同一个 option 工厂,只换地图名和数据,代码量能压到很低,后续加图层也只改一处。

4.1 基础 option:map 系列与 visualMap 的配合

// option 工厂:全国 / 省级 / 市级共用同一套结构 function buildMapOption({ mapName, data, visualRange }) { return { tooltip: { trigger: 'item', formatter: (p) => `${p.name}<br/>指标值:${p.value ?? '—'}`, }, visualMap: { type: 'continuous', min: visualRange.min, // 用真实数据算,不要写死 max: visualRange.max, calculable: true, left: 16, bottom: 16, text: ['高', '低'], inRange: { color: ['#eaf3ff', '#7fb0e8', '#1f5fa9'] }, // 三段渐变更易读 }, series: [ { type: 'map', map: mapName, // 必须与 registerMap 的名字完全一致 roam: true, // 允许缩放拖动 scaleLimit: { min: 0.8, max: 6 }, zoom: 1.05, label: { show: false }, // 全国层级文字太密,先关掉 itemStyle: { areaColor: '#f4f8fd', borderColor: '#9dbcd9', borderWidth: 0.6, }, emphasis: { label: { show: true, color: '#1a1a1a' }, itemStyle: { areaColor: '#ffd666' }, }, data, // [{ name, value }] }, ], }; }

几个参数值得单独说。map是registerMap注册时的名字,类型也要对得上——注册用字符串'100000',这里写数字100000就匹配不到。roam: true在大屏上很好用,但一定要配scaleLimit,否则用户滚两下滚轮就把地图缩成一个点,找不回来。visualMap的min/max必须从当前层级的数据里算,写死成 0 到 1000 的话,切到市级数据量级一变,整张图会变成清一色的浅蓝,看起来像没渲染。inRange.color建议给三段以上,两段渐变在中值附近几乎看不出差别。

4.2 下钻与回退:事件只绑一次,换图用 notMerge

点击事件的绑定要放在初始化阶段,只绑一次,靠state判断当前能不能下钻:

chart.on('click', async (params) => { if (!params.name) return; // 点到空白区域直接返回 if (state.level === 'city') return; // 已经是最深一级 const adcode = nameIndex.get(normalizeName(params.name)); if (!adcode) return; // 名称没命中索引,不下钻 await drillTo({ adcode, level: nextLevel(state.level) }); });

下钻函数按「先加载、再换图、最后改状态」的顺序写:

async function drillTo(target) { const prev = { adcode: state.adcode, level: state.level }; await ensureMap(target.adcode); // 1) 确保目标地图已注册 const rows = await fetchRegionValues(target.adcode); // 2) 拉该层级指标 chart.setOption( buildMapOption({ mapName: String(target.adcode), data: rows, visualRange: calcRange(rows), // 用本层数据算色阶 }), { notMerge: true } // 3) 整图替换,清掉上一级 series ); state.stack.push(prev); // 4) 成功后才改状态 state.adcode = target.adcode; state.level = target.level; }

notMerge: true是这段里最关键的一个参数。setOption默认是合并模式,series 按数组下标合并,上一级那份data会原封不动留着,视觉表现就是「切到省级了,全国的色块还盖在上面」。加上notMerge之后整份配置被替换,代价是tooltip、visualMap这些也要重新给全——所以 option 工厂必须是完整的,不能只返回{ series: [...] }。

回退就是弹栈后重跑同一套流程:

async function goBack() { const prev = state.stack.pop(); if (!prev) return; // 已经在最外层 await ensureMap(prev.adcode); const rows = await fetchRegionValues(prev.adcode); chart.setOption( buildMapOption({ mapName: String(prev.adcode), data: rows, visualRange: calcRange(rows), }), { notMerge: true } ); state.adcode = prev.adcode; state.level = prev.level; }

如果还想还原用户进入下一级之前的缩放和中心点,把它们一起压进prev,回退时赋给zoom和center即可。这一步不做也不算错,但答辩演示时点进去再返回、地图位置跳回原点,看起来会有点业余。

4.3 散点与飞线:共用一套 geo 坐标系

只画行政区着色,信息量有限;加业务点位(门店、基站、站点)和连线,才是大数据可视化大屏常见的样子。点位和连线要和地图共用同一套投影,否则缩放时点会飘。做法是把map系列换成geo组件,其余系列用geoIndex绑上去:

const option = { geo: { map: '100000', roam: true, scaleLimit: { min: 0.8, max: 6 }, itemStyle: { areaColor: '#f4f8fd', borderColor: '#9dbcd9' }, }, series: [ // 地图系列绑定到 geo 组件,与散点共享投影 { type: 'map', geoIndex: 0, data: regionValues }, { type: 'effectScatter', coordinateSystem: 'geo', geoIndex: 0, symbolSize: 8, rippleEffect: { scale: 3, brushType: 'stroke' }, data: points, // [{ name, value: [lng, lat, weight] }] }, { type: 'lines', coordinateSystem: 'geo', geoIndex: 0, effect: { show: true, period: 4, trailLength: 0.2, symbolSize: 5 }, lineStyle: { width: 1, opacity: 0.6, curveness: 0.25 }, data: lines, // [{ coords: [[lng, lat], [lng, lat]] }] }, ], };

coordinateSystem: 'geo'加geoIndex: 0这两句不能省,少了任何一个,散点会被当成普通直角坐标系数据画在左上角。value数组里第三个元素是权重,可以映射成符号大小或颜色,业务上通常用来表示单量、人数这类强度。飞线的curveness控制弧度,0 是直线,0.2 到 0.3 之间比较自然,太大就像弹簧。

4.4 渲染与交互优化:大屏场景下的几个开关

大屏容器尺寸变化比普通页面频繁(分屏、全屏切换、侧栏折叠都会触发),用window.resize监听会漏掉容器自身的变化,换成ResizeObserver更准:

// 监听容器自身尺寸变化,比 window.resize 可靠 const ro = new ResizeObserver(() => chart.resize()); ro.observe(document.getElementById('map-container')); // 组件销毁时记得断开,否则会持有 DOM 引用 // ro.disconnect();

另外几个开关值得提前设好:animationDurationUpdate控制在 300 到 500 毫秒之间,太长会让下钻显得迟钝,太短会显得生硬;点位数量上千时给散点系列加progressive: 400和progressiveThreshold: 1000,分批渲染避免首帧卡死;渲染器选 canvas,SVG 在几千条路径的地图上性能会明显吃紧。地名标签在市级以下容易重叠,与其硬调labelLayout,不如默认关掉、只在emphasis时显示,交互成本更低。

5. 避坑与排查:地图白屏、层级错乱、数据对不上的 5 个高频问题

这套结构跑通之后,剩下的时间基本都花在排查上。下面五条是按出现频率排的,每条都按「看到什么现象、根子在哪、怎么处理」写,遇到问题时可以照着顺序对一遍。

5.1 整块白屏,控制台却没有任何报错

现象:页面打开只有一个空白区域,连边框都没有,Network 面板里 GeoJSON 已经 200,控制台干干净净。

原因:三个地方最常见。一是容器高度为 0,大屏布局里父级用了 flex 却没给子级高度,ECharts 初始化时拿到的高度就是 0,画了等于没画。二是地图没注册就setOption,map名在注册表里查不到,ECharts 会静默跳过这个系列。三是map名类型不匹配,注册用字符串、配置写数字,看着一样,实际是两个 key。

解决:按顺序查三下。先console.log(chart.getHeight()),是 0 就去改 CSS,给容器一个显式高度或min-height。再console.log(echarts.getMap(mapName)),返回undefined说明没注册成功,回去检查ensureMap的调用链和动态import的路径。最后核对map名的类型,统一用String(adcode)能一劳永逸。

5.2 边界画出来了,颜色却一片素

现象:地图轮廓、省界都正常,鼠标悬停也有高亮,但所有区域都是同一个底色,图例摆在那儿没有任何分层。

原因:series.data的name和 GeoJSON 里的properties.name对不上,这是第一嫌疑。其次是value给成了字符串,visualMap拿不到数值就没法映射。还有一种容易被忽略:visualMap的min/max写死了,切到别的层级后数据全部超出区间,被压成同一个颜色。

解决:把两边名称各打印十个对比,差异通常是空格、全角字符、行政后缀。用第 3 章那套归一化索引来匹配,匹配成功再取 adcode。value统一Number()转一遍,拿不到数就给null,ECharts 会把这类区域按areaColor处理,比给 0 更诚实。色阶区间一律用当前层数据现算,别图省事写死。

5.3 下钻之后,上一级的数据还留在图上

现象:从全国点进某个省,市级边界出来了,但全国的省份色块还叠在下面,颜色最深的那几个省糊成一片。

原因:setOption默认是合并模式,series 按下标合并,你没有覆盖到的字段会原样保留。上一级的data数组长度和新地图不一致,多出来的项就留在那儿了。

解决:换图时统一加{ notMerge: true },让整份配置被替换。代价是 option 必须完整,tooltip、visualMap、series都要重新给,所以 option 工厂不能只返回半个对象。另一个替代方案是每次都构造数量一致的 series 并显式给空数组,但那样代码里到处是「补空位」的痕迹,不如notMerge干净。

5.4 点到空白区域也触发了下钻

现象:鼠标点在海洋、点在地图外的留白处、点在两个省之间的缝隙,都会莫名其妙进入某个省,有时候还跳到完全不相干的地方。

原因:地图系列的click事件在背景区域同样会触发,params.name可能是空字符串、undefined,或者上一个悬停区域的残留值。不判断就直接拿去查索引,命中什么全看运气。

解决:事件回调第一行就拦住空名称,if (!params.name) return;。第二道防线是索引查找,nameIndex.get(...)返回undefined就不下钻,这样即使名称是脏数据也不会误跳。如果做过名称归一化,还要注意归一化后可能撞名(比如两个不同层级同名),索引里存数组并带上层级字段,按当前state.level过滤一次更稳妥。

5.5 来回切十几次之后,页面越来越卡

现象:功能都正常,但连续下钻、回退十几次后,动画开始掉帧,滚轮缩放有明显延迟,任务管理器里内存一路往上走。

原因:多半是事件重复绑定。把chart.on('click', ...)写在某个会重复执行的初始化函数里,每进一次页面就多一个监听器,一次点击触发五遍下钻。其次是地图重复注册,每次下钻都调一次registerMap同名覆盖,旧对象不一定被及时回收。还有ResizeObserver或者window.resize监听在组件卸载时没解绑。

解决:事件绑定放在组件挂载阶段,且只执行一次,能不能下钻交给state判断,不要靠重复绑新回调。注册前先查缓存,有了就直接返回。所有监听器在onUnmounted或beforeDestroy里成对解绑,ro.disconnect()、chart.dispose()一个都别落下。排查时可以在回调里console.count(),数字涨得比点击次数快,就说明绑重了。

6. 验收与答辩:用一张检查表证明这套地图真的能跑

功能写完不等于能交。地图这类交互密集的模块,最怕演示时手一抖点快了就崩,所以验收环节我一般会拉一张表,逐项过一遍,过不了的当场记下来改,而不是等到答辩前一天才发现问题。

验收项操作方法通过标准
全国图渲染冷启动打开首屏省级边界完整无缺块,两秒内出图
数据着色切三组不同量级的数据颜色分层清晰,图例区间与最大值对应
下钻连续点击任意省份边界切换为市级,面包屑同步更新
回退点面包屑回全国数据、缩放、图例恢复进入前状态
连点测试快速双击、连续切换十次无残留、无重复下钻、无卡死
缺数据删掉某个省的指标数据该区域保持底色,不报错、不影响其它省
自适应拖动窗口改变容器宽高地图等比缩放,不变形、不溢出

缺数据那一项特别值得单独验。真实业务里总会有几个区域暂时没有指标,如果代码里默认每条数据都存在,data.find()拿到undefined再取属性就会抛错,整张图直接黑掉。验收时手动删掉一条数据跑一遍,能省掉上线后的很多解释。

答辩演示建议按「总—分—回」三步走,控制在两分钟内:先停在国图,讲清楚指标口径和数据来源;再挑一个数据差异最明显的省下钻,让评委看到市级粒度和颜色分层的变化;最后点面包屑返回,特意强调「状态是可恢复的,不是重新加载页面」。这三步走完,评委对方案的完整度基本就有判断了,剩下的时间留给他们提问。如果时间允许,再补一个「快速连点十次」的压力演示,比讲十句「做了性能优化」都有说服力。

至于我自己,早些年交课程设计时就吃过亏:功能全做完了,演示当天手快连点了两下,页面直接白屏,只能现场刷新重来。从那以后我每次交付地图类项目,都强制走一遍上面这张表,尤其是连点和缺数据两项,一个都不能跳。这套地图结构本身不复杂,难的是把边界情况想在前面。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询