做数据大屏时ECharts自带地图不够用?用GeoJSON自定义绘制城市地图完整教程含常见问题解决方案
昨天有个朋友找我吐槽,说公司要做数据大屏,领导指名要某市的行政区划地图,结果ECharts自带的china.js地图里压根没有这个城市的详细边界。那一刻我才意识到,很多做数据可视化的伙伴都卡在这一步——想要什么地图,但ECharts自带的那套地图集根本覆盖不全。
别急,这事儿其实没那么难。今天咱们就掰开揉碎了讲清楚,怎么用GeoJSON自定义绘制城市地图,顺手把坑都给你填上。
为什么ECharts自带的地图总是不够用
先说个实在话,ECharts内置的地图数据来源主要是geojson china这个项目,里面覆盖的是中国省级和主要城市的粗略边界。你去翻一下echarts/map/json目录,能看到的城市大概就那么几十来个:北京、上海、广州、深圳、杭州……
但如果你要展示的是:
- 某地级市的下辖区县地图(比如苏州市下辖的9个区)
- 某个县城甚至某个街道的边界
- 某个特定省份的细分区域(比如新疆的兵团师团)
- 非中国大陆的行政区划
那你就会发现,自带的地图库基本帮不上忙。之前我一个做物流大屏的朋友,要展示浙江省下辖30个县的配送覆盖率,打开ECharts文档一看,只有浙江全省的轮廓,里面细分完全不存在。
GeoJSON是什么,一句话讲清楚
GeoJSON是一种基于JSON格式的地理数据交换标准,RFC 7946就是它的官方规范。简单说,它就是用代码来描述一个地方的”形状”——某个城市、某个区、某条河流的边界坐标长什么样。
一个GeoJSON文件通常长这样:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "上城区",
"adcode": "330102"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[120.15, 30.24],
[120.18, 30.25],
[120.20, 30.22],
[120.15, 30.24]
]
]
}
}
]
}
type字段告诉你这是什么类型的地理对象,properties里可以存任意你需要的属性(比如名称、编码),geometry里的coordinates就是实际画地图用的坐标数据。一个多边形(Polygon)的坐标是[[经度1,纬度1],[经度2,纬度2],...],坐标点首尾相接就围成一个区域。
你要是自己手搓GeoJSON,那基本是在找虐。好在有现成的工具可以下载。
从哪儿搞到靠谱的GeoJSON数据
1. DataV.GeoAtlas(强烈推荐)
这是阿里云开源的一个地图边界数据工具,官网在这里,选择你想要的省市区县,可以直接下载GeoJSON格式的数据。数据质量靠谱,坐标系是标准的WGS84,拿来就能用。
操作很简单:在页面上选好区域(比如”浙江省-杭州市-上城区”),点击下载按钮,就能拿到一个.json文件。建议下载后把文件放到项目的public或assets目录下,方便前端直接引用。
2. 国家基础地理信息中心
如果做政府项目或者需要更权威的边界数据,可以去国家基础地理信息中心申请下载。这个数据更精确,但手续稍微麻烦一些,适合对精度要求很高的场景。
3. Natural Earth
如果你想画世界地图或者外国地图,Natural Earth是最好的开源数据来源,提供1:10m、1:50m、1:110m三种精度,直接下载GeoJSON或Shapefile格式即可。
4. 自己从矢量地图导出
有些GIS从业者手头有ArcGIS或者QGIS,可以从这些软件里导出GeoJSON。如果你用的是Shapefile格式,可以用ogr2ogr命令行工具直接转换:
ogr2ogr -f GeoJSON out.json input.shp
在ECharts里用GeoJSON绘制自定义地图的完整流程
先把最简单的情况搞定——在ECharts里渲染一个城市区县地图。
第一步:准备GeoJSON文件
假设你已经从DataV下载了”杭州市下辖区县”的GeoJSON文件,保存为hangzhou_districts.geojson,放在项目的public/geojson/目录下。
第二步:用fetch加载GeoJSON并注册到ECharts
import * as echarts from 'echarts'
async function initMap() {
// 加载GeoJSON数据
const response = await fetch('/geojson/hangzhou_districts.geojson')
const geoJson = await response.json()
// 注册到ECharts
echarts.registerMap('hangzhou', geoJson)
// 初始化图表
const chart = echarts.init(document.getElementById('mapContainer'))
// 模拟一些数据
const data = [
{ name: '上城区', value: 1200 },
{ name: '拱墅区', value: 980 },
{ name: '西湖区', value: 1500 },
{ name: '滨江区', value: 870 },
{ name: '萧山区', value: 2100 },
{ name: '余杭区', value: 1800 },
{ name: '临平区', value: 1100 },
{ name: '钱塘区', value: 950 },
{ name: '富阳区', value: 720 },
{ name: '临安区', value: 650 },
{ name: '桐庐县', value: 430 },
{ name: '淳安县', value: 310 },
{ name: '建德市', value: 560 }
]
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}<br/>数值:{c}'
},
visualMap: {
min: 0,
max: 2500,
left: 'left',
top: 'bottom',
text: ['高', '低'],
calculable: true,
inRange: {
color: ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']
}
},
series: [
{
name: '各区数据',
type: 'map',
map: 'hangzhou', // 这里对应 registerMap 注册的名字
roam: true,
zoom: 1.2,
label: {
show: true,
fontSize: 10,
color: '#333'
},
emphasis: {
label: {
fontSize: 12,
color: '#fff'
},
itemStyle: {
areaColor: '#ffd700',
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.5)'
}
},
select: {
disabled: true
},
data: data
}
]
}
chart.setOption(option)
return chart
}
initMap()
这段代码跑起来之后,你就能看到一张杭州市下辖区县的色块地图,每个区根据value值着色。
第三步:处理异步加载的兼容写法
如果你的项目是在Vue/React等框架里,建议用组件挂载完成后加载:
Vue 3示例:
<template>
<div id="mapChart" style="width: 100%; height: 500px;"></div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
const chartInstance = ref(null)
onMounted(async () => {
const response = await fetch('/geojson/hangzhou_districts.geojson')
const geoJson = await response.json()
echarts.registerMap('hangzhou', geoJson)
chartInstance.value = echarts.init(document.getElementById('mapChart'))
chartInstance.value.setOption({
series: [{
type: 'map',
map: 'hangzhou',
roam: true,
emphasis: { label: { show: true } }
}]
})
})
</script>
React示例:
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
export default function MapChart() {
const chartRef = useRef(null)
useEffect(() => {
fetch('/geojson/hangzhou_districts.geojson')
.then(res => res.json())
.then(geoJson => {
echarts.registerMap('hangzhou', geoJson)
const chart = echarts.init(chartRef.current)
chart.setOption({
series: [{
type: 'map',
map: 'hangzhou',
roam: true
}]
})
return () => chart.dispose()
})
}, [])
return <div ref={chartRef} style={{ width: '100%', height: '500px' }} />
}
常见问题和解决方案
问题一:地图上区域显示为空白,什么都看不到
这是最常见的问题,几乎每个新手都会碰到。原因一般有以下几种:
原因1:坐标系的投影问题
DataV下载的GeoJSON数据通常是CGCS2000坐标系(和WGS84几乎一样),但有些老旧的GeoJSON数据可能是GCJ02(国测局加密坐标)。ECharts默认期望的是WGS84坐标,如果你的GeoJSON是GCJ02的,地图会偏移甚至显示异常。
验证方法:打开GeoJSON文件,随便找一个坐标点,用坐标拾取工具对比一下,看看坐标值是否在合理范围内。
原因2:注册名和map属性不对应
// 注册时用的是 'myCustomMap'
echarts.registerMap('myCustomMap', geoJson)
// 但series里写的是 'hangzhou'
{ type: 'map', map: 'hangzhou' } // 错了!要改成 'myCustomMap'
原因3:没有正确处理features数组的格式
有些GeoJSON文件的结构不是标准的FeatureCollection,比如只有features数组没有外层包装,或者坐标顺序是[纬度, 经度]而不是标准的[经度, 纬度]。
ECharts对GeoJSON的解析比较严格,建议用GeoJSONLint检查一下格式是否正确。
问题二:鼠标悬停没有高亮效果,或者高亮区域不对
这个一般是因为properties.name和你的数据name对不上。ECharts匹配区域是靠properties.name字段来识别的,如果你的GeoJSON里每个Feature的properties.name叫的是”上城”,但你的数据里写的是”上城区”,那就匹配不上。
解决方案:在渲染前打印一下GeoJSON里的名称列表,确保一一对应。
// 调试:查看所有区域的名称
const names = geoJson.features.map(f => f.properties.name)
console.log(names)
// 打印出来之后,对比你的data数组里的name字段
如果发现名字不一致,可以做个映射表来处理:
// 建立名称映射关系
const nameMap = {
'上城': '上城区',
'拱墅': '拱墅区',
'西湖': '西湖区'
// ...以此类推
}
const data = geoJson.features.map(feature => ({
name: nameMap[feature.properties.name] || feature.properties.name,
value: Math.floor(Math.random() * 2000)
}))
问题三:地图加载很慢,GeoJSON文件太大
有些区县级别的GeoJSON文件动辄几MB,如果直接在页面里fetch会很慢。优化方案有几个:
方案1:用工具压缩GeoJSON
有个叫touch- simplifier的工具,可以去掉多余的坐标点来减小文件体积。也可以用在线工具MapShaper导入GeoJSON后选择导出简化版本。
方案2:按需加载(懒加载)
不要一开始就把所有数据都加载进来,等用户真的切换到这张地图时才去请求:
let mapChart = null
let isLoaded = false
async function loadMapIfNeeded() {
if (isLoaded) return mapChart
const response = await fetch('/geojson/hangzhou_districts.geojson')
const geoJson = await response.json()
echarts.registerMap('hangzhou', geoJson)
mapChart = echarts.init(document.getElementById('mapContainer'))
mapChart.setOption({ /* ...配置... */ })
isLoaded = true
return mapChart
}
// 在用户点击"查看地图"按钮时调用
button.addEventListener('click', loadMapIfNeeded)
方案3:服务端缓存
如果是生产环境,建议把GeoJSON文件放到CDN或者用Nginx配好缓存策略,别每次都从零加载。
问题四:地图显示方向反了(上下颠倒)
这个一般是因为GeoJSON里坐标是[纬度, 经度]顺序,而ECharts期望的是[经度, 纬度]。有些国外数据源(比如Natural Earth的部分数据)会是这样。
解决方案:遍历所有坐标点,把每个点的x和y交换:
function fixCoordinateOrder(geoJson) {
function swapCoords(coordinates) {
if (typeof coordinates[0] === 'number') {
// 单个点
return [coordinates[1], coordinates[0]]
}
// 数组递归处理
return coordinates.map(swapCoords)
}
function processFeature(feature) {
feature.geometry.coordinates = swapCoords(feature.geometry.coordinates)
return feature
}
if (geoJson.type === 'FeatureCollection') {
geoJson.features = geoJson.features.map(processFeature)
} else if (geoJson.type === 'Feature') {
processFeature(geoJson)
}
return geoJson
}
// 使用
const fixedGeoJson = fixCoordinateOrder(geoJson)
echarts.registerMap('hangzhou', fixedGeoJson)
问题五:多边形内部有洞(湖泊、岛屿),显示不正常
标准的GeoJSON Polygon支持带洞的多边形,内环坐标的顺序要和外环相反(顺时针 vs 逆时针)。但ECharts对这种复杂多边形的渲染有时会有问题。
如果你发现地图上的湖泊区域显示异常,可以尝试:
- 在QGIS等GIS软件里把带洞的多边形拆分成纯多边形
- 或者用turf.js库的
simplify和clean函数处理一下:
import * as turf from '@turf/turf'
// 简化并清理GeoJSON
const simplified = turf.simplify(geoJson, {
tolerance: 0.001,
mutate: true
})
echarts.registerMap('hangzhou', simplified)
问题六:地图和柱状图/折线图混用时,地图位置对不齐
在数据大屏里,地图常常不是单独占满整个屏幕的,旁边会搭配柱状图、饼图等。如果地图的roam(缩放平移)开着,用户拖动地图后,地图的视觉位置会变,但坐标系的映射关系不会自动更新。
解决方案:监听dataZoom或brushEnd事件,在用户操作后做适当的对齐处理:
chart.on('click', function(params) {
// 点击地图某个区域后的处理逻辑
console.log('点击了:', params.name)
})
chart.on('highlight', function(params) {
// 悬停高亮时同步更新旁边的数据面板
updateSidePanel(params.data)
})
一个完整的实战案例:区县热力地图
我们把前面的内容串起来,做一个稍微完整一点的大屏地图组件。
<template>
<div class="map-dashboard">
<div class="map-container" ref="chartRef"></div>
<div class="stats-panel">
<div class="stat-item" v-for="item in statsData" :key="item.name">
<span class="stat-name">{{ item.name }}</span>
<span class="stat-value">{{ item.value.toLocaleString() }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as echarts from 'echarts'
const chartRef = ref(null)
let chart = null
onMounted(async () => {
// 加载GeoJSON
const response = await fetch('/geojson/hangzhou_districts.geojson')
const geoJson = await response.json()
echarts.registerMap('hangzhou', geoJson)
chart = echarts.init(chartRef.value)
chart.setOption({
backgroundColor: '#0a1628',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(10, 22, 40, 0.9)',
borderColor: '#1a3a5c',
textStyle: { color: '#fff' },
formatter: (params) => {
if (params.componentType === 'series') {
return `${params.name}<br/>数据量:${params.value?.toLocaleString() ?? '暂无数据'}`
}
return params.name
}
},
visualMap: {
show: true,
min: 0,
max: 3000,
left: 'left',
top: 'bottom',
text: ['高', '低'],
textStyle: { color: '#8899aa' },
calculable: true,
inRange: {
color: [
'rgba(10, 22, 40, 0.3)',
'#0d3b66',
'#1a6fa0',
'#2d9cdb',
'#5ec9f0',
'#a8e6ff'
]
},
outOfRange: {
color: '#1a3a5c'
}
},
series: [
{
name: '各区数据分布',
type: 'map',
map: 'hangzhou',
roam: true,
zoom: 1.1,
center: [120.15, 30.25],
label: {
show: true,
fontSize: 9,
color: 'rgba(255,255,255,0.7)',
formatter: (params) => {
return params.name.replace(/区|县|市/, '')
}
},
emphasis: {
label: {
show: true,
fontSize: 11,
color: '#fff',
fontWeight: 'bold'
},
itemStyle: {
areaColor: '#ffd700',
shadowBlur: 15,
shadowColor: 'rgba(255, 215, 0, 0.5)'
}
},
select: { disabled: true },
data: [
{ name: '上城区', value: 1230 },
{ name: '拱墅区', value: 980 },
{ name: '西湖区', value: 1850 },
{ name: '滨江区', value: 2100 },
{ name: '萧山区', value: 2450 },
{ name: '余杭区', value: 2200 },
{ name: '临平区', value: 1100 },
{ name: '钱塘区', value: 950 },
{ name: '富阳区', value: 720 },
{ name: '临安区', value: 680 },
{ name: '桐庐县', value: 430 },
{ name: '淳安县', value: 310 },
{ name: '建德市', value: 560 }
]
}
]
})
// 响应式适配
window.addEventListener('resize', () => chart?.resize())
})
onBeforeUnmount(() => {
window.removeEventListener('resize', () => chart?.resize())
chart?.dispose()
})
</script>
<style scoped>
.map-dashboard {
display: flex;
gap: 20px;
height: 100%;
}
.map-container {
flex: 1;
height: 500px;
background: #0a1628;
border-radius: 8px;
}
.stats-panel {
width: 180px;
background: rgba(26, 58, 92, 0.3);
border-radius: 8px;
padding: 15px;
color: #8899aa;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(26, 58, 92, 0.5);
}
.stat-value {
color: #5ec9f0;
font-weight: bold;
}
</style>
这个组件跑起来就是一个深色风格的数据大屏地图,左边是交互式地图,右边是数据面板。你可以根据实际项目需要调整配色和布局。
几个能提升体验的小技巧
给地图加边框描边,让各个区域之间分得更清楚:
itemStyle: {
borderColor: '#1a3a5c',
borderWidth: 1,
areaColor: '#0d2a45'
}
用series叠加层实现点击跳转,比如点击某个区跳转到该区的详情页:
chart.on('click', function(params) {
if (params.componentType === 'series') {
// 跳转到详情页,params.name 就是区域名称
router.push(`/district/${params.name}`)
}
})
动态切换地图层级,从省→市→区县三级联动:
const mapLevels = {
'浙江': 'zhejiang',
'杭州': 'hangzhou',
'上城区': 'shangcheng'
}
function switchMap(regionName) {
const mapName = mapLevels[regionName]
if (mapName) {
// 重新加载并注册对应层级的GeoJSON
fetch(`/geojson/${mapName}.geojson`)
.then(res => res.json())
.then(geoJson => {
echarts.registerMap(mapName, geoJson)
chart.setOption({ series: [{ map: mapName }] })
})
}
}
总结
用GeoJSON自定义ECharts地图,核心就三步:拿数据、注册地图、配置series。最难的部分其实是数据源——只要拿到靠谱的GeoJSON文件,后面的事情ECharts都帮你做好了。
踩过的坑我基本都列在上面了,大部分问题都出在数据格式和坐标系上。建议你拿到GeoJSON文件后,先打印一下features数组看看里面的properties和geometry结构对不对,然后再注册到ECharts里,这样能省掉一半的调试时间。
做数据大屏的时候,地图往往是最显眼的部分。花点时间把它弄好,整个大屏的质感会提升很多。还有什么不清楚的,随时可以来问。
