用Echarts自定义地图绘制从0开始手把手教你用GeoJSON打造专属城市地图并解决区域重叠渲染异常问题
先说说为啥要用自定义地图
你是不是也遇到过这种抓狂的情况——想用Echarts画地图,结果官网里压根没你那个城市的数据?或者你想要展示的行政区划跟默认的完全对不上?别慌,GeoJSON就是来救场的。
GeoJSON说白了就是一种”地图语言”,它用JSON格式记录了地图上各个区域的边界坐标。你给它一份某个城市的GeoJSON数据,Echarts就能把这个城市画出来,你想怎么着色、怎么标注都行。这玩意儿的好处是,只要你拿到数据,全世界任何地方你都能画。
第一步:搞定你的GeoJSON数据
没有数据一切白搭。获取GeoJSON的途径有几个:
方式一:下载现成数据
GeoJSON.IO 是个好地方,里面有很多现成的地图数据。你也可以去 阿里云DataV.GeoAtlas 找中国各省市的GeoJSON数据,这个对国内用户特别友好,下载下来直接用。
方式二:自己绘制
如果你需要的区域比较小众,比如某个县城、某个园区,甚至你自己画个简单形状,都可以用 mapshaper.org 这个工具,导入底图后手动描边界,导出就是标准的GeoJSON。
下面是一段典型的GeoJSON结构,我拿一个简单的例子给你看:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "朝阳区"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[116.48, 39.92],
[116.52, 39.92],
[116.52, 39.96],
[116.48, 39.96],
[116.48, 39.92]
]
]
}
},
{
"type": "Feature",
"properties": {
"name": "海淀区"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[116.28, 39.94],
[116.36, 39.94],
[116.36, 40.00],
[116.28, 40.00],
[116.28, 39.94]
]
]
}
}
]
}
关键概念:coordinates 数组里的每一组坐标,围起来就是一个区域。外面的数组是闭合环(外边界),里面如果还有数组,那就是岛屿或者 hole(空洞)。先有概念,后面出问题才知道往哪个方向排查。
第二步:把GeoJSON注册到Echarts里
拿到数据之后,第一件事就是注册。这一步非常简单,但很多新手在这里卡住是因为不知道有这一步。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>自定义地图示例</title>
<!-- 引入Echarts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
</head>
<body>
<div id="main" style="width:100%;height:600px;"></div>
<script>
// 模拟一段GeoJSON数据(实际使用时用fetch或axios加载文件)
const geoJson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "name": "朝阳区" },
"geometry": {
"type": "Polygon",
"coordinates": [[
[116.48, 39.92], [116.52, 39.92],
[116.52, 39.96], [116.48, 39.96],
[116.48, 39.92]
]]
}
},
{
"type": "Feature",
"properties": { "name": "海淀区" },
"geometry": {
"type": "Polygon",
"coordinates": [[
[116.28, 39.94], [116.36, 39.94],
[116.36, 40.00], [116.28, 40.00],
[116.28, 39.94]
]]
}
}
]
};
// 核心:注册地图
echarts.registerMap('myCity', geoJson);
const chart = echarts.init(document.getElementById('main'));
chart.setOption({
geo: {
map: 'myCity', // 对应上面注册的名称
roam: true, // 允许缩放和平移
label: {
show: true,
color: '#333'
},
itemStyle: {
fillColor: '#4fc0d8',
borderColor: '#fff',
borderWidth: 1
},
emphasis: {
itemStyle: { fillColor: '#ff6b6b' },
label: { color: '#fff' }
}
}
});
</script>
</body>
</html>
运行这段代码,你就能在页面上看到两个矩形区域,分别标着”朝阳区”和”海淀区”。虽然它们现在是矩形,但原理跟真实地图完全一样。
第三步:真实世界的数据长什么样
真实城市的GeoJSON可不会这么规整。下面是一个真实场景——用北京的部分区县数据,配合异步加载的方式:
// 从阿里云DataV下载后,用fetch加载
fetch('beijing_districts.geojson')
.then(res => res.json())
.then(geoJson => {
echarts.registerMap('beijing', geoJson);
const chart = echarts.init(document.getElementById('main'));
// 随便编一些模拟数据
const data = [
{ name: '朝阳区', value: 380 },
{ name: '海淀区', value: 290 },
{ name: '东城区', value: 150 },
{ name: '西城区', value: 130 },
{ name: '丰台区', value: 210 },
{ name: '石景山区', value: 90 }
];
chart.setOption({
title: {
text: '北京市各区县数据分布',
left: 'center',
textStyle: { fontSize: 18 }
},
tooltip: {
trigger: 'item',
formatter: '{b}<br/>数值:{c}'
},
visualMap: {
min: 0,
max: 400,
left: 'left',
top: 'bottom',
text: ['高', '低'],
calculable: true,
inRange: {
color: ['#e8f5e9', '#4caf50', '#1b5e20']
}
},
geo: {
map: 'beijing',
roam: true,
label: { show: true, fontSize: 10 },
itemStyle: {
borderColor: '#fff',
borderWidth: 1.5,
areaColor: '#f0f0f0'
},
emphasis: {
label: { color: '#fff', fontSize: 12 },
itemStyle: { areaColor: '#81c784' }
}
},
series: [{
type: 'map',
geoIndex: 0, // 绑定到上面定义的 geo
data: data
}]
});
});
这段代码展示的是最典型的用法:geo 组件负责地图的渲染,series 里的 map 类型负责把数据叠上去,visualMap 负责做颜色映射。三件套齐了,地图就活了。
第四步:区域重叠渲染异常——这才是重点
好了,地图能画出来了,但问题来了:你有没有遇到过以下情况?
- 同一个区域被渲染了两次,出现了重影或者颜色不一样的块
- 区域边界错位,看起来像是地图被撕裂了
- 鼠标悬停时,hover的区域跟实际位置对不上
- 缩放后区域之间出现缝隙或者交叉
这些问题的根源,大多跟GeoJSON数据的拓扑关系有关。下面我把常见的坑一个个拆解。
坑一:坐标顺序不一致(顺时针 vs 逆时针)
GeoJSON标准规定:外环应该是顺时针方向,内环(孔洞)应该是逆时针方向。如果你的数据是反的,Echarts渲染时就会出现区域自相交,表现为颜色乱跳或者区域被”吃掉”一块。
// 检查一下你的GeoJSON,确保外环是顺时针
// 一个简单的判断方法:用 shoelace公式算多边形面积
function isClockwise(coords) {
let sum = 0;
for (let i = 0; i < coords.length - 1; i++) {
sum += (coords[i + 1][0] - coords[i][0]) * (coords[i + 1][1] + coords[i][1]);
}
return sum > 0; // 正数表示顺时针
}
如果你的数据里混了顺时针和逆时针的环,建议用工具统一处理一下。推荐 mapshaper,导入数据后执行 simplify 命令并导出,它会帮你修复拓扑问题。
坑二:相邻区域边界坐标不严格对齐
这是最常见的重叠渲染问题来源。比如A区的东边界是 [116.5, 39.95],而B区的西边界对应的点是 [116.500001, 39.95],虽然只差了一点点,但Echarts渲染时因为浮点精度问题,这两个边界没法完美贴合,就会在交界处出现细线或者颜色断层。
解决方案:使用拓扑处理工具清洗数据。
# 用mapshaper清洗数据,让相邻区域共享精确的边界
mapshaper input.geojson \
-clean \
-dissolve name \
-simplify 0.01\% \
-o output.geojson
-clean 命令会修复自相交,-dissolve 可以合并相同属性的区域,-simplify 用极小的容差简化但不破坏边界精度。
坑三:同一个区域在数据里出现了两次
有时候你拿到的GeoJSON数据里,某个区县被拆成了两个Feature(比如因为历史沿革数据合并的问题),而你在series的data里又写了一次同样的名字,Echarts不知道该怎么处理,就会叠加渲染。
排查方法:
// 注册前检查一下有没有重复的name
const names = geoJson.features.map(f => f.properties.name);
const duplicates = names.filter((name, index) => names.indexOf(name) !== index);
if (duplicates.length > 0) {
console.warn('发现重复区域名称:', [...new Set(duplicates)]);
// 合并重复的Feature
const mergedFeatures = {};
geoJson.features.forEach(f => {
const key = f.properties.name;
if (!mergedFeatures[key]) {
mergedFeatures[key] = f;
} else {
// 合并geometry(简单情况直接取并集,复杂情况用 Turf.js)
mergedFeatures[key].geometry.coordinates = [
...mergedFeatures[key].geometry.coordinates,
...f.geometry.coordinates
];
}
});
geoJson.features = Object.values(mergedFeatures);
}
更稳健的做法是用 Turf.js 做合并:
// 引入 turf.js (https://turfjs.org/)
import * as turf from '@turf/turf';
// 按name分组,用union合并重叠/重复的几何体
const featuresByGroup = {};
geoJson.features.forEach(feature => {
const key = feature.properties.name;
if (!featuresByGroup[key]) featuresByGroup[key] = [];
featuresByGroup[key].push(feature);
});
const mergedFeatures = Object.entries(featuresByGroup).map(([name, features]) => {
if (features.length === 1) return features[0];
// 合并多个几何体
return turf.union(...features);
});
geoJson.features = mergedFeatures;
坑四:投影问题导致的渲染错位
有些GeoJSON数据用的是 Web Mercator 投影(EPSG:3857),而有些是 WGS84 地理坐标(EPSG:4326)。Echarts默认期望的是 WGS84 经纬度坐标。如果你的数据是投影坐标,数字会大得离谱(比如 x 在 -2e7 到 2e7 之间),地图渲染出来就会跑到屏幕外面去。
判断方法: 看看坐标值。如果经度在 -180 到 180 之间、纬度在 -90 到 90 之间,那就是 WGS84,没问题。如果数值在百万级别,那就是投影坐标。
解决方案:用 Turf.js 转换
import * as turf from '@turf/turf';
import { transform } from '@turf/turf';
// 将 Web Mercator 转回 WGS84
const wgs84GeoJson = {
type: 'FeatureCollection',
features: geoJson.features.map(feature => {
return transform(feature, 'EPSG:3857', 'EPSG:4326');
})
};
echarts.registerMap('myCity', wgs84GeoJson);
坑五:hover区域与实际区域偏移——坐标精度丢失
这个问题比较隐蔽。当你缩放地图时,如果发现鼠标悬停的位置和实际高亮的区域对不上,多半是坐标精度问题。GeoJSON里的坐标如果保留了过多小数位但底层用了浮点数计算,在Echarts渲染时会产生微小的偏移。
解决方法:适当简化坐标精度
// 将坐标精度统一保留到小数点后5位(约1米精度,对城市级地图完全够用)
function roundCoordinates(geoJson, precision = 5) {
const round = (num) => Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision);
return {
...geoJson,
features: geoJson.features.map(feature => ({
...feature,
geometry: {
...feature.geometry,
coordinates: roundCoordinatesRecursive(feature.geometry.coordinates, precision)
}
}))
};
}
function roundCoordinatesRecursive(coords, precision) {
if (typeof coords[0] === 'number') {
return coords.map(c => Math.round(c * Math.pow(10, precision)) / Math.pow(10, precision));
}
return coords.mapRing => roundCoordinatesRecursive(mRing, precision);
}
// 使用
const cleanedGeoJson = roundCoordinates(geoJson, 5);
echarts.registerMap('myCity', cleanedGeoJson);
坑六:嵌套环(holes)处理不当导致的空洞渲染异常
有些复杂区域(比如一个区里面有个飞地,或者地图本身有湖泊),GeoJSON里会有嵌套的多边形。Echarts对这种数据的处理有时候会出问题,表现为空洞被填充了颜色,或者边界出现乱线。
// 使用 mapshaper 的 -clean 命令可以自动修复嵌套环问题
// 命令行方式:
// mapshaper input.geojson -clean -o output.geojson
// 或者用 Turf.js 的 booleanPointInPolygon 验证每个孔洞是否真的在外部环内
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
function validatePolygons(geoJson) {
let issues = [];
geoJson.features.forEach((feature, idx) => {
const coords = feature.geometry.coordinates;
coords.forEach((ringSet, ringIdx) => {
ringSet.forEach((ring, ringInnerIdx) => {
// 取环上第一个点作为测试点
const point = turf.point(ring[0]);
const isInside = booleanPointInPolygon(point, {
type: 'Polygon',
coordinates: [ringSet[0]] // 只用外环测试
});
// 内环(holes)的点不应该在外环内
if (ringInnerIdx > 0 && isInside) {
issues.push(`Feature ${idx}, Ring ${ringIdx}, Inner Ring ${ringInnerIdx} 孔洞验证失败`);
}
});
});
});
return issues;
}
第五步:完整的健壮处理流程
把上面所有坑都串起来,给你一份完整的生产级处理代码:
import * as turf from '@turf/turf';
/**
* 健壮的地���GeoJSON预处理函数
* 解决重叠、精度、投影、重复等常见问题
*/
function prepareGeoJson(rawGeoJson) {
let processed = { ...rawGeoJson };
// 1. 检查并转换投影坐标
const sampleCoord = processed.features[0]?.geometry.coordinates?.[0]?.[0]?.[0];
if (sampleCoord && Math.abs(sampleCoord) > 180) {
// 疑似投影坐标,尝试转换
processed = {
type: 'FeatureCollection',
features: processed.features.map(f =>
turf.transform(f, 'EPSG:3857', 'EPSG:4326')
)
};
}
// 2. 统一坐标精度(保留5位小数,约1米精度)
const round = (num) => Math.round(num * 100000) / 100000;
processed = {
...processed,
features: processed.features.map(feature => ({
...feature,
geometry: {
...feature.geometry,
coordinates: roundCoordsRecursive(feature.geometry.coordinates)
}
}))
};
// 3. 合并重复区域(按name)
const grouped = {};
processed.features.forEach(f => {
const key = f.properties?.name || JSON.stringify(f.geometry);
if (!grouped[key]) grouped[key] = f;
else grouped[key] = turf.union(grouped[key], f);
});
processed.features = Object.values(grouped);
// 4. 验证并修复拓扑问题
const issues = validatePolygons(processed);
if (issues.length > 0) {
console.warn('拓扑问题:', issues);
// 用simplify轻微修正
processed = turf.simplify(processed, { tolerance: 0.0001, mutate: true });
}
return processed;
}
function roundCoordsRecursive(coords) {
if (typeof coords[0] === 'number') {
return coords.map(round);
}
return coords.map(ring => roundCoordsRecursive(ring));
}
function validatePolygons(geoJson) {
const issues = [];
geoJson.features.forEach((feature, idx) => {
const rings = feature.geometry.coordinates;
const outerRing = rings[0];
rings.slice(1).forEach((hole, i) => {
const testPoint = turf.point(hole[0]);
const inside = turf.booleanPointInPolygon(testPoint, turf.polygon([outerRing]));
if (inside) {
issues.push(`Feature ${idx}: 孔洞 ${i} 验证失败(点在外部环内)`);
}
});
});
return issues;
}
// 使用示例
fetch('city.geojson')
.then(res => res.json())
.then(raw => {
const cleaned = prepareGeoJson(raw);
echarts.registerMap('myCity', cleaned);
// 后续渲染逻辑...
});
第六步:调试技巧——快速定位渲染异常
当你发现地图渲染有问题,不知道怎么排查时,可以用这几个技巧:
1. 用红色边框高亮所有区域
geo: {
itemStyle: {
borderColor: 'red',
borderWidth: 2,
areaColor: 'rgba(255,0,0,0.1)'
}
}
这样你可以一眼看出哪些区域有重叠——如果两个区域颜色叠加后变深了,说明它们在空间上重叠了。
2. 打印每个区域的中心点,验证位置是否正确
geoJson.features.forEach(feature => {
const centroid = turf.centroid(feature);
console.log(`${feature.properties.name}: [${centroid.geometry.coordinates.join(', ')}]`);
});
3. 用 GeoJSONLint 在线验证数据合法性
geojsonlint.com 可以帮你检查GeoJSON是否符合规范,哪些地方有语法错误。
4. 用 QGIS 打开看看
如果你本地装了 QGIS(免费开源的GIS软件),直接拖入GeoJSON文件,它会在地图上渲染出来。这是最直观的检查方式——QGIS渲染不正常的地方,Echarts大概率也会有问题。
总结一下
用Echarts做自定义地图,核心就三步:拿数据 → 注册 → 渲染。但真正让你头疼的,是数据本身的质量问题。区域重叠、坐标精度、投影不对、重复Feature——这些坑每一个都足够让你debug半天。
我的经验是,不要信任任何来源的GeoJSON数据。哪怕是从官方渠道下载的,也先用 prepareGeoJson 那段代码过一遍。清洗数据花5分钟,debug渲染异常花5小时,这笔账怎么算都值。
最后给你留一个思考题:如果你要画的不是城市,而是全国各省份的地图,数据量一下子大了几百倍,怎么处理才能保证页面流畅不卡顿?答案是用 simplify 做层级简化——缩放级别低时展示简化后的轮廓,放大后再展示精细边界。有兴趣的话,下次可以聊聊这个。
