前端开发踩坑实录:如何用 Echarts 自定义地图实现全国各省份销量数据可视化配色方案教程
前端开发踩坑实录:如何用 Echarts 自定义地图实现全国各省份销量数据可视化配色方案教程
朋友,咱们今天来聊聊一个挺有意思的话题——用 Echarts 给全国地图上色,把各省份的销量数据可视化出来。这事儿听着简单,真做起来坑还挺多的,特别是配色这块,搞不好整个页面看起来就像小学生画的示意图。
为什么选择 Echarts 做地图可视化
说实话,做地图可视化可选的方案不少,D3、Highcharts、AntV 都能干,但为啥我推荐 Echarts?两个原因:
第一,生态成熟。 官方出了这么多示例,社区有人踩过坑,你能搜到的答案比别人多。
第二,中文文档友好。 这对国内开发者太重要了,有些库的文档全是英文,查起来费劲。
但说归说,Echarts 官方提供的中国地图 GeoJSON 本身就挺粗糙的,南海诸岛经常有问题,自定义配色也不是拖个颜色就完事。你得懂原理,才能避坑。
先搞明白一个概念:省份名称映射
这是第一个大坑。你以为你往 Echarts 里传个”广东”就能匹配上?别急。
Echarts 默认支持的标准省份名称是这样的:
广东、江苏、浙江、山东、河南、四川、湖北、湖南、河北、福建
但你业务系统里存的数据,可能是这样的:
广东省、江苏省、广东省深圳市、广东(粤)、GD、440000
这三种情况随便一种,地图就匹配不上,数据直接丢,一片空白。
我见过最离谱的是,运营同学拿到的 Excel 表格里,省份列写的是”华南区”、”华东区”这种大区划分,让你做地图,你看着一张白纸怀疑人生。
配色方案的科学,而不是瞎选颜色
很多人做地图,上来就从取色器里随便点几个颜色,看着顺眼就用了。这是最差的实践。
2.1 量化数据的配色原则
销量数据是连续数值型数据,这种数据用分阶渐变色(Sequential)最合适。我的经验是:
- 低值区域用浅色系,比如浅蓝、浅绿、米黄
- 高值区域用深色系,比如深蓝、深红、墨绿
- 中间值用过渡色,不要有突兀的跳变
2.2 避开常见的配色雷区
| 雷区 | 问题 | 替代方案 |
|---|---|---|
| 红色系全部 | 大面积红色让人焦虑,且有负面联想 | 蓝绿渐变体系 |
| 彩虹色 | 各区域之间没有逻辑关联,视觉混乱 | 同色系渐变 |
| 高饱和度颜色 | 刺眼,长时间观看疲劳 | 降低饱和度 20%-30% |
| 冷暖色混用 | 冷暖混在一起没有数据语义 | 统一使用一种色调 |
我自己用的是一套蓝绿渐变方案,代码长这样:
colorRange: [
'#e8f4f8', // 最低值:极浅蓝
'#b8d8e8', // 浅蓝
'#7eb8d4', // 中浅蓝
'#4a9bb8', // 中蓝
'#2d7d9f', // 中深蓝
'#1a5c7a', // 深蓝
'#0d3d52' // 最高值:极深蓝
]
这套配色在浅色背景(白或浅灰)的页面上效果很好,不会有刺眼的感觉。
2.3 颜色数值的映射算法
有了颜色数组,还得知道怎么把销量数字映射到颜色。最常见的是线性映射和分级映射。
线性映射
function linearMap(value, min, max, colorRange) {
const ratio = (value - min) / (max - min);
const index = ratio * (colorRange.length - 1);
const lowerIndex = Math.floor(index);
const upperIndex = Math.ceil(index);
const t = index - lowerIndex;
if (lowerIndex === upperIndex) {
return colorRange[lowerIndex];
}
// 简单的线性插值
return interpolateColor(colorRange[lowerIndex], colorRange[upperIndex], t);
}
function interpolateColor(color1, color2, ratio) {
const r1 = parseInt(color1.slice(1, 3), 16);
const g1 = parseInt(color1.slice(3, 5), 16);
const b1 = parseInt(color1.slice(5, 7), 16);
const r2 = parseInt(color2.slice(1, 3), 16);
const g2 = parseInt(color2.slice(3, 5), 16);
const b2 = parseInt(color2.slice(5, 7), 16);
const r = Math.round(r1 + (r2 - r1) * ratio);
const g = Math.round(g1 + (g2 - g1) * ratio);
const b = Math.round(b1 + (b2 - b1) * ratio);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
这个函数传入一个销量值,返回对应的颜色字符串。原理就是算出这个值在最小值和最大值之间的比例,然后去颜色数组里找对应位置。
分级映射(更实用)
线性映射有个问题:如果数据分布不均匀(比如少数几个省份销量特别高,大部分都很低),线性映射会让大部分省份颜色看起来差不多。
这时候用自然断点分级(Jenks Natural Breaks)更合适。但手动实现比较麻烦,Echarts 提供了 visualMap 组件,可以帮你自动处理。
option = {
visualMap: {
min: 0,
max: 1000000, // 最大销量
left: 'left',
top: 'bottom',
text: ['高', '低'],
calculable: true,
inRange: {
color: ['#e8f4f8', '#b8d8e8', '#7eb8d4', '#4a9bb8', '#2d7d9f', '#1a5c7a', '#0d3d52']
},
seriesIndex: 0,
textStyle: {
color: '#666'
}
},
// ...
}
visualMap 的好处是,它会自动根据数据的分布来划分色阶,你不需要手动算每个值对应的颜色。而且它还带有一个可拖动的滑块,用户可以自己调整可视范围,这对数据分析非常有帮助。
自定义地图的完整流程
步骤一:获取中国地图 GeoJSON
Echarts 3.x 之后,官方地图数据不再内置,需要自己引入 GeoJSON。
你可以从这些来源获取:
- 阿里云 DataV 地理小工具:https://datav.aliyun.com/portal/school/atlas/area_selector
- ** Natural Earth**:全球地理数据,可以提取中国部分
- 高德地图开放平台:有中国各省市的 GeoJSON
我用的是阿里云 DataV 的数据,质量比较稳定,省份边界也比较准确。
下载下来之后,你需要做一件事:清理数据。
很多 GeoJSON 里,南海诸岛是单独的一组图层,默认不显示。你得处理一下,把它们合并进去,或者单独显示。否则地图上南海那一大片空白,用户会以为出了 bug。
// 加载 GeoJSON
fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json')
.then(response => response.json())
.then(geoJson => {
// 清理和合并数据处理
echarts.registerMap('china', geoJson);
renderMap();
});
步骤二:处理省份名称映射
这是最关键的一步。假设你的业务数据长这样:
const salesData = [
{ province: '广东省', value: 1250000 },
{ province: '江苏省', value: 1180000 },
{ province: '浙江省', value: 980000 },
{ province: '山东省', value: 920000 },
{ province: '河南省', value: 850000 },
{ province: '四川', value: 720000 }, // 少了个"省"字
{ province: '湖北省', value: 680000 },
{ province: '湖南', value: 650000 },
{ province: '河北省', value: 620000 },
{ province: '福建省', value: 580000 },
{ province: 'GD', value: 1250000 }, // 缩写
{ province: '440000', value: 1250000 }, // 行政区划代码
];
你需要写一个转换函数,把这些乱七八糟的格式统一成 Echarts 能识别的标准名称:
// 省份名称映射表
const provinceMapping = {
// 标准名称 -> 标准名称(本身)
'广东': '广东', '广东省': '广东',
'江苏': '江苏', '江苏省': '江苏',
'浙江': '浙江', '浙江省': '浙江',
'山东': '山东', '山东省': '山东',
'河南': '河南', '河南省': '河南',
'四川': '四川', '四川省': '四川',
'湖北': '湖北', '湖北省': '湖北',
'湖南': '湖南', '湖南省': '湖南',
'河北': '河北', '河北省': '河北',
'福建': '福建', '福建省': '福建',
'安徽': '安徽', '安徽省': '安徽',
'辽宁': '辽宁', '辽宁省': '辽宁',
'陕西': '陕西', '陕西省': '陕西',
'山西': '山西', '山西省': '山西',
'江西': '江西', '江西省': '江西',
'云南': '云南', '云南省': '云南',
'贵州': '贵州', '贵州省': '贵州',
'广西': '广西', '广西壮族自治区': '广西',
'内蒙古': '内蒙古', '内蒙古自治区': '内蒙古',
'新疆': '新疆', '新疆维吾尔自治区': '新疆',
'西藏': '西藏', '西藏自治区': '西藏',
'青海': '青海', '青海省': '青海',
'甘肃': '甘肃', '甘肃省': '甘肃',
'黑龙江': '黑龙江', '黑龙江省': '黑龙江',
'吉林': '吉林', '吉林省': '吉林',
'北京': '北京', '北京市': '北京',
'天津': '天津', '天津市': '天津',
'上海': '上海', '上海市': '上海',
'重庆': '重庆', '重庆市': '重庆',
'海南': '海南', '海南省': '海南',
'宁夏': '宁夏', '宁夏回族自治区': '宁夏',
'香港': '香港', '香港特别行政区': '香港',
'澳门': '澳门', '澳门特别行政区': '澳门',
'台湾': '台湾', '台湾省': '台湾',
// 缩写映射
'GD': '广东', 'GZ': '贵州', 'JS': '江苏',
'ZJ': '浙江', 'SD': '山东', 'HEN': '河南',
'SC': '四川', 'HUB': '湖北', 'HUN': '湖南',
'HEB': '河北', 'FJ': '福建', 'AH': '安徽',
'LN': '辽宁', 'SN': '陕西', 'SX': '山西',
'JX': '江西', 'YN': '云南', 'HX': '海南',
'XZ': '西藏', 'QH': '青海', 'GS': '甘肃',
'HLJ': '黑龙江', 'JL': '吉林', 'NMG': '内蒙古',
'XJ': '新疆', 'NX': '宁夏',
// 行政区划代码映射
'440000': '广东', '320000': '江苏', '330000': '浙江',
'370000': '山东', '410000': '河南', '510000': '四川',
'420000': '湖北', '430000': '湖南', '130000': '河北',
'350000': '福建', '340000': '安徽', '210000': '辽宁',
'610000': '陕西', '140000': '山西', '360000': '江西',
'530000': '云南', '520000': '贵州', '450000': '广西',
'150000': '内蒙古', '650000': '新疆', '540000': '西藏',
'630000': '青海', '620000': '甘肃', '230000': '黑龙江',
'220000': '吉林', '110000': '北京', '120000': '天津',
'310000': '上海', '500000': '重庆', '460000': '海南',
'640000': '宁夏', '810000': '香港', '820000': '澳门',
'710000': '台湾',
};
function normalizeProvinceName(name) {
if (!name) return null;
const trimmed = name.trim();
// 直接在映射表里找
if (provinceMapping[trimmed]) {
return provinceMapping[trimmed];
}
// 模糊匹配:去掉"省"、"市"、"自治区"、"特别行政区"等后缀
const cleanName = trimmed
.replace(/省$/, '')
.replace(/市$/, '')
.replace(/自治区$/, '')
.replace(/特别行政区$/, '')
.replace(/壮族自治区$/, '')
.replace(/维吾尔自治区$/, '')
.replace(/回族自治区$/, '');
if (provinceMapping[cleanName]) {
return provinceMapping[cleanName];
}
console.warn(`无法识别的省份名称: ${name}`);
return null;
}
// 转换数据
const normalizedData = salesData
.map(item => ({
name: normalizeProvinceName(item.province),
value: item.value
}))
.filter(item => item.name !== null);
console.log('转换后的数据:', normalizedData);
这个函数处理了常见的省份名称变体。如果你的数据源更奇怪(比如写的是”华南-广东”),你只需要在映射表里加一条就行。
步骤三:完整配置项
// 假设 normalizedData 已经处理好了
const maxSales = Math.max(...normalizedData.map(d => d.value));
const minSales = Math.min(...normalizedData.map(d => d.value));
const option = {
backgroundColor: '#f5f7fa',
tooltip: {
trigger: 'item',
formatter: function(params) {
if (params.value) {
return `${params.name}<br/>销量:<strong>${params.value.toLocaleString()}</strong>`;
}
return params.name;
},
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e0e6ed',
textStyle: {
color: '#333'
},
extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 4px;'
},
visualMap: {
min: minSales,
max: maxSales,
left: 'left',
top: 'bottom',
text: ['高销量', '低销量'],
calculable: true,
inRange: {
color: ['#e8f4f8', '#b8d8e8', '#7eb8d4', '#4a9bb8', '#2d7d9f', '#1a5c7a', '#0d3d52']
},
textStyle: {
color: '#666',
fontSize: 12
},
seriesIndex: 0
},
geo: {
map: 'china',
roam: true,
zoom: 1.2,
center: [105.0, 36.0],
label: {
show: true,
color: '#444',
fontSize: 10,
fontWeight: 'normal'
},
emphasis: {
label: {
color: '#1a5c7a',
fontSize: 12,
fontWeight: 'bold'
},
itemStyle: {
areaColor: '#1a5c7a',
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.2)'
}
},
select: {
disabled: true
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
areaColor: '#f0f4f8'
}
},
series: [
{
name: '销量',
type: 'map',
geoIndex: 0,
data: normalizedData,
emphasis: {
label: {
show: true
}
}
}
]
};
const chart = echarts.init(document.getElementById('mapContainer'));
chart.setOption(option);
这段代码有几个细节值得说:
geo和series的配合:geo定义地图的外观(投影、颜色、标签),series定义数据如何映射到地图上。两者通过geoIndex关联。emphasis:这是鼠标悬停时的样式。我加了一层阴影和深色描边,让选中效果更明显。center和zoom:中国地图默认的 center 可能不太合适,你可以根据需要调整。[105.0, 36.0]大致是中国的中心点,zoom 设为 1.2 可以让地图稍微放大一点。
步骤四:处理南海诸岛
这是很多人忽略的地方。标准的中国地图 GeoJSON 里,南海诸岛通常是单独的一个 feature 数组,不显示在地图上。你得手动把它们叠加上去。
// 南海诸岛相对位置修正
function adjustSouthChinaSea(mapData) {
// 南海诸岛的偏移参数(根据实际 GeoJSON 结构调整)
const southChinaSeaOffset = {
longitude: 18, // 向东偏移
latitude: -12 // 向南偏移
};
mapData.features.forEach(feature => {
if (feature.properties && feature.properties.name === '南海诸岛') {
// 对南海诸岛的坐标进行偏移处理
feature.geometry = adjustCoordinates(
feature.geometry,
southChinaSeaOffset
);
}
});
return mapData;
}
function adjustCoordinates(geometry, offset) {
// 递归处理坐标,根据 geometry 类型(Polygon/MultiPolygon)调整
// 这里省略具体实现,视 GeoJSON 结构而定
return geometry;
}
如果你用的是阿里云 DataV 的完整地图(包含南海诸岛),这个问题可能已经处理好了。但如果是其他来源的数据,你需要检查一下。
常见踩坑点总结
坑一:省份数据显示”未找到”
错误信息类似:echarts: province not found, please check the province name。
原因: 省份名称映射不匹配。
排查方法: 在 normalizeProvinceName 函数里加日志,看哪些名字匹配不上。然后把匹配不上的名字加到映射表里。
function normalizeProvinceName(name) {
if (!name) return null;
const trimmed = name.trim();
if (provinceMapping[trimmed]) {
return provinceMapping[trimmed];
}
// 模糊匹配...
// 加一个兜底的日志,方便排查
console.warn(`无法识别的省份: "${name}",已添加到待处理列表`);
return null;
}
坑二:地图看起来”空了”或”缺一块”
原因: GeoJSON 数据不完整,或者投影设置不对。
排查方法: 打印出 GeoJSON 的 feature 数量,确认是否包含了所有省份。另外检查 geo.projection 是否正确(Echarts 默认使用 echarts.projection.geoJson,一般不需要手动设置)。
坑三:颜色不随数据变化
原因: visualMap 的 seriesIndex 没有正确设置,或者 inRange.color 的颜色数组长度和数据分级数不匹配。
排查方法: 确认 visualMap 的 seriesIndex 指向了你的 map series(从 0 开始计数)。另外,尝试把颜色数组换成更明显的渐变,比如 ['red', 'yellow', 'green'],先验证逻辑是否正确,再换成你想要的配色。
坑四:鼠标悬停时省份高亮颜色不对
原因: emphasis.itemStyle.areaColor 被固定写死了,导致 hover 时颜色不受 visualMap 控制。
排查方法: 不要在 emphasis.itemStyle 里写死颜色,让 Echarts 自动根据 visualMap 计算 hover 时的颜色。或者,如果你确实需要自定义 hover 颜色,确保它和 visualMap 的色阶范围一致。
// 错误示范:固定颜色
emphasis: {
itemStyle: {
areaColor: '#ff0000' // 这会让所有省份 hover 时都变成红色
}
}
// 正确做法:不设置 areaColor,让 visualMap 自动处理
emphasis: {
label: {
show: true,
color: '#fff'
}
// 不设置 itemStyle,默认会根据 visualMap 的色阶来高亮
}
坑五:移动端适配问题
原因: 地图在移动端没有正确处理触摸事件,或者字体太小看不清。
排查方法: 给 geo 加上 roam: true,允许用户缩放和平移。字体大小在移动端可以适当放大:
label: {
show: true,
color: '#444',
fontSize: window.innerWidth < 768 ? 12 : 10
}
进阶:给地图加点”料”
气泡图叠加
光有颜色填充,信息量有点少。你可以在各省份的省会位置加上气泡,气泡大小代表销量,这样一眼就能看出哪些地方是销量高地。
series: [
{
name: '销量',
type: 'map',
geoIndex: 0,
data: normalizedData
},
{
name: '销量气泡',
type: 'effectScatter',
coordinateSystem: 'geo',
data: normalizedData.map(item => ({
name: item.name,
value: [
getProvinceCenter(item.name)[0], // 经度
getProvinceCenter(item.name)[1], // 纬度
item.value
]
})),
symbolSize: function(val) {
return Math.sqrt(val[2]) / 50; // 气泡大小和销量平方根成正比
},
rippleEffect: {
brushType: 'stroke'
},
label: {
show: true,
formatter: '{b}',
position: 'right',
color: '#333'
},
itemStyle: {
color: '#4a9bb8'
}
}
]
// 获取省份中心坐标的函数
function getProvinceCenter(provinceName) {
// 这里可以根据省份名称返回预定义的经纬度
// 或者从 GeoJSON 的几何中心计算
const centerMap = {
'广东': [113.26, 23.13],
'江苏': [118.78, 32.07],
'浙江': [120.15, 30.28],
// ... 其他省份
};
return centerMap[provinceName] || [105, 36];
}
动态数据更新
销量数据是实时变化的,你需要支持数据的动态更新。用 setOption 的 notMerge: false 模式:
function updateSalesData(newData) {
const normalized = newData.map(item => ({
name: normalizeProvinceName(item.province),
value: item.value
})).filter(item => item.name !== null);
chart.setOption({
series: [{
data: normalized
}, {
data: normalized.map(item => ({
name: item.name,
value: [
getProvinceCenter(item.name)[0],
getProvinceCenter(item.name)[1],
item.value
]
}))
}]
}, false); // notMerge: false,保留之前的配置
}
配色方案的最终建议
经过多次迭代,我总结了一套比较实用的配色方案,可以直接用:
// 推荐配色方案:蓝绿渐变(适合大部分商务场景)
const colorRanges = {
// 浅色系 - 适合浅色背景
light: [
'#f0f7fb', '#d0e8f2', '#a5d0e4',
'#6bb5cc', '#3d8fb0', '#1e6e8e', '#0d4f6a'
],
// 深色系 - 适合深色背景
dark: [
'#1a2a3a', '#2d4a5e', '#456d7e',
'#6490a0', '#89b4c0', '#b3d4d8', '#d8e8ea'
],
// 活力色系 - 适合年轻化的产品
vibrant: [
'#fff4e6', '#ffe0b2', '#ffcc80',
'#ffb74d', '#ffa726', '#ff9800', '#f57c00'
],
// 冷静色系 - 适合金融、数据类应用
calm: [
'#e8f5e9', '#c8e6c9', '#a5d6a7',
'#81c784', '#66bb6a', '#4caf50', '#388e3c'
]
};
// 使用示例
option.visualMap.inRange.color = colorRanges.light;
写在最后
做地图可视化,踩坑是难免的。我写这篇教程,就是把踩过的坑都摊开来给你看。最重要的几点:
- 数据预处理是关键,省份名称映射一定要做,而且要留日志方便排查
- 配色要有逻辑,不要随便挑颜色,连续数据用渐变色,分类数据用离散色
- 南海诸岛别忽略,虽然小,但不处理会显得不专业
- 移动端适配,缩放手势、字体大小都要考虑
希望这篇教程能帮你少踩几个坑。如果还有问题,随时问我。
