ECharts自定义地图绘制实战教程
写在前面
说实话,ECharts的地图功能说实话一直是个”坑多活重”的领域。官方文档看着挺简单,但真正上手之后,从GeoJSON数据获取到最终地图渲染,中间能卡住你的点太多了。今天这篇,我就把自己踩过的坑、总结的经验,原原本本地分享出来。不管你是刚接触地图可视化,还是已经碰到各种奇奇怪怪的问题,这篇应该都能帮到你。
一、先搞懂GeoJSON是什么
在动手之前,我得先确认一件事——你真的理解GeoJSON吗?不是那种”大概知道是地图数据”的模糊认知,而是真正搞清楚它的结构。
GeoJSON是一种基于JSON格式的地理空间数据交换格式。它描述了几何对象——点、线、面,以及这些对象的集合。对于地图绘制来说,我们最常用的就是Polygon(多边形)和MultiPolygon(多重多边形)。
// 一个简单的GeoJSON示例,代表一个虚构的行政区划
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "示例区域",
"adcode": "110000"
},
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
// 外层环
[
[116.40, 39.90],
[116.50, 39.90],
[116.50, 40.00],
[116.40, 40.00],
[116.40, 39.90]
],
// 内层环(孔洞)
[
[116.42, 39.92],
[116.48, 39.92],
[116.48, 39.98],
[116.42, 39.98],
[116.42, 39.92]
]
]
]
}
}
]
}
看到没有?type字段决定了这个对象是什么类型,geometry里的coordinates才是真正存储坐标数据的地方。对于多边形来说,坐标格式是[[经度, 纬度], [经度, 纬度], ...]这样的嵌套数组。
一个容易忽略的点:GeoJSON标准规定,多边形的外环必须是逆时针方向,内环(孔洞)必须是顺时针方向。不过好在大多数数据来源都遵循这个规范,我们暂时不用太担心。
二、地图数据从哪里来
这是很多初学者最容易卡住的地方——数据怎么获取?
2.1 官方推荐来源
ECharts官方提供了一套全国省级、地级市、区县级别的地图数据,存放在GitHub仓库里。这些是.json格式的文件,直接引入即可。
但说实话,官方数据有个问题——更新不及时。有时候新的行政区划调整了,官方数据还没跟上。
2.2 更靠谱的来源:阿里云DataV
我强烈推荐使用阿里云DataV.GeoAtlas(现在叫DataV.GeoAI或者GeoAtlas),这是目前国内最稳定的免费地理数据源之一。
访问地址:https://datav.aliyun.com/portal/school/atlas/area_selector
这个工具支持:
- 全国34个省级行政区
- 所有地级市
- 区县级别数据
- 支持导出为GeoJSON格式
2.3 其他备选方案
| 数据源 | 特点 | 推荐程度 |
|---|---|---|
| 阿里云GeoAtlas | 免费、稳定、覆盖全面 | ⭐⭐⭐⭐⭐ |
| Natural Earth | 国际开源数据,精度较低 | ⭐⭐⭐ |
| GADM | 全球行政区划数据,细节丰富 | ⭐⭐⭐⭐ |
| 国家统计局 | 权威但格式不统一 | ⭐⭐ |
三、格式转换——最容易被忽视的关键一步
拿到的GeoJSON数据,往往不能直接用。你需要做几个转换处理:
3.1 坐标系转换
ECharts默认使用WGS84坐标系(经纬度直接映射),但有些数据来源可能使用GCJ-02(火星坐标系,国内常用)或BD-09(百度坐标系)。
// 如果数据是GCJ-02坐标系,需要转换回WGS84
// 这里提供一个简单的转换函数(近似转换)
function gcj02ToWgs84(gcjLng, gcjLat) {
const a = 6378245.0; // 长半轴
const ee = 0.00669342162296594323; // 扁率
let dLng = transformLng(gcjLng - 105.0, gcjLat - 35.0);
let dLat = transformLat(gcjLng - 105.0, gcjLat - 35.0);
const radLng = gcjLng / 180.0 * Math.PI;
const radLat = gcjLat / 180.0 * Math.PI;
let outputLng = gcjLng - dLng;
let outputLat = gcjLat - dLat;
return [outputLng, outputLat];
}
function transformLng(lng, lat) {
let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng));
ret += (2.0 * Math.PI * 3.0 * Math.sin(6.0 * lng * Math.PI) +
2.0 * Math.PI * 2.0 * Math.sin(2.0 * lng * Math.PI) +
2.0 * Math.PI * Math.sin(6.0 * lat * Math.PI)) / 6.0;
ret += (2.0 * Math.PI * Math.sin(lng * 9.0 * Math.PI) +
2.0 * Math.PI * Math.sin(lng * 15.0 * Math.PI) +
2.0 * Math.PI * Math.sin(2.0 * lng * 9.0 * Math.PI)) / 6.0;
ret += (2.0 * Math.PI * Math.sin(lat * 9.0 * Math.PI) +
2.0 * Math.PI * Math.sin(lat * 15.0 * Math.PI)) / 6.0;
ret += (2.0 * Math.PI * Math.sin(2.0 * lat * 9.0 * Math.PI)) / 6.0;
return ret;
}
function transformLat(lng, lat) {
let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat +
0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
ret += (2.0 * Math.PI * 3.0 * Math.sin(6.0 * lng * Math.PI) +
2.0 * Math.PI * 2.0 * Math.sin(2.0 * lng * Math.PI)) / 6.0;
ret += (2.0 * Math.PI * Math.sin(6.0 * lat * Math.PI)) / 6.0;
ret += (2.0 * Math.PI * Math.sin(Math.PI * lng) +
2.0 * Math.PI * Math.sin(2.0 * Math.PI * lng)) / 6.0;
ret += (2.0 * Math.PI * Math.sin(Math.PI * lat) +
2.0 * Math.PI * Math.sin(2.0 * Math.PI * lat)) / 6.0;
ret += (2.0 * Math.PI * Math.sin(6.0 * Math.PI * lat)) / 6.0;
return ret;
}
注意:这个转换是近似值,精度有限。如果你需要高精度的坐标系转换,建议使用专业的地理信息库如Proj4js。
3.2 数据精简
下载的GeoJSON文件通常很大,特别是区县级别的数据,单个文件可能就有几MB。如果直接加载,页面性能会受到影响。我们可以用Douglas-Peucker算法对多边形进行简化。
// 使用简化的Douglas-Peucker算法精简坐标
function simplifyPolygon(coordinates, tolerance = 0.01) {
if (coordinates.length <= 2) return coordinates;
// 找到距离最大的点
let maxDistance = 0;
let maxIndex = 0;
const start = coordinates[0];
const end = coordinates[coordinates.length - 1];
for (let i = 1; i < coordinates.length - 1; i++) {
const distance = pointToLineDistance(
coordinates[i], start, end
);
if (distance > maxDistance) {
maxDistance = distance;
maxIndex = i;
}
}
if (maxDistance > tolerance) {
const left = simplifyPolygon(
coordinates.slice(0, maxIndex + 1), tolerance
);
const right = simplifyPolygon(
coordinates.slice(maxIndex), tolerance
);
return left.concat(right);
} else {
return [start, end];
}
}
function pointToLineDistance(point, lineStart, lineEnd) {
const dx = lineEnd[0] - lineStart[0];
const dy = lineEnd[1] - lineStart[1];
const lengthSq = dx * dx + dy * dy;
if (lengthSq === 0) {
return Math.sqrt(
Math.pow(point[0] - lineStart[0], 2) +
Math.pow(point[1] - lineStart[1], 2)
);
}
let t = ((point[0] - lineStart[0]) * dx +
(point[1] - lineStart[1]) * dy) / lengthSq;
t = Math.max(0, Math.min(1, t));
const projectionX = lineStart[0] + t * dx;
const projectionY = lineStart[1] + t * dy;
return Math.sqrt(
Math.pow(point[0] - projectionX, 2) +
Math.pow(point[1] - projectionY, 2)
);
}
3.3 属性字段映射
不同的数据源,属性字段的命名可能不同。比如有的用name表示地名,有的用NAME;有的用adcode表示行政区划代码,有的用Adcode。你需要统一处理:
// 统一GeoJSON属性字段
function normalizeGeoJSON(geojson) {
const normalized = JSON.parse(JSON.stringify(geojson));
normalized.features.forEach(feature => {
const props = feature.properties || {};
// 统一名称字段
if (!props.name && props.NAME) {
props.name = props.NAME;
}
if (!props.name && props.name_cn) {
props.name = props.name_cn;
}
// 统一编码字段
if (!props.adcode && props.adcode_id) {
props.adcode = props.adcode_id;
}
if (!props.adcode && props.Adcode) {
props.adcode = props.Adcode;
}
// 确保必要字段存在
if (!props.name) {
props.name = 'Unknown';
}
feature.properties = props;
});
return normalized;
}
四、注册自定义地图坐标系
这是ECharts地图功能的核心步骤。你下载的GeoJSON数据,必须通过echarts.registerMap()注册之后才能使用。
4.1 基本注册方式
// 引入地图数据(假设已经从阿里云下载并处理好了)
import chinaMapData from './maps/china.json';
// 注册地图
echarts.registerMap('china', chinaMapData);
// 初始化图表
const chart = echarts.init(document.getElementById('map-container'));
chart.setOption({
series: [{
type: 'map',
map: 'china', // 对应registerMap的第一个参数
roam: true, // 开启缩放和平移
label: {
show: true,
color: '#333'
},
emphasis: {
label: {
color: '#d44848'
},
itemStyle: {
areaColor: '#ffecd2'
}
}
}]
});
4.2 多坐标系注册
有时候你需要同时显示不同精度的地图,比如全国地图点击后钻取到省级地图:
// 注册多个地图
echarts.registerMap('china', chinaData);
echarts.registerMap('guangdong', guangdongData);
echarts.registerMap('foshan', foshanData);
// 通过点击事件实现钻取
chart.on('click', function(params) {
const nextLevel = getMapByAdcode(params.name);
if (nextLevel) {
echarts.registerMap(nextLevel.name, nextLevel.data);
chart.setOption({
series: [{ map: nextLevel.name }]
});
}
});
4.3 自定义坐标系(高级用法)
如果你需要把地图叠加在其他坐标系上(比如在百度地图上叠加),可以使用geo组件的自定义坐标系:
option = {
geo: {
type: 'map',
map: 'china',
roam: true,
zoom: 1.2,
center: [105.0, 36.0],
// 自定义坐标系变换
transform: function(point) {
// point是[经度, 纬度],这里可以做任意变换
// 比如适配到Canvas的像素坐标
return [
(point[0] - 73.5) / 135.0 * 1000, // x轴映射
(80.0 - point[1]) / 55.0 * 600 // y轴映射(注意反转)
];
}
},
series: [{
type: 'map',
geoIndex: 0,
data: [...]
}]
};
五、省市区县地图渲染实战
这里我直接给出一个完整的、可以运行的示例,涵盖从数据加载到最终渲染的全流程。
5.1 HTML结构
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>ECharts自定义地图实战</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
color: #1a1a2e;
margin-bottom: 8px;
}
.header p {
color: #666;
font-size: 14px;
}
.control-panel {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.control-panel button {
padding: 8px 20px;
border: none;
border-radius: 6px;
background: #4a6cf7;
color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.control-panel button:hover {
background: #3a5ce5;
transform: translateY(-1px);
}
.control-panel button.active {
background: #1a1a2e;
}
.map-wrapper {
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
overflow: hidden;
}
#mapChart {
width: 100%;
height: 600px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🇨🇳 中国行政区划地图可视化</h1>
<p>支持省/市/区三级钻取 · 动态数据绑定 · 交互体验优化</p>
</div>
<div class="control-panel">
<button id="btnProvince" class="active" onclick="switchLevel('province')">省级地图</button>
<button id="btnCity" onclick="switchLevel('city')">地级市地图</button>
<button id="btnDistrict" onclick="switchLevel('district')">区县地图</button>
<button id="btnHeatmap" onclick="toggleHeatmap()">热力图模式</button>
<button id="btnReset" onclick="resetMap()">重置视图</button>
</div>
<div class="map-wrapper">
<div id="mapChart"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
5.2 JavaScript核心逻辑
// app.js
const MAP_LEVELS = {
province: {
url: 'https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json',
key: 'province',
labelKey: 'name'
},
city: {
url: 'https://geo.datav.aliyun.com/areas_v3/bound/100000_city.json',
key: 'city',
labelKey: 'name'
},
district: {
url: 'https://geo.datav.aliyun.com/areas_v3/bound/100000_county.json',
key: 'district',
labelKey: 'name'
}
};
// 模拟动态数据
const mockDataMap = {
province: generateProvinceData(),
city: generateCityData(),
district: generateDistrictData()
};
let currentLevel = 'province';
let chartInstance = null;
let heatmapMode = false;
// 初始化
document.addEventListener('DOMContentLoaded', () => {
chartInstance = echarts.init(document.getElementById('mapChart'));
loadMap('province');
});
// 加载地图数据
async function loadMap(level) {
showLoading();
try {
const config = MAP_LEVELS[level];
const response = await fetch(config.url);
const geojson = await response.json();
// 处理数据
const processedData = processGeoJSON(geojson);
// 注册地图
const mapName = `map_${level}`;
echarts.registerMap(mapName, processedData);
// 设置配置项
const option = getMapOption(mapName, level, processedData);
chartInstance.setOption(option, true);
hideLoading();
} catch (error) {
console.error('地图加载失败:', error);
showError('地图数据加载失败,请稍后重试');
}
}
// 处理GeoJSON数据
function processGeoJSON(geojson) {
const features = geojson.features || [];
const nameMap = {};
// 构建名称到特征的映射
features.forEach(feature => {
const name = feature.properties?.name;
if (name) {
nameMap[name] = feature;
}
});
// 为每个feature添加属性索引
features.forEach((feature, index) => {
feature.properties._index = index;
});
return {
features: features,
nameMap: nameMap,
// 用于快速查找的adcode映射
adcodeMap: buildAdcodeMap(features)
};
}
// 构建行政区划代码映射
function buildAdcodeMap(features) {
const map = {};
features.forEach(feature => {
const adcode = feature.properties?.adcode;
if (adcode) {
map[adcode] = feature;
}
});
return map;
}
// 生成模拟数据
function generateProvinceData() {
const provinces = [
'广东省', '北京市', '上海市', '浙江省', '江苏省',
'四川省', '山东省', '河南省', '湖北省', '湖南省'
];
return provinces.map(name => ({
name: name,
value: Math.floor(Math.random() * 10000) + 1000,
intensity: Math.random()
}));
}
function generateCityData() {
const cities = [
'广州市', '深圳市', '东莞市', '佛山市', '珠海市',
'杭州市', '宁波市', '温州市', '嘉兴市', '湖州市',
'南京市', '苏州市', '无锡市', '常州市', '南通市'
];
return cities.map(name => ({
name: name,
value: Math.floor(Math.random() * 5000) + 500,
intensity: Math.random()
}));
}
function generateDistrictData() {
const districts = [
'天河区', '海珠区', '荔湾区', '越秀区', '白云区',
'黄埔区', '番禺区', '花都区', '南沙区', '从化区'
];
return districts.map(name => ({
name: name,
value: Math.floor(Math.random() * 2000) + 200,
intensity: Math.random()
}));
}
// 获取地图配置项
function getMapOption(mapName, level, geoData) {
const dataMap = mockDataMap[level];
return {
tooltip: {
trigger: 'item',
formatter: function(params) {
if (params.componentType === 'series') {
const value = params.value || 0;
return `<div style="padding: 8px 12px;">
<strong>${params.name}</strong><br/>
<span style="color: #666;">数值: <strong style="color: #4a6cf7;">${value.toLocaleString()}</strong></span><br/>
<span style="color: #666;">占比: <strong style="color: #d44848;">${(value / 10000 * 100).toFixed(1)}%</strong></span>
</div>`;
}
return params.name;
},
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#333',
fontSize: 13
},
extraCssText: 'box-shadow: 0 4px 12px rgba(0,0,0,0.1); border-radius: 8px;'
},
visualMap: {
min: 0,
max: 10000,
left: 'left',
top: 'bottom',
text: ['高', '低'],
textStyle: {
color: '#666'
},
calculable: true,
inRange: {
color: ['#e8f4ff', '#4a6cf7', '#1a1a2e']
},
textStyle: {
fontSize: 12
}
},
series: [{
type: 'map',
map: mapName,
roam: true,
zoom: 1.1,
center: level === 'province' ? [105.0, 36.0] :
level === 'city' ? [113.5, 23.2] : [113.26, 23.13],
label: {
show: level === 'district',
fontSize: 10,
color: '#333'
},
emphasis: {
label: {
show: true,
fontSize: 12,
fontWeight: 'bold',
color: '#d44848'
},
itemStyle: {
areaColor: '#ffecd2',
shadowBlur: 10,
shadowColor: 'rgba(0,0,0,0.2)'
}
},
select: {
disabled: true
},
// 视图控制
viewControl: {
autoTop: true,
distance: 100,
alpha: 30,
beta: 0,
minDistance: 50,
maxDistance: 200
},
data: dataMap,
// 样式设置
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
areaColor: '#e8f4ff'
},
// 高亮样式
emphasis: {
itemStyle: {
areaColor: '#ffecd2'
},
label: {
show: true
}
}
}]
};
}
// 切换地图级别
function switchLevel(level) {
currentLevel = level;
heatmapMode = false;
// 更新按钮状态
document.querySelectorAll('.control-panel button').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById(`btn${level.charAt(0).toUpperCase() + level.slice(1)}`).classList.add('active');
document.getElementById('btnHeatmap').classList.remove('active');
loadMap(level);
}
// 切换热力图模式
function toggleHeatmap() {
heatmapMode = !heatmapMode;
const btn = document.getElementById('btnHeatmap');
btn.classList.toggle('active', heatmapMode);
if (chartInstance) {
const currentMap = chartInstance.getOption().series[0].map;
const level = currentMap.replace('map_', '');
const option = {
series: [{
type: 'map',
map: currentMap,
roam: true,
zoom: 1.1,
center: level === 'province' ? [105.0, 36.0] :
level === 'city' ? [113.5, 23.2] : [113.26, 23.13],
label: { show: level === 'district' },
itemStyle: {
borderColor: '#fff',
borderWidth: 0.5,
areaColor: heatmapMode ? 'rgba(255, 77, 79, 0.6)' : '#e8f4ff'
},
emphasis: {
itemStyle: {
areaColor: heatmapMode ? 'rgba(255, 77, 79, 0.8)' : '#ffecd2'
}
},
data: mockDataMap[level]
}]
};
chartInstance.setOption(option, true);
}
}
// 重置视图
function resetMap() {
if (chartInstance) {
chartInstance.setOption({
series: [{
roam: true,
zoom: 1.1,
viewControl: {
autoTop: true
}
}]
}, true);
}
}
// 显示加载中
function showLoading() {
chartInstance.showLoading({
text: '地图加载中...',
color: '#4a6cf7',
textColor: '#4a6cf7',
maskColor: 'rgba(255, 255, 255, 0.8)',
zlevel: 0
});
}
// 隐藏加载
function hideLoading() {
chartInstance.hideLoading();
}
// 显示错误
function showError(msg) {
chartInstance.clear();
chartInstance.setOption({
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: msg,
fill: '#d44848',
fontSize: 16,
fontWeight: 'bold'
}
}]
});
}
5.3 动态数据绑定进阶
上面的示例展示了基础的地图渲染,但实际项目中,数据往往是动态的。下面展示几种常见的动态绑定方式:
方式一:实时数据更新
// 模拟WebSocket实时更新
function connectWebSocket() {
const ws = new WebSocket('wss://your-api.com/maps/data');
ws.onmessage = function(event) {
const newData = JSON.parse(event.data);
chartInstance.setOption({
series: [{
data: newData.map(item => ({
name: item.name,
value: item.value
}))
}]
}, false); // false表示不合并,直接替换
};
}
// 定时刷新
setInterval(() => {
fetchLatestData().then(data => {
updateChartData(data);
});
}, 30000); // 每30秒刷新
方式二:多级钻取联动
// 点击地图钻取下一级
chartInstance.on('click', async function(params) {
const currentLevel = getCurrentLevel();
const adcode = params.data?.adcode;
if (!adcode) return;
// 查找下一级数据
const nextLevelData = await fetchSubRegionData(adcode);
if (nextLevelData && nextLevelData.geojson) {
const mapName = `map_${currentLevel}_${adcode}`;
// 注册新地图
echarts.registerMap(mapName, nextLevelData.geojson);
// 更新配置
chartInstance.setOption({
series: [{
map: mapName,
data: nextLevelData.values,
zoom: 1.15,
center: nextLevelData.center
}]
});
// 更新面包屑导航
updateBreadcrumb(currentLevel, params.name, adcode);
}
});
// 返回上一级
function goBack() {
const breadcrumb = getBreadcrumb();
if (breadcrumb.length > 1) {
const prevLevel = breadcrumb[breadcrumb.length - 2];
switchLevel(prevLevel.level);
}
}
六、常见问题排查
这一部分是我踩过的所有坑的总结,希望能帮你节省时间。
问题一:地图显示空白
现象:页面没有报错,但地图区域一片空白。
排查步骤:
// 1. 检查地图数据是否正确加载
console.log('GeoJSON数据:', geojson);
console.log('features数量:', geojson?.features?.length);
// 2. 检查注册是否成功
const registeredMap = echarts.getMap('china');
console.log('注册地图:', registeredMap);
// 3. 检查series配置
option.series.forEach(series => {
console.log('series map:', series.map);
});
// 4. 常见原因检查清单
const checks = {
// 检查1: map名称是否匹配
mapNameMatch: option.series[0].map === 'registeredMapName',
// 检查2: GeoJSON格式是否正确
geojsonValid: geojson?.type === 'FeatureCollection' &&
Array.isArray(geojson?.features),
// 检查3: 每个feature是否有geometry
featuresHaveGeometry: geojson?.features?.every(
f => f?.geometry?.coordinates
),
// 检查4: 坐标系是否正确(经纬度范围)
validCoordinates: geojson?.features?.every(f => {
const coords = f.geometry.coordinates;
return Array.isArray(coords) && coords.length > 0;
})
};
console.log('检查项:', checks);
常见原因:
map参数名称与registerMap的第一参数不一致- GeoJSON文件格式损坏或格式错误
- 数据加载中出现了跨域问题(CORS)
- 地图容器没有设置宽度或高度
问题二:地图显示位置偏移
现象:地图轮廓正确,但位置不对,或者缩放后位置飘了。
排查步骤:
// 检查坐标系类型
function detectCoordinateSystem(geojson) {
const firstCoord = geojson.features[0].geometry.coordinates[0][0][0];
console.log('第一个坐标点:', firstCoord);
const [lng, lat] = firstCoord;
// 判断坐标系
if (lng > 70 && lng < 140 && lat > 18 && lat < 54) {
return 'WGS84'; // 中国范围内,可能是WGS84或GCJ-02
}
// 进一步判断:WGS84和GCJ-02在中国的偏差通常在几十米到几百米
// 可以通过已知地标坐标对比来确认
return 'unknown';
}
// 如果确认是GCJ-02,需要转换
const correctedGeojson = convertToWGS84(geojson);
echarts.registerMap('china', correctedGeojson);
解决方案:
- 确保数据来源的坐标系与ECharts期望的一致
- 如果需要转换,使用上面提供的坐标转换函数
- 考虑使用专门的坐标转换库如proj4
问题三:大数据量渲染卡顿
现象:区县级别的数据量很大(全国3000+区县),渲染和交互都很卡顿。
优化方案:
// 方案一:按需加载 + 动态聚合
function optimizeMapRender(geojson, level) {
// 根据当前缩放级别决定显示精度
const currentZoom = chartInstance.getModel().getComponent('geo').zoom;
if (currentZoom < 3 && level === 'district') {
// 缩放较小时,聚合区县为市级
return aggregateToCityLevel(geojson);
}
return geojson;
}
// 方案二:使用WebGL渲染(ECharts GL)
// 需要引入 echarts-gl
import 'echarts-gl';
// 改用gl系列
option = {
series: [{
type: 'mapGL', // 注意这里是mapGL而不是map
map: mapName,
// WebGL渲染性能更好,支持更大数据量
}]
};
// 方案三:后端切片 + 前端懒加载
// 将大地图拆分成多个tile,只加载当前视野内的部分
function loadVisibleTiles(viewRect) {
const visibleTiles = tileRegistry.filter(tile =>
intersects(viewRect, tile.boundingRect)
);
visibleTiles.forEach(tile => {
if (!loadedTiles.has(tile.id)) {
fetchTile(tile.url).then(data => {
mergeTileData(data);
loadedTiles.add(tile.id);
});
}
});
}
问题四:tooltip显示内容不正确
现象:鼠标悬停时,tooltip显示的数据与预期不符。
// 检查formatter函数
tooltip: {
formatter: function(params) {
console.log('tooltip params:', params);
// params.data 应该是你传入的data项
// params.name 应该是区域名称
if (params.componentType === 'series') {
// 确保value存在
const value = params.value || 0;
return `${params.name}: ${value}`;
}
return params.name;
}
}
// 常见问题:data中的name与GeoJSON中的name不匹配
// 解决方案:确保data中的name与地图feature的properties.name一致
问题五:点击事件不触发或触发异常
// 正确的点击事件绑定
chartInstance.on('click', function(params) {
console.log('点击了:', params.name, params.data);
// params.data 包含你在series.data中传入的数据
// params.name 是区域名称
if (params.data) {
// 执行你的逻辑
handleRegionClick(params.data);
}
});
// 注意:如果使用了emphasis的hoverStyle,可能会影响点击事件
// 确保emphasis的itemStyle不会阻止点击
emphasis: {
itemStyle: {
areaColor: '#ffecd2'
// 不要设置pointerEvents: 'none'
}
}
七、性能优化最佳实践
最后,分享一些我在实际项目中总结的性能优化经验:
1. 数据预处理
// 在生产环境中,应该提前处理并缓存地图数据
// 而不是每次页面加载都重新下载和处理
// 使用Service Worker缓存GeoJSON
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(registration => {
console.log('SW registered');
});
}
// sw.js
self.addEventListener('fetch', event => {
if (event.request.url.includes('/maps/')) {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
}
});
2. 按需加载
// 不要一次性加载所有层级的地图数据
// 而是根据用户交互逐步加载
const loadedMaps = new Set();
async function ensureMapLoaded(mapName, url) {
if (loadedMaps.has(mapName)) return;
const response = await fetch(url);
const geojson = await response.json();
echarts.registerMap(mapName, geojson);
loadedMaps.add(mapName);
}
// 用户点击省份时,才加载该省的地图
provinceBtn.addEventListener('click', async () => {
await ensureMapLoaded('map_guangdong', '/maps/guangdong.json');
switchLevel('city', 'map_guangdong');
});
3. 使用压缩数据
// 下载GeoJSON时,请求压缩格式
fetch('/maps/china.json.gz', {
headers: {
'Accept-Encoding': 'gzip, deflate'
}
}).then(response => {
// 浏览器自动解压
return response.json();
});
// 或者使用protobuf等二进制格式
// 需要将GeoJSON转换为TopoJSON再传输
// TopoJSON比GeoJSON小30-80%
4. 内存管理
// 及时清理不需要的地图数据
function cleanupMap(mapName) {
echarts.unregisterMap(mapName);
// 同时清理相关的图形对象
chartInstance.clear();
}
// 在页面卸载时清理
window.addEventListener('beforeunload', () => {
chartInstance.dispose();
});
写在最后
ECharts自定义地图这条路,说实话走下来挺折腾的。从数据获取、格式转换、坐标系处理,到最终的渲染优化,每一步都可能踩坑。但当我看到自己画的地图在页面上完美呈现,数据动态更新,交互流畅自然的时候,那种成就感也是实实在在的。
希望这篇教程能帮你少走一些弯路。如果还有具体问题,欢迎随时交流。记住,遇到问题先console.log,把数据看清楚,往往就能找到问题的根源。
