简介:在县级行政区划可视化场景中,ECharts 地图的数据准备与两种实现方式常让开发者反复尝试。压缩包面向前端工程师、数据分析师及地理信息爱好者,整理了三百四十四个县区的地图数据,并附带两种可运行示例:一种直接调用内置地图数据快速出图,另一种通过自定义地图加载边界信息,覆盖从引入库、初始化容器到配置渲染的完整流程。包内共三百九十一个文件,以三百八十个县区数据文件为主,配合脚本、示例页面及一份针对地图文件无法下载问题的排错文档,整体约二点八二兆,结构轻量、便于对照学习。目前已有 1552 人学习下载,适合正在搭建地域数据看板、需要快速集成县级地图的开发者。运行两种示例,可清晰对比不同做法的适用场景,理解边界数据与 ECharts 配置项的对应关系,也能规避地图下载失败、路径配置等常见坑点,直接复用到实际项目中。
1. eCharts地图344个县区:两种方式demo背后的数据链路和真正难点
做区域数据可视化时,常见的是省级或者市级轮廓图,至于直接铺开几百个县区的地图,很多人是在拿到GeoJSON那一刻才意识到问题:文件体积大、名称对不上、低端机器上转圈半天。我这次处理的是一个覆盖344个县区的业务地图,同时做了两种加载方式的demo:一种是全量注册一次出图,另一种是按需注册支持点选下钻。这篇笔记把数据预处理、两种demo的完整实现以及排过的坑完整记录下来,适合正在做县域级数据可视化、需要复用344县区地图的开发者。
2. 先解决数据:344个县区的GeoJSON合并、压缩与校验
2.1 GeoJSON结构里藏着的渲染底线
eCharts地图本质上吃的是GeoJSON,不是PNG位图。一个完整的344县区GeoJSON通常是一个FeatureCollection,里面的每个Feature代表一个县区边界,geometry字段描述它的轮廓,properties字段里存名称、编码等信息。eCharts的registerMap要求传入的就是这种标准FeatureCollection,如果数据源本身是零散的单个县区GeoJSON,第一步就是把它们合并成一个整体。
合并前先确认坐标系。市面上常见的县区边界数据有的是GCJ-02(火星坐标系),有的是WGS84。若拿到的是GCJ-02且直接用在高德外的标准地图上,会出现几百米到几公里的偏移。我在合并前先用一个已知坐标点做了抽样比对,确认当前数据源是WGS84才继续往下走。这个问题如果不先查,后面出图时地图位置偏到窗口外,排查起来极浪费时间。
还要检查properties里的名称字段统一性。有些区县下辖的街道办、管委会也会出现在GeoJSON里,而业务数据使用的一般是行政区划名。合并前需要整理一份名称对照表,把GeoJSON里的名称与业务库里的名称对齐,否则第三步注册地图后,series.data里的name匹配不上,对应区域就会静默空白。
2.2 合并脚本:用Python把344个县区拼成一个FeatureCollection
我现在处理的项目是把某个省下辖的344个县区边界合并为一张地图。数据源是每个县一个独立GeoJSON文件,合并逻辑并不复杂:读取所有文件,把features数组拼接成一个大的FeatureCollection。实际操作时有很多细节需要注意,下面这个脚本是我整理后的可运行版本,假设输入目录geo_json里存放以县区编码命名的文件:
import json import os INPUT_DIR = 'geo_json' OUTPUT_FILE = 'merged_344.json' merged_features = [] for filename in os.listdir(INPUT_DIR): if not filename.endswith('.json'): continue filepath = os.path.join(INPUT_DIR, filename) with open(filepath, 'r', encoding='utf-8') as f: data = json.load(f) # 校验数据源本身是FeatureCollection if data.get('type') != 'FeatureCollection': print(f'{filename} 不是标准的FeatureCollection,跳过') continue features = data.get('features', []) # 有些县区文件里可能包含多个feature,需要全部取出 for feature in features: props = feature.get('properties', {}) name = props.get('name', '') if not name: # 有的数据源把名称放在adcode属性里 name = props.get('adcode', filename.replace('.json', '')) feature['properties']['name'] = name merged_features.append(feature) output = { 'type': 'FeatureCollection', 'features': merged_features } with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: json.dump(output, f, ensure_ascii=False) print(f'合并完成,共 {len(merged_features)} 个feature')这段脚本做了三件关键事情:一是过滤掉非GeoJSON文件;二是统一提取名称字段,因为不同来源的GeoJSON有的用name、有的用adcode;三是对缺失名称的县区自动用文件名补位,保证后续注册时每个feature都有可识别的名称。合并完成后输出一个包含所有县区的FeatureCollection文件,可直接用于eCharts的registerMap。
2.3 压缩与减面:让地图文件从30MB降到3MB
合并后的GeoJSON往往体积很大。我实际拿到的一份344县区数据大约28MB,浏览器加载后eCharts渲染时明显卡顿。压缩有两个方向:一是用gzip或zlib压缩传输,但这只是减小网络流量,前端解析后体积不变;二是做减面处理,即使用简化几何算法去掉冗余坐标点。
减面操作建议用地表精度阈值来简化。县级边界在1:100万比例尺下,保留精度到百米的坐标点就够了。以我用的工具为例:
# 使用mapshaper进行减面,保留5%的精度 mapshaper merged_344.json -simplify 5% -o format=geojson simplify_344.jsonmapshaper是命令行工具,npm安装后可以直接用。减面后的文件从28MB降到了2.7MB,渲染速度提升明显。简化后再在eCharts里全量注册,344个县区的边界绘制就流畅了很多。需要注意的是减面精度不能设置太低,否则县区边界会变得歪歪扭扭,缩放时尤其明显。实践下来,县级地图保留5%到10%的精度,观感损失不大。减面后记得重新校验一下文件,确保没有产生空的坐标数组。
2.4 校验脚本:检查重复名称和非法坐标
在合并和减面之后,不要急着放进前端,先用一段Python做完整校验。我遇到过重复名称导致注册后部分地区不显示的情况,也遇到过坐标数组为空导致控制台报错的情况。下面这段校验脚本会在每次处理数据后跑一遍:
import json with open('merged_344.json', 'r', encoding='utf-8') as f: data = json.load(f) features = data.get('features', []) names = [] error_count = 0 for idx, feat in enumerate(features): props = feat.get('properties', {}) geom = feat.get('geometry', {}) geom_type = geom.get('type', '') name = props.get('name', f'feature_{idx}') names.append(name) # 校验geometry类型 if geom_type not in ('Polygon', 'MultiPolygon'): print(f'[{name}] 意外的geometry类型: {geom_type}') error_count += 1 continue # 校验坐标数组非空 coords = geom.get('coordinates', []) if not coords: print(f'[{name}] 坐标为空') error_count += 1 # 校验是否包含字符串坐标(某些数据源会导出字符串数字) def check_recursive(lst, depth=0): for item in lst: if isinstance(item, list): if check_recursive(item, depth + 1): return True elif isinstance(item, str): return True return False if check_recursive(coords): print(f'[{name}] 坐标中存在字符串,需要转换') # 查重 dup_set = {n for n in names if names.count(n) > 1} if dup_set: print(f'存在重复名称: {dup_set}') if error_count == 0: print('校验通过')这段脚本会找出三类问题:geometry类型异常、坐标为空、坐标中包含字符串。坐标字符串化的问题比较隐蔽,某些工具导出时会把数字变成字符串,eCharts解析时会报类型错误。我一般每次修改数据后都跑一遍,避免把脏数据带到前端再排查。
3. 两种demo方式:全量注册与按需注册的完整实现
3.1 方式一:一次性注册344县区,适合数据看板首屏直出
如果业务场景是所有县区数据需要同时出现在一张地图上,比如全区域总览大屏,那么全量注册是最直接的方式。把减面后的344县区GeoJSON一次性registerMap,再通过series.type为map的配置项加载业务数据。下面是Vue环境中的完整逻辑:
<template> <div ref="mapContainer" class="map-container"></div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue'; import * as echarts from 'echarts'; import regionGeoJson from '@/assets/geo/simplify_344.json'; const mapContainer = ref(null); let chart = null; onMounted(() => { // 注册344县区地图 echarts.registerMap('region344', regionGeoJson); chart = echarts.init(mapContainer.value); // 业务数据:数组元素必须与GeoJSON里properties.name对应 const businessData = [ { name: '甲县', value: 238 }, { name: '乙县', value: 105 }, // ...344条数据 ]; chart.setOption({ tooltip: { trigger: 'item', formatter: '{b}<br/>指标值: {c}' }, series: [{ type: 'map', map: 'region344', roam: true, label: { show: false }, itemStyle: { borderColor: '#ccc', areaColor: '#f5f5f5' }, emphasis: { label: { show: true }, itemStyle: { areaColor: '#409eff' } }, data: businessData }] }); // 固定视角中心,避免全屏时地图偏向某一边 chart.setOption({ geo: { center: [经度, 纬度], zoom: 1.2 } }); }); onBeforeUnmount(() => { chart && chart.dispose(); }); </script> <style scoped> .map-container { width: 100%; height: 600px; } </style>这段配置里有几个细节值得展开。首先,registerMap调用一次即可,之后多次setOption不需要重复注册。其次,series.data里的name必须是GeoJSON中properties.name的精确值,少一个字或多了空格都会让对应区域无法着色。第三,roam设为true后用户可以自由缩放、拖拽,但如果地图本身有344个县区,缩放到全屏时边界线非常密集,label最好关闭,只在鼠标悬停的emphasis状态下显示。最后,geo.center和zoom不是必须的,但如果整张地图初始显示时偏离屏幕中心,可以通过这两个参数校正。
3.2 方式二:按需加载县区级GeoJSON,适合下钻型业务
另一种场景是用户先看到344县区的大致轮廓,点击某个具体县区后,再加载该县区的更详细地图并展示该县的数据详情。这种方式适合业务上需要县区级下钻的页面,不必一开始就把全量边界数据塞给浏览器。
按需加载的做法是:先用一份轻量的简化GeoJSON注册总览地图,只画344个县的粗略边界;点击某个县时,再动态fetch该县的详细GeoJSON并重新注册。下面是实现思路:
import * as echarts from 'echarts'; let chart = null; let currentMapName = 'overview'; // 总览地图注册 async function initOverview() { const overviewGeo = await fetch('/geo/overview_344.json').then(res => res.json()); echarts.registerMap('overview', overviewGeo); chart = echarts.init(document.getElementById('map')); chart.setOption({ series: [{ type: 'map', map: 'overview', roam: true, data: overviewData }] }); // 点击县区时下钻 chart.on('click', async (params) => { const countyName = params.name; await drillToCounty(countyName); }); } async function drillToCounty(countyName) { try { const countyGeo = await fetch(`/geo/county/${encodeURIComponent(countyName)}.json`) .then(res => res.json()); // 动态注册该县区的详细地图 echarts.registerMap(`county_${countyName}`, countyGeo); // 切换map,第二个参数true代表notMerge,清掉旧地图数据 chart.setOption({ series: [{ type: 'map', map: `county_${countyName}`, roam: true, data: countyDetailData[countyName] || [] }] }, true); currentMapName = `county_${countyName}`; } catch (err) { console.error('县区地图加载失败:', err); } } // 提供返回总览的方法 function backToOverview() { chart.setOption({ series: [{ type: 'map', map: 'overview', data: overviewData }] }, true); currentMapName = 'overview'; }按需加载的关键是fetch时机的控制。用户点击某个县的瞬间,发起fetch并等待响应,期间最好显示一个loading状态。如果用户在网络慢的条件下连续点击多个县,也需要一个请求队列或防抖机制,避免后续响应覆盖掉当前激活的前一个县区。我实际在项目里使用的是一个简单的请求令牌:每次点击时生成一个递增的请求ID,只有最新请求的响应才能setOption,旧请求直接丢弃。
另一个要点是notMerge参数。切换地图时,如果第二个参数不传true,eCharts会尝试把新地图和旧地图的数据merge,结果往往是旧的边界轮廓还残留在画布上。加上true后,每次切换都是重新绘制,干净利落。
3.3 两种方式的取舍:内存、加载速度与交互复杂度
全量注册与按需注册不是互斥的,实际项目里经常混用。全量注册适合县域数量在几百、GeoJSON文件压缩后不超过3MB的场景,内存占用可控,且省去了网络请求的开销;如果数据量更大或者县区数量超过上千,全量注册会导致首屏加载慢、低端设备render时间过长。按需注册则把大文件拆成小文件,首屏只加载总览,下钻到哪个县才加载哪个县的详细数据,体验更轻,但代码复杂度更高,还需要处理缓存、防抖、恢复总览等一系列交互状态。
从真实开发经验来看,我倾向于采用一种折衷方案:总览用简化且gzip过的全量文件,保证首屏秒开;点击县区后,通过fetch加载该县更精细的边界数据,加载完注册并替换地图。这样既避免全量加载的卡顿,又保留了下钻的平滑体验。缓存方面,用一个Map对象存储已经加载过的县区GeoJSON,下次点击直接从缓存读取,不重复请求。
const geoCache = new Map(); async function getCountyGeo(countyName) { if (geoCache.has(countyName)) { return geoCache.get(countyName); } const res = await fetch(`/geo/${countyName}.json`); const data = await res.json(); geoCache.set(countyName, data); return data; }缓存的价值在下钻场景里非常大。用户从总览进入甲县,又从甲县返回总览,再进入甲县时,如果能直接从缓存里取GeoJSON,整个交互过程几乎无等待。同时也让用户感知更好。不过要注意,缓存的数据是原始GeoJSON对象,eCharts注册后不要随意修改这个对象,否则下次使用会碰到意外问题。
4. eCharts地图避坑指南:344个县区实战中的5个高频坑
4.1 县区名称与业务数据对不上,区域静默不显示
现象:地图渲染成功,344个县基本轮廓清晰,但某些县区显示为默认灰色,业务数据没有铺上去,控制台也没有明显报错。
原因:GeoJSON中properties.name与series.data里填的name不是完全一致。常见差异包括全角半角括号、省市前缀、名字缺字。GeoJSON里写的是“甲乙县”,你的数据里写的是“甲乙”,或者反过来。eCharts匹配name时做的是精确匹配,任何差异都会导致该区域不着色。
解决:维护一份名称映射表,在数据进入图表前统一转换。我的做法是在前端做一个nameMap对象,value为业务数据名称,key为GeoJSON中的名称。setOption前遍历数据,用映射表完成对齐。更稳妥的做法是在数据侧用县区编码而非名称作为主键,eCharts的series.data用name展示,但可以在GeoJSON里把properties.name设置为“编码+名字”的形式,业务数据也通过编码来匹配,彻底绕开中文名差异。
4.2 地图坐标偏移,整张图跑到屏幕外
现象:地图刚渲染出来时不在画布中心,甚至偏离到屏幕外边,需要手动拖拽才能看到区域轮廓。设置roam可用,但初始视角完全不对。
原因:GeoJSON的坐标系不是当前地图组件期望的标准坐标,或者经纬度范围超出预期范围。有些数据源的坐标系是GCJ-02或者缺少坐标转换处理,导致经纬度整体偏移。另一种情况是GeoJSON本身范围非常小或非常大,默认缩放级别下不可见。
解决:在setOption里显式指定geo.center和geo.zoom。方法是用一个已知的全县区中心点作为center,再根据实际可视范围设置zoom。中心点可以用Python脚本计算所有县features的平均坐标:
import json with open('simplify_344.json', 'r', encoding='utf-8') as f: data = json.load(f) all_lons = [] all_lats = [] for feat in data['features']: coords = feat['geometry']['coordinates'] # 遍历所有多边形的所有坐标点 stack = [coords] while stack: item = stack.pop() if isinstance(item, (list, tuple)): if isinstance(item[0], (int, float)): all_lons.append(item[0]) all_lats.append(item[1]) else: stack.extend(item) center = [sum(all_lons) / len(all_lons), sum(all_lats) / len(all_lats)] print('建议center:', center) print('建议zoom: 1.5 ~ 3')算出center后填到geo配置里,一般能解决大部分视角跑偏问题。如果仍然不准,可以用eCharts的dispatchAction手动设置zoom和center,效果更直观。
4.3 切换地图后旧边界残留
现象:在全量注册和按需注册混用的项目里,从总览下钻到某个县后,再返回总览,地图上还残留着上一个县的边界线。或者从甲县直接点击跳到乙县时,甲县的轮廓和乙县的轮廓叠在一起。
原因:这是setOption默认merge逻辑导致的。每调用一次setOption,新的option会和旧option深度合并,旧的series配置没有完全清除。当两个地图的GeoJSON结构不同时,残留的边界要素就会继续存在。
解决:在切换地图的setOption中显式传入第二个参数true,表示notMerge。这样旧option会被完全替换,地图重新生成。我一般在每个地图切换函数里都加上这个参数,统一处理,避免遗漏。
chart.setOption(newOption, true);4.4 全量加载下低端设备卡顿
现象:电脑上测试流程很顺畅,但在配置较低的机器或者浏览器中,打开344县区地图页面要等好几秒才看到画面,缩放时拖拽明显掉帧。
原因:GeoJSON体积偏大,344个县区的精细边界可能包含几十万甚至上百万个坐标点。eCharts渲染这种复杂多边形时,CPU消耗巨大,尤其是在roam开启状态下,缩放手势触发频繁的边界重绘。
解决:核心是减面压缩。把GeoJSON在服务端提前用simplify算法处理,保留5%~10%精度,文件大小和渲染速度能提升一个量级。另外,渲染时关掉不必要的地图label,只在emphasis状态显示;把动画关掉也是常用做法,在大数据量地图上动画反而干扰体验。
series: [{ type: 'map', map: 'region344', animation: false, // 关闭动画,减少渲染压力 label: { show: false }, emphasis: { label: { show: true } } }]4.5 label文字重叠得没法看
现象:地图缩放级别较大时,344个县区的名称全部显示在屏幕上,密集区域文字挤成一团,完全不可读;地图放小时label又互相覆盖,只看到一堆文字遮挡边界。
原因:label默认是每个区域都显示,没有做碰撞检测或者分级显示。344个县级行政区在省级尺度下,文字密度远超浏览器可接受范围。实际上,label每个县都显示,在较密集的区域是必然重叠的。
解决:常规做法是把label.show设为false,只在鼠标悬停或点击时在emphasis状态显示。如果确实需要常驻显示某些重要地区的名字,可以用label.formatter做一个筛选,只显示特定名称列表里的县区;或者采用geo组件与series组合,用独立的label层来控制显示密度。再配合visualMap的透明度分级,让重点区域突出、非重点区域弱化,视觉上会清爽很多。
5. 生产环境里更好用的三个技巧:联动、视觉映射与数据Schema
5.1 点击县区联动外部数据表格
地图不只是展示,还需要配合业务查询。344县区地图最常见的交互是点击某个县,旁边的信息面板显示该县的详细指标。实现上只用监听chart的click事件即可。关键点是事件对象里的name就是系列对应的县区名,直接用这个name去数据源里查找详情。
chart.on('click', (params) => { const name = params.name; const detailData = getCountyDetail(name); detailPanel.value.render(detailData); // 高亮当前点击的县区,便于确认 chart.dispatchAction({ type: 'highlight', seriesIndex: 0, name: name }); });注意click事件会在多次触发时重复绑定,我通常会在init之后统一注册,并在组件销毁时解除绑定,避免页面切换后产生重复请求。
5.2 visualMap区间划分的实用策略
344个县区数值分散度通常很大,直接用连续色带会导致底下的小数值区域颜色极其接近,看不出差异。我一般会结合分位数把数据切成区间,例如[min, p25, p50, p75, max],这样每个县的颜色分布能比较均匀地体现出来。设置visualMap时用pieces区间而不是continuous,可以清晰表达分段语义。
visualMap: { type: 'piecewise', pieces: [ { min: 0, max: 50, label: '0-50' }, { gte: 50, lte: 100, label: '50-100' }, { gte: 100, lte: 200, label: '100-200' }, { gte: 200, label: '200+' } ], left: 20, bottom: 20 }区间划分没有绝对标准,要结合业务场景。如果只是为了展示排名,可以用rank而非原值;如果要观察梯度变化,用分位数更好。生产项目里,我经常会先把数据分布打印出来,再决定区间边界,而不是拍脑袋写死。
5.3 提升首屏感知速度的模拟加载骨架
全量注册方式即使做了GeoJSON压缩,网络传输和解析仍然需要几百毫秒。如果页面是嵌入大屏的,建议做好状态反馈。我习惯在地图初始化前先渲染一个简单的canvas骨架占位,数据加载完成后再填充地图。这个骨架不需要复杂,一个带渐变更明显的loading动画即可。与此同时,把地图初始化的优先级放到requestIdleCallback中,避免阻塞其他关键渲染。
requestIdleCallback(() => { initMap(); });这一层处理虽然不影响最终效果,但用户感知体验会好很多,特别是在仪表盘大屏项目中,地图是首屏核心,不能让它白屏让人干等。从那以后我每次做大数据量地图,都会在数据准备阶段先跑一遍减面和校验,再做加载占位与缓存方案,这套流程走完,后面基本不会和地图打架,希望帮到你。
本文还有配套的精品资源,点击获取